@o3co/auth-provider-session
v0.15.0
Published
Session and federation routes module for auth.provider
Readme
@o3co/auth-provider-session
Session and federation routes module for auth.provider.
Handles username/password login, logout, and OAuth 2.0 federation. Concrete
providers such as Google and GitHub live in separate provider packages and
contribute their FederationProvider to this module via the manifest model
(per-federation defineModule(...) — see
@o3co/auth-provider-federation-google and
@o3co/auth-provider-federation-github).
Uses RFC 6749 authorization code flow internally.
Install
This package is private — it is not published to npm and is only available within the auth.provider monorepo.
// packages/*/package.json
{
"dependencies": {
"@o3co/auth-provider-session": "workspace:*"
}
}Peer dependencies (install separately in the workspace root):
express@^5.0.0Public API
sessionModule
import { sessionModule } from "@o3co/auth-provider-session";
// → sessionModule is a const Module (manifest), NOT a factory.
// Add it to the manifest list passed to createApp / createTestApp.Const Module. Contributes two route bundles, both mounted at /session:
| Method | Path | Description | |--------|---------------------------------------------------|---------------------------------| | GET | /session/csrf | Issue a double-submit CSRF token | | POST | /session/login | Username / password login | | POST | /session/logout | Session logout — see what it invalidates | | GET | /session/oauth/federation/:name | Initiate OAuth federation flow | | GET | /session/oauth/federation/:name/callback | Federation callback |
The :name path parameter corresponds to the federation key in config.federations (e.g. google, github, google-work). Unknown names return 404.
What POST /session/logout invalidates
This provider has two logout endpoints and they do not invalidate the same things. Pick by what the session holds.
POST /session/logout — the browser's own logout, and the one a BFF /
auth.proxy injection topology calls. It answers 200 {"message": "Logged out
successfully"} and invalidates:
| What | Effect |
|------|--------|
| the express-session cookie | destroyed |
| the UserSession record for the session's sid | deleted — this is what makes /oauth/introspect report active: false and /oauth/userinfo refuse a token minted by the session grant |
| the subjectSessionIndex entry | removed, so revokeAllForSubject stops enumerating a dead sid |
| federationTokenStore + sessionFederationIndex entries for the sid | removed, so upstream-IdP tokens are not left at rest |
| refresh-token families bound to the sid | not revoked |
That last row is the one to read twice. A browser that logged in here and then
completed an /authorize → authorization_code flow holds a refresh token
whose family this endpoint does not revoke; the refresh token keeps working
until it expires. Use POST /oauth/logout with an id_token_hint for that
session — it runs the full cascade (refresh-family revoke, RP registry,
federation, session delete) and ends the browser session too.
The boundary is structural, not an oversight: the cascade
(packages/oauth/src/logout/cascadeLogout.mts) needs
refreshTokenFamilyRevocation, sessionFamilyIndex and sessionRPRegistry,
which this module declares none of, and @o3co/auth-provider-session may not
import @o3co/auth-provider-oauth — they are siblings over core.
The session grant issues no refresh token, so a deployment whose tokens all
come from that grant has no family to revoke and /session/logout is
sufficient on its own.
Failure modes. Every store step is best-effort and logged, never
propagated: a store outage must not turn a logout into a 5xx that leaves the
user holding a live cookie. The UserSession delete runs first, before the
cookie is destroyed and before the best-effort hygiene, so a federation-store
outage cannot prevent the invalidation that matters. Failures are logged as
logout_user_session_delete_failed,
logout_subject_session_index_remove_failed,
logout_federation_token_remove_failed and
logout_session_federation_index_remove_failed — alert on the first. If the
cookie destroy itself fails the response is still 500 server_error (unchanged),
and by then the records are already gone, so /authorize refuses the surviving
cookie on its own account. A composition wiring no userSessionStore, or a
session carrying no sid, logs out exactly as before.
CSRF on the state-changing routes (#272)
POST /session/login and POST /session/logout accept a request that carries
either a same-origin (or explicitly trusted) Origin / Referer, or a
valid double-submit CSRF token. A request carrying neither is rejected with
403 access_denied — previously a missing Origin header skipped the check
entirely.
- Browsers need no change: the browser sets
Originon a same-originfetch/ form post, and that satisfies the check on its own. - Header-less clients (curl, server-side agents, test harnesses) call
GET /session/csrf, which sets a JS-readable<session.name>.csrfcookie and returns the same value ascsrf_token. Send both back: the cookie plus either anx-csrf-tokenheader or acsrf_tokenform field. - A foreign
Originis rejected even when a token is present, since it is positive evidence of a cross-site request. - A successful login returns a fresh CSRF cookie, so the follow-up logout needs no extra round trip.
The token is a signed, stateless HMAC over a random nonce and an expiry, keyed
by an HKDF expansion of session.secret — a subdomain able to write the
parent-domain cookie still cannot forge one. Cross-origin login UIs list their
origin on session.csrf.trustedOrigins; cors.allowedOrigins no longer grants
CSRF trust.
checkRequestOrigin, createCsrfProtection, createCsrfProtectionFromConfig,
createCsrfGuard and createCsrfIssueHandler are exported for compositions
that mount their own login page or protect their own routes.
requires: userRepository, userSessionStore, federationTokenStore,
sessionFederationIndex (sibling stores), plus the synthetic keys
federationProviders and federationRedirectPolicyResolver populated by the
boot planner from per-federation modules. See
@o3co/auth-provider-federation-google for an
example federation module.
extractFederationSection
function extractFederationSection(
federations: Record<string, unknown>,
name: string,
): { type: string; [key: string]: unknown } | undefined;Pure utility — normalizes a federation config slice into a flat credential
object. Handles flat ({ enabled, clientId, callbackURL }), nested
({ enabled, type, [type]: {...} }), and shorthand (key serves as type)
shapes; rejects mixed shapes; returns undefined for absent or
enabled !== true entries. Used by per-federation modules to read their own
config slice.
FederationProvider (interface)
interface FederationProvider {
readonly name: string;
readonly scope: readonly string[];
readonly responseMode?: "query" | "form_post"; // default "query"
buildAuthorizationUrl(params: {
readonly redirectUri: string;
readonly state: string;
readonly codeVerifier: string;
readonly nonce?: string;
}): URL;
exchangeCode(params: {
readonly code: string;
readonly codeVerifier: string;
readonly redirectUri: string;
readonly nonce?: string;
readonly callbackParams?: Readonly<Record<string, string>>;
}): Promise<FederationProfile>;
}Implement this interface to add a custom OAuth 2.0 / OIDC federation provider. Optionally mix in SupportsLogout, SupportsClaimMapping, SupportsRefresh, or SupportsDelegatedAuthorization.
name— unique provider identifier. Used as both the Map key infederationProvidersand the route:nameparameter.scope— OAuth 2.0 scopes to request.buildAuthorizationUrl— builds the RFC 6749 §4.1 + RFC 7636 authorization URL. Receives a pre-generatedcodeVerifierfrom the route layer; implementations should computecode_challengeviacodeChallenge(codeVerifier).exchangeCode— exchanges an authorization code for a normalizedFederationProfile. Must includeissuerandsub; all other fields are optional.responseMode— how the IdP delivers the authorization response. Optional, and absence means"query", so every provider written before #479 is unaffected. See below.callbackParams— the rest of the callback's parameters (query string or form body), string values only, excludingcodeandstate. Those two are the framework's to bind and are already accounted for —codehas its own field,stateis what the route compared against the session — so they are not repeated in a generic bag where an adapter could read the unvalidated copy. What remains is present so an IdP that returns identity data beside the token response can be adapted: Sign in with Apple sends the end user's name once, in auserJSON field on the first authorization, and never in the id_token. The values are relayed through the user agent and are not signed — thestatecheck binds them to the session and binds nothing else, so treat anything read here as self-asserted and letmapClaims+ claim precedence decide where it may land. Protocol response parameters travel here too, and one of them matters to every adapter: the RFC 9207iss. An adapter built on an OAuth library hands it the authorization response as a URL, and has to rebuild that URL because the route passescodeand the rest separately. UsecallbackUrlForExchange({ redirectUri, code, callbackParams })for that: it setscode, forwardsisswhen the callback carried one, and forwards nothing else from the bag (anerror,response,id_tokenortokenon that URL would change how the library reads the response). Rebuilding the URL fromcodealone dropsiss: the mix-up check then never runs, and every login fails against an issuer that advertisesauthorization_response_iss_parameter_supported(#595). Configure the library with the issuer the IdP actually publishes, or the comparison refuses every login.
Note (A5 split, v0.5.0): redirect URL handling —
validateRedirect/resolveCallbackRedirect— was moved offFederationProviderand onto a dedicatedFederationRedirectPolicycapability. Per-federation modules contribute the policy viafederationRedirectPolicies.<name>; built-ins usecreateFederationRedirectPolicy(...). Custom providers do not implement these methods onFederationProvider.
Response mode: query and form_post (#479)
Most IdPs redirect the browser back to the callback with the authorization response in the query string. Sign in with Apple does not: whenever the requested scope includes name or email, Apple POSTs an application/x-www-form-urlencoded body to the callback, because the first-authorization user field does not fit a redirect URL.
A provider opts in by declaring one field:
const appleProvider: FederationProvider = {
name: "apple",
scope: ["name", "email"],
responseMode: "form_post",
// …
};That single declaration changes three things in the route layer, and nothing in the adapter:
- The start route appends
response_mode=form_postto the URLbuildAuthorizationUrlreturned. The parameter is written once, in the router, rather than in every adapter — and nothing at all is appended for the default mode, so a"query"federation's authorization URL is byte-for-byte what its adapter produced. POST /oauth/federation/<name>/callbackstarts accepting the form body. It is the same handler as the GET callback over a different parameter source: same envelope lookup, samestatecomparison, same consume-before-any-async-work reuse prevention, same PKCE verifier and nonce read from the stored envelope rather than from the request, same rollback ladder. One handler, but not one surface: each response mode accepts exactly one method. A provider that did not declareform_postanswers405 method_not_allowed(withAllow: GET) to a POST, so no existing federation gains a POST surface; aform_postprovider answers405 method_not_allowed(withAllow: POST) to a GET, since its IdP only ever posts and its transaction cookie is offered to every cross-site request that reaches the path (#502).- That federation's ephemeral state moves out of the session and into a federation transaction with a cookie of its own. The application session cookie is not modified.
The SameSite consequence, and what actually carries the state
A form_post callback arrives as a cross-site POST from the IdP's origin. A SameSite=Lax cookie — the deployment default, and the right default — is not sent on a cross-site POST, so a callback relying on the session cookie would land with no session: no state to compare against, no PKCE verifier. It fails closed, but it fails for everyone.
The flow therefore needs a cookie that survives a cross-site POST. It does not need the session cookie to be that cookie, and making it one was #494: GET /oauth/federation/<name> requires no authentication, and a SameSite=Lax cookie is sent on a top-level GET, so any third party who caused one navigation permanently downgraded the victim's authenticated session cookie. Permanently, because express-session serialises req.session.cookie into the store and Store.prototype.createSession rebuilds it from there — with every own key — on every later request.
So the cross-site part gets its own cookie and its own record:
| | value |
|---|---|
| cookie name | __Secure-<session.name, minus any prefix>.federation — e.g. __Host-auth.session and auth.session both give __Secure-auth.session.federation |
| attributes | HttpOnly; Secure; SameSite=None, Path scoped to that provider's callback URL, Max-Age = the transaction TTL (10 minutes by default) |
| contents | an opaque 256-bit id, and nothing else |
| record | state, codeVerifier, nonce, redirectTo and the provider name, in the session store under a fedtx: key prefix |
The name is derived from session.name the way the CSRF cookie's is, so it inherits the operator's naming. The prefix is the one deviation, and it is applied unconditionally — a session cookie with no prefix still yields a __Secure- transaction cookie.
__Secure- rather than __Host- because __Host- requires Path=/, and this cookie is deliberately path-scoped to the callback, so a __Host- name would be dropped by every browser. Unconditionally because, unlike the session cookie — whose Secure flag is the operator's session.secure to set — this cookie is SameSite=None and therefore always issued with Secure (every current browser drops a SameSite=None cookie that is not Secure; the pairing application.schema.mts already enforces for the config-level value, #282 — and Apple refuses a non-https redirect URI anyway). The prefix states that invariant where the browser will enforce it.
That choice has a cost, and it is the one place the transaction is weaker than the cookie it replaced (#502). __Host- is what pins a cookie to exactly one host; __Secure- only pins it to HTTPS. So a related-domain attacker — anyone who controls a sibling subdomain of the deployment's cookie domain, or can write a parent-domain cookie from one — can set __Secure-<name>.federation with Domain=<parent> in the victim's browser, while the session cookie, __Host- by default and enforced as such in sessionStoreModule.mts, cannot be planted that way.
- What the attacker needs: control of any sibling subdomain (a forgotten staging host, a dangling DNS record, XSS on a lower-trust app, a shared-hosting neighbour). Nothing about this deployment, no session, no
state. - What it gets them: they start their own federation flow, plant their transaction id, and auto-submit their
stateandcodeto the callback. The victim's browser is logged into the attacker's federated account, and whatever the victim does next is recorded against it. It does not read the victim's session, disclose credentials, or reach the victim's own account. - Why signing the cookie would not help: the attacker's transaction is genuinely theirs, so any value the server would accept from its own issuance is a value the attacker legitimately holds. This is inherent to path-scoping, not a defect in the binding.
- What to do: treat every host under the cookie domain as part of the deployment's trust boundary — the same rule the CSRF section's signed token exists to survive, and the reason
session.domaindefaults tonull. If a subdomain must host untrusted content, it does not belong under the domain the auth cookies are scoped to.
The application session cookie keeps the attributes the deployment configured, on every session, whether or not it ever started a form_post federation — a deployment running Apple beside Google sees no difference on any Google login, and no difference on the Apple browser's own session either.
The transaction is what binds the callback to the browser that started it, which is the property the session cookie used to provide. The state comparison is unchanged and still runs; the transaction cookie is an addition to it, never a replacement. A caller who presents a stolen state without the matching transaction cookie is refused before state is read at all.
Both the record and the cookie are dropped on every callback exit that judged the transaction — success, invalid_state, exchange_failed, unknown_user alike.
They are deliberately not dropped by a refusal that judged nothing (#502). The rule is: a refusal spends the transaction when the request made a claim about it, and leaves it alone when it made none. A state is that claim. A callback carrying no state claims nothing and costs nothing (400 invalid_request, record untouched); a GET is refused with 405 before the cookie is read at all. A wrong state is different in kind — that is an attempt on this transaction, and it still spends it, so a guess gets no second try. The distinction matters because the cookie is SameSite=None by necessity, so it accompanies any cross-site request to the callback path: while every refusal consumed the record, a third party could destroy a victim's in-flight login with one <img> tag.
That last row is form_post-only. A query federation keeps its envelope in the session and retires it only on the path that matched state, so a wrong state there leaves the envelope in place — deliberately, because the session cookie is SameSite=Lax and is sent on a top-level cross-site GET, so spending the envelope on a mismatch would hand a third party the same availability bug in the one branch that never had it. The guess it would defend against is not a real one: state is 128 bits from the CSPRNG. Only the "no state" rule is shared by both branches.
An abandoned flow leaves only the short-lived cookie, and the record expires with it: the expiry is written into the record as cookie.expires, which is exactly what MemoryStore reaps on read and what connect-redis turns into the key's EX.
What "single use" guarantees, and what enforces it
Retiring the record is a get followed by a destroy, and those are two round trips. The express-session Store API is get / set / destroy: there is no compare-and-delete on it, and no atomic read-and-consume can be composed from the three. So the guarantee is worth stating exactly (#502):
| | |
|---|---|
| Guaranteed | A callback arriving after an earlier one completed its delete finds no record and is refused. That covers the replay this is for: a code and state lifted from a proxy log, the back button, a retried request. |
| Not guaranteed | Callbacks that overlap. Two that both read the record before either deletes it both pass the state comparison and both reach exchangeCode. MemoryStore answers synchronously and happens to serialise them; a store with network latency does not. |
| What bounds the overlap | The IdP. An authorization code is single-use at the IdP, racing callbacks necessarily carry the same one, and at most one exchange succeeds however many get that far — the rest get 502 exchange_failed. PKCE binds that exchange to the verifier held in the record. |
This is weaker than DeviceCodeStore (#298), which is an atomic read-and-consume with a racing conformance test. The difference is the API each works over: DeviceCodeStore owns its adapter and can push the consume into one Redis round trip, while a federation transaction deliberately shares the session store rather than adding a component slot of its own — a slot that would have to be declared in AppConfigSchema and configured in every deployment. Strict atomicity here means paying that, for a property the IdP already provides. Federation.transactionConcurrency.test.mts pins both halves so neither the code nor this table can drift from the other.
A "query" federation is untouched by all of this. Its callback is a same-site top-level GET, its envelope stays in req.session.federation, and its authorization URL, cookies and error surface are byte-for-byte what they were.
Client secrets that rotate (#479)
clientSecret was a string because most IdPs issue a long-lived opaque one. Apple's is an ES256 JWT the relying party signs itself, capped at six months, so a value would mean a deployment that silently stops authenticating half a year after it was configured.
The contract widens to a union — one field, one meaning, with the callable form saying only that the secret is computed rather than stored:
type FederationClientSecret = string | (() => string | Promise<string>);
const secret = await resolveClientSecret(config.clientSecret);- The static form is unchanged.
federations.google.clientSecret = "…"in HOCON, and Google's / GitHub's provider config, keep working exactly as before — a config file can still only carry the string form. resolveClientSecretis called once per token exchange (and per refresh) and deliberately does not cache. Only the adapter knows when its secret expires, so caching belongs there:federation-appleregenerates its JWT when it comes within 24 h ofexp.- An empty or non-string result is rejected locally, rather than posted upstream as an empty
client_secretand returned as an opaqueinvalid_client.
SupportsLogout (optional capability)
Optional capability for providers whose IdP exposes an OIDC RP-Initiated Logout (end-session) endpoint.
interface EndSessionRequest {
idTokenHint?: string;
postLogoutRedirectUri?: string;
state?: string;
}
interface EndSessionResult {
url: URL;
method: "GET";
}
interface SupportsLogout {
endSession(req: EndSessionRequest): Promise<EndSessionResult>;
}
function supportsLogout(
provider: FederationProvider | undefined | null,
): provider is FederationProvider & SupportsLogout;Provider packages may implement SupportsLogout when the upstream IdP exposes
an end-session endpoint. External integrations (Microsoft Entra ID, Auth0,
Okta, etc.) can add the capability by mixing it into their custom provider.
Minimum custom provider example:
import type {
FederationProvider,
SupportsLogout,
EndSessionRequest,
EndSessionResult,
} from "@o3co/auth-provider-session";
function createMyIdPProvider(): FederationProvider & SupportsLogout {
return {
name: "myidp",
scope: ["openid"],
buildAuthorizationUrl({ redirectUri, state, codeVerifier }) { /* ... */ },
async exchangeCode({ code, codeVerifier, redirectUri }) { /* ... */ },
async endSession(req: EndSessionRequest): Promise<EndSessionResult> {
const url = new URL("https://myidp.example/oidc/logout");
if (req.idTokenHint) url.searchParams.set("id_token_hint", req.idTokenHint);
if (req.postLogoutRedirectUri) url.searchParams.set("post_logout_redirect_uri", req.postLogoutRedirectUri);
if (req.state) url.searchParams.set("state", req.state);
return { url, method: "GET" };
},
};
}Consumers detect the capability at the call site:
import { supportsLogout } from "@o3co/auth-provider-session";
if (supportsLogout(provider)) {
const { url } = await provider.endSession({ idTokenHint, postLogoutRedirectUri, state });
res.redirect(url.toString());
} else {
// fall back to local session destroy only
}SupportsClaimMapping (optional capability)
Optional capability for providers that can produce a normalized claim set from an OAuth profile.
interface MappedClaims {
readonly email?: string;
readonly emailVerified?: boolean;
readonly name?: string;
readonly picture?: string;
readonly groups?: ReadonlyArray<string>;
readonly [key: string]: unknown; // non-standard IdP claims (e.g. Google's "hd")
}
interface FederationProfile {
readonly issuer: string;
readonly sub: string; // OIDC sub — stable identifier at this IdP
readonly email?: string;
readonly emailVerified?: boolean;
readonly name?: string;
readonly picture?: string;
readonly accessToken?: string;
readonly refreshToken?: string;
readonly idToken?: string;
// absolute expiry of accessToken, or null when the provider issues no finite expiry
// (e.g. GitHub OAuth Apps classic tokens). Required; consumers MUST treat null as
// "do not refresh; reuse".
readonly expiresAt: Date | null;
readonly [key: string]: unknown; // provider-specific extension claims
}
interface SupportsClaimMapping {
mapClaims(profile: FederationProfile): MappedClaims;
}
function supportsClaimMapping(
provider: FederationProvider | undefined | null,
): provider is FederationProvider & SupportsClaimMapping;Providers that implement SupportsClaimMapping translate a FederationProfile into OIDC-standard claim names. Custom providers can add it by exposing a mapClaims method:
import { supportsClaimMapping } from "@o3co/auth-provider-session";
if (supportsClaimMapping(provider)) {
const claims = provider.mapClaims(profile);
// claims.email, claims.name, claims.picture …
}Claim precedence: local wins, federated is namespaced
What mapClaims returns is an assertion by an upstream IdP, not a fact about this deployment. The federation callback route therefore never merges it into the session's claims envelope. It applies one rule (#279):
- The local record is authoritative. Any claim
extractUserClaimsread off theUserstands; a federated value never replaces it. - Three claims may fill a gap —
email,name,picture(PROMOTABLE_FEDERATED_CLAIMS), and only where the local record left the field absent, and only when the federated value is a string. - Everything else is namespaced under
claims.federated[<providerName>], verbatim and complete — including values that were also promoted and values that lost to a local claim.
So an IdP cannot contribute groups (nor a roles / scope / permissions an adapter invents): those reach claims.federated[<providerName>] and nothing else. filterClaimsByScope never emits provider-specific claims, so nothing under the namespace can appear in an id_token or /userinfo response by accident.
The federated claim is optional — read it with a presence check. It is written only when the provider actually mapped at least one claim, so it is absent on a session whose provider implements no SupportsClaimMapping, and on one whose mapClaims returned {} or a non-object. The provider key is likewise not guaranteed: a session carries the one provider that authenticated it. Use claims.federated?.[name]?.groups, never claims.federated[name].groups. Absence rather than an empty federated: {} is deliberate — it says "this IdP asserted nothing" instead of only "a code path ran", the same absent-is-not-a-value discipline #297 established for emailVerified.
emailVerified is excluded from promotion for the same reason. Since #297 it is Store-owned state that oauth.requireEmailVerified can read as a gate on token issuance, and an upstream IdP verifies an address it controls — the provider:sub linkage never forces that to be the local account's address. A deployment that wants to act on the assertion reads claims.federated?.[<providerName>]?.emailVerified and publishes the result on the User, which is where #297 put the field.
// user: { id, username, email: "[email protected]", groups: ["staff"] }
// mapClaims → { email: "[email protected]", picture: "https://…", groups: ["admin"] }
{
email: "[email protected]", // local wins
groups: ["staff"], // federated groups cannot reach here
picture: "https://…", // gap filled
federated: {
google: { email: "[email protected]", picture: "https://…", groups: ["admin"] },
},
}emailVerified is a boolean here, whatever the IdP sent
MappedClaims.emailVerified is boolean | undefined, and normalising to it is the adapter's job — the merge does not coerce, and nothing downstream does either.
This is not a formality. Sign in with Apple sends email_verified as the string "true" on some responses and as a boolean on others, and is_private_email behaves the same way. Boolean("false") is true, so an adapter that passes the raw claim through — or coerces it — reports an unverified address as verified, on a claim that gates token issuance through oauth.requireEmailVerified. federation-apple reads "true" / "false" to their booleans and treats every other shape as absent, because absence is not false (#297); a new adapter for an IdP with the same habit should do likewise.
A non-boolean that does reach mapClaims's output is not promoted — emailVerified is not in PROMOTABLE_FEDERATED_CLAIMS at all — but it is recorded verbatim under claims.federated[<providerName>], where a deployment reading it as a gate would then be reading a string.
The merge is exported as mergeFederatedClaims for consumers that build a claims envelope of their own.
SupportsRefresh (optional capability)
Optional capability for providers that can exchange a refresh token for a fresh access token.
Note:
SupportsRefresh,RefreshedTokensandsupportsRefreshare exported from@o3co/auth-provider-session, and are not a stable public API (subject to change before 1.0).
The interface shape is:
interface RefreshedTokens {
readonly issuer?: string;
readonly sub?: string;
readonly email?: string;
readonly emailVerified?: boolean;
readonly name?: string;
readonly picture?: string;
readonly accessToken?: string;
readonly refreshToken?: string;
readonly idToken?: string;
readonly expiresAt?: Date | null;
/** `expires_in` exactly as the token response carried it; `null` when it carried none. */
readonly expiresIn?: number | null;
/** `scope` as the token response carried it, space-delimited. */
readonly scope?: string;
/** `token_type` as the adapter's library reports it (oauth4webapi lower-cases it). */
readonly tokenType?: string;
readonly [key: string]: unknown;
}
interface SupportsRefresh {
refreshToken(refreshToken: string): Promise<RefreshedTokens>;
}The fields are named rather than derived from FederationProfile: Omit over a type with a string index signature keeps only the index signature, so until #593 a snapshot whose accessToken was a number type-checked. Every field is optional, so { issuer, sub } still passes; a wrong type on a named field does not. An adapter that used scope or tokenType as an extension field of another type is now a type error.
Providers implementing SupportsRefresh can keep federation tokens alive without user interaction. The FederationTokenStore (wired via AppOptions) stores the initial tokens; the refresh flow retrieves and updates them automatically.
SupportsDelegatedAuthorization (optional capability)
The capability behind federation grants (#593): a provider that can send a user to authorize a delegation — a client holding the upstream's tokens without a session — exchange the code its callback brings back, and refresh those tokens without a session. Detected by all three methods being present.
interface DelegatedAuthorizationRequest {
readonly redirectUri: string;
readonly state: string;
readonly codeVerifier: string;
readonly nonce: string; // required
readonly scopes: readonly string[]; // the intent's, not the provider's
readonly resource?: string; // RFC 8707
readonly authorizationParams?: Readonly<Record<string, string>>;
}
interface DelegatedCodeExchangeRequest {
readonly code: string;
readonly codeVerifier: string;
readonly redirectUri: string;
readonly nonce: string; // the one sent at authorization
readonly resource?: string;
readonly callbackParams?: Readonly<Record<string, string>>; // what the callback carried, e.g. RFC 9207 `iss`
readonly signal?: AbortSignal;
readonly identityClaims?: readonly string[]; // the connection's, carried off the verified id_token
}
interface DelegatedAuthorizationResult {
readonly upstream: { readonly issuer: string; readonly subject: string; readonly claims: Readonly<Record<string, string>> };
readonly tokens: DelegatedTokens;
}
interface DelegatedRefreshRequest {
readonly refreshToken: string;
readonly scopes?: readonly string[]; // RFC 6749 §6: no more than was granted
readonly resource?: string;
readonly signal?: AbortSignal;
}
interface DelegatedTokens { // every field optional: `{ refreshToken }` alone is a valid answer
readonly accessToken?: string;
readonly refreshToken?: string;
readonly expiresIn?: number | null; // seconds, exactly as issued
readonly expiresAt?: Date | null; // the adapter's own now + expiresIn
readonly scope?: string;
readonly tokenType?: string;
}
interface SupportsDelegatedAuthorization {
buildDelegatedAuthorizationUrl(params: DelegatedAuthorizationRequest): URL;
exchangeDelegatedCode(params: DelegatedCodeExchangeRequest): Promise<DelegatedAuthorizationResult>;
refreshDelegatedToken(params: DelegatedRefreshRequest): Promise<DelegatedTokens>;
}
function supportsDelegatedAuthorization(
provider: FederationProvider | undefined | null,
): provider is FederationProvider & SupportsDelegatedAuthorization;Rules an implementation keeps (the generic OIDC adapter does):
- The scopes are the intent's, not the provider's login scopes;
nonceis required;resourceis sent as the RFC 8707 parameter at authorization and at refresh alike. authorizationParamsmay not name a parameter the provider owns —client_id,response_type,redirect_uri,state,code_challenge,code_challenge_method,nonce,scope,resource,request,request_uri,response_mode— and the provider throws when one does: openid-client setsclient_idandresponse_typeonly when absent, so a copied parameter would send the consent to another registration.prompt=consentis added whenoffline_accessis asked for (OIDC Core §11); an operator's ownpromptwins.- An answer the adapter's library did not accept — could not parse, or could not verify the id_token of, its JWKS unreachable — may still carry the rotated refresh token; the adapter answers
{ refreshToken }rather than throwing, so that the only valid credential is not lost, and core treats it as after a malformed answer. An error the IdP answered with is thrown as the library throws it. expiresInis the rawexpires_inas sent — a number or a string of digits; anything else withholds the access token and keeps the refresh token;expiresAtis dated when the answer arrived, before any verification the library does;tokenTypeis as the library reports it.
Provider package notes
@o3co/auth-provider-federation-google
- Requests
openid profile emailscope by default. - Uses stable Google OAuth/OIDC endpoints.
FederationProfile.subis the Google numeric account ID.
@o3co/auth-provider-federation-apple
- Default scope is
["name", "email"]— Apple's two documented values, and requesting either is what makes Apple POST the callback, so the module declaresresponseMode: "form_post". FederationProfile.subis Apple's stable team-scoped opaque identifier.- The verified id_token is the only identity source (Apple publishes no
userinfo_endpoint),nonceis required, andemail_verifiedmay arrive as the string"true"— see the note above. is_private_emailis surfaced asisPrivateEmailfor Hide My Email relay addresses; it is namespaced, never promoted.- The user's display name arrives once, in the first authorization's POST
userbody, and never in the id_token.
@o3co/auth-provider-federation-github
- Default scope is
["read:user", "user:email"]. - When the primary profile object omits an
emailfield, the provider enriches the profile by calling the GitHub/user/emailsAPI to retrieve the primary verified email. FederationProfile.subis the GitHub numeric user ID.- Federation token format:
${federationName}:${sub}wherefederationNameequals the configuredname(e.g."github"by default, or"github-enterprise"for a custom tenant).
@o3co/auth-provider-federation-oidc (#524)
- Any OpenID Connect provider, selected by
issuer;oidcFederationModule(<name>)is a factory, one call per issuer, so several IdPs coexist in one deployment with their own callbacks. - Discovery runs at boot and a failure refuses boot;
discovery = falseplusendpoints { ... }runs from hand-typed values instead. client_secret_basic(clientSecret, a string or a resolver) orprivate_key_jwt(privateKey, a PEM key) — exactly one.- Default scope is
["openid", "profile", "email"];openidis mandatory. The id_token is verified against the issuer's JWKS —iss,aud,exp,iat,nonce, andat_hashwhen present — and UserInfo, when the issuer publishes it, is bound to the id_token'ssub. FederationProfile.subis whatever the issuer says: opaque and stable per issuer, never keyed onemail. The Store decides who exists; an unlinked<name>:<sub>is a 401.
What a session records about the authentication (#481)
Every session carries authTime, and since #481 amr — RFC 8176 values naming how the user authenticated — so /authorize can honour max_age, prompt=login and acr_values, and the id_token can say auth_time, amr and acr (the whole picture is in the oauth package README):
| login path | amr |
| --- | --- |
| POST /session/login | ["pwd"] |
| federation callback | the upstream IdP's amr when the provider surfaces it on the profile (profile.amr, a string array), plus fed — the deployment-defined marker for "through a federation", exported as FEDERATED_AMR. RFC 8176 has no value for it, and OIDC Core leaves amr values to the deployment. |
| a resumed MFA login (POST /auth/mfa/verify, composed by the deployment) | whatever the deployment's resume handler records: the first factor's value plus mfa, and the factor's own (otp, …). CreateUserSessionInput.amr is the seam. |
| account linking (?link=1) | unchanged — a link is not a login |
Re-authentication is a new session: POST /session/login and the federation callback always create one with a fresh authTime, which is what max_age and prompt=login measure. A login page that bounces an already-authenticated browser straight back to /authorize is answered login_required there, not looped.
Account linking across federations (#482)
A federated identity is <provider>:<sub> — the federation's name and the IdP's opaque, stable subject — and that string is what the callback hands to UserRepository.authenticateByToken. The Store decides who that is. The session package never links by e-mail: the same person signing in with Google on the web and with Apple on iOS is two identities, and whether they are one account is the Store's record, not an inference from an address an IdP asserted.
An account gains a second identity through an explicit, authenticated action:
- The browser already holds a session (
isAuthenticated, a liveUserSession). - It starts the federation with
?link=1:GET /session/oauth/federation/<name>?link=1, from a link or a form on the deployment's own pages. The start is a GET and the session cookie isSameSite=Lax, so without a check any page could send a signed-in user there, and paired with a login CSRF at the IdP the attacker's identity would be linked to the victim's account. The start therefore needs positive evidence:Sec-Fetch-Site: same-origin, ornone(a typed URL or bookmark).cross-siteis refused.same-siteis not enough on its own — it covers every host on the registrable domain, including a user-controlledblog.example.com— so it, and a request with noSec-Fetch-Site(an older browser), must name this origin or one onsession.csrf.trustedOriginsin itsReferer; a missingRefereris refused, because the navigating page picks its own referrer policy. An account page on a sibling host is therefore listed insession.csrf.trustedOrigins, and must not sendReferrer-Policy: no-referrer. A refusal is403 link_requires_trusted_origin. Without an authenticated session it is401 login_required; when the Store's repository does not implementlinkFederatedIdentity,400 link_unsupported— all before the browser is sent anywhere. - On the callback, after
state, PKCE andnonceare checked exactly as for a login, the identity is resolved:- nobody →
userRepository.linkFederatedIdentity(currentUserId, { provider, sub, token, claims }).oklinks it; the Store'srefusedis403 link_refused, itsconflictis409 identity_conflict. - another account →
409 identity_conflict; the Store is not asked. Linking never merges accounts. - this account → nothing to link; the callback proceeds.
- nobody →
- The federation is attached to the live session —
sessionFederationIndexandfederationTokenStoreunder the currentsid— and the browser is redirected as after a login. No newUserSessionis minted and the express session is not regenerated: a link is not a login, and the session's claims envelope is unchanged (the next login through the new provider builds one the usual way).
The transaction records the session that asked (link: { sid }), and the callback links to that session's account. A form_post federation's callback is a cross-site POST the application session cookie (SameSite=Lax) does not accompany, so the record is what binds it — Sign in with Apple links exactly as a query federation does — and a browser that presents a different authenticated session at the callback is refused 401 login_required: the identity is never linked to whichever session the browser holds now. If attaching to the live session fails after the Store has linked, the half-attached federation is removed from the session best-effort (one the session already carried is left as it was) and the callback answers 503; the Store's link stands, and the next login through that federation lands on the account.
Without link=1 nothing changes: an authenticated session that completes a federation whose identity the Store does not know is 401 unknown_user, as before. There is no implicit linking — a session cookie plus a stray identity is the login-CSRF shape, and link=1 on an authenticated session is what makes the action the user's.
Two audit events: federation.identity.linked and federation.identity.link_refused (details.reason: conflict or refused), both with subject = the account.
What a Store must check before it links. The seam receives claims as the provider mapped them — the IdP's assertions, nothing more:
- Never bind on an e-mail alone. An address the IdP did not verify (
emailVerified !== true, with the #297 discipline: absent is notfalse, and a string is absent), a relay address (Apple's@privaterelay.appleid.com, surfaced asisPrivateEmail), or an IdP that lets a user change their address must never be matched against an existing account. The classic account takeover is exactly that match. - The link request is already authenticated — that is what
link=1on a live session guarantees — so a matching address is not what authorises the link; the session is. A Store may still refuse: one identity per provider per account, a maximum re-authentication age, a verified address required on the new identity. subis opaque and stable per issuer. Store<provider>:<sub>verbatim; never derive an identity fromemail.
@o3co/auth-provider-foundation's HttpUserRepository implements the seam when linkFederatedIdentityUrl is configured (CLIENT_USER_LINK_FEDERATED_IDENTITY_URL in the scaffold): it POSTs { userId, provider, sub, token, claims } and reads a 2xx User as linked, 401 / 403 as refused and 409 as conflict. The in-memory repository links in memory only — development, not persistence.
FederationResult<T> (type)
type FederationResult<T> =
| { ok: true; value: T }
| { ok: false; status: number; error: string; errorDescription: string };Discriminated union returned by FederationProvider methods. Check ok before accessing value.
Usage Example
Basic usage
import { createApp } from "@o3co/auth-provider-core";
import { sessionModule } from "@o3co/auth-provider-session";
import { googleFederationModule } from "@o3co/auth-provider-federation-google";
const handle = await createApp({
modules: [
sessionModule, // const — no factory call
googleFederationModule, // contributes federations.google + federationRedirectPolicies.google
// ... composition-root modules that supply userRepository, the four-store split, etc.
],
bootstrapComponents: { config, pathResolver },
});The boot planner aggregates federations.<name> and
federationRedirectPolicies.<name> contributions from per-federation modules
into the synthetic federationProviders and federationRedirectPolicyResolver
ComponentMap entries that sessionModule's federation routes consume. The
planner enforces the pairing invariant between contribution kinds: every
contributed federations.<name> MUST have a paired
federationRedirectPolicies.<name> and vice versa, otherwise boot fails with
BootError({ reason: "federation-redirect-policy-unpaired" }).
The planner does NOT cross-check config.federations against contributions —
if a federation is enabled in config but no module contributes its provider
pair, boot still succeeds and /session/oauth/federation/:name returns 404
at request time. Composition roots that want fail-fast on misconfiguration
should add the matching per-federation module (or a config-bootstrap module
that throws when its federation slice is enabled but no provider package is
installed). sessionModule does enforce one config-derived invariant at boot:
every enabled federation in config.federations must declare a callbackURL,
otherwise boot fails (the same fail-fast invariant the v0.4.x module
enforced at init() time).
HOCON federation configuration
Shorthand (key name = provider type):
federations {
google {
enabled = true
clientId = ${FEDERATIONS_GOOGLE_CLIENT_ID}
clientSecret = ${FEDERATIONS_GOOGLE_CLIENT_SECRET}
callbackURL = "https://auth.example.com/session/oauth/federation/google/callback"
}
github {
enabled = true
clientId = ${FEDERATIONS_GITHUB_CLIENT_ID}
clientSecret = ${FEDERATIONS_GITHUB_CLIENT_SECRET}
callbackURL = "https://auth.example.com/session/oauth/federation/github/callback"
}
}Explicit multi-tenant (two Google instances):
federations {
google-personal {
enabled = true
type = "google"
google {
clientId = ${FEDERATIONS_GOOGLE_PERSONAL_CLIENT_ID}
clientSecret = ${FEDERATIONS_GOOGLE_PERSONAL_CLIENT_SECRET}
callbackURL = "https://auth.example.com/session/oauth/federation/google-personal/callback"
}
}
google-work {
enabled = true
type = "google"
google {
clientId = ${FEDERATIONS_GOOGLE_WORK_CLIENT_ID}
clientSecret = ${FEDERATIONS_GOOGLE_WORK_CLIENT_SECRET}
callbackURL = "https://auth.example.com/session/oauth/federation/google-work/callback"
}
}
}Mixed shape — top-level fields alongside a nested sub-section — is rejected with a clear error at startup.
type = "oidc" selects the generic OpenID Connect provider from
@o3co/auth-provider-federation-oidc (#524) — any issuer, one section per IdP,
each with its own callback; its fields are in that package's README.
Redirect allowlist (redirectAllowlist)
GET /session/oauth/federation/:name accepts a redirect_to query parameter
naming where the browser lands after the callback. Every value it may name has
to be listed:
federations {
google {
enabled = true
# …credentials…
redirectAllowlist = [
"https://app.example.com/welcome"
"https://app.example.com/account/linked"
"http://localhost:5173/welcome" # local dev front-end
]
sessionDomain = ".example.com"
authCallbackUrl = "https://app.example.com/auth/callback"
clientUrl = "https://app.example.com/"
}
}Four rules are worth knowing before writing the list:
- Matching is exact. Scheme, host, port, path, query and fragment all
count. Only case, the default port,
..segments and percent-encoding are normalized away. There is no wildcard, prefix or subdomain matching — an entry does not admit its own siblings, and a target that carries dynamic query parameters cannot be listed as a family. Make it a fixed path and carry the variable part in the session. - An absent or empty list refuses every
redirect_to. That is the right setting for a deployment that does not use the parameter; it is not a way to allow everything. Before #278 an unset allowlist accepted any http(s) URL, which made the endpoint an open redirect — nothing falls back to that now. httpsis required, except on loopback.localhost,127.0.0.0/8and[::1]may usehttp://, which is what lets a local development front-end and a native client's loopback listener work without a certificate. The port is still matched, so list the port the client binds — RFC 8252 §7.3's port-agnostic loopback comparison is not implemented here.sessionDomain, when set, constrains the list itself. Every non-loopback entry must be inside it, checked when the policy is built, so an entry outside it fails startup rather than sitting in the config looking effective. UnsetsessionDomainif a cross-domain redirect target is genuinely intended.
authCallbackUrl and clientUrl are read by resolveCallbackRedirect, not by
the allowlist: the former is the bridge page a redirect_to is handed to, the
latter the fallback for a callback that carries none.
Custom federation provider
Custom federations are added by writing a per-federation defineModule(...)
that contributes both federations.<name> (the FederationProvider) and
federationRedirectPolicies.<name> (the redirect policy). The const-Module
pattern with a typed ComponentMap config slot is the recommended shape — see
@o3co/auth-provider-federation-google's google.mts
for the reference implementation. The minimal sketch:
import { defineModule } from "@o3co/auth-provider-core";
import {
codeChallenge,
createFederationRedirectPolicy,
type FederationProvider,
} from "@o3co/auth-provider-session";
declare module "@o3co/auth-provider-core" {
interface ComponentMap {
readonly microsoftFederationConfig?: { clientId: string; callbackURL: string };
}
}
export const microsoftFederationModule = defineModule({
name: "federation:microsoft",
requires: ["microsoftFederationConfig"] as const,
contributes: {
federations: {
microsoft: (deps) => buildMicrosoftProvider(deps.microsoftFederationConfig),
},
federationRedirectPolicies: {
microsoft: (deps) => createFederationRedirectPolicy(deps.microsoftFederationConfig),
},
},
});
function buildMicrosoftProvider(cfg: { clientId: string; callbackURL: string }): FederationProvider {
return {
name: "microsoft",
scope: ["openid", "profile", "email"],
buildAuthorizationUrl({ redirectUri, state, codeVerifier }) {
const url = new URL("https://login.microsoftonline.com/common/oauth2/v2.0/authorize");
url.searchParams.set("client_id", cfg.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge(codeVerifier));
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("scope", "openid profile email");
return url;
},
async exchangeCode({ code, codeVerifier, redirectUri, callbackParams }) {
// With an OAuth library: hand it callbackUrlForExchange({ redirectUri, code, callbackParams }),
// so the RFC 9207 `iss` reaches its issuer check. By hand: compare
// callbackParams?.iss with the issuer yourself before spending the code.
// Then POST to the token endpoint + optional userinfo; normalize to FederationProfile.
return { issuer: "https://login.microsoftonline.com/common/v2.0", sub: "...", expiresAt: null };
},
};
}The composition root supplies microsoftFederationConfig via a small
config-bootstrap module that runs extractFederationSection(config.federations,
"microsoft") and surfaces the credentials on the typed slot. The session
module's federation routes consume the aggregated federationProviders map
and route by :name.
TODO-F-3 changes
- Local login session tracking.
POST /session/loginnow creates aUserSessionrecord viauserSessionStore.create()and writes the resultingsidintoreq.session.sidwhenAppOptions.userSessionStoreis wired. This mirrors the federation-callback session-creation path established in F-2 and ensures that tokens issued after a local login carry a validsidclaim.
Migrating from v0.3.x to v0.4.0
v0.4.0 removes passport as a direct dependency from this package.
Breaking changes
FederationProviderBaserenamed toFederationProvider. If you implement custom providers, rename the interface in your imports.setupPassportStrategy(passport, ctx)removed. ImplementbuildAuthorizationUrl({ redirectUri, state, codeVerifier }): URLandexchangeCode({ code, codeVerifier, redirectUri }): Promise<FederationProfile>instead. The new interface is vendor-agnostic — no passport types leak into the signature.FederationProfile.rawremoved. OIDC-standard claims are first-class fields (sub,email,emailVerified,name,picture,accessToken,refreshToken,idToken,expiresAt). Provider-specific claims (Googlehd, Microsofttid) are carried by the index signature[key: string]: unknown.FederationProfile.idrenamed tosub,expiresIn: numberreplaced withexpiresAt: Date | null(required). Adapters MUST make an explicit decision: return aDatewhen the provider issues a finite expiry,nullwhen it does not (e.g. GitHub OAuth Apps classic tokens). The route layer no longer invents a fallback expiry —nullsignals "do not refresh; reuse until the provider invalidates".FederationTokens.expiresAtonFederationTokenStorefollows the same contract.createPassport()andSetupPassportContextremoved from the public API. State (CSRF) and PKCE are managed by the route layer internally; providers are pure functions.UserSessionStoreandFederationTokenStoreare now required (previously optional with legacy fallback). They are now declared insessionModule.requires; the boot planner rejects withBootError(reason: 'missing-required-component')if no module provides them./loginerror responses follow RFC 6749 §5.2 shape:{ error, error_description }. If your client parses the old{ message: "..." }format, update accordingly.SupportsRefresh.refreshTokenreturnsRefreshedTokens(new type), an interface of named optional fields since #593 — see its definition above; it wasOmit<FederationProfile, "issuer"|"sub"> & { issuer?: string; sub?: string }, which checked nothing. Google/GitHub refresh responses legitimately omitsub; the route layer preserves stored identity.
Custom provider migration example
Before (v0.3.x, passport-based):
class CustomProvider implements FederationProviderBase {
name = "custom";
scope = ["openid"];
async setupPassportStrategy(passport, ctx) {
passport.use(this.name, new CustomStrategy({...}, (accessToken, refreshToken, profile, done) => {
done(null, { id: profile.id, raw: profile });
}));
}
validateRedirect(url) { /* ... */ }
resolveCallbackRedirect(session) { /* ... */ }
}After (v0.4.0, pure-function interface):
import { codeChallenge } from "@o3co/auth-provider-session";
class CustomProvider implements FederationProvider, SupportsClaimMapping {
readonly name = "custom";
readonly scope = ["openid"] as const;
buildAuthorizationUrl({ redirectUri, state, codeVerifier }) {
const url = new URL("https://idp.example.com/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", this.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge(codeVerifier));
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("scope", this.scope.join(" "));
return url;
}
async exchangeCode({ code, codeVerifier, redirectUri }) {
// POST to token endpoint + optional userinfo; normalize to FederationProfile
return {
issuer: "https://idp.example.com",
sub: userId,
email,
accessToken,
refreshToken,
expiresAt,
};
}
mapClaims(profile) { return { email: profile.email }; }
validateRedirect(url) { /* unchanged */ }
resolveCallbackRedirect(session) { /* unchanged */ }
}Module wiring
In v0.5.0 sessionModule is a const Module (no factory call). Its
requires declares the dependencies the boot planner must supply:
userRepository, the four-store split (userSessionStore,
federationTokenStore, sessionFederationIndex), and the synthetic keys
federationProviders + federationRedirectPolicyResolver.
See Also
@o3co/auth-provider-oauth— OAuth 2.0 token and authorization routes@o3co/auth-provider-core— shared types (Module,UserRepository,PathResolver,AppConfig)
