@o3co/auth-provider-core
v0.16.0
Published
Core types, config, and utilities for auth.provider
Readme
@o3co/auth-provider-core
Last updated: 2026-09-26
Responsibility
@o3co/auth-provider-core is the package every other auth.provider package builds on: the module system and the boot planner (createApp), the grant-handler contract and the token helpers every grant mints with, the repository and store ports with in-process adapters for a single replica, the key store, and the configuration schema. It sits under every other package and imports none of them. That is why it is a package of its own: a contract several packages share lives here, because those packages do not all depend on one another — session and oauth are independent, and oauth-token-exchange and webauthn implement grants without depending on oauth — so core is the one place they all depend on.
It owns no grant type and no /oauth/* endpoint (@o3co/auth-provider-oauth and the grant packages): the only route core mounts itself is the discovery document, and its JWKS, health and readiness routers are installed by a composition root. It owns no durable adapter (@o3co/auth-provider-redis), no federation adapter (the @o3co/auth-provider-federation-* packages), no login or browser session (@o3co/auth-provider-session) and no Store client (@o3co/auth-provider-foundation). Which directory inside owns what, and why each is separate, is src/README.md.
Vocabulary: the Store is auth.provider's term for the consumer's upstream user service — the system of record for identity, credentials, and email-verification state. Defined on the User doc in src/repositories/types.mts; auth.provider reads Store-published state and never writes it. Design-campaign identifiers cited in this package's sources resolve in docs/design-campaign-index.md.
Install
npm install @o3co/auth-provider-core
# and, for createApp:
npm install express@^5.0.0Optional peer dependency: express@^5.0.0, needed only for createApp. The
package depends on bcrypt, jose, js-yaml and zod.
Public API
Configuration
AppConfigSchema is a Zod schema that validates the full application configuration. AppConfig is the inferred TypeScript type.
import { AppConfigSchema, type AppConfig } from "@o3co/auth-provider-core";
const config: AppConfig = AppConfigSchema.parse(rawConfig);The schema strips keys it does not declare — that is Zod's default for an object, and it is what makes the parse a validation rather than a passthrough. It matters here because this parse runs before createApp, which is where each installed module's own configSchema is composed and applied: a section this schema does not know about is gone by the time the module that reads it runs, and most module schemas supply a default, so what follows is not an error but a quietly different deployment.
So the schema declares every configuration section owned by a module in this repository — oauth.mtls, oauth.dpop, oauth.deviceAuthorization, webauthn, memoryRateLimiter / redisRateLimiter and the redis* store namespaces included — even though core itself reads none of them. Their bounds and defaults stay with the packages that own them (in each one's reference.conf and configSchema); the declaration here only keeps the values from being dropped in transit. module-config-key-parity.test.mts fails the build if a module declares a key this schema does not.
A module from outside this repository is not covered by that check. If one reads its own config section, extend the schema before parsing — AppConfigSchema.extend({ mySection: … }) — or hand createApp the unparsed configuration and let the composed module schemas validate it.
Defaults live in config/reference.conf, not in the schema. Top-level fields (the sections every deployment carries; module-owned sections are documented by the package that owns them):
| Field | Description |
| --- | --- |
| http.port | HTTP listen port |
| http.trustProxy | Express trust proxy: false, an address list (IPs, CIDR ranges, or the named ranges loopback / linklocal / uniquelocal), a hop count, or true. Entries are validated at boot. Prefer naming the proxy over true, which believes a forwarded client address from anyone who can reach the process |
| oauth.jwt | JWT signing config — issuer, signingKey (a provider plus its sub-section), jwksPath, jwksCacheMaxAge |
| oauth.accessToken.defaultExpiresIn | Access token lifetime, in seconds, that every grant mints when the request asks for none. Only token exchange lets a request ask (its expires_in parameter); every other grant ignores that parameter. Read the lifetime with resolveAccessTokenLifetime(config), which throws a RangeError naming the key for a value the schema would refuse (isLifetimeSeconds is the rule, exported for a lifetime handed over as a number); every bundled grant reads it when it is built, so a hand-built configuration it refuses fails construction (and boot) rather than a request |
| oauth.accessToken.maxExpiresIn | The most a token-exchange expires_in can obtain; a larger request is clamped to it. Unset means the default, so nothing is extended unless you opt in. A default above it fails boot naming both keys |
| oauth.accessToken.expiresIn | Deprecated alias of defaultExpiresIn, read only while that key is unset (reference.conf keeps the shipped 3600 here). The parsed config also carries the resolved default under this name |
| oauth.refreshToken.expiresIn | Refresh token lifetime, in seconds: a whole number from 1 to a year. Read it with resolveRefreshTokenLifetime(config), the key's one reader, which throws a RangeError naming the key for anything else, absence included. Every grant that mints a refresh token reads it when it is built, so a hand-built configuration it refuses fails construction and never spends a code or a challenge |
| oauth.grants | Per-grant-type config, keyed by grant type. The oauth package reads enabled for the grants it registers — session, authorization_code, refresh_token, client_credentials and the jwt-bearer URN — and registers each only when it is true. The other grant packages do not read this key: token exchange and WebAuthn register their grant whenever their module is installed, and the device grant registers its grant only when oauth.deviceAuthorization.enabled is true, decided by deviceGrantModule({ config }) from the config it is handed |
| session | The browser session cookie and its store — secret, name, maxAge, secure, sameSite, domain, redirectAllowlist, storage, csrf |
| session.csrf | CSRF policy for the state-changing session routes — trustedOrigins, ttlSeconds |
| rateLimit | login: the /session/login budget (windowMs, limit) both bundled limiters seed from. failMode: what the OAuth-endpoint limiter does when its backend fails — closed answers 503, open lets the request through and logs an error. The OAuth-endpoint limits themselves are the limiter module's (memoryRateLimiter.* / redisRateLimiter.*) |
| federations | Federation providers, keyed by name: { enabled, type?, … }. Core reads enabled (the federation-stores wiring check at boot); type and the rest of the entry belong to the adapter package that reads it — the adapter packages are listed in the root README |
| repositories | Repository config for clients, users, and codes — each a type plus its sub-section |
| endpoints | login.url: the deployment's login page. consent.url: its consent page for clients that are not first-party (default /consent) |
| cors.allowedOrigins | Browser origins allowed to read the token, userinfo, revocation and discovery/JWKS responses — see CORS. Empty (the default) means CORS is off. It grants no CSRF trust — use session.csrf.trustedOrigins |
Grant System
The grant system is the extension point for OAuth 2.0 grant types. Each grant type is implemented as a GrantHandler and declared on a module via contributes.grants; the boot planner instantiates and registers handlers internally.
Interfaces and types
The definitions are in src/grants/types.mts: GrantHandler, GrantContext, SessionData, AuthenticatedClient, GrantHandlerResult, GrantDependencies, GrantFactory. What a handler may trust (authenticatedClient, never body.client_id) and what it must not do is documented on the fields themselves; the directory's responsibility map is src/grants/README.md.
Grant registration
A module declares its grants in contributes.grants, keyed by grant type. Whether a grant is contributed at all is the module's decision: the oauth package's modules contribute each of their grants only when oauth.grants.<name>.enabled is true, while token exchange and WebAuthn contribute theirs whenever their module is installed, and deviceGrantModule({ config }) contributes the device grant only when oauth.deviceAuthorization.enabled is true in the config it is handed. Boot runs each factory, registers the handler under its grant type — two modules contributing the same grant type refuse boot — and freezes the registry at stage 5, so a registration after boot throws. Consumer code never imports or builds the registry: GrantRegistry is internal and not exported from the package root.
A GrantHandler has no teardown hook. AppHandle.dispose() runs each provided component's lifecycle[K].cleanup in reverse-topological order, then Symbol.asyncDispose on module-provided values that declared none, then the LifecycleRegistrar drain — and never touches the registry. A module that holds a resource on a handler's behalf releases it through its own lifecycle[K].cleanup; see src/grants/README.md.
Resource indicators (RFC 8707)
A grant that honours resource reads it with extractResourceParam, derives the audience it names with deriveAudienceFromResources, and refuses an issued aud that does not represent it with unrepresentedResources — src/grants/resourceIndicator.mts. Each value is kept whole (a URI may contain a comma), the empty entries of a repeated parameter are dropped, and an all-empty parameter means none was requested. The oauth grants, /authorize and the WebAuthn grant all read it there, so a custom grant that does the same gives the same answer.
Underneath is readTargetParameter, the strict reading of a target parameter — resource, or RFC 8693's audience — from a form or JSON body: the values it names ([] when none), or null when it is malformed, that is neither a string nor an array of strings. A malformed value is never converted to a string, since String([["https://x"]]) names https://x. extractResourceParam reads a malformed resource as none requested. The token-exchange grant reads resource and audience with readTargetParameter and refuses a malformed one with invalid_target: RFC 8707 §2's answer to a resource the server "fails to parse", given to audience by symmetry.
Error text (RFC 6749)
RFC 6749 Appendix A.7 and A.8 limit error and error_description to 1*NQSCHAR: printable ASCII without " and \. The rule is in src/errors/envelope.mts:
errorEnvelope(error, description?, uri?)builds the RFC 6749 §5.2 error body and applies the rule itself, so every writer that goes through it conforms whatever it was handed: core's token-binding middleware (a mechanism'sretryInstructionorunavailabletext, the kinds a dispatch conflict names), the protected-resource binding, the rate limiter (a limiter adapter'sreason), the session routes and a contributed module's own routes. A description character outside the set is sent as?; a description that is not a string is dropped like an empty one. A malformederrorcode is sent asserver_errorand logged aserror_envelope_code_malformedthroughconsoleLogger: the code came from server-side code, and the envelope does not know the status its caller answers with.error_uriis sent only when it is anhttp:orhttps:URI — §5.2's human-readable web page — or a relative reference, parsed component by component against RFC 3986's grammar (no userinfo —https://[email protected]/goes to evil.example — brackets only around an IP-literal host, no colon in a relative path's first segment, one fragment) and resolved by the WHATWG URL parser. Every character that grammar admits is in RFC 6749'serror_uriset (Appendix A.9). Any othererror_uriis dropped, not altered, and logged aserror_envelope_uri_malformed.sanitizeErrorTextreplaces every character outside the set with?, and answersundefinedfor a value that is not a string, so the caller falls back to its own default. A writer that builds its body itself — a redirect's query, a literal{ error, error_description }— sends what it echoes through it.auditErrorTextdoes the same and caps the text at 200 characters, for a log line or an audit event.auditErrorList(values, maxItems = 10)records a list a client chose (the scopes it asked for, the resources it named) for a log line or an audit event: still an array, each entry throughauditErrorText, the firstmaxItemskept. A small, well-formed list comes back as it was; the caller adds a count of the entries sent when the list was cut (requestedScopeCount,missingResourceCount). AmaxItemsthat is not a positive integer is aRangeError.isWellFormedErrorCodechecks anerrorcode before it goes out. A caller that builds a code from something it does not control and knows its answer is a refusal of the client's request falls back to a client-error code itself: the token-binding middleware answers a refusal whoseinvalid_<kind>_proofwould be malformed asinvalid_request, and/oauth/tokenand/oauth/authorizedo the same for a grant policy's deny (below).
Text written in this repository's own words is held to the set where it is written by __tests__/errorText.drift.test.mts: quote a value with ', write "section" for the section sign, and use no em dash.
Token Utilities
generateToken(data, options), generateTokenResponse(tokens) and formatObject are in src/grants/token.mts, with Token, TokenResponse and GenerateTokenOptions beside them.
generateToken signs a JWT with the current signing key of options.keyStore; alg and kid are the key store's, typ is options.tokenType, cnf is emitted only when options.confirmation is given, and jti / issuedAt are minted unless the caller reserved them first (#449). exp is iat + options.expiresIn, so expiresIn must be a positive whole number of seconds: a fraction, NaN, Infinity, zero or less is a RangeError before anything is signed, and so is a lifetime that would put exp past Number.MAX_SAFE_INTEGER (the configuration schema refuses the same values for oauth.accessToken.* and oauth.refreshToken.expiresIn). generateTokenResponse formats an access token, an optional refresh token and an optional id_token into the OAuth 2.0 token endpoint response shape, with the token_type read off the access token's own confirmation (the confirmation generateToken echoes on the Token): DPoP for cnf.jkt (RFC 9449 §5), Bearer for cnf.x5t#S256 (RFC 8705 §3) and for an unbound token — so the envelope cannot disagree with the claim. A grant stamps ownedConfirmation(ctx.tokenBinding), the member the binding's mechanism owns, never ctx.tokenBinding.confirmation as a mechanism returned it. formatObject strips undefined and null values from an object.
Key Store
The KeyStore interface abstracts over symmetric (HS256) and asymmetric (RS256, ES256, EdDSA) signing keys, including key rotation. Rotation is shape-specific: asymmetric algorithms use previousKeys (kid + public key + expiry), and HS256 uses previousSecrets (kid + secret + expiry). getVerificationKey(kid) resolves the key by kid — the keystore returns the matching key directly, never trial-verifies across keys — and throws UnknownKidError for a kid it does not hold and ExpiredKidError for one whose expiresAt has passed, so a caller can tell a fabricated kid from a retired one. Anything else it throws — a remote key service that timed out — is the keystore failing to answer, not a finding about the token: verifyJwt reports it as verification_key_unavailable, never kid_unknown, and every route answers it 503 temporarily_unavailable (see Token verification). So a custom keystore must answer a kid it does not hold with UnknownKidError, never with another error. The kid is untrusted — the token's own header, read before any signature is checked; verifyJwt passes only a well-formed key id (isWellFormedKid: a string of 1 to MAX_KID_LENGTH (256) characters with no control character), but any other character may be in it — so an adapter that looks keys up remotely (a KMS, an HSM, a JWKS endpoint) checks it against its own key naming before it reaches that system, and answers one that fails with UnknownKidError. The contract is written on getVerificationKey in src/keys/KeyStore.mts. The same rule holds where a kid is chosen: oauth.jwt.signingKey and all three bundled keystores refuse a current or previous kid that is not a well-formed key id when they are built (src/keys/kid.mts). Otherwise the server would sign tokens its own verifier refuses as kid_unknown. sign(options) returns a compact JWT; the KeyStore self-injects the alg and kid protected header fields, so callers cannot override them. This contract lets remote-sign adapters (KMS/HSM) implement sign() without exposing private key material. getSigningKidFallback() is a cheap accessor returning the current signing kid for verifying legacy/malformed tokens that lack a kid header. Do not use it for rotation-safe lookup.
The definitions — KeyStore, SignJwtOptions, JWTPayload, ManagedKey, KeyLike, the two errors, AsymmetricKeyStoreOptions, SymmetricPreviousSecret, createAsymmetricKeyStore and createSymmetricKeyStore — are in src/keys/KeyStore.mts.
Signing without holding the private key (KMS / HSM / Vault)
createRemoteSigningKeyStore is a KeyStore whose private key never enters this process. The whole seam is one method, RemoteSigner.sign(kid, data), which answers the signature in JWS form (RFC 7515 §3.3), not the provider's native encoding. Its options carry public key material only, and verifyOnConstruction defaults to true; the definitions are in src/keys/remoteSigning.mts.
Everything else a KeyStore owes — building the protected header, base64url encoding, assembling the compact JWT, rotation bookkeeping, publishing JWKS — is done for you, so an integrator writes the provider call and nothing else.
No vendor is bundled. Wire AWS KMS, PKCS#11, or a Vault transit key by supplying signer; core stays free of any of their SDKs. There is no remote entry in the key-store factory for the same reason a RemoteSigner is a function: build the store in your composition root and supply it as the keyStore component.
ES256 returns DER from almost every provider, and JWS does not accept it. AWS KMS, PKCS#11 and OpenSSL all return an ASN.1 SEQUENCE; JWS wants the raw R || S concatenation. derToJoseEcdsaSignature(der) converts it. Getting this wrong produces signatures that fail at the relying party while the signer reports success, which is why the store signs one token at construction and verifies it against the public key — a signer returning the wrong form fails boot with a message naming both likely causes. Pass verifyOnConstruction: false only where a provider call at boot is itself the problem.
There is no HS256 variant, deliberately. A shared secret has no public half, so "the key never leaves the boundary" cannot be true of it — every verifier needs the same bytes the signer has. Offering it here would let a deployment believe it had moved key material out of reach when it had not.
// Sketch: AWS KMS, ES256
const store = await createRemoteSigningKeyStore({
algorithm: "ES256",
kid: "v1",
publicKeyPem: await fetchPublicKeyPem(),
signer: {
async sign(_kid, data) {
const { Signature } = await kms.send(new SignCommand({
KeyId: KMS_KEY_ID,
Message: data,
MessageType: "RAW",
SigningAlgorithm: "ECDSA_SHA_256",
}));
return derToJoseEcdsaSignature(Signature!); // KMS returns DER
},
},
});createKeyStoreFactory() creates a new factory with no registered types. registerBuiltinKeyStores(factory) registers the built-in "local" provider, which dispatches to createAsymmetricKeyStore or createSymmetricKeyStore based on algorithm. Both are in src/keys/factory.mts. The factory follows the same AdapterFactory<T> contract as the ClientRepository, UserRepository, and CodeRepository factories.
Algorithm default and key requirements
reference.conf ships algorithm = "EdDSA" (DEFAULT_SIGNING_ALGORITHM). Asymmetric by default because HS256 leaves a relying party with two bad options: verify nothing (there is no public key to publish) or hold the shared secret — which also lets it mint tokens.
The "local" builder has no fallbacks:
- An absent
algorithmis an error, not an implicitHS256. - An asymmetric algorithm with no
privateKey/privateKeyPath(or no public half) throws a message naming the exact config keys, the exact environment variables, and theopenssl genpkey -algorithm ed25519command that produces them. HS256requiressecretto carry at leastMIN_SECRET_ENTROPY_BYTES(32) of key material, and so does everypreviousSecrets[].secret.
Entropy is measured on the decoded value, taking the smallest plausible reading (measureSecretEntropyBytes): a 64-character hex string is 32 bytes and passes; a 32-character hex string is 16 bytes and does not. The same floor applies to session.secret, enforced by AppConfigSchema. assertSecretEntropy / describeWeakSecret are exported so a composition root that accepts its own operator secrets can apply the identical check.
Note that the floor lives in the builder and the schema — the config boundaries. createSymmetricKeyStore is the low-level primitive and does not enforce it, so a composition root calling it directly owns the check.
HS256 key rotation
To rotate an HS256 signing key without a maintenance window:
Record the current
kidandsecretvalues.Generate a new secret:
openssl rand -hex 32.Update
application.confto set the newkid+secretand move the old pair intopreviousSecrets:oauth.jwt.signingKey.local { algorithm = "HS256" kid = "v1" # new kid secret = "<new-secret>" previousSecrets = [{ kid = "v0" # old kid secret = "<old-secret>" expiresAt = "2026-06-05T00:00:00Z" # access-token TTL + buffer }] }Restart the server. Tokens signed by
v0continue to verify (resolved bykidfrom the JWT header) untilexpiresAt.After the overlap window passes (all
v0tokens have expired), removev0frompreviousSecretsand restart again.
Both the new secret and every previousSecrets[].secret must clear the 32-byte floor — a retired secret is still a live verification key for the whole overlap window, so it carries the same forgery risk the current one does.
The schema rejects mixing the asymmetric previousKeys shape with HS256, and the builder rejects the reverse (previousSecrets under RS256/ES256/EdDSA) — operators on an asymmetric algorithm use the previousKeys field instead.
Token verification
verifyJwt (src/jwt/verify.mts) verifies the tokens this provider issued and throws a JwtVerificationError whose reason is either a finding about the token — a bad signature, a kid nobody holds (kid_unknown), a retired one (kid_expired), expiry, a revocation (revoked) — or an outage: verification_key_unavailable (the keystore could not answer) or revocation_unavailable (the jti denylist or the subject watermark could not be read). isVerificationUnavailable(err) tells the two apart, and VERIFICATION_UNAVAILABLE_DESCRIPTION names the dependency for the wire. Every surface in this repository answers an outage 503 temporarily_unavailable and never with a verdict — not 401 invalid_token, 400 invalid_grant, active: false or a revocation's 200 — because each of those describes the token and sends the client to replace a credential that may be perfectly good. The token is refused either way.
REVOCATION_RETENTION_ALLOWANCE_MS is how long past a token's exp a record that revokes it must be kept: the verifier's clock tolerance, a replica allowance and a second of rounding. /oauth/revoke keeps a denylisted jti that much past exp, and a revoked refresh-token family is kept by the same rule (Refresh-token families).
A JWT's exp, iat and nbf are NumericDates (RFC 7519 §2) only when finite and within the Date range: isNumericDate and malformedNumericDateClaim (src/jwt/numericDate.mts) state the rule, and the jwt-bearer registry verifier, private_key_jwt and DPoP refuse an assertion or proof that breaks it before computing any expiry from it. jose checks only that such a claim is a number, and JSON's 1e400 parses to Infinity. A fraction is allowed. An assertion whose jti is recorded for single use — a private_key_jwt client assertion, an ID-JAG — may also run at most MAX_ASSERTION_LIFETIME_SECONDS (an hour) past now, and have been issued at most that long ago (src/assertions/lifetime.mts): its replay record lives until exp, so an unbounded exp would be an unbounded record. Both verifiers allow their clock tolerance on top, as for every other time check, and compare exp through the same assertionLifetime, so a client or IdP whose clock runs a little ahead is not refused on one path and admitted on the other.
Sealing values at rest
A store that keeps a secret at rest seals it in the v2 key-ring envelope (src/sealing/envelope.mts). sealWithKeyRing(plaintext, ring, { purpose, record }) returns v2.<key id>.<iv>.<ciphertext>.<tag>, AES-256-GCM under the ring's first key. openWithKeyRing(envelope, ring, { purpose, record }) answers an OpenedSeal: ok with the value and the keyId that opened it, so a caller can re-seal a value opened under a key that is no longer first; key_unavailable with the keyId the envelope names when the ring no longer holds it, which an operator undoes by putting that key back; or unreadable for anything else, a value bound to another purpose or record and a tag or IV of any length but 16 and 12 bytes included. It never throws for the envelope. The purpose label (1 to 64 printable ASCII characters, no space) and the record's bytes are authenticated and not stored, so a value copied into another record, or read by another caller sealing under the same ring, does not open.
A ring (SealingKeyRing: SealingKey entries { id, key }, in src/sealing/keyRing.mts) seals with its first key and opens with any. An ID is 1 to 64 characters of A-Za-z0-9_- (isSealingKeyId) and a key is a Buffer of SEALING_KEY_BYTES (32) bytes. checkSealingKeyRing(ring, setting) refuses a ring that breaks the rule with a RangeError whose message starts with setting: a store calls it when it is built, under the configuration key or option it read the ring from (federationGrants.encryptionKeys), so a bad ring is refused at boot and names where it was written. Every refusal names the entry by its index and none quotes an ID: a 32-byte key spelled in hex or unpadded base64url passes the ID rule, so an operator who swapped an ID and its key would otherwise see the key. Sealing and opening check the ring again, as the "sealing key ring", and throw a RangeError on a purpose outside its rule, and sealing on an empty ring. decodeSealingKey reads a configured key: canonical base64 of exactly 32 bytes with no whitespace, or undefined for the caller to refuse, naming its configuration key. @o3co/auth-provider-redis's federation grant store seals its credentials this way.
Repositories
Repository interfaces define the data access contract. Built-in in-memory implementations are provided for development and testing.
Interfaces and types
The ports are src/repositories/ClientRepository.mts (findById, authenticate; PublicClient is Client without clientSecret. It throws only when its store cannot answer — an unknown client or a wrong secret is null — because client authentication and /authorize answer a throw 503 temporarily_unavailable; the clientId it is handed has passed isWellFormedClientId from src/repositories/clientId.mts — no control character, at most MAX_CLIENT_ID_LENGTH (256) characters — but is still the client's input, bound as a parameter and never interpolated), src/repositories/UserRepository.mts (authenticate, authenticateByToken, and the optional federated-identity link and lookup methods) and src/repositories/CodeRepository.mts (createCode, findByCode, consumeByCode — the atomic single-use gate — and removeByCode). The records — Client, User, CodeData, Code, TokenEndpointAuthMethod — are in src/repositories/types.mts, where a field's semantics are documented once, on the field — except the three logout URI fields, which carry no doc there: postLogoutRedirectUris takes the registered-redirect-URI grammar of allowedRedirectUris, custom schemes included, while backchannelLogoutUri and frontchannelLogoutUri are http/https only; that note sits beside the schema in src/repositories/InMemoryClientRepository.mts.
createCode requires client_id and redirect_uri, and Client.tokenEndpointAuthMethod is required. Every other Code field is a required key holding undefined where nothing was recorded, and createCode takes CreateCodeInput, in which only expiresIn may be left out (the repository's default then applies). nonce and sid carry the OIDC nonce and the session id from /authorize to /token; grantedScope / grantedAudience are the grant policy's decision at /authorize, which the authorization_code grant reads instead of evaluating the policy again. The directory's responsibility map is src/repositories/README.md.
Built-in implementations
InMemoryClientRepository and InMemoryUserRepository take a Map of entries validated by ClientEntrySchema / UserEntrySchema; InMemoryCodeRepository takes an optional defaultExpiresIn and runs a GC timer that its dispose() clears. loadYamlMap(filePath, schema) (src/repositories/loadYamlMap.mts) reads a YAML file whose top-level keys are record IDs and validates each entry against schema; pass the result to InMemoryClientRepository or InMemoryUserRepository — see Loading clients and users from YAML. A file that does not parse is refused as Invalid YAML in <file> at <line>:<column>: <reason>, with no cause and nothing of the file: js-yaml's own error quotes the lines around the fault and holds the whole file, and these files hold secrets.
Adapter factory primitives
createAdapterFactory<T>(kind, ctx?), AdapterFactory<T>, AdapterBuilder<T> (a function of the config section and a read-only BuilderContext, whose fields are all optional and only ever added to), LifecycleRegistrar and AdapterFactoryError are defined in src/adapters/AdapterFactory.mts. createRepositoryFactories(ctx?) in src/repositories/RepositoryFactory.mts returns the client, user and code factories.
Key contract properties:
create()always returnsPromise<T>, even for synchronous builders.register()throws if atypeis registered twice (silent-override prevention);replace()is the explicit override and throws for atypethat is not registered.create()throwsAdapterFactoryErrorwhentypeis not registered; the error carries areason(unknown,duplicateorunknown-replace), thekind, thetype, and theregisteredlist.BuilderContextis shared by reference across builder invocations for a given factory. Treat it as read-only from builders.
createRepositoryFactories returns three factories pre-registered with the built-in yaml/static (client, user) and memory (code) types. Use registerBuiltinAdapters from @o3co/auth-provider-foundation to add the http user-authentication adapter, or register your own types to support other backends. For Redis-backed code/store adapters, see @o3co/auth-provider-redis.
Module System
Modules extend the app with routes, grant handlers and DI-graph components. A module is a declarative manifest written with defineModule({...}): it declares requires / optional (typed ProviderDeps keys), provides components, and contributes to ContributesMap kinds such as grants, routes and federations. The boot planner injects the typed deps into every factory; a module never mutates shared state. The vocabulary is src/modules/manifest/, also published as the @o3co/auth-provider-core/modules/manifest subpath.
const myModule = defineModule({
name: "my-module",
requires: ["config", "clientRepository"] as const,
contributes: {
routes: [
(deps) => ({ id: "my-route", mountPath: "/my", handler: makeRouter(deps) }),
],
},
});App Factory
createApp(options): Promise<AppHandle> is the boot planner in src/boot/. CreateAppOptions and AppHandle are defined in src/boot/types.mts.
createApp validates the manifests, composes and parses the configuration, materialises the component graph, applies every contribution, freezes the world and mounts the routes. A refused boot is a BootError (src/boot/types.mts): its message names the error behind it by loggableError's rules — never quoting what a parser quoted, a Redis reply's arguments or a thrown value that is not an Error — and printed (util.inspect, console.error, Node's unhandled-rejection printer) it shows every error it carries as its projection. cause and details.originalError still hold the thrown value, for code that reads them. The returned router is ready to mount (app.use(handle.router)) or to serve through handle.listen(port); handle.dispose() runs every cleanup in reverse-topological order and rejects with an AggregateError carrying every failure. There is no separate init() step.
What core mounts on its own, in this order: corsMw when cors.allowedOrigins is non-empty, the single tokenBindingMw composed from the contributed mechanisms when at least one was contributed, the protected-resource sender-constraint check (always, on every request but the token endpoint's POST), the grantMiddleware contributions ahead of grant dispatch, the OIDC discovery route when an issuer is configured and a module declares providerRoot, and, after every route, a terminal error handler (src/middleware/terminalError.mts). That handler answers what a route it assembled let through, so none of those errors reaches the host's own handlers (a route a host adds to handle.router after boot sits after it, and is not covered): a body parser's refusal — read by body-parser's own type — is 400 malformed_body, 413 body_too_large or 415 unsupported_encoding (a path Express could not decode, 400 malformed_path) in the RFC 6749 envelope, logged nowhere, and any other exposed http-errors 4xx keeps its status as invalid_request / request_refused, with a 401's WWW-Authenticate or a 405's Allow (up to 1 KiB) when it carries one; anything else is 500 server_error, logged once at error as unhandled_request_error with endpoint and the error's projection; every answer is Cache-Control: no-store and Pragma: no-cache. An error after a response's headers went out is logged the same way, and the connection closed unless the response had already ended. A request no route answered still passes on to the host. The handler is exported as terminalErrorHandler(logger), for a host that mounts routes of its own beside the router and wants the same answer after them. Everything else — JWKS (jwksModule), liveness and readiness (createHealthcheckRouter, createReadinessRouter), the OAuth and session routes — is a module or a router the composition root installs.
express is an optional peer dependency, loaded lazily: createApp imports it (await import("express")) to build the router, and boot also requires it (createRequire) for the express() factory handle.listen() wraps the router in — and for the router, if the import failed.
CORS
cors.allowedOrigins is consumed by corsMw (src/middleware/cors.mts), which assembleApp mounts first — ahead of every other middleware and every route contribution. An empty list (the default) mounts nothing: no CORS headers and no Vary.
Surface
browserFacingCorsRoutes(config) is the table, and it is an allowlist — the opposite polarity to the sender-constraint mount beside it. That one guards a credential and must therefore cover routes core has never heard of; this one grants a cross-origin read, so a route core has never heard of is exactly the one that must not silently acquire it.
| Path | Methods |
|---|---|
| /oauth/token | POST |
| /oauth/userinfo | GET, POST |
| /oauth/revoke | POST |
| /.well-known/openid-configuration | GET |
| /.well-known/oauth-authorization-server | GET |
| oauth.jwt.jwksPath (default /.well-known/jwks.json) | GET |
The two discovery rows are the same document: OIDC Discovery 1.0 appends its suffix to the issuer, RFC 8414 inserts its well-known string between host and path, and discoveryPathsFor (src/discovery/wellKnownPaths.mts) forms both for the configured issuer — for https://as.example/tenant-a that is /tenant-a/.well-known/openid-configuration and /.well-known/oauth-authorization-server/tenant-a — so the route, its advertisement and this table cannot drift.
/oauth/introspect is off the list because it is server-to-server and already refuses public clients; /oauth/authorize because it is a top-level navigation, not a fetch. The /oauth/* paths are coupled to the bundled oauthModule's mountPath, like the /oauth/token mounts in boot/assemble-app.mts — a downstream that re-mounts the OAuth router elsewhere builds its own table and passes it to corsMw.
Headers
Access-Control-Allow-Origincarries the matched entry, echoed exactly. An arbitrary origin is never reflected and*is never emitted — not even for the unauthenticated documents, because one code path that can emit*is one code path away from emitting it on a response carrying a token.- No
Access-Control-Allow-Credentials, ever. A cross-origin SPA here is a public client using PKCE and holds no cookie of ours. Allowing credentials would reach the cookie-backedsessiongrant, which exchanges an authenticated browser session for tokens — a much larger grant than "may read the response to a request it authenticated itself", and CORS delivers the two together. - A preflight (
OPTIONScarryingAccess-Control-Request-Method) is answered204with the route's methods,Access-Control-Allow-Headers: content-type, authorization, dpop, andAccess-Control-Max-Age: 600. Access-Control-Expose-Headers: WWW-Authenticate, Retry-After— both are diagnostics the caller cannot act on otherwise (an opaque429with nothing to back off by; a401that will not say which scheme it wanted).Vary: Originon every response from these routes, including the ones with no CORS headers, so a shared cache cannot serve one origin's response to another.
Origins
Entries are validated at boot by checkSerializedOrigin (src/net/origin.mts) and refused by index, because matching is exact string equality: a trailing slash, an explicit :443, an uppercase host, a path, or a wildcard is an allowlist that admits nobody with nothing anywhere to say so. https is required except for a loopback host, through the shared isLoopbackHostname home. corsMw re-applies the same check and warns on anything it drops, so a hand-built AppConfig that never passed the schema cannot install an entry the schema would have refused.
The list takes two spellings. The comma-separated string an environment variable carries (CORS_ALLOWED_ORIGINS) is split on commas, each entry trimmed and empty entries dropped, so an empty variable is no list. An array keeps its string entries, trimmed, and an empty one is refused by the check above; a non-string entry is dropped. normalizeAllowedOrigins in the same file reads both and is exported; the WebAuthn package reads WEBAUTHN_ORIGIN / WEBAUTHN_TOP_ORIGIN with it, so every origin list set from the environment is spelled alike.
Usage Example
import express from "express";
import {
AppConfigSchema,
createApp,
createRepositoryFactories,
createKeyStoreFactory,
defineModule,
registerBuiltinKeyStores,
} from "@o3co/auth-provider-core";
const config = AppConfigSchema.parse(rawConfig);
// Both repositories.* (uses 'type') and oauth.jwt.signingKey (uses 'provider') follow
// the same nested adapter sub-section pattern. flatten() normalises either selector
// to { type, ...subSectionFields } before forwarding to the factory:
const flatten = (
section: ({ type: string } | { provider: string }) & Record<string, unknown>,
) => {
const selector =
(section as { type?: string; provider?: string }).type
?? (section as { provider?: string }).provider;
if (typeof selector !== "string") {
throw new TypeError("flatten: section requires 'type' or 'provider' string");
}
const sub = section[selector];
const flattenedSub =
typeof sub === "object" && sub !== null && !Array.isArray(sub)
? (sub as Record<string, unknown>)
: {};
return { type: selector, ...flattenedSub };
};
const keyStoreFactory = createKeyStoreFactory();
registerBuiltinKeyStores(keyStoreFactory);
const keyStore = await keyStoreFactory.create(flatten(config.oauth.jwt.signingKey));
const { clientFactory, userFactory, codeFactory } = createRepositoryFactories();
const clientRepository = await clientFactory.create(flatten(config.repositories.client));
const userRepository = await userFactory.create(flatten(config.repositories.user));
const codeRepository = await codeFactory.create(flatten(config.repositories.code));
const localComponentsModule = defineModule({
name: "local-components",
provides: {
keyStore: () => keyStore,
clientRepository: () => clientRepository,
userRepository: () => userRepository,
codeRepository: () => codeRepository,
},
});
const handle = await createApp({
modules: [
localComponentsModule,
// additional modules go here
],
bootstrapComponents: { config, pathResolver: import.meta.resolve },
});
const server = express();
server.use(handle.router);
server.listen(config.http.port);Implementing a custom grant type
import {
defineModule,
type GrantFactory,
generateToken,
generateTokenResponse,
} from "@o3co/auth-provider-core";
const myGrantFactory: GrantFactory = (deps) => ({
async handle(ctx) {
const token = await generateToken({}, {
keyStore: deps.keyStore,
subject: "user-id",
tokenType: "at+jwt",
});
return {
result: { status: 200, tokens: generateTokenResponse({ accessToken: token }) },
};
},
});
const myGrantModule = defineModule({
name: "my-grant",
requires: ["config", "keyStore"],
contributes: {
grants: { my_grant: myGrantFactory },
},
});Add myGrantModule to the modules array passed to createApp. A GrantFactory receives GrantDependencies, whose required slots are config and keyStore, so the module requires both. The boot planner registers the grant under my_grant, and /oauth/token dispatches to it through the grantHandlerResolver synthetic key.
Loading clients and users from YAML
import {
loadYamlMap,
ClientEntrySchema,
UserEntrySchema,
InMemoryClientRepository,
InMemoryUserRepository,
} from "@o3co/auth-provider-core";
const clients = loadYamlMap("./clients.yaml", ClientEntrySchema);
const users = loadYamlMap("./users.yaml", UserEntrySchema);
const clientRepo = new InMemoryClientRepository(clients);
const userRepo = new InMemoryUserRepository(users);Extension points
Five optional extension points: a slot or contribution kind a composition root fills, or leaves empty.
MFA
Deprecated. Everything in this section is unwired, and the multi-factor design in
docs/adr/2026-09-25-multi-factor-authentication.md(D3) replaces it:createMfaRoutercontinues flows through callbacks at a route with no CSRF guard, a failed verification leaves its transaction open to retries until it expires, andMfaTransactionStore's get-then-delete lets two verifications in flight both pass. The names D3 removes are marked@deprecated;MfaFactor(and themfaFactorscontribution kind),MfaCoordinatorandMfaTransactionStorekeep their names with a new contract. Do not build on this surface.
MfaProvider, with the optionalSupportsEnrollment/SupportsRevocationcapabilities, the guardssupportsEnrollment()/supportsRevocation(), and theMfaCoordinator/MfaTransactionStoretypes —src/mfa/types.mts; the factorycreateMfaProviderFactory()—src/mfa/factory.mts.createMfaRouter(express, deps)buildsPOST /auth/mfa/verify { transaction_id, proof }: it loads the pending transaction from theMfaTransactionStore, verifies the proof with the provider of the transaction'sproviderKind, and hands the resumed flow to theonAuthorizeResume/onFederationResume/onLoginResumecallbacks you supply.- Core provides the port and the router and nothing that uses them. No route in this repository consults MFA:
/oauth/authorize, the session login and the federation callback never callMfaCoordinator.listEnrolledor start a transaction, nothing mountscreateMfaRouter, and no product code reads themfaFactorscontributions boot collects. A composition root that wants MFA starts the transaction in its own login flow, mounts the router and supplies the callbacks. - Boot refuses a composition that provides
mfaCoordinatorwithout bothmfaProviderFactoryandmfaTransactionStore(mfa-partial-wiring). - No factor is bundled;
@o3co/auth-provider-webauthnships passkeys as a grant (contributes.grants), not as anmfaFactorscontribution.
Audit
AuditSink.record(event)fire-and-forget- Factory:
createAuditSinkFactory(), built-in"console"viaregisterBuiltinAuditSinks() - Errors swallowed by core — audit failure never blocks auth flow
- Every built-in event reaches its sink through
recordAuditEvent(sink, event)(src/audit/factory.mts) —emitAuditEventcalls it and detaches; an emitter that waits on its sink (federation grants) calls it directly and gets the sink's promise. It hands the sink the event withipan IPv4 or IPv6 address (net.isIP, an IPv6%zonestripped) or left out — an SIEM that maps the field as an IP type rejects a whole event overX-Forwarded-For: x— anduserAgentsanitised and capped asauditErrorTextdoes (RFC 6749 NQSCHAR,?for anything else, at most 200 characters); either is dropped when it is not a string. Behindtrust proxy,req.ipis what the caller wrote inX-Forwarded-For, and a user agent is the caller's own header. An ordinary address or user agent is carried unchanged, the event keeps its own key order, and a sink that throws synchronously or answers something that is not a promise never throws into the route. BesidesemitAuditEvent, two emitters call it directly: the federation-grants routes' bridge, which returns the sink's promise for core to bound and a shutdown to drain, and oauth's subject-revocation auditor, which logs a rejection (federation_grant_audit_failed) rather than waiting on the sink.logErrorProjection.drift.test.mtspins that nothing else in the workspace writes a sink - An event carries an error it reports as
details.cause,auditedError(err)(src/audit/auditedError.mts):{ name, code?, cause?: { name, code? } }— the name and codeloggableErrorreads, and one level of its cause, sanitised and capped, and never a message. A sink is a record other systems read, and a store's or an IdP's message is theirs: the arguments a Redis reply quotes, the input a JSON parse error quotes, an upstream's description.rate_limit.unavailable,introspect.store_unavailableandfederation.logout.idp_unreachablecarry it - Each
detailskey keeps one type in every event, because a sink that fixes a field's type on first sight (Elasticsearch dynamic mapping, a BigQuery schema, a Datadog facet) drops the events that disagree:details.erroris a string wherever it appears (an OAuth code, a reason), and a code indetails.causeis a string.AuditEventDetailstypes both keys, andauditEventInventory.drift.test.mtsreads every emission for them
The details contract: AuditEventDetails and AuditedError
AuditEvent.details is AuditEventDetails: an open record, with two keys typed so that no event can give them a second type:
| Key | Type | What it holds |
| --- | --- | --- |
| details.error | string | An OAuth error code or a refusal's reason — never an error object and never an error's message |
| details.cause | AuditedError | The error the event reports: { name: string, code?: string, cause?: { name: string, code?: string } } |
Every other key is open, and is still expected to keep one type across the events that carry it.
A custom emitter (a module calling
emitAuditEventorrecordAuditEvent, or a sink wrapper that builds events) — one that callssink.recorditself skips the bound onipanduserAgent:- puts an error it reports under
details.cause, built withauditedError(err)and nothing else; - never writes an error object, its message or its stack anywhere in
details; - writes
details.erroronly as a string.
An event written as an object literal is held to the two keys by the compiler. A
detailsbuilt first as aRecord<string, unknown>is not, so an emitter that assembles one owns the rule itself.- puts an error it reports under
A custom sink (an
AuditSinkimplementation, or a wrapper that relays events):- may rely on
details.errorbeing a string anddetails.causeanAuditedErrorwherever they appear; - if it transforms or redacts details, keeps those types: a
causeit will not carry is replaced with anAuditedError({ name: "[redacted]" }, say), never with a string or a message; - may drop a key, but should not change its type.
Every name and code in an
AuditedErroris already held to printable ASCII without"and\and capped at 200 characters.- may rely on
Rate limiter
RateLimiter.check(key, ctx)atomic check + increment- Factory:
createRateLimiterFactory();registerBuiltinRateLimiters()registers"memory"only. The"redis"backend is@o3co/auth-provider-redis(redisRateLimiterBuilder, or the declarativeredisRateLimiterModule);ratelimit/__tests__/factory.test.mtsasserts it is not registered here - 429 +
Retry-Afteremitted by core on denial; the decision'sreasonis theerror_description, within RFC 6749's characters, andRate limit exceededwhen it is absent, empty or not a string
Refresh-token families (RFC 6819 §5.2.2.3 replay detection)
- The port is
RefreshTokenFamilyRotation/RefreshTokenFamilyRevocationinsrc/refresh-token-family/types.mts - Every
rt+jwtcarries afamily_idclaim - Provide the
refreshTokenFamilyRotation/refreshTokenFamilyRevocationslots (a family store —memoryRefreshTokenFamilyStoreModuleor the Redis adapter — withdefaultRefreshTokenFamilyRotationModuleanddefaultRefreshTokenFamilyRevocationModule) for replay detection and family revocation.oauthAuthorizationModulerefuses to boot with therefresh_tokengrant on unless both are wired (the oauth package) - A revoked family is remembered until the last access token it could have minted stops being accepted: the revoking write — a revocation, or the replay that revokes the family — keeps the record until the later of the family's own expiry and now plus
oauth.accessToken.maxExpiresIn, plusREVOCATION_RETENTION_ALLOWANCE_MS(src/refresh-token-family/retention.mts). A family whose record has already run out is recorded as revoked all the same.createRefreshTokenFamilyRevocationandcreateRefreshTokenFamilyRotationtake that horizon asaccessTokenHorizonMs(resolveFamilyAccessTokenHorizonMs(config)), and the default modules read it fromconfig - The memory store forgets every family on a restart, revoked ones included, so an access token of a family revoked before the restart passes the family check afterwards until it expires; it is single-replica and development only
GrantPolicyHook (scope / audience / token exchange policy)
GrantPolicyHook.evaluate(request, ctx)returns allow (with optional narrowing) or deny- A deny's
errormust be an RFC 6749 error code,1*NQSCHAR: non-empty printable ASCII without"and\(isWellFormedErrorCode,errors/envelope.mts)./oauth/tokenanswers any other codeinvalid_request, and/oauth/authorizeanswers itaccess_denied, logging the policy's code sanitised /oauth/authorizeevaluates once;/oauth/tokenre-usesgrantedScope/grantedAudiencepersisted on the Code record (no re-evaluation forauthorization_code)- Other grants (refresh / client_credentials / token-exchange) evaluate at the token endpoint
All five are optional. The audit sink carries an absence policy (AUDIT_SINK_ABSENCE_POLICY): when nothing fills the slot, the config must declare it absent (audit.sink.type = "none") or boot refuses. The other four are simply off when absent.
Token-binding mechanisms
Sender-constrained token binding is a first-class extension surface. The tokenBindingMechanisms contribution slot lets a module ship a custom TokenBindingMechanism without forking core. See ADR 2026-05-20-token-binding-first-class-abstraction.md for the design rationale.
Public types
TokenBinding(src/grants/tokenBinding.mts) — the cross-cutting binding shape: akind, theconfirmation, and the optionalresponseHeadersa mechanism asks the response to carry (DPoP-Nonce).kindis open so downstream mechanisms can extend additively.Confirmation(src/grants/confirmation.mts) — the RFC 7800cnfclaim payload, a closed union ofjktandx5t#S256; adding a variant is a core semver-minor change.TokenBindingMechanism(src/middleware/tokenBinding.mts) — the verb-side abstraction: akind,intentExplicit(truefor header-driven mechanisms such as DPoP,falsefor ambient ones such as mTLS) andextract(req).TokenBindingRefusal(same file) — whatextractthrows to refuse, read by duck type, and the mechanism's word on which of three answers it is. A verdict on the material is400 <code>at the token endpoint and401 invalid_tokenwith a challenge at a protected resource. AretryInstruction(DPoP'suse_dpop_nonce) is400 <code>at the token endpoint and401challenging with that code at a protected resource. Anunavailableoutage — the mechanism could not reach a verdict, such as a replay store that cannot be read — is503 <code>at both, with no challenge, because the credential is not at fault. The dispatcher that answers the503owns its one error-level line —token_binding_unavailableorprotected_resource_binding_unavailable, with themechanismandcode— so a mechanism need not, and should not, log the outage itself. An outage refusal may add areason(its own name for it — logged when it is a code, as on the verdict line below, never sent) and the failure that stopped the verdict as the standardcause, whoseloggableErrorprojection the line carries. A verdict is one warn line —token_binding_proof_invalidorprotected_resource_binding_proof_invalid— with themechanism, thecode, the refusal'sreasonwhen it is a code, and the refusal'sloggableErrorprojection aserr, whose owncauseis the error that made the mechanism refuse (a parser's, a library's); so a verdict refusal, too, states itsreasonand carries a parser's or a library's error ascause, never in its message. Every401 invalid_tokenrefusal at a protected resource is also onesender_constraint_rejectedwarn line (the503outage and the retry instruction's401— DPoP'suse_dpop_nonce— are not), withrejectionnaming the sender-constraint rule that refused the request —compound_cnf,scheme_mismatch,proof_invalid,no_matching_binding— besideschemeandsite; it wasreason, the name the verdict line uses for the mechanism's own. The dispatchers never learn a mechanism's codes.TokenBindingMechanismFactory<Deps>(src/modules/manifest/contributes-map.mts) — the contribution-slot entry: it answers a mechanism, ornullwhen the module is disabled by config (secure-default opt-in).
Built-in mechanism packages
@o3co/auth-provider-dpop— RFC 9449 DPoP (explicit-intent).@o3co/auth-provider-mtls— RFC 8705 mTLS certificate-bound tokens (ambient).
Both packages contribute via tokenBindingMechanisms. Core's assembleApp collects all contributions, filters nulls, and composes ONE tokenBindingMw mounted on /oauth/token. It and the grantMiddleware contributions run for the token endpoint alone — a POST to /oauth/token, with or without a trailing slash, in any letter case — and not for another method or a longer path beneath it; inside them the request is what a use mount on /oauth/token shows (req.path /, req.baseUrl ending in /oauth/token). The sender-constraint check exempts exactly the same requests.
Dispatch policy
When multiple mechanisms are installed, oauth.tokenBinding.dispatch-policy (in core's bundled CoreConfigSchema — single source of truth) arbitrates:
intent-explicit(default) — prefer explicit-intent mechanisms over ambient.strict-mutual-exclusion— rejectinvalid_requestif more than one mechanism'sextractreturns a binding.
Env override: OAUTH_TOKEN_BINDING_DISPATCH_POLICY.
Grant-side allowlist
The grants in @o3co/auth-provider-oauth emit cnf-bound RTs only for mechanisms in an explicit allowlist (bindingIsDpop || bindingIsMtls). Adding a new mechanism to bound-RT issuance MUST land its refresh-time enforcement matrix in the same PR — see packages/oauth for the §9.2 matrix pattern.
Session stores and federation tokens
Two groups of optional slots for federation and OIDC support, provided by a module (memorySessionStoresModule, memoryFederationTokenStoreModule) or by the Redis adapters:
userSessionStoreand its sid- and subject-keyed siblings: session metadata (auth_time, active RPs, family IDs, OIDC claims), the logout fan-out indexes, and subject-wide revocation —src/user-sessions/README.md.federationTokenStore:(sid, federationName)-keyed upstream IdP tokens, deleted at logout. The Redis adapter encryptsrefresh_tokenwith AES-256-GCM;allow-plaintextis opt-in and emits a warning. A store must round-trip every field ofFederationTokens—expiresAt: nullincluded, andundefined, nevernull, for a field with nothing recorded. The port contract, field by field, is in src/README.md, and what a store implementer changes for the required keys is docs/upgrading-required-record-keys.md.
@o3co/auth-provider-oauth consumes both: logout and cascading revocation, id_token and /userinfo, and POST /oauth/federation/:name/token. When any federations.<name>.enabled is true, boot refuses a composition missing any of userSessionStore, sessionRPRegistry, sessionFamilyIndex, sessionFederationIndex, federationTokenStore and refreshTokenFamilyRevocation (federation-stores-incomplete).
SupportsLock— optional capability onFederationTokenStorefor per-(sid, federationName)advisory locks, which keep concurrent refreshes from stampeding the upstream. Both bundled stores implement it; detect it with thesupportsLock(store)guard. The lock implementations behind them — core'screateInProcessLock(src/federation-tokens/lock/memory.mts) and@o3co/auth-provider-redis'screateRedisLock— are internal and not exported; a custom store that needs locking exposesSupportsLockinstead.Client.allowedAzpForFederationToken— opt-in flag on theClientrecord; absent meansfalse. A client that consumesPOST /oauth/federation/:name/tokenmust set it totrue.
OIDC id_token and claim filter
Two low-level helpers used by the authorization_code grant and the /oauth/userinfo endpoint.
generateIdToken
generateIdToken(opts) is in src/grants/idToken.mts, with its options, GenerateIdTokenOptions, beside it; expiresIn defaults to 3600 s.
Signs and returns an OIDC id_token JWT (OIDC Core §2). Claim composition:
iss,sub,aud,exp,iat,jti— standard JWT claimsauth_time— seconds since epoch, fromopts.authTimesid— session identifier for back-channel logoutazp— authorized party, included when providednonce— reflected verbatim from the authorization request when providedamr,acr— when the session recorded them; an emptyamris omitted, not emitted as[]- scope-filtered user claims via
filterClaimsByScope
Header uses typ: "JWT" — the standard spelling, kept deliberately disjoint from RFC 9068's at+jwt so an id_token can never pass an access-token surface. An id_token carrying id+jwt is refused as an ordinary typ mismatch.
filterClaimsByScope
filterClaimsByScope(claims, scopes) (src/grants/claimFilter.mts) maps UserSessionClaims to the JWT-shaped claim subset that the granted scopes authorize. Strict whitelist — only the mappings in the table below are emitted; any other UserSessionClaims fields (e.g. provider-specific fields like hd) are never forwarded.
| Scope | Emitted claims |
| --- | --- |
| openid | (no claims — governs id_token issuance; sub is added by generateIdToken) |
| profile | name, picture |
| email | email, email_verified |
| groups | groups |
/.well-known/openid-configuration
OIDC Discovery 1.0 metadata endpoint. Synthesized and mounted by core when config.oauth.jwt.issuer is configured AND a module declares the provider surface (oauthModule sets providerRoot: true on its discoveryMetadata contribution). Core aggregates every module's discoveryMetadata slice into one document. issuer and id_token_signing_alg_values_supported are core's own, and a module may not set them; a document missing a field OIDC Discovery requires refuses boot (`discovery-docum
