import { type HookHandler, type PluginFunction } from "@celsian/core"; import * as jose from "jose"; /** Options for the JWT plugin. */ export interface JWTOptions { /** HMAC shared secret (HS256/HS384/HS512). Mutually exclusive with `publicKey`/`jwksUri`. */ secret?: string; /** * Public key for asymmetric verification (RS*, PS*, ES*, EdDSA), as a PEM * SPKI string or a JWK object. Verification only unless `privateKey` is set. */ publicKey?: string | jose.JWK; /** Private key for asymmetric signing, as a PEM PKCS#8 string or a JWK object. */ privateKey?: string | jose.JWK; /** * HTTPS URL of a JWKS endpoint (Auth0, Clerk, Cognito, ...). The key set is * cached, rotated, and selected by the token's `kid` header. Must be `https:`. */ jwksUri?: string; /** Tuning for the remote JWKS fetch. */ jwks?: JWKSOptions; /** Allowed signature algorithms. Always pinned on every verify. */ algorithms?: string[]; /** Required `iss` claim. Set on tokens produced by `sign()`. */ issuer?: string | string[]; /** Required `aud` claim. Set on tokens produced by `sign()`. */ audience?: string | string[]; /** Required `sub` claim. */ subject?: string; /** Clock skew tolerance, e.g. `'30s'` or seconds as a number. Default: none. */ clockTolerance?: string | number; /** Maximum age since `iat`, e.g. `'1h'`. */ maxTokenAge?: string | number; /** * Reject tokens that carry no `exp` claim. Default: `true`. A token without * `exp` is a permanent bearer credential and this package has no revocation * mechanism, so it fails closed unless you opt out explicitly. */ requireExpiration?: boolean; /** * Default lifetime applied by `sign()` when the call site does not pass one. * Default: `'15m'`. Pass `false` to mint non-expiring tokens, which also * requires `requireExpiration: false`. */ expiresIn?: string | number | false; } /** Tuning for remote JWKS fetching. */ export interface JWKSOptions { /** Minimum time between refetches after a `kid` miss, in ms. Default: 30_000. */ cooldownDurationMs?: number; /** Maximum age of the cached key set before a background refresh, in ms. Default: 600_000. */ cacheMaxAgeMs?: number; /** Fetch timeout in ms. Default: 5_000. */ timeoutMs?: number; } /** JWT payload with standard claims (iss, sub, exp, etc.) plus custom fields. */ export interface JWTPayload { [key: string]: unknown; iss?: string; sub?: string; aud?: string | string[]; exp?: number; iat?: number; } /** Sign and verify methods exposed on `app.jwt` after registering the plugin. */ export interface JWTNamespace { sign(payload: JWTPayload, options?: { expiresIn?: string | number | false; }): Promise; verify(token: string): Promise; } /** * A registered JWT realm: a plugin function that also exposes a guard bound to * this exact realm. Use `.guard()` whenever an app runs more than one realm, * it never depends on ambient request state, so it cannot resolve to a * neighbouring realm's key material. */ export interface JWTPlugin extends PluginFunction { /** Guard bound to THIS realm's key material and claim policy. */ guard(): HookHandler; /** Sign a token with this realm's key material (usable before registration). */ sign(payload: JWTPayload, options?: { expiresIn?: string | number | false; }): Promise; /** Verify a token against this realm. */ verify(token: string): Promise; } /** * JWT authentication plugin. Decorates `app.jwt` with `sign()` and `verify()`. * * With a SECOND realm on the same app, `app.jwt` becomes ambiguous (it is one * app-wide property) and starts rejecting: sign and verify through the realm * handle instead. Give each realm a prefix as well, an un-prefixed realm is * app-wide and two of them cover the same routes, which no ambient guard can * resolve. * * @example * ```ts * // Single realm * await app.register(jwt({ secret: process.env.JWT_SECRET! })); * const token = await app.jwt.sign({ sub: userId }); * app.addHook('preHandler', createJWTGuard()); * * // Multiple realms on one app: one prefix each, and a guard bound to its realm * const tenantA = jwt({ secret: process.env.TENANT_A_SECRET!, issuer: 'tenant-a' }); * await app.register(tenantA, { prefix: '/tenant-a' }); * app.addHook('preHandler', tenantA.guard()); * const token = await tenantA.sign({ sub: userId }); // NOT app.jwt.sign() * ``` */ export declare function jwt(options: JWTOptions): JWTPlugin; /** * Create a preHandler hook that verifies Bearer tokens and populates `request.user`. * * When called without arguments, the realm is resolved from the request at * request time: the realm whose scope covers the matched route. It THROWS * rather than guessing whenever that answer is not exactly one realm, either * because the route lies inside SEVERAL realms (what two un-prefixed realms * produce, since an un-prefixed plugin is app-wide) or because it lies inside * NONE while the app runs more than one. Pass an explicit `{ secret }` here, or * use the realm-bound `jwt(...).guard()`, for those routes. * * A single-realm app is never ambiguous and resolves everywhere, inside that * realm's scope or outside it. * * @example * ```ts * // Option 1: No args, single-realm apps * await app.register(jwt({ secret: process.env.JWT_SECRET! })); * app.addHook('preHandler', createJWTGuard()); * * // Option 2: Explicit config, required when several realms share one app * app.addHook('preHandler', createJWTGuard({ secret: process.env.JWT_SECRET!, issuer: 'api' })); * ``` */ export declare function createJWTGuard(options?: JWTOptions): HookHandler; declare module "@celsian/core" { interface CelsianApp { /** JWT sign/verify methods. Available after `app.register(jwt({ secret }))`. */ jwt: JWTNamespace; } } declare module "@celsian/core" { interface CelsianRequest { /** JWT payload populated by `createJWTGuard()`. */ user?: JWTPayload; } }