Skip to content

Instantly share code, notes, and snippets.

@leeliwei930
Last active July 5, 2022 11:07
Show Gist options
  • Save leeliwei930/f35d5ea70531765e0bdc6c8c6fd2665d to your computer and use it in GitHub Desktop.
Save leeliwei930/f35d5ea70531765e0bdc6c8c6fd2665d to your computer and use it in GitHub Desktop.
Singpass With Laravel Medium Embed
SINGPASS_CLIENT_ID="<given-by-singpass-authority>"
SINGPASS_REDIRECT_URI="<your-callback-url-that-must-be-same-as-you-provided-for-onboarding>"
SINGPASS_SIGNING_KEY="storage/singpass/singpass-signing-key-encrypted.pem"
SINGPASS_VERIFICATION_KEY="storage/singpass/singpass-verification-key.pem"
SINGPASS_ENCRYPTION_KEY="/storage/singpass/singpass-encryption-key.pem"
SINGPASS_DECRYPTION_KEY="/storage/singpass/singpass-decryption-key-encrypted.pem"
SINGPASS_WELL_KNOWN_CONFIGURATION_URL="https://stg-id.singpass.gov.sg/.well-known/openid-configuration"
SINGPASS_DECRYPTION_KEY_PASSPHRASE="<your-singpass-decryption-key-passphrase>"
SINGPASS_SIGNING_KEY_PASSPHRASE="<your-singpass-signing-key-passphrase>"
<?php
Route::get('/singpass-qr-param', "InvokeGenerateSingPassQRParameter@__invoke");
<?php
Route::get('/ndi/singpass/jwks', function () {
$keys = \App\Services\SingpassHelper::generateJWKS();
return response()->json($keys);
});
<?php
namespace App\Providers;
use App\Services\SingPassHelper;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider {
public function boot()
{
$socialite = $this->app->make('Laravel\Socialite\Contracts\Factory');
$socialite->extend(
'singpass',
function ($app) use ($socialite) {
$config = $app['config']['services.singpass'];
return $socialite->buildProvider(SingPassProvider::class, $config);
}
);
}
public function register()
{
}
}
@extends("layouts.app")
@section('title')
Singpass Login
@endsection
@section('content')
<div class="flex flex-col h-screen items-center justify-center" id="singpass-app">
<singpass-qr-login
client-id="{{ config('singpass.client_id') }}"
redirect-uri="{{config('singpass.redirect')}}"
>
</singpass-qr-login>
</div>
@endsection
@section('scripts')
<script>
Vue.component('singpass-qr-login', {
props: {
qrParamEndpoint: {
type: String,
default: "/singpass/api/singpass-qr-param"
},
clientId: {
type: String,
default: ""
},
redirectUri: {
type: String,
default: ""
}
},
template: `
<div :id="'singpass-qr-login-' + _uid"></div>
`,
data(){
return {
authSession: null,
authParams: {
state: "",
nonce: ""
},
qrParams: {
clientId: '',
redirectUri: '',
scope: 'openid',
responseType: ''
}
}
},
methods: {
loadNDIParams(){
this.$axios.get(this.qrParamEndpoint).then((response) => {
if(response.status === 200){
let params = response.data.singpass_qr_param;
this.authParams = {
state: params.state,
nonce: params.nonce
}
this.qrParams = {
clientId: this.clientId,
redirectUri: this.redirectUri,
scope: 'openid',
responseType: params.response_type
}
this.setupQRCode()
}
})
},
setupQRCode(){
this.authSession = this.$singpassQR.initAuthSession(
`singpass-qr-login-${this._uid}`,
{
...this.qrParams
},
()=> this.authParams,
(errorId, message) => {
console.log(`onError. errorId:${errorId} message:${message}`);
}
);
}
},
created(){
},
mounted(){
this.loadNDIParams();
},
beforeDestroy(){
if (this.authSession === 'SUCCESSFUL') {
this.$singpassQR.cancelAuthSession()
}
}
})
</script>
@endsection
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\URL;
class InvokeGenerateSingpassQRParameter extends Controller {
// this code will generate the QRCode parameters that will be used by singpass JS
public function __invoke(Request $request)
{
$nonce = bin2hex(openssl_random_pseudo_bytes(8));
$state = bin2hex(openssl_random_pseudo_bytes(8));
if(!$request->wantsJson()){
$request->session()->put('state', $state);
}
$params = [
'client_id' => config('singpass.client_id'),
'nonce' => $nonce,
'state' => $state,
'redirect_uri' => URL::to(config('singpass.redirect_uri')),
'response_type' => 'code'
];
return response()->json([
'singpass_qr_param' => $params
]);
}
}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>@yield('title')</title>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet"/>
<script src="https://stg-id.singpass.gov.sg/static/ndi_embedded_auth.js"></script>
</head>
<body>
<div id="myinfo-app">
@yield('content')
</div>
@yield('scripts')
<script>
window.Vue.prototype.$singpassQR = window.NDI;
window.Vue.prototype.$axios = window.axios;
new Vue({
el: '#singpass-app',
})
</script>
</body>
</html>
<?php
Route::get('/callback', function(){
$user = \Laravel\Socialite\Facades\Socialite::driver('singpass')->user();
return response()->json($user);
});
{
"token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx",
"refreshToken": null,
"expiresIn": null,
"id": "ed5d300e-0661-43b9-a543-xxxxx",
"nickname": null,
"name": "singpass-user@ed5d300e-0661-43b9-a543-xxxxx",
"email": null,
"avatar": null,
"user": {
"s": "SxxxxxxxB",
"u": "ed5d300e-0661-43b9-a543-xxxxx"
},
"singpass_user_id": "SxxxxxxxB",
"foreigner_id": "",
"country_of_issuance": ""
}
<?php
return [
'singpass' => [
'well_known_configuration_url' => env('SINGPASS_WELL_KNOWN_CONFIGURATION_URL', ""),
'client_id' => env('SINGPASS_CLIENT_ID', ""),
'redirect' => env('SINGPASS_REDIRECT_URI', ""),
'signing_key' => env('SINGPASS_SIGNING_KEY', ""),
'decryption_key' => env('SINGPASS_DECRYPTION_KEY', ""),
"signing_key_passphrase" => env("SINGPASS_SIGNING_KEY_PASSPHRASE" , ""),
"decryption_key_passphrase" => env("SINGPASS_DECRYPTION_KEY_PASSPHRASE", ""),
]
];
# encrypt private key use for token decryption
openssl ec -in singpass-decryption-key.pem -out singpass-decryption-key-encrypted.pem -aes256
# encrypt private key use to sign the client assertions
openssl ec -in singpass-signing-key.pem -out singpass-signing-key-encrypted.pem -aes256
<?php
namespace App\Services;
use Jose\Component\Core\JWKSet;
use Jose\Component\KeyManagement\JWKFactory;
final class SingpassHelper {
public static function generateJWKS()
{
$verificationKey = base_path(config('services.singpass.verification_key'));
$encryptionKey = base_path(config('services.singpass.encryption_key'));
/** import the verification public key file,
the second parameter which will be a blank password due to the
public key served to be exposed **/
$verifyJWK = JWKFactory::createFromKeyFile(
$verificationKey,
"",
[
'kid' => "acme_verification_key",
'use' => 'sig'
]
);
/** import the encryption public key file,
the second parameter which will be a blank password due to the
public key served to be exposed **/
$encryptionJWK = JWKFactory::createFromKeyFile(
$encryptionKey,
"",
[
'kid' => "acme_enc_key",
'use' => 'enc',
/** supported algorithm which will be in https://stg-id.singpass.gov.sg/.well-known/openid-configuration,
id_token_encryption_alg_values_supported **/
'alg' => "ECDH-ES+A256KW" // tell Singpass API server using ECDH-ES+A256KW to encrypt the content
]
);
// construct new JWK set
$keySet = new JWKSet([$verifyJWK, $encryptionJWK]);
return $keySet;
}
}
# Generate a private key for content decryption
openssl ecparam -name prime256v1 -genkey -noout -out singpass-decryption-key.pem
# Generate a public key for content encryption
openssl ec -in singpass-decryption-key.pem -pubout -out singpass-encryption-key.pem
# Generate a private key for signature signing key
openssl ecparam -name prime256v1 -genkey -noout -out singpass-signing-key.pem
# Generate a public key for signature verification key
openssl ec -in singpass-signing-key.pem -pubout -out singpass-verification-key.pem
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// Return an Authorization Endpoint
protected function getAuthUrl($state)
{
$config = $this->getOpenIDConfiguration();
if($this->isStateless()){
return '';
}
return $config['authorization_endpoint'];
}
protected function getTokenUrl()
{
$config = $this->getOpenIDConfiguration();
return $config['token_endpoint'];
}
public function getAccessTokenResponse($code)
{
// construct token exchange request
try {
$clientAssertion = $this->generateClientAssertion();
info("Client Assertion Generated: " . $clientAssertion);
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'charset' => "ISO-8859-1"
],
'form_params' => [
'client_id' => config('services.singpass.client_id'),
'redirect_uri' => config('services.singpass.redirect'),
'grant_type' => 'authorization_code',
'code' => $code,
'scope' => 'openid',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $clientAssertion
]
]);
$responseBody = json_decode($response->getBody(), true);
// due to on next method call getUserByToken only extract the key field
// which is access token so we need to replace the value of access token to id_token
$responseBody['access_token'] = $responseBody['id_token'];
return $responseBody;
}
catch (ClientException $requestException){
Log::error( $requestException->getMessage());
abort(Response::HTTP_BAD_REQUEST, "Invalid parameter pass in while requesting singpass token");
} catch (ServerException $guzzleException){
// catch if there any internal server error occurred at sinpass
$errorResponse = $guzzleException->getResponse()->getBody()->getContents();
$errorResponse = json_decode($errorResponse, true);
Log::error('SingPass Internal Server Error' , $errorResponse);
abort(Response::HTTP_BAD_GATEWAY, "Unable to login using Singpass right now");
}
}
// A Client Assertion which replace the needed of client secret
public function generateClientAssertion(){
$config = $this->getOpenIDConfiguration();
$signingKeyPassphrase = config('services.singpass.signing_key_passphrase');
// import signature signing key
$signingKey = JWKFactory::createFromKeyFile("file://" . base_path(config('services.singpass.signing_key')) , $signingKeyPassphrase);
$algorithmFactory = new AlgorithmManagerFactory();
// initiate the algorithm aliases
$algorithmFactory->add('ES256' , new ES256());
$algorithmFactory->add('ES384' , new ES384());
$algorithmFactory->add('ES512' , new ES512());
// load all the support signature algorithm based on singpass API openid configuration
$algorithmFactory->create($config['token_endpoint_auth_signing_alg_values_supported']);
$algorithmManager = new AlgorithmManager($algorithmFactory->all());
$jwsBuilder = new JWSBuilder($algorithmManager);
// JWT issue timestamp
$issuedAt = now();
//build jwt
$jwt = $jwsBuilder->create()
->withPayload(json_encode([
'sub' => config('services.singpass.client_id'),
'aud' => $config['issuer'],
'iss' =>config('services.singpass.client_id'),
'iat' => $issuedAt->unix(),
'exp' => $issuedAt->addMinutes(2)->unix(),
])) // insert claims data
->addSignature($signingKey, ['typ' => 'JWT', 'alg' => 'ES256']) // sign it and add the JWS protected header
->build();
$serializer = new CompactSerializer(); // The serializer
// generate base64 encoded JWT
$clientAssertion = $serializer->serialize($jwt, 0);
return $clientAssertion;
}
/**
* The id token which is a JWE that is needed to decrypt
* @param string $idToken
* @return array|string
*/
protected function getUserByToken($token)
{
$jwe = $token;
info("Encrypted JWE: $jwe");
// decrypt the JWE
$content = $this->decryptJWE($jwe);
info("Decrypted JWE: $content");
if(!is_null($content)){
// verify the content of JWT
$jws = $this->verifyTokenSignature($content);
if(!$jws){
// abort if signature check failed
abort(Response::HTTP_UNAUTHORIZED, "Singpass Signature checking failed");
}
return $jws;
} else {
abort(Response::HTTP_BAD_REQUEST, "Unable to decrypt JWE");
}
}
private function decryptJWE($idToken){
info($idToken);
$config = $this->getOpenIDConfiguration();
$keyEncryptionsAlgo = new AlgorithmManagerFactory();
// create algorithm alias for token encryption that might used by singpass
$keyEncryptionsAlgo->add('ECDH-ES+A256KW', new ECDHESA256KW());
$keyEncryptionsAlgo->add('ECDH-ES+A192KW', new ECDHESA192KW());
$keyEncryptionsAlgo->add('ECDH-ES+A128KW', new ECDHESA128KW());
$keyEncryptionsAlgo->add('RSA-OAEP-256', new RSAOAEP256());
$keyEncryptionsAlgo->create($config['id_token_encryption_alg_values_supported'] ?? []);
// create algorithm alias for content encryption that used by singpass based on openid configuration
$contentEncryptionAlgo = new AlgorithmManagerFactory();
$contentEncryptionAlgo->add('A256CBC-HS512', new A256CBCHS512());
$contentEncryptionAlgo->create($config['id_token_encryption_enc_values_supported'] ?? []);
$compressionMethodManager = new CompressionMethodManager([
new Deflate()
]);
$keyEncryptionAlgorithmManager = new AlgorithmManager($keyEncryptionsAlgo->all());
$contentEncryptionAlgorithmManager = new AlgorithmManager($contentEncryptionAlgo->all());
// create a JWE decrypter
$decrypter = new JWEDecrypter(
$keyEncryptionAlgorithmManager,
$contentEncryptionAlgorithmManager,
$compressionMethodManager
);
$decryptionKeyPassphrase = config('services.singpass.decryption_key_passphrase');
// import decryption key
$jwk = JWKFactory::createFromKeyFile("file://" . base_path(config('services.singpass.decryption_key')), $decryptionKeyPassphrase);
$serializerManager = new JWESerializerManager([new \Jose\Component\Encryption\Serializer\CompactSerializer()]);
$jwe = $serializerManager->unserialize($idToken);
// if decryption is success return the decrypted payload
if($decrypter->decryptUsingKey($jwe, $jwk, 0)){
info("user: ".$jwe->getPayload());
return $jwe->getPayload();
}
return null;
}
/**
* Convert the JWT user claims, separated comma user info to array
* @param array $user
* @return User
*/
protected function mapUserToObject($user)
{
$parseUserData = $this->parseUser($user->sub);
return (new User)->setRaw($parseUserData)->map([
"singpass_user_id" => $parseUserData['s'] ?? "",
"foreigner_id" => $parseUserData['fid'] ?? "",
"country_of_issuance" => $parseUserData['coi'] ?? "",
"id" => $parseUserData['u'] ?? "",
"name" => "singpass-user@".$parseUserData['u']
]);
}
/**
* Split the JWT claims sub and convert to a dictionary type
* @param $content
* @return array
*/
private function parseUser($content){
$processedData = [];
$dataRecord = explode("," , $content);
foreach ($dataRecord as $record) {
$data = explode("=", $record);
$processedData[$data[0]] = $data[1] ?? "";
}
return $processedData;
}
/**
* Retrieve SingPass API OpenID configuration
* @return mixed
* @throws GuzzleException
*/
public function getOpenIDConfiguration()
{
if(Cache::has('singpassOpenIDConfig')){
return Cache::get('singpassOpenIDConfig');
}
$response = $this->getHttpClient()->get(config('services.singpass.well_known_configuration_url'), [
'headers' => ['Accept' => 'application/json']
]);
$openIDConfig = json_decode($response->getBody(), true);
Cache::put('singpassOpenIDConfig', $openIDConfig, now()->addHour());
return $openIDConfig;
}
public function verifyTokenSignature($token)
{
$config = $this->getOpenIDConfiguration();
// load Singpass JWKS
$singpassJWKS = $this->retrieveSingPassVerificationKey();
$jwks = JWKSet::createFromJson($singpassJWKS);
// select Signature key
$verificationKey = $jwks->selectKey('sig');
$signatureAlgo = new AlgorithmManagerFactory();
$signatureAlgo->add('ES256', new ES256());
$signatureAlgo->create($config['id_token_signing_alg_values_supported'] ?? []);
$signatureAlgoManager = new AlgorithmManager($signatureAlgo->all());
$serializerManager = new JWSSerializerManager([
new CompactSerializer()
]);
$jwsVerifier = new JWSVerifier($signatureAlgoManager);
$jws = $serializerManager->unserialize($token);
$isVerified = $jwsVerifier->verifyWithKey($jws, $verificationKey, 0);
return $isVerified ? json_decode($jws->getPayload()) : false;
}
/**
* Load the SingPass API verification key from SingPass JWKS endpoints
* @return string
* @throws GuzzleException
*/
public function retrieveSingPassVerificationKey(): string
{
$config = $this->getOpenIDConfiguration();
try {
$response = $this->getHttpClient()->get($config['jwks_uri'], [
'headers' => ['Accept' => 'application/json'
]]);
return $response->getBody()->getContents();
} catch (ServerException $e){
$errorResponse = $e->getResponse()->getBody()->getContents();
$errorResponse = json_decode($errorResponse, true);
Log::error('Unable to retrieve Singpass JWKS' , $errorResponse);
abort(Response::HTTP_BAD_GATEWAY, "Unable to login using Singpass right now");
}
}
}
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// Return an Authorization Endpoint
protected function getAuthUrl($state)
{
}
protected function getTokenUrl()
{
}
public function getAccessTokenResponse($code)
{
}
/**
* The id token which is a JWE that is needed to decrypt
* @param string $idToken
* @return array|string
*/
protected function getUserByToken($token)
{
}
/**
* Convert the JWT user claims, separated comma user info to array
* @param array $user
* @return User
*/
protected function mapUserToObject($user)
{
}
}
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// ...
/**
* Retrieve SingPass API OpenID configuration
* @return mixed
* @throws GuzzleException
*/
public function getOpenIDConfiguration()
{
if(Cache::has('singpassOpenIDConfig')){
return Cache::get('singpassOpenIDConfig');
}
$response = $this->getHttpClient()->get(config('services.singpass.well_known_configuration_url'), [
'headers' => ['Accept' => 'application/json']
]);
$openIDConfig = json_decode($response->getBody(), true);
Cache::put('singpassOpenIDConfig', $openIDConfig, now()->addHour());
return $openIDConfig;
}
/**
* Load the SingPass API verification key from SingPass JWKS endpoints
* @return string
* @throws GuzzleException
*/
public function retrieveSingPassVerificationKey(): string
{
$config = $this->getOpenIDConfiguration();
try {
$response = $this->getHttpClient()->get($config['jwks_uri'], [
'headers' => ['Accept' => 'application/json'
]]);
return $response->getBody()->getContents();
} catch (ServerException $e){
$errorResponse = $e->getResponse()->getBody()->getContents();
$errorResponse = json_decode($errorResponse, true);
Log::error('Unable to retrieve Singpass JWKS' , $errorResponse);
abort(Response::HTTP_BAD_GATEWAY, "Unable to login using Singpass right now");
}
}
// A Client Assertion which replace the needed of client secret
public function generateClientAssertion(){
$config = $this->getOpenIDConfiguration();
$signingKeyPassphrase = config('services.singpass.signing_key_passphrase');
// import signature signing key
$signingKey = JWKFactory::createFromKeyFile("file://" . base_path(config('services.singpass.signing_key')) , $signingKeyPassphrase);
$algorithmFactory = new AlgorithmManagerFactory();
// initiate the algorithm aliases
$algorithmFactory->add('ES256' , new ES256());
$algorithmFactory->add('ES384' , new ES384());
$algorithmFactory->add('ES512' , new ES512());
// load all the support signature algorithm based on singpass API openid configuration
$algorithmFactory->create($config['token_endpoint_auth_signing_alg_values_supported']);
$algorithmManager = new AlgorithmManager($algorithmFactory->all());
$jwsBuilder = new JWSBuilder($algorithmManager);
// JWT issue timestamp
$issuedAt = now();
//build jwt
$jwt = $jwsBuilder->create()
->withPayload(json_encode([
'sub' => config('services.singpass.client_id'),
'aud' => $config['issuer'],
'iss' =>config('services.singpass.client_id'),
'iat' => $issuedAt->unix(),
'exp' => $issuedAt->addMinutes(2)->unix(),
])) // insert claims data
->addSignature($signingKey, ['typ' => 'JWT', 'alg' => 'ES256']) // sign it and add the JWS protected header
->build();
$serializer = new CompactSerializer(); // The serializer
// generate base64 encoded JWT
$clientAssertion = $serializer->serialize($jwt, 0);
return $clientAssertion;
}
}
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// ...
// Due to singpass required RP to integrate their SingpassQRLogin JS, thus redirect to authorization endpoint is not supported
protected function getAuthUrl($state)
{
$config = $this->getOpenIDConfiguration();
if($this->isStateless()){
return '';
}
return $config['authorization_endpoint'];
}
protected function getTokenUrl()
{
$config = $this->getOpenIDConfiguration();
return $config['token_endpoint'];
}
public function getAccessTokenResponse($code)
{
// construct token exchange request
try {
$clientAssertion = $this->generateClientAssertion();
info("Client Assertion Generated: " . $clientAssertion);
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'charset' => "ISO-8859-1"
],
'form_params' => [
'client_id' => config('services.singpass.client_id'),
'redirect_uri' => config('services.singpass.redirect'),
'grant_type' => 'authorization_code',
'code' => $code,
'scope' => 'openid',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => $clientAssertion
]
]);
$responseBody = json_decode($response->getBody(), true);
// due to on next method call getUserByToken only extract the key field
// which is access token so we need to replace the value of access token to id_token
$responseBody['access_token'] = $responseBody['id_token'];
return $responseBody;
}
catch (ClientException $requestException){
Log::error( $requestException->getMessage());
abort(Response::HTTP_BAD_REQUEST, "Invalid parameter pass in while requesting singpass token");
} catch (ServerException $guzzleException){
// catch if there any internal server error occurred at sinpass
$errorResponse = $guzzleException->getResponse()->getBody()->getContents();
$errorResponse = json_decode($errorResponse, true);
Log::error('SingPass Internal Server Error' , $errorResponse);
abort(Response::HTTP_BAD_GATEWAY, "Unable to login using Singpass right now");
}
}
/**
* The id token which is a JWE that is needed to decrypt
* @param string $idToken
* @return array|string
*/
protected function getUserByToken($token)
{
$jwe = $token;
info("Encrypted JWE: $jwe");
// decrypt the JWE
$content = $this->decryptJWE($jwe);
info("Decrypted JWE: $content");
if(!is_null($content)){
// verify the content of JWT
$jws = $this->verifyTokenSignature($content);
if(!$jws){
// abort if signature check failed
abort(Response::HTTP_UNAUTHORIZED, "Singpass Signature checking failed");
}
return $jws;
} else {
abort(Response::HTTP_BAD_REQUEST, "Unable to decrypt JWE");
}
}
/**
* Convert the JWT user claims, separated comma user info to array
* @param array $user
* @return User
*/
protected function mapUserToObject($user)
{
$parseUserData = $this->parseUser($user->sub);
return (new User)->setRaw($parseUserData)->map([
"singpass_user_id" => $parseUserData['s'] ?? "",
"foreigner_id" => $parseUserData['fid'] ?? "",
"country_of_issuance" => $parseUserData['coi'] ?? "",
"id" => $parseUserData['u'] ?? "",
"name" => "singpass-user@".$parseUserData['u']
]);
}
}
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// ...
private function decryptJWE($idToken){
info($idToken);
$config = $this->getOpenIDConfiguration();
$keyEncryptionsAlgo = new AlgorithmManagerFactory();
// create algorithm alias for token encryption that might used by singpass
$keyEncryptionsAlgo->add('ECDH-ES+A256KW', new ECDHESA256KW());
$keyEncryptionsAlgo->add('ECDH-ES+A192KW', new ECDHESA192KW());
$keyEncryptionsAlgo->add('ECDH-ES+A128KW', new ECDHESA128KW());
$keyEncryptionsAlgo->add('RSA-OAEP-256', new RSAOAEP256());
$keyEncryptionsAlgo->create($config['id_token_encryption_alg_values_supported'] ?? []);
// create algorithm alias for content encryption that used by singpass based on openid configuration
$contentEncryptionAlgo = new AlgorithmManagerFactory();
$contentEncryptionAlgo->add('A256CBC-HS512', new A256CBCHS512());
$contentEncryptionAlgo->create($config['id_token_encryption_enc_values_supported'] ?? []);
$compressionMethodManager = new CompressionMethodManager([
new Deflate()
]);
$keyEncryptionAlgorithmManager = new AlgorithmManager($keyEncryptionsAlgo->all());
$contentEncryptionAlgorithmManager = new AlgorithmManager($contentEncryptionAlgo->all());
// create a JWE decrypter
$decrypter = new JWEDecrypter(
$keyEncryptionAlgorithmManager,
$contentEncryptionAlgorithmManager,
$compressionMethodManager
);
$decryptionKeyPassphrase = config('services.singpass.decryption_key_passphrase');
// import decryption key
$jwk = JWKFactory::createFromKeyFile("file://" . base_path(config('services.singpass.decryption_key')), $decryptionKeyPassphrase);
$serializerManager = new JWESerializerManager([new \Jose\Component\Encryption\Serializer\CompactSerializer()]);
$jwe = $serializerManager->unserialize($idToken);
// if decryption is success return the decrypted payload
if($decrypter->decryptUsingKey($jwe, $jwk, 0)){
info("user: ".$jwe->getPayload());
return $jwe->getPayload();
}
return null;
}
}
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// ...
public function verifyTokenSignature($token)
{
$config = $this->getOpenIDConfiguration();
// load Singpass JWKS
$singpassJWKS = $this->retrieveSingPassVerificationKey();
$jwks = JWKSet::createFromJson($singpassJWKS);
// select Signature key
$verificationKey = $jwks->selectKey('sig');
$signatureAlgo = new AlgorithmManagerFactory();
$signatureAlgo->add('ES256', new ES256());
$signatureAlgo->create($config['id_token_signing_alg_values_supported'] ?? []);
$signatureAlgoManager = new AlgorithmManager($signatureAlgo->all());
$serializerManager = new JWSSerializerManager([
new CompactSerializer()
]);
$jwsVerifier = new JWSVerifier($signatureAlgoManager);
$jws = $serializerManager->unserialize($token);
$isVerified = $jwsVerifier->verifyWithKey($jws, $verificationKey, 0);
return $isVerified ? json_decode($jws->getPayload()) : false;
}
/**
* Load the SingPass API verification key from SingPass JWKS endpoints
* @return string
* @throws GuzzleException
*/
public function retrieveSingPassVerificationKey(): string
{
$config = $this->getOpenIDConfiguration();
try {
$response = $this->getHttpClient()->get($config['jwks_uri'], [
'headers' => ['Accept' => 'application/json'
]]);
return $response->getBody()->getContents();
} catch (ServerException $e){
$errorResponse = $e->getResponse()->getBody()->getContents();
$errorResponse = json_decode($errorResponse, true);
Log::error('Unable to retrieve Singpass JWKS' , $errorResponse);
abort(Response::HTTP_BAD_GATEWAY, "Unable to login using Singpass right now");
}
}
}
<?php
namespace App\Socialite;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\AlgorithmManagerFactory;
use Jose\Component\Core\JWKSet;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA128KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA192KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHESA256KW;
use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP256;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use Jose\Component\Encryption\Compression\Deflate;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
class SingPassProvider extends AbstractProvider implements ProviderInterface{
// ...
/**
* Split the JWT claims sub and convert to a dictionary type
* @param $content
* @return array
*/
private function parseUser($content){
$processedData = [];
$dataRecord = explode("," , $content);
foreach ($dataRecord as $record) {
$data = explode("=", $record);
$processedData[$data[0]] = $data[1] ?? "";
}
return $processedData;
}
}
<?php
Route::get('/callback', function(){
$user = \Laravel\Socialite\Facades\Socialite::driver('singpass')->stateless()->user();
return response()->json($user);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment