accredifysg/singpass-login
Laravel package for SingPass Login, MyInfo, and CorpPass using FAPI 2.0-style auth: OpenID discovery, Pushed Authorization Requests (PAR) with DPoP, PKCE, and private-key JWT client assertions. Includes shared services and thin provider controllers.
Installation:
composer require accredifysg/singpass-login
php artisan vendor:publish --provider="Accredifysg\SingPassLogin\SingPassLoginServiceProvider" --tag="config"
Publish the default listener (optional):
php artisan vendor:publish --provider="Accredifysg\SingPassLogin\SingPassLoginServiceProvider" --tag="listener"
Configure Environment:
Add credentials to .env (e.g., SINGPASS_CLIENT_ID, SINGPASS_REDIRECT_URI, SINGPASS_DISCOVERY_ENDPOINT).
Ensure NDI_SIGNING_KID and NDI_PRIVATE_JWKS are set for JWT signing.
First Use Case: Trigger a SingPass login from a frontend route:
async function startSingPassLogin() {
const res = await fetch('/ndi/sp/login?scopes=openid,name,email');
const { redirect_url } = await res.json();
window.location.href = redirect_url;
}
Handle the callback in your Laravel app (default routes are pre-registered).
Authentication Flow:
/ndi/sp/login (or /ndi/mi/initiate for MyInfo) with scopes./ndi/sp/callback (or /ndi/mi/callback). The package handles:
SingPassSuccessfulLoginEvent (or MyInfoDataRetrievedEvent) to process the response.Custom Controllers:
Override default controllers by setting controller_class in config (e.g., singpass-login.php):
'controller_class' => \App\Http\Controllers\CustomSingPassLoginController::class,
Scope Management:
openid,name,email,mobileno.entity.identity,user.identity).available_scopes in config (e.g., config/myinfo.php).Data Extraction:
nric from SingPassUser (deprecated: $user->getNric()).MyInfoDataRetrievedEvent to fetch structured data (e.g., $event->getMyInfoData()['name']['value']).CorpPassUser (e.g., $user->getEntityId(), $user->getIdentityNumber()).Error Handling:
SingPassLoginException, MyInfoRequestException, or CorpPassLoginException in listeners.NDI_LOGS_ENABLED=true in config/ndi.php.Frontend Integration:
Use the redirect_url from the /login endpoint to trigger SingPass/MyInfo/CorpPass flows. Ensure credentials: 'same-origin' is set for cookie-based sessions.
fetch('/ndi/sp/login', { credentials: 'same-origin' })
.then(res => res.json())
.then(({ redirect_url }) => window.location.href = redirect_url);
Backend Processing:
Register listeners for events in EventServiceProvider:
protected $listen = [
SingPassSuccessfulLoginEvent::class => [
\App\Listeners\HandleSingPassLogin::class,
],
];
Testing:
Use SingPass’s sandbox environment for testing. Mock the FapiAuthenticationService or FapiCallbackService in unit tests.
Custom Scopes:
Extend ProviderConfig to support additional scopes if needed (e.g., for CorpPass’s tpauthinfo).
JWKS Configuration:
NDI_SIGNING_KID/NDI_PRIVATE_JWKS causes DPoP failures.NDI_SIGNING_KID="your_kid"
NDI_PRIVATE_JWKS='{"kty":"EC","kid":"your_kid","d":"private_key_base64","x":"public_key_base64"}'
/ndi/jwks to verify the endpoint returns valid keys.Redirect URI Mismatch:
SINGPASS_REDIRECT_URI must exactly match the URI registered with SingPass (including https://).config/singpass-login.php and ensure it matches the callback route (e.g., https://yourdomain.com/ndi/sp/callback).Scope Validation:
mobileno for MyInfo) throws MyInfoRequestException.available_scopes in config/myinfo.php and update your frontend/backend scope requests accordingly.State Validation:
state parameter is missing or tampered with.state is preserved across the redirect (handled automatically by the package).Token Decoding:
scope claim) cause UserInfoRequestException.FapiCallbackService or log the raw token for debugging.Event Order:
CorpPassDataRetrievedEvent may fire before CorpPassSuccessfulLoginEvent if UserInfo scopes are requested.NRIC Deprecation:
$singPassUser->getNric() is deprecated. Use $singPassUser->nric directly.$nric = $event->getSingPassUser()->nric; // Instead of $user->getNric()
Discovery Endpoint:
pushed_authorization_request_endpoint in the OpenID discovery response.SINGPASS_DISCOVERY_ENDPOINT points to the correct FAPI endpoint (e.g., https://id.singpass.gov.sg/fapi/.well-known/openid-configuration).Enable Logging:
Set NDI_LOGS_ENABLED=true in config/ndi.php to log FAPI requests/responses, token payloads, and errors.
Validate Discovery:
Manually check the discovery endpoint (e.g., https://id.singpass.gov.sg/fapi/.well-known/openid-configuration) to ensure it includes:
{
"pushed_authorization_request_endpoint": "https://id.singpass.gov.sg/fapi/par",
"token_endpoint": "https://id.singpass.gov.sg/fapi/token",
"userinfo_endpoint": "https://id.singpass.gov.sg/fapi/userinfo"
}
Inspect Tokens:
Decode JWTs manually (e.g., jwt.io) to verify claims like scope, sub, or nric. Example:
echo "PASTE_ACCESS_TOKEN_HERE" | base64 -d | jq
Test with Postman:
Simulate the PAR flow by sending a POST to the pushed_authorization_request_endpoint with a DPoP-proofed request:
POST /fapi/par HTTP/1.1
Host: id.singpass.gov.sg
Content-Type: application/jose+json
Authorization: DPoP <proof>
Use the FapiAuthenticationService as a reference for the request format.
Custom Providers:
Extend the shared FapiAuthenticationService to support additional FAPI 2.0 providers (e.g., custom OIDC endpoints). Override:
ProviderConfig for provider-specific settings.FapiCallbackService::shouldCallUserInfo() to customize UserInfo logic.Token Storage:
Store tokens (ID/access) in the session or database by extending SingPassSuccessfulLoginEvent listeners:
public function handle(SingPassSuccessfulLoginEvent $event) {
$tokens = $event->getTokens();
session(['singpass_tokens' => $tokens]);
}
UI Customization:
How can I help you explore Laravel packages today?