accredifysg/singpass-login
Laravel package for SingPass Login, MyInfo, and CorpPass with a FAPI 2.0–style flow: PAR, DPoP, PKCE, and private-key JWT client assertions. Shared services handle discovery, token exchange, and JWE/JWS processing, with thin per-provider controllers.
1. Install and publish.
composer require accredifysg/singpass-login
php artisan vendor:publish --provider="Accredifysg\SingPassLogin\SingPassLoginServiceProvider" --tag="config"
php artisan vendor:publish --provider="Accredifysg\SingPassLogin\SingPassLoginServiceProvider" --tag="listener"
You get four config files. Read them in this order:
| Order | File | Why |
|---|---|---|
| 1 | config/ndi.php |
Shared FAPI infrastructure — signing keys, JWKS, DPoP algorithm, failure redirect, logging. Everything else depends on this being right. |
| 2 | config/singpass-login.php |
The flow you will almost certainly ship first. |
| 3 | config/myinfo.php / config/corppass-login.php |
Only if you need profile data or corporate identity. |
2. Fill the environment. Minimum viable set for SingPass Login:
NDI_SIGNING_KID=
NDI_JWKS=
NDI_PRIVATE_JWKS=
SINGPASS_CLIENT_ID=
SINGPASS_REDIRECT_URI=
SINGPASS_DOMAIN=https://stg-id.singpass.gov.sg/fapi
SINGPASS_DISCOVERY_ENDPOINT=https://stg-id.singpass.gov.sg/fapi/.well-known/openid-configuration
SINGPASS_USE_DEFAULT_LISTENER=true
Note the two singpass URLs are not interchangeable: SINGPASS_DOMAIN must equal the discovery document's issuer (it ends in /fapi), and SINGPASS_DISCOVERY_ENDPOINT must be the FAPI well-known URL, not the plain OIDC one — the package rejects discovery documents without pushed_authorization_request_endpoint. This is the single most common first-run failure.
3. First use case — log a citizen in. Hit GET /ndi/sp/login from your frontend, follow the returned redirect_url, let the callback fire SingPassSuccessfulLoginEvent, and let the published listener map NRIC → User → Auth::login(). That is the whole happy path; everything else in the package is a variation on it.
Where to look when something breaks: NDI_LOGS_ENABLED=true first, then the discovery cache, then your JWKS reachability.
The two-step bootstrap is mandatory, not stylistic. Auth controllers return 200 {"redirect_url": "..."} rather than a 302. Your SPA must fetch that endpoint with credentials so the session cookie carrying state/PKCE verifiers is set, then navigate:
const res = await fetch(`/ndi/sp/login?${new URLSearchParams({ scopes })}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const { redirect_url } = await res.json();
window.location.assign(redirect_url);
Do the navigation server-side if you are not building an SPA — a plain redirect to /ndi/sp/login will dump JSON in the browser.
Listener as the integration seam. Keep the listener thin: resolve a local user, guard on missing NRIC, log in. Register it in EventServiceProvider (or rely on auto-discovery) and set SINGPASS_USE_DEFAULT_LISTENER=false when you supply your own listener_class. Treat the listener as your anti-corruption layer — it is the piece you rewrite if you ever swap providers, so avoid leaking SingPassUser into controllers or models.
Scope selection drives the data path. FapiCallbackService::shouldCallUserInfo() inspects the access token's scope claim against the provider's loginScopes. Login-only scopes → you get the ID token payload in the login event. Any data scope → the UserInfo endpoint is called and a second, provider-specific event fires (MyInfoDataRetrievedEvent, CorpPassDataRetrievedEvent). Plan for two listeners per flow if you request data scopes, and don't assume ordering guarantees between them beyond "login event first".
Use MyInfo as enrichment, not as your primary login. The natural workflow is SingPass Login for authentication, then a separate MyInfo flow when you actually need verified profile fields (name, email, nationality, DOB). That keeps the login path fast and avoids asking for consent you don't need on every sign-in.
CorpPass is a two-identity model. sub is the entity (UEN), act.sub is the actor (NRIC/FIN). Store them separately — a single users table keyed on NRIC will not capture "which company was this person acting for". Model a corporate_entities table plus a membership/pivot, and resolve authorisation from the authinfo UserInfo payload when you need to know what the entity permitted.
Custom controllers. If you need to gate initiation on tenancy, add rate limiting, or log intent, override *_controller in config rather than forking the package. The shared FapiAuthenticationService/FapiCallbackService do the heavy lifting; your controller just delegates and can add middleware freely.
Toggle routes off for non-browser consumers. SINGPASS_USE_DEFAULT_ROUTES, MYINFO_USE_DEFAULT_ROUTES, CORPPASS_USE_DEFAULT_ROUTES let you expose only what you use. The JWKS endpoint is always registered — it must remain public because the OP fetches it to verify your private_key_jwt client assertions.
The JWKS endpoint must be internet-reachable over HTTPS, including in local dev. The OpenID Provider fetches /ndi/jwks to verify your client assertion signature; localhost will never work. Tunnel it (ngrok/Expose/Valet share) and make sure the tunnel host matches whatever you registered. If your kid in NDI_SIGNING_KID does not match a key in the published JWKS, you get an opaque invalid_client from the OP, not a package exception.
Key generation is undocumented. The README tells you to supply NDI_JWKS / NDI_PRIVATE_JWKS but not how to produce them. You are expected to bring your own RSA keypair and JWKS serialiser. Budget an afternoon for this on first setup, and write the artefact into your deploy tooling — never generate keys at runtime.
Discovery caching is a footgun when switching environments. ProviderConfig carries a cache_key per provider. If you flip SINGPASS_DOMAIN/SINGPASS_DISCOVERY_ENDPOINT from staging to production in a shared cache (Redis, database), you will keep issuing requests to the old issuer until the entry expires — producing issuer mismatches that look like credential problems. Either namespace the cache by environment or flush the discovery key as part of deploy.
getNric() is deprecated. Read $singPassUser->nric (readonly, populated from sub_attributes.identity_number). The old method will be removed in a future major — migrate now rather than inheriting a surprise on upgrade, especially since you are on a 0.x-era package with 2 stars and no dependents to surface breaking changes for you.
NRIC is null without the user.identity scope. Listeners that throw on null NRIC are correct; listeners that fall back to matching on email will silently produce wrong-account logins. Request the scope explicitly and fail loudly.
Malformed access tokens fail visibly, by design. If the token is not a three-part JWT, or scope is missing or not a string, UserInfoRequestException is thrown rather than silently degrading to openid-only. This is good behaviour but it means an OP-side change breaks the callback hard. Catch it, log the raw token shape (not the token), and redirect via NDI_FAILURE_REDIRECT_URL.
Failure redirects assume route('login') exists. With NDI_FAILURE_REDIRECT_URL unset, failures redirect to the named login route with session-flashed errors. In an API-only or differently-named app this throws inside the failure handler, masking the original error. Always set the URL explicitly in production.
The routes are in the web middleware group. Sessions and CSRF state are load-bearing. Don't move them behind api middleware, and don't try to use the package from a stateless token-issuing endpoint without reimplementing the state handling.
DPoP clock skew. NDI_DPOP_SIGNING_ALGORITHM (default ES256) must be one the provider accepts, and your server clock must be accurate — DPoP proofs carry iat and are rejected outside a narrow window. In containerised environments, verify NTP is actually running before debugging anything else.
Treat this as a thin, volatile dependency. Two stars, zero dependents, a single maintainer, and a release date that is already in the future suggest a young package that has not yet been hardened by community use. Pin the exact version in composer.json, wrap initiation in your own route/controller so you can swap implementations, and keep your event listeners dependency-free of package classes where practical. There are no test doubles or fake providers documented, so plan to exercise the flow against SingPass staging with real sandbox credentials — you cannot meaningfully unit-test the callback path in CI.
How can I help you explore Laravel packages today?