auth.provider API
    Preparing search index...

    Module @o3co/auth-provider-core

    @o3co/auth-provider-core

    Grant registry, token service, repository interfaces, app config, and module system for auth.provider. This package defines the core abstractions that all other packages build on.

    npm install @o3co/auth-provider-core
    

    Peer dependencies: express@^5.0.0 (optional — required only when using createApp)

    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);

    Top-level fields:

    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 (provider + per-provider sub-section)
    oauth.accessToken.expiresIn Access token lifetime
    oauth.refreshToken.expiresIn Refresh token lifetime
    oauth.grants Per-grant-type config (session, authorization, refresh_token, and custom keys)
    session Express session — secret, maxAge, secure, sameSite, domain, storage, csrf
    session.csrf CSRF policy for the state-changing session routes — trustedOrigins, ttlSeconds (#272)
    rateLimit Rate limit config for login, token, and authorize endpoints
    federations Federation providers — z.record(string, { enabled, type?, ...passthrough }). Built-in types: "google", "github".
    repositories Repository config for clients, users, and codes
    endpoints Path overrides for login, client, and authCallback routes
    cors.allowedOrigins CORS allowed origins. Since #272 this does not grant CSRF trust — use session.csrf.trustedOrigins.

    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.

    interface SessionData {
    user?: Record<string, unknown>;
    client?: Record<string, unknown>;
    code?: string;
    code_client_id?: string;
    granted_scopes?: string[];
    isAuthenticated?: boolean;
    }

    interface GrantContext {
    body: Record<string, unknown>;
    session: SessionData;
    issuer?: string;
    metadata: Record<string, unknown>;
    }

    interface GrantSuccess {
    status: number;
    tokens: TokenResponse;
    }

    interface GrantError {
    status: number;
    error: string;
    errorDescription?: string;
    }

    type GrantResult = GrantSuccess | GrantError;

    interface SessionMutation {
    clear?: string[];
    set?: Record<string, unknown>;
    }

    interface GrantHandlerResult {
    result: GrantResult;
    sessionMutation?: SessionMutation;
    }

    interface GrantHandler {
    handle(ctx: GrantContext): Promise<GrantHandlerResult>;
    cleanup?(): void;
    }

    interface GrantDependencies {
    config: AppConfig;
    keyStore: KeyStore;
    pathResolver?: PathResolver;
    }

    type GrantFactory = (deps: GrantDependencies) => GrantHandler;

    interface GrantModule {
    grants: Record<string, GrantFactory>;
    configSchema?: z.ZodType;
    }

    Grant handlers are wired into the boot planner via contributes.grants on a module's defineModule manifest (see A2-γ §3.3). The boot planner instantiates each GrantFactory, registers the resulting handler, and (after addModule for all modules) calls freeze() so post-boot mutation throws loudly.

    Consumer code does NOT need to import or instantiate any registry class. Cleanup of grant handlers runs through the unified handle.dispose() returned by createAppAppHandle.dispose() runs per-component lifecycle[K].cleanup callbacks in reverse-topological order per A2-β §8.1.

    Removed: the GrantRegistry and GrantRegistryError classes (deprecated as public re-exports in v0.5.1 per AS-8) are no longer exported from @o3co/auth-provider-core. They remain as internal implementation detail of the boot planner. Existing consumers of the v0.4.x new GrantRegistry() pattern should migrate to module-based contributes.grants declarations. See CHANGELOG for the release that performed this removal.

    interface Token {
    token: string;
    expiresIn?: number;
    subject?: string;
    scope?: string;
    tokenType?: "at+jwt" | "rt+jwt";
    audience?: string;
    issuer?: string;
    }

    interface TokenResponse {
    access_token: string;
    token_type: string;
    scope?: string;
    refresh_token?: string | null;
    expires_in?: number;
    }

    interface GenerateTokenOptions {
    expiresIn?: number;
    keyStore: KeyStore;
    issuer?: string | null;
    audience?: string | null;
    subject?: string | null;
    authorizedParty?: string | null;
    scope?: string | null;
    tokenType?: "at+jwt" | "rt+jwt";
    }

    function generateToken(data: object, options: GenerateTokenOptions): Promise<Token>;
    function generateTokenResponse(tokens: { accessToken: Token; refreshToken?: Token }): TokenResponse;
    function formatObject<T extends object>(data: T): Partial<T>;

    generateToken signs a JWT using the current signing key from keyStore. generateTokenResponse formats an access token and optional refresh token into the OAuth 2.0 token endpoint response shape. formatObject strips undefined values from an object.

    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 + publicKey + expiresAt), and HS256 uses previousSecrets (kid + secret + expiresAt). getVerificationKey(kid) resolves the key by kid — the keystore returns the matching key directly, never trial-verifies across keys. 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.

    type KeyLike = CryptoKey | KeyObject | Uint8Array;

    interface ManagedKey {
    kid: string;
    publicKey: KeyLike;
    expiresAt?: Date;
    }

    interface JWTPayload {
    iss?: string;
    sub?: string;
    aud?: string | string[];
    jti?: string;
    nbf?: number;
    exp?: number;
    iat?: number;
    [propName: string]: unknown;
    }

    interface SignJwtOptions {
    claims: JWTPayload; // RFC 7519 claims
    header?: { typ?: string }; // alg / kid are KeyStore-injected; caller cannot override
    }

    interface KeyStore {
    readonly algorithm: "HS256" | "RS256" | "ES256" | "EdDSA";
    sign(options: SignJwtOptions): Promise<string>;
    getSigningKidFallback(): string;
    getVerificationKeys(): Promise<ManagedKey[]>;
    getVerificationKey(kid: string): Promise<KeyLike>;
    }

    interface AsymmetricKeyStoreOptions {
    algorithm: "RS256" | "ES256" | "EdDSA";
    kid: string;
    privateKeyPem: string;
    publicKeyPem: string;
    previousKeys?: Array<{ kid: string; publicKeyPem: string; expiresAt: Date }>;
    }

    type KeyStoreFactory = AdapterFactory<KeyStore>;

    interface SymmetricPreviousSecret {
    kid: string;
    secret: string;
    expiresAt: Date;
    }

    function createAsymmetricKeyStore(options: AsymmetricKeyStoreOptions): Promise<KeyStore>;
    function createSymmetricKeyStore(
    secret: string,
    kid?: string,
    previousSecrets?: ReadonlyArray<SymmetricPreviousSecret>,
    ): KeyStore;
    function createKeyStoreFactory(): KeyStoreFactory;
    function registerBuiltinKeyStores(factory: KeyStoreFactory): void;

    createKeyStoreFactory creates a new factory with no registered types. registerBuiltinKeyStores registers the built-in "local" provider, which dispatches to createAsymmetricKeyStore or createSymmetricKeyStore based on algorithm. The factory pattern follows the same AdapterFactory<T> contract as ClientRepository, UserRepository, and CodeRepository factories.

    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 algorithm is an error, not an implicit HS256.
    • An asymmetric algorithm with no privateKey/privateKeyPath (or no public half) throws a message naming the exact config keys, the exact environment variables, and the openssl genpkey -algorithm ed25519 command that produces them.
    • HS256 requires secret to carry at least MIN_SECRET_ENTROPY_BYTES (32) of key material, and so does every previousSecrets[].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.

    To rotate an HS256 signing key without a maintenance window:

    1. Record the current kid and secret values.

    2. Generate a new secret: openssl rand -hex 32.

    3. Update application.conf to set the new kid + secret and move the old pair into previousSecrets:

      oauth.jwt.signingKey.local {
        algorithm = "HS256"
        kid = "v1"           # new kid
        secret = ""
        previousSecrets = [{
          kid = "v0"          # old kid
          secret = ""
          expiresAt = "2026-06-05T00:00:00Z"  # access-token TTL + buffer
        }]
      }
      
    4. Restart the server. Tokens signed by v0 continue to verify (resolved by kid from the JWT header) until expiresAt.

    5. After the overlap window passes (all v0 tokens have expired), remove v0 from previousSecrets and 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.

    Repository interfaces define the data access contract. Built-in in-memory implementations are provided for development and testing.

    interface Client {
    clientId: string;
    clientSecret: string;
    allowedRedirectUris: string[];
    allowedScopes: string[];
    // Logout metadata (TODO-F-5):
    postLogoutRedirectUris?: string[];
    backchannelLogoutUri?: string;
    backchannelLogoutSessionRequired?: boolean; // default: true
    frontchannelLogoutUri?: string;
    frontchannelLogoutSessionRequired?: boolean; // default: true
    }

    type PublicClient = Omit<Client, "clientSecret">;

    interface User {
    id: string;
    username: string;
    [key: string]: unknown;
    }

    interface CodeData {
    code_challenge?: string;
    code_challenge_method?: string;
    }

    interface Code extends CodeData {
    code: string;
    expiresIn?: number;
    }

    interface ClientRepository {
    findById(clientId: string): Promise<PublicClient | null>;
    authenticate(clientId: string, secret: string): Promise<PublicClient | null>;
    }

    interface UserRepository {
    authenticate(username: string, password: string): Promise<User | null>;
    authenticateByToken(token: string): Promise<User | null>;
    }

    interface CodeRepository {
    createCode(params: {
    code_challenge?: string;
    code_challenge_method?: string;
    expiresIn?: number;
    }): Promise<Code>;
    findByCode(code: string): Promise<Code | null>;
    consumeByCode(code: string): Promise<Code | null>;
    removeByCode(code: string): Promise<void>;
    }
    class InMemoryClientRepository implements ClientRepository {
    constructor(clients: Map<string, ClientEntry>);
    }

    class InMemoryUserRepository implements UserRepository {
    constructor(users: Map<string, UserEntry>);
    }

    class InMemoryCodeRepository implements CodeRepository {
    constructor(options?: { defaultExpiresIn?: number });
    dispose(): void; // clears internal timers
    }
    const ClientEntrySchema: z.ZodObject<...>;
    const UserEntrySchema: z.ZodObject<...>;

    function loadYamlMap<T extends z.ZodTypeAny>(
    filePath: string,
    schema: T
    ): Map<string, z.infer<T>>;

    loadYamlMap reads a YAML file whose top-level keys are record IDs and validates each entry against schema. Pass the result directly to InMemoryClientRepository or InMemoryUserRepository.

    interface BuilderContext {
    // Intentionally empty in v1; future additions (logger, tracer, abortSignal, ...)
    // are guaranteed to be optional field additions (additive-only evolution).
    }

    type AdapterBuilder<T> = (
    config: Record<string, unknown>,
    ctx: BuilderContext,
    ) => Promise<T> | T;

    interface AdapterFactory<T> {
    register(type: string, builder: AdapterBuilder<T>): void;
    create(config: { type: string; [key: string]: unknown }): Promise<T>;
    registeredTypes(): string[];
    }

    function createAdapterFactory<T>(
    kind: string,
    ctx?: BuilderContext,
    ): AdapterFactory<T>;

    class AdapterFactoryError extends Error {
    readonly kind: string;
    readonly type: string;
    readonly registered: readonly string[];
    }

    function createRepositoryFactories(): {
    clientFactory: AdapterFactory<ClientRepository>;
    userFactory: AdapterFactory<UserRepository>;
    codeFactory: AdapterFactory<CodeRepository>;
    };

    Key contract properties:

    • create() always returns Promise<T>, even for synchronous builders.
    • register() throws if a type is registered twice (silent-override prevention).
    • create() throws AdapterFactoryError when type is not registered; the error carries the kind, type, and registered list.
    • BuilderContext is 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.

    Modules extend the app with additional routes, grant handlers, or DI-graph components. v0.5.0 modules are declarative manifests authored via defineModule({...}) — they declare requires / optional (typed ProviderDeps keys) and contribute to ContributesMap slots like grants, routes, federations.

    type PathResolver = (specifier: string) => string;

    const myModule = defineModule({
    name: "my-module",
    requires: ["config", "clientRepository"] as const,
    contributes: {
    routes: [
    (deps) => ({ id: "my-route", mountPath: "/my", handler: makeRouter(deps) }),
    ],
    },
    });

    Note: the v0.4.x LegacyModule / ModuleContext shape (a function returning { name, init(context) }) was removed in Phase 9 of the v0.5.0 redesign. The boot planner injects typed deps directly into the contribution lambdas; modules no longer mutate a shared ModuleContext.

    interface CreateAppOptions {
    modules: readonly Module[];
    bootstrapComponents: { config: AppConfig; pathResolver: PathResolver };
    contributionKinds?: ContributionKindMap;
    overrideComponents?: Partial<ComponentMap>;
    }

    interface AppHandle {
    router: Router;
    components: Partial<ComponentMap>;
    routes: readonly OrderedRouteContribution[];
    listen(port: number): Promise<HttpServer>;
    dispose(): Promise<void>;
    }

    function createApp(options: CreateAppOptions): Promise<AppHandle>;

    createApp wires together config, key store, grant registry, and modules into a single Express router. Call init() to run all module initializers. The router is ready to mount after init() resolves.

    Built-in routes registered unconditionally:

    • GET /health — returns 200 OK
    • GET /.well-known/jwks.json — returns the public key set from keyStore

    ExpressLike is a structural type — any object with Router(), json(), and urlencoded() methods satisfies it. Pass the express default export directly.

    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);
    import { defineModule } from "@o3co/auth-provider-core";
    import type { GrantFactory, GrantHandler } 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: ["keyStore"],
    contributes: {
    grants: { my_grant: myGrantFactory },
    },
    });

    Add myGrantModule to the modules array passed to createApp. The boot planner registers the grant through the contributes.grants projection per A2-γ Amendment 3 (grantHandlerResolver synthetic key).

    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);

    Five extension points introduced in v0.4.0.

    • MfaProvider, optional SupportsEnrollment / SupportsRevocation capabilities
    • Factory: createMfaProviderFactory(), type guards supportsEnrollment() / supportsRevocation()
    • Flow: /oauth/authorize + /auth/federation/callback consult MfaCoordinator.listEnrolled(userId); on MFA required, transaction saved via MfaTransactionStore, user posts to POST /auth/mfa/verify { transaction_id, proof }, core dispatches via providerKind
    • No built-in providers in v0.4.0 — TOTP / WebAuthn / backup codes ship in later spec
    • AuditSink.record(event) fire-and-forget
    • Factory: createAuditSinkFactory(), built-in "console" via registerBuiltinAuditSinks()
    • Errors swallowed by core — audit failure never blocks auth flow
    • RateLimiter.check(key, ctx) atomic check + increment
    • Factory: createRateLimiterFactory(), built-in "memory" and "redis" via registerBuiltinRateLimiters()
    • 429 + Retry-After emitted by core on denial
    • The built-in "redis" limiter requires config.client matching { incr(key): Promise<number>; expire(key, seconds): Promise<number> }. Core does not depend on the redis package and does not create its own client (RateLimiter has no disposal hook — lifecycle stays with the consumer). Any redis-compatible client satisfying that shape works.
    • RefreshTokenStoreBase.rotate(previousJti, newJti, familyId, expiresAt) atomic primitive
    • All rt+jwt tokens carry family_id claim (always emitted, backward-compatible)
    • Optional: set AppOptions.refreshTokenStore to enable replay detection + family revocation
    • GrantPolicyHook.evaluate(request, ctx) returns allow (with optional narrowing) or deny
    • /oauth/authorize evaluates once; /oauth/token re-uses grantedScope / grantedAudience persisted on the Code record (no re-evaluation for authorization_code)
    • Other grants (refresh / client_credentials / token-exchange) evaluate at the token endpoint

    All five adapters are optional — absence = no-op default.

    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 full design rationale.

    • TokenBinding{ readonly kind: string; readonly confirmation: Confirmation }. The cross-cutting binding shape; kind is open so downstream mechanisms can extend additively.
    • Confirmation — narrow union { readonly jkt: string } | { readonly "x5t#S256": string }. RFC 7800 cnf claim payload; adding a new variant is a core semver-minor change.
    • TokenBindingMechanism{ kind, intentExplicit, extract(req) }. The verb-side abstraction; intentExplicit: true for header-driven mechanisms (DPoP), false for ambient ones (mTLS).
    • TokenBindingMechanismFactory<Deps>(deps) => TokenBindingMechanism | null. The contribution-slot entry shape; return null when the module is disabled by config (secure-default opt-in).
    • @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.

    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 — reject invalid_request if more than one mechanism's extract returns a binding.

    Env override: OAUTH_TOKEN_BINDING_DISPATCH_POLICY.

    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.

    Two new optional AppOptions fields introduced for federation + OIDC support:

    • userSessionStore: sid-keyed session metadata (auth_time, active RPs, family IDs, OIDC claims). Built-in adapters: memory, redis.
    • federationTokenStore: (sid, federationName)-keyed upstream IdP tokens. Built-in adapters: memory, redis (with mandatory AES-256-GCM encryption of refresh_token; allow-plaintext is opt-in and emits a warning).

    Both stores are consumed by upcoming TODO-F-3 (cascading revocation), F-4 (id_token + /userinfo), F-5 (logout), and F-6 (/oauth/federation/:name/token). This plan (F-1) only adds the plumbing. See the exported type signatures in @o3co/auth-provider-core (UserSessionStore, FederationTokenStore) for the full interface; the redis adapter's encryption contract is documented inline at its construction site.

    F-3 consumers activated. CodeData now carries two optional fields wired by the login paths:

    • nonce? — OIDC nonce forwarded from the authorization request, persisted on the code record for later id_token/userinfo use.
    • sid? — Session ID (UserSession.sid) written by the login handler and used to bind minted tokens to the session.

    The CodeRepository.createCode params and InMemoryCodeRepository accept nonce, sid, and grantedScope in the same call. Consumers (the authorization_code grant) read codeData.grantedScope (set by GrantPolicyHook at /oauth/authorize) as the authoritative scope for token minting instead of session.granted_scopes.

    Two low-level helpers used by the authorization_code grant and the /oauth/userinfo endpoint.

    interface GenerateIdTokenOptions {
    readonly sub: string;
    readonly aud: string;
    readonly azp?: string;
    readonly authTime: Date;
    readonly nonce?: string;
    readonly sid: string;
    readonly scopes: ReadonlyArray<string>;
    readonly userClaims: UserSessionClaims;
    readonly keyStore: KeyStore;
    readonly issuer: string;
    readonly expiresIn?: number; // default 3600 s
    }

    function generateIdToken(opts: GenerateIdTokenOptions): Promise<Token>;

    Signs and returns an OIDC id_token JWT (OIDC Core §2). Claim composition:

    • iss, sub, aud, exp, iat, jti — standard JWT claims
    • auth_time — seconds since epoch, from opts.authTime
    • sid — session identifier for back-channel logout (TODO-F-5)
    • azp — authorized party, included when provided
    • nonce — reflected verbatim from the authorization request when provided
    • scope-filtered user claims via filterClaimsByScope

    Header uses typ: "JWT" (#394) — the standard spelling, kept deliberately disjoint from RFC 9068's at+jwt so an id_token can never pass an access-token surface. Tokens minted before #394 carry id+jwt, which verification accepts through the #402 migration window.

    function filterClaimsByScope(
    claims: UserSessionClaims,
    scopes: ReadonlyArray<string>,
    ): Record<string, unknown>;

    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

    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 — oauthModule contributes the endpoints + capabilities, jwksModule contributes jwks_uri — into one document advertising:

    • issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, introspection_endpoint
    • jwks_uri — always advertised (contributed by jwksModule); an issuer-configured composition MUST install jwksModule or boot fails fast with DiscoveryDocumentError. The route never publishes an empty key set (#282): an HS256 deployment answers 404 jwks_not_published, and an asymmetric keystore that yields no exportable public key answers 503 jwks_unavailable, both with Cache-Control: no-store. The symmetric secret is never published either way. A 200 from this route always carries at least one key.
    • revocation_endpoint — advertised when POST /oauth/revoke can revoke anything at all. Two arms, resolved the way the route resolves them: the refresh arm is a wired refreshTokenFamilyRevocation; the access arm is a wired accessTokenDenylist and oauth.revocation.accessToken not being "unsupported" (an explicit "unsupported" disables that path however the composition is wired — see #277). Either arm suffices: RFC 7009 §2.2.1 defines unsupported_token_type precisely so an AS may revoke one token type and not the other, so a refresh-only endpoint is a revocation endpoint, and withholding the URL would leave a client that wants to revoke a refresh token at logout unable to revoke anything. With neither arm the route still answers RFC 7009's mandatory 200 and revokes nothing, so advertising it would restate the #277 failure as metadata. Which token types it revokes is still not derivable from discovery — RFC 7009 / RFC 8414 define no per-type metadata field, and the access-token answer surfaces at the endpoint itself as unsupported_token_type.
    • response_types_supported: ["code"]
    • subject_types_supported: ["public"]
    • id_token_signing_alg_values_supported — derived from the configured KeyStore.algorithm
    • scopes_supported: ["openid", "profile", "email", "groups"]
    • grant_types_supported — read off the grant-handler registry /oauth/token dispatches against, so it lists exactly the grants this composition registered (each gated by oauth.grants.<name>.enabled) and nothing else. Emitted even when empty: RFC 8414 §2 reads an omitted grant_types_supported as ["authorization_code", "implicit"], which advertised an implicit flow this AS has never implemented.
    • token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"]
    • introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"] — no none: /oauth/introspect refuses public clients per RFC 7662 §2.1
    • revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"] — alongside revocation_endpoint; none is present because RFC 7009 §2.1 lets a public client revoke its own tokens
    • code_challenge_methods_supported: ["S256"]S256 only, and deliberately not derived from oauth.grants.authorization_code.pkce.supportedMethods. This array is server-wide metadata: every client that reads it concludes "I may use any of these". plain never satisfies that (/authorize refuses it outright for public clients per RFC 9700 §2.1.1), so it stays out — a per-client exception does not belong in a server-wide array in either direction.
    • dpop_signing_alg_values_supported — contributed by @o3co/auth-provider-dpop when oauth.dpop.enabled = true, carrying that module's alg-whitelist verbatim (RFC 9449 §5.1)
    • tls_client_certificate_bound_access_tokens: true — contributed by @o3co/auth-provider-mtls when oauth.mtls.enabled = true (RFC 8705 §3.3). Omitted otherwise, which the RFC already defines as false. Independent of oauth.mtls.source: both the TLS-layer and the trusted-proxy header path produce the same cnf["x5t#S256"] on the token, and this flag describes the token.
    • end_session_endpoint — added in TODO-F-5
    • backchannel_logout_supported: true — added in TODO-F-5
    • backchannel_logout_session_supported: true — added in TODO-F-5
    • frontchannel_logout_supported: true — added in TODO-F-5
    • frontchannel_logout_session_supported: true — added in TODO-F-5

    Low-level helpers used by POST /oauth/logout in @o3co/auth-provider-oauth.

    interface GenerateLogoutTokenOptions {
    readonly issuer: string;
    readonly sub: string;
    readonly aud: string | string[];
    readonly sid?: string;
    readonly includeSid?: boolean; // default true
    readonly keyStore: KeyStore;
    readonly expiresIn?: number; // default 300 s
    }

    function generateLogoutToken(opts: GenerateLogoutTokenOptions): Promise<Token>;

    Generates a signed logout_token JWT (OIDC Back-Channel Logout 1.0 §2.4). Header typ: logout+jwt. Claim composition: iss, sub, aud, iat, exp, jti, and events carrying { [BACKCHANNEL_LOGOUT_EVENT_URI]: {} }. The sid claim is included by default; set includeSid: false for RPs registered with backchannel_logout_session_required: false. Default TTL is 300 s. The nonce claim is never included (spec §2.4 requirement).

    const BACKCHANNEL_LOGOUT_EVENT_URI: "http://schemas.openid.net/event/backchannel-logout";
    

    The canonical event URI required in every logout_token's events claim. Exported so downstream code and tests can reference it without re-literalizing.

    interface Logger {
    warn(message: string, ...args: unknown[]): void;
    }

    Minimal structural logger interface accepted by cascadeLogout, broadcastBackchannelLogout, and other internal call sites. Structurally compatible with console, pino, winston, bunyan, etc. Additional methods (info, error, debug) are added when an internal consumer needs them.

    Low-level building blocks used by POST /oauth/federation/:name/token in @o3co/auth-provider-oauth.

    The lock primitives below (createInProcessLock, createRedisLock) are internal implementation details of the built-in adapters and are not exported from @o3co/auth-provider-core's public entrypoint. Custom stores that need locking should expose the public SupportsLock capability rather than depending on these internal helpers.

    • SupportsLock — optional capability on FederationTokenStore for per-(sid, federationName) advisory locks. Used to prevent concurrent-refresh thundering herds. Built-in memory and redis adapters both implement this capability. Consumers detect it via the supportsLock(store) type guard.
    • createInProcessLock() — internal in-memory lock implementation used by the built-in memory adapter. Not exported from @o3co/auth-provider-core's public entrypoint.
    • createRedisLock({ client, keyPrefix }) — internal redis-backed lock implementation used by the built-in redis adapter. Not exported from the public entrypoint. Custom stores that need locking should expose the public SupportsLock capability rather than depending on this internal helper.
    • Client.allowedAzpForFederationToken — opt-in flag on the Client interface; default false. Clients that consume POST /oauth/federation/:name/token must set this to true.
    middleware/express.mjs
    modules/manifest
    testing