@o3co/auth-provider-federation-oidc
v0.15.0
Published
Generic OpenID Connect federation provider for auth.provider — any OIDC-compliant IdP, configured by issuer
Downloads
327
Readme
@o3co/auth-provider-federation-oidc
Generic OpenID Connect federation provider for auth.provider: any
OIDC-compliant identity provider — Okta, Entra ID, Auth0, Keycloak, a
customer's own tenant — from configuration alone, and as many instances as a
deployment has issuers (#524).
The FederationProvider contract lives in @o3co/auth-provider-session; the
Google, GitHub and Apple packages implement it for one IdP each. This package
implements it for every IdP that publishes an OpenID Connect discovery
document, so adding an IdP is a config section, not a package.
Usage
Each instance is one module, made by oidcFederationModule(<name>). Every
instance reads its config from the shared oidcFederationConfigs slot, which
the composition root fills — normally straight from the federations config
section with readOidcFederationConfigs:
import { createApp, defineModule } from "@o3co/auth-provider-core";
import {
oidcFederationModule,
oidcFederationNames,
readOidcFederationConfigs,
} from "@o3co/auth-provider-federation-oidc";
import { sessionModule } from "@o3co/auth-provider-session";
const oidcConfigBridgeModule = defineModule({
name: "oidc-federation-config",
requires: ["config"] as const,
provides: {
oidcFederationConfigs: ({ config }) => readOidcFederationConfigs(config.federations),
},
});
const handle = await createApp({
modules: [
sessionModule,
oidcConfigBridgeModule,
...oidcFederationNames(config.federations).map((name) => oidcFederationModule(name)),
// ... composition-root modules supplying userRepository + the session stores
],
bootstrapComponents: { config, pathResolver },
});The scaffold (@o3co/create-auth-provider) does exactly this in
src/buildModules.mts, so a scaffolded deployment adds an IdP by editing
config/application.conf alone.
Configuration
A federations.<name> section whose type is oidc. The section name is the
federation name: the browser starts at /session/oauth/federation/<name>, the
IdP sends it back to callbackURL, and the identity handed to the Store is
<name>:<sub>.
federations {
okta {
enabled = true
type = "oidc"
issuer = "https://dev-123.okta.com"
clientId = ${OKTA_CLIENT_ID}
clientSecret = ${OKTA_CLIENT_SECRET}
callbackURL = "https://auth.example.com/session/oauth/federation/okta/callback"
redirectAllowlist = ["https://app.example.com/welcome"]
}
keycloak {
enabled = true
type = "oidc"
issuer = "https://sso.example.com/realms/staff"
clientId = ${KEYCLOAK_CLIENT_ID}
privateKey = ${KEYCLOAK_PRIVATE_KEY_PEM} # private_key_jwt
callbackURL = "https://auth.example.com/session/oauth/federation/keycloak/callback"
scopes = ["openid", "profile", "email", "groups"]
}
}Two issuers, two sections, two callbacks — that is the whole of multi-IdP
support. The nested shape (okta { type = "oidc", oidc { ... } }) that
extractFederationSection accepts works too.
| Field | Required | Meaning |
| --- | --- | --- |
| issuer | yes | Issuer identifier, exactly as the IdP writes it into iss. https; plain http only on a loopback host (local Keycloak). |
| clientId | yes | Client identifier registered at the IdP. |
| clientSecret | one of | client_secret_basic (RFC 6749 §2.3.1). In code the value may be a resolver (() => Promise<string>), consulted on every token request, for secrets that rotate. |
| privateKey | one of | private_key_jwt (RFC 7523 / OIDC Core §9). A PEM-encoded PKCS#8 key, or { pem, kid?, alg? }. The JWS algorithm is inferred from the key (RSA → RS256, P-256 → ES256, P-384 → ES384, P-521 → ES512, Ed25519 → EdDSA) unless alg says otherwise; kid goes in the assertion header. |
| callbackURL | yes | Where the IdP sends the browser back. The session routes read it from the same section. |
| scopes | no | Default ["openid", "profile", "email"]. openid is mandatory — without it there is no id_token — and its absence refuses boot. |
| discovery | no | Default true. See below. |
| endpoints | no | authorizationEndpoint, tokenEndpoint, jwksUri, userinfoEndpoint, endSessionEndpoint. Applied over the discovered metadata; the first three are mandatory when discovery = false. |
| idTokenSignedResponseAlg | no | Pin the id_token JWS algorithm. Otherwise the issuer's advertised id_token_signing_alg_values_supported is trusted; none and symmetric algorithms are never accepted against a JWKS. |
| userInfo | no | Default: call UserInfo when the issuer publishes an endpoint. false builds the profile from the id_token alone; true refuses boot if there is no endpoint. |
| clockToleranceSeconds | no | Skew tolerated on exp / iat. Default 30. |
| redirectAllowlist, sessionDomain, authCallbackUrl, clientUrl | no | The redirect_to policy, as for every federation — see the session package README. |
fetch (code only) replaces the fetch every upstream request goes through —
for a proxy, or a test double.
What happens at boot
Discovery. Each instance fetches <issuer>/.well-known/openid-configuration
when the app boots, checks the document's issuer against the configured one,
and keeps authorization_endpoint, token_endpoint, jwks_uri,
userinfo_endpoint and end_session_endpoint. A failure is fatal: an
unreachable issuer, a document naming another issuer, or one without a
jwks_uri refuses boot with the federation's name in the error. There is no
silent fallback to hand-typed endpoints — a deployment that wants those sets
discovery = false and writes them under endpoints, and then no document is
fetched at all.
Boot also refuses a config with both clientSecret and privateKey, with
neither, with a name that is not one URL path segment, or with a private key
that cannot be parsed.
What happens at login
- Authorization request —
authorization_codewith PKCE S256,stateandnonce. All three are minted by the session routes per transaction and stored in the session; the provider refuses to build a request without a nonce (OIDC Core §3.1.3.7). - Code exchange — at
token_endpoint, authenticated with the configured method,redirect_uriechoing the callback andcode_verifierclosing the PKCE loop. Before it, the callback'sissparameter (RFC 9207) is compared with the configured issuer, as an exact string: a different one is refused without spending the code. An issuer whose discovered metadata advertisesauthorization_response_iss_parameter_supported— Keycloak's does by default — must also send one. Withdiscovery = falsethere is no metadata to advertise it, soissis compared when present and never required. - ID token validation — signature against the issuer's JWKS (fetched by
kid, cached, refetched when an unknownkidappears — but not within a minute of the last fetch, so an IdP that rotates keys must publish the new key before signing with it, which every IdP does);issequal to the configured issuer;audcontaining the client id and no untrusted extra audience;expandiatwithin tolerance;nonceequal to the transaction's;at_hashrecomputed from the access token when the claim is present (OIDC Core §3.3.2.11). A response without an id_token is refused. - UserInfo — when enabled, fetched with the access token and bound to the
id_token's
sub; a mismatch is refused. UserInfo values fillemail,emailVerified,name,pictureandgroups, falling back to the id_token's claims.email_verifiedis normalised to a boolean (some IdPs send"true");groupsis carried only as a string array. - Identity —
subis opaque and stable per issuer; the profile is never keyed onemail. The session routes hand<name>:<sub>to the Store exactly as they do for Google or GitHub. An identity the Store does not know is refused with401 unknown_user— this package provisions nothing; the Store stays the source of truth for who exists.
Any refusal in steps 2–4 surfaces from the callback as 502 exchange_failed
(the session routes' answer to an upstream exchange the provider refused) and
never reaches the Store.
Optional capabilities
SupportsRefresh—refreshToken()runs therefresh_tokengrant at the issuer.SupportsDelegatedAuthorization(#593) —buildDelegatedAuthorizationUrl()builds the authorization request for a federation grant: the intent's scopes, a required nonce, the RFC 8707resource,prompt=consentwhenoffline_accessis asked for, and the connection'sauthorizationParams, which may not name a parameter the adapter owns.exchangeDelegatedCode()exchanges the connect callback's code — PKCE, the nonce, theresourceat the token endpoint,issforwarded — never calls UserInfo, and answers the verified id_token's issuer and subject, plus the claims the caller names inidentityClaims(#611), copied from that id_token only, as non-empty strings only.refreshDelegatedToken()runs therefresh_tokengrant with the grant's scopes and resource under the caller'sAbortSignal, answers the rawexpires_in,scopeandtoken_type, and keeps a rotated refresh token out of an answer the library did not accept — one it could not parse, or one whose id_token it could not verify.SupportsLogout— present only when the issuer publishes anend_session_endpoint(orendpoints.endSessionEndpointnames one): RP-initiated logout withid_token_hint,post_logout_redirect_uriandstate.SupportsClaimMapping—mapClaims()promotesemail,emailVerified,name,pictureandgroups; everything else stays namespaced under the federation per the claim precedence rules in the session package.
Scaffold environment variables
The scaffold ships one instance, federations.oidc, disabled by default:
| Variable | Default | Description |
| --- | --- | --- |
| FEDERATIONS_OIDC_ENABLED | false | Enable the instance |
| FEDERATIONS_OIDC_ISSUER | — | Issuer identifier |
| FEDERATIONS_OIDC_CLIENT_ID | — | Client ID |
| FEDERATIONS_OIDC_CLIENT_SECRET | — | Client secret (client_secret_basic) |
| FEDERATIONS_OIDC_CALLBACK_URL | http://localhost:3000/session/oauth/federation/oidc/callback | Callback |
More instances are more sections in config/application.conf.
Public API
createOidcProvider(name, config): Promise<OidcProvider>— the provider itself; asynchronous because discovery happens here.oidcFederationModule(name): Module— one const-Module per instance, requiringoidcFederationConfigsand contributingfederations.<name>andfederationRedirectPolicies.<name>.readOidcFederationConfigs(federations)— the slot from afederationsconfig section; refuses a malformed field byfederations.<name>.<field>.oidcFederationNames(federations)— the names of every enabled section of typeoidc, sorted.OIDC_FEDERATION_TYPE("oidc"),DEFAULT_OIDC_SCOPES.- Types:
OidcProviderConfig,OidcEndpointOverrides,OidcPrivateKey,OidcProvider.
