@spinajs/authn-oidc
v3.0.23
Published
OpenID Connect authentication for SpinaJS
Readme
@spinajs/authn-oidc
OpenID Connect and OAuth2 login for SpinaJS (design §22.15, rule A27): the authorization code flow with PKCE S256, discovery, full ID token checks, userinfo, claims mapping, account linking and provider presets (Google, Microsoft Entra ID, Keycloak, Auth0, Okta, GitHub).
Setup
export default {
authn: {
strategies: [{ name: 'oidc:corp', service: 'oidc:corp' }],
oidc: {
providers: {
corp: { issuer: 'https://sso.example.com/realms/corp', clientId: 'canvas', clientSecret: { secret: 'env://OIDC_SECRET' } },
},
redirects: { success: '/app', failure: '/login' },
},
},
};- Each provider becomes the strategy
oidc:<name>. The package's bootstrapper registers one strategy service per provider under that same name, so list{ name: 'oidc:<name>', service: 'oidc:<name>' }. - Routes:
GET /auth/oidc/:provider(optional?returnTo=/path),GET /auth/oidc/:provider/callback, and for linkingPOST/DELETE /auth/oidc/:provider/link. - The provider must have
<origin>/auth/oidc/<name>/callbackregistered exactly, or theredirectUriyou configure. The origin followsauthn.http.trustProxy(elsehttp.trustProxy): forwarded proto and host count only from a trusted proxy. Behind a TLS proxy without that setting the derived origin is http, which the provider refuses; a warning says so. - Needs
NonceStoreandIdentityLinkStore(contracts of@spinajs/authn; the app imports@spinajs/authn-ormfor the tablesauthn_nonce,authn_identity),AuthnService,SessionIssuer, and for linking anOidcAccountLinksregistration (see below).
Behaviour
- Flow cookie. The flow lives in an HttpOnly, SameSite=Lax cookie
(
__Host-spinajs-oidcoutside development and test) holding a random seed. State, nonce and the PKCE verifier derive from it; the state also binds the provider, the time, the return path, the redirect URI and the mode. A state is claimed once inauthn_nonce, before any call to the provider. A flow lives 10 minutes. - Checks. Discovery issuer equal to the configured one; https everywhere
outside development and test; RFC 9207
isswhen sent, and required when the provider advertises it; ID tokeniss(or a preset alias),aud,azp,exp,iat,nbf,nonce, algorithm allow-list (nevernone, never HMAC),at_hashwhen present; userinfosubequal to the ID token's. Discovery is cached an hour; the JWKS an hour, refreshed on an unknownkidat most once a minute. - Answers. Every failure is the login failure (401
E_AUTH_FAILED, withRetry-Afterwhen throttled). A browser navigation goes toredirects.failureinstead, with no reason. On success a browser gets a small same-site page that moves it on to the return path, because a SameSite=Strict session cookie does not travel on the cross-site redirect chain; other clients get a 303. - Linking. An unlinked subject logs in only when both the global
authn.linkingswitch and the provider'slinkingswitch are on, for JIT and for matching by verified email alike. Without the provider'sbyVerifiedEmail, hooks and the core seeemailVerified: falseeven when the provider said true. Entra and GitHub never mark an email verified. - Hooks. The core (
AuthnService) runs theauthn.login.identifiedaction hooks of@spinajs/hookswith the identity and the mapped subject (input.external:claims.name,claims.groups,claims.raw) before MFA and the session. A refusal or a throwing hook fails the login: no session is issued and the core audits oneloginfailure with reasonhook:<code>orhook_error. Earlier hooks are not rolled back, so hooks must be idempotent. Group sync to authz is an app's hook (authn never imports authz). The back-channel logout route runsauthn.logout.backchannelaround the revocation; it is not refusable.
Not in this package
OidcAccountLinksdefaults toCoreOidcAccountLinks, which delegates to the core'sAccountLinkService(fresh session, subject not linked elsewhere, never the last credential).- The link routes read the restored session from
req.storage.AuthnSession, which authn-http's middleware writes.
