diff --git a/docs/mcp/DEPLOY.md b/docs/mcp/DEPLOY.md index e4182d593..92312860f 100644 --- a/docs/mcp/DEPLOY.md +++ b/docs/mcp/DEPLOY.md @@ -156,6 +156,20 @@ await oauthProvider.registerClientManual( For self-service client registration (Dynamic Client Registration, RFC 7591), start the server with `--enable-dcr`. DCR is off by default. +DCR requests may include an optional `token_ttl_seconds` field (integer, +seconds) to request a per-client access-token lifetime. The server clamps the +request into an admin-configured window — never rejects over it — persists the +effective value as the client's TTL override, and echoes it back as +`token_ttl_seconds` in the registration response. Subsequent `/token` responses +for that client carry the matching `expires_in`. Clients that omit the field +keep the server default (`--token-ttl`). Configure the window (defaults: 300 +seconds to 7 days): + +```bash +gbrain config set oauth.dcr_ttl_min_seconds 600 +gbrain config set oauth.dcr_ttl_max_seconds 86400 +``` + ### 3. Expose the server **v0.34 — bind explicitly.** `gbrain serve --http` defaults to `127.0.0.1`. diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 19f2d74a7..05a5c8c1d 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -26,7 +26,13 @@ import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/sdk/sha import type { BrainEngine } from '../core/engine.ts'; import { operations, OperationError } from '../core/operations.ts'; import type { OperationContext, AuthInfo } from '../core/operations.ts'; -import { GBrainOAuthProvider, validateTokenEndpointAuthMethod } from '../core/oauth-provider.ts'; +import { + GBrainOAuthProvider, + validateTokenEndpointAuthMethod, + dcrRegistrationContext, + DEFAULT_DCR_TTL_MIN_SECONDS, + DEFAULT_DCR_TTL_MAX_SECONDS, +} from '../core/oauth-provider.ts'; import type { SqlQuery } from '../core/oauth-provider.ts'; import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts'; import { summarizeMcpParams, dispatchToolCall } from '../mcp/dispatch.ts'; @@ -565,11 +571,39 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // constructor option instead of monkey-patching `_clientsStore` after // construction. Same outcome (no /register endpoint when --enable-dcr // is not passed); cleaner shape for tests and future maintainers. + // #2179: admin-configured clamp window for DCR-requested token TTLs. + // DB-plane config keys (`gbrain config set oauth.dcr_ttl_min_seconds ...`); + // unset/invalid values fall back to the defaults, and an inverted window + // falls back wholesale (fail-safe: a bad config can't reject registrations). + const parseDcrTtlBound = (raw: unknown, fallback: number): number => { + const n = Number(raw); + return raw != null && Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback; + }; + let dcrTtlMinSeconds = DEFAULT_DCR_TTL_MIN_SECONDS; + let dcrTtlMaxSeconds = DEFAULT_DCR_TTL_MAX_SECONDS; + try { + dcrTtlMinSeconds = parseDcrTtlBound(await engine.getConfig('oauth.dcr_ttl_min_seconds'), DEFAULT_DCR_TTL_MIN_SECONDS); + dcrTtlMaxSeconds = parseDcrTtlBound(await engine.getConfig('oauth.dcr_ttl_max_seconds'), DEFAULT_DCR_TTL_MAX_SECONDS); + } catch { + // Config read is best-effort; the defaults are the fail-safe. + } + if (dcrTtlMinSeconds > dcrTtlMaxSeconds) { + console.error( + `[serve-http] WARNING: oauth.dcr_ttl_min_seconds (${dcrTtlMinSeconds}) exceeds ` + + `oauth.dcr_ttl_max_seconds (${dcrTtlMaxSeconds}); using defaults ` + + `${DEFAULT_DCR_TTL_MIN_SECONDS}..${DEFAULT_DCR_TTL_MAX_SECONDS}.`, + ); + dcrTtlMinSeconds = DEFAULT_DCR_TTL_MIN_SECONDS; + dcrTtlMaxSeconds = DEFAULT_DCR_TTL_MAX_SECONDS; + } + const oauthProvider = new GBrainOAuthProvider({ sql, tokenTtl, dcrDisabled: !enableDcr, allowClientCredentialsDcr: enableDcrInsecure === true, + dcrTtlMinSeconds, + dcrTtlMaxSeconds, }); // #1353: loud stderr security WARN when DCR is enabled. DCR is an @@ -683,6 +717,20 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption app.use('/register', cors(corsOAuthOptions)); app.use('/revoke', cors(corsOAuthOptions)); + // #2179: capture the optional `token_ttl_seconds` DCR extension field + // BEFORE the SDK's /register handler runs — its request schema strips + // unknown body members, so the value would never reach registerClient. + // The rest of the chain runs inside dcrRegistrationContext; the clients + // store clamps + persists it. Malformed values are ignored (fail-safe: + // absent → server default; out-of-range → clamped downstream; a TTL hint + // never rejects a registration). express.json() here is idempotent with + // the SDK router's own body parser. + app.use('/register', express.json(), (req: Request, _res: Response, next: NextFunction) => { + const raw = (req.body as Record | null | undefined)?.token_ttl_seconds; + const tokenTtlSeconds = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined; + dcrRegistrationContext.run({ tokenTtlSeconds }, next); + }); + // --------------------------------------------------------------------------- // Custom client_credentials handler (before mcpAuthRouter) // SDK's token handler only supports authorization_code and refresh_token diff --git a/src/core/config.ts b/src/core/config.ts index 590a936ca..bccdaa141 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1060,6 +1060,10 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [ 'takes.bootstrap_enabled', 'sync.cost_gate_min_usd', 'sync.federated_v2', + // #2179: clamp window for DCR-requested per-client token TTLs. Read by + // `gbrain serve --http` at startup; defaults 300s / 7d when unset. + 'oauth.dcr_ttl_min_seconds', + 'oauth.dcr_ttl_max_seconds', 'embed.backfill_cooldown_min', 'embed.backfill_max_usd_per_source_24h', 'embed.backfill_max_usd', diff --git a/src/core/oauth-provider.ts b/src/core/oauth-provider.ts index 17f383a7b..d8eaac8c6 100644 --- a/src/core/oauth-provider.ts +++ b/src/core/oauth-provider.ts @@ -13,6 +13,7 @@ * - Legacy access_tokens fallback for backward compat */ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { Response } from 'express'; import type { OAuthClientInformationFull, @@ -203,14 +204,68 @@ interface GBrainOAuthProviderOptions { * (operator-trusted, registers grants directly). */ allowClientCredentialsDcr?: boolean; + /** + * #2179: lower bound (seconds) for DCR-requested per-client token TTLs. + * Requests below it clamp up. Default DEFAULT_DCR_TTL_MIN_SECONDS (300). + */ + dcrTtlMinSeconds?: number; + /** + * #2179: upper bound (seconds) for DCR-requested per-client token TTLs. + * Requests above it clamp down. Default DEFAULT_DCR_TTL_MAX_SECONDS (7 days). + */ + dcrTtlMaxSeconds?: number; } +// --------------------------------------------------------------------------- +// DCR token TTL (#2179) +// --------------------------------------------------------------------------- + +/** + * Default clamp window for DCR-requested token TTLs (#2179). Admins override + * via the `oauth.dcr_ttl_min_seconds` / `oauth.dcr_ttl_max_seconds` config + * keys, read once by `gbrain serve --http` at startup. + */ +export const DEFAULT_DCR_TTL_MIN_SECONDS = 300; // 5 minutes +export const DEFAULT_DCR_TTL_MAX_SECONDS = 7 * 24 * 3600; // 7 days + +/** + * Clamp a DCR-requested token TTL into the admin-configured [min, max] + * window. Fail-safe by design (#2179): out-of-range values clamp to the + * nearest bound — registration is never rejected over a TTL hint. Non-integer + * requests floor; an inverted window collapses to the min bound. + */ +export function clampDcrTokenTtl( + requested: number, + min: number = DEFAULT_DCR_TTL_MIN_SECONDS, + max: number = DEFAULT_DCR_TTL_MAX_SECONDS, +): number { + const lo = Math.max(1, Math.floor(min)); + const hi = Math.max(lo, Math.floor(max)); + return Math.min(hi, Math.max(lo, Math.floor(requested))); +} + +/** + * Request-scoped carrier for the `token_ttl_seconds` DCR extension field + * (#2179). The MCP SDK's /register handler validates the request body against + * a strict schema and STRIPS unknown members before they reach + * `clientsStore.registerClient`, so serve-http's /register middleware parses + * the raw body and runs the SDK chain inside this AsyncLocalStorage context; + * the store reads it back out at registration time. No context (CLI, admin + * API, programmatic registration) means "no TTL request" — default behavior. + */ +export const dcrRegistrationContext = new AsyncLocalStorage<{ tokenTtlSeconds?: number }>(); + // --------------------------------------------------------------------------- // Clients Store // --------------------------------------------------------------------------- class GBrainClientsStore implements OAuthRegisteredClientsStore { - constructor(private sql: SqlQuery, private allowClientCredentialsDcr = false) {} + constructor( + private sql: SqlQuery, + private allowClientCredentialsDcr = false, + private dcrTtlMin: number = DEFAULT_DCR_TTL_MIN_SECONDS, + private dcrTtlMax: number = DEFAULT_DCR_TTL_MAX_SECONDS, + ) {} async getClient(clientId: string): Promise { const rows = await this.sql` @@ -360,6 +415,27 @@ class GBrainClientsStore implements OAuthRegisteredClientsStore { } } + // #2179: optional `token_ttl_seconds` hint from the DCR request body, + // carried via dcrRegistrationContext (the SDK strips unknown body + // members). Fail-safe posture: absent or malformed → server default TTL; + // out-of-range → clamped into [dcrTtlMin, dcrTtlMax]; never rejected. + // Persist into oauth_clients.token_ttl (the same per-client override the + // admin API writes) and echo the EFFECTIVE value in the registration + // response so the caller can show the user what it actually got. + let effectiveTtl: number | undefined; + const requestedTtl = dcrRegistrationContext.getStore()?.tokenTtlSeconds; + if (typeof requestedTtl === 'number' && Number.isFinite(requestedTtl)) { + const clamped = clampDcrTokenTtl(requestedTtl, this.dcrTtlMin, this.dcrTtlMax); + try { + await this.sql`UPDATE oauth_clients SET token_ttl = ${clamped} WHERE client_id = ${clientId}`; + effectiveTtl = clamped; + } catch (e) { + // Pre-migration schema without the token_ttl column: keep the + // registration, but do NOT echo a TTL that wasn't persisted. + if (!isUndefinedColumnError(e, 'token_ttl')) throw e; + } + } + // Public clients: omit `client_secret` entirely from the response so // the wire payload matches RFC 7591 §3.2.1 ("if the client is a // public client, the authorization server MUST NOT issue a client @@ -371,6 +447,9 @@ class GBrainClientsStore implements OAuthRegisteredClientsStore { client_id_issued_at: now, }; if (clientSecret) response.client_secret = clientSecret; + if (effectiveTtl !== undefined) { + (response as Record).token_ttl_seconds = effectiveTtl; + } return response; } } @@ -388,7 +467,12 @@ export class GBrainOAuthProvider implements OAuthServerProvider { constructor(options: GBrainOAuthProviderOptions) { this.sql = options.sql; - this._clientsStore = new GBrainClientsStore(this.sql, options.allowClientCredentialsDcr === true); + this._clientsStore = new GBrainClientsStore( + this.sql, + options.allowClientCredentialsDcr === true, + options.dcrTtlMinSeconds ?? DEFAULT_DCR_TTL_MIN_SECONDS, + options.dcrTtlMaxSeconds ?? DEFAULT_DCR_TTL_MAX_SECONDS, + ); this.dcrDisabled = options.dcrDisabled === true; this.tokenTtl = options.tokenTtl || 3600; this.refreshTtl = options.refreshTtl || 30 * 24 * 3600; @@ -848,20 +932,10 @@ export class GBrainOAuthProvider implements OAuthServerProvider { const requestedScopes = requestedScope ? parseScopeString(requestedScope) : allowedScopes; const grantedScopes = requestedScopes.filter(s => hasScope(allowedScopes, s)); - // Per-client TTL override (stored in oauth_clients.token_ttl) - // Column may not exist on PGLite/older schemas — graceful fallback - let clientTtl: number | undefined; - try { - const ttlRows = await this.sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${clientId}`; - if (ttlRows.length > 0 && ttlRows[0].token_ttl) clientTtl = Number(ttlRows[0].token_ttl); - } catch (e) { - // F5 hardening: same posture as the deleted_at probe above. Only the - // "column doesn't exist" path is a non-fatal fall-through. - if (!isUndefinedColumnError(e, 'token_ttl')) throw e; - } - // Client credentials: access token only, NO refresh token (RFC 6749 4.4.3) - return this.issueTokens(clientId, grantedScopes, undefined, false, clientTtl); + // Per-client TTL (oauth_clients.token_ttl) is applied inside issueTokens + // so all three grant paths honor it (#2179). + return this.issueTokens(clientId, grantedScopes, undefined, false); } // ------------------------------------------------------------------------- @@ -1071,17 +1145,36 @@ export class GBrainOAuthProvider implements OAuthServerProvider { // Internal: Issue access + optional refresh tokens // ------------------------------------------------------------------------- + /** + * Per-client TTL override lookup (oauth_clients.token_ttl). Set by the + * admin API, the CLI, or a DCR `token_ttl_seconds` request (#2179). + * Column may not exist on older schemas — graceful fallback to undefined. + */ + private async lookupClientTokenTtl(clientId: string): Promise { + try { + const ttlRows = await this.sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${clientId}`; + if (ttlRows.length > 0 && ttlRows[0].token_ttl) return Number(ttlRows[0].token_ttl); + } catch (e) { + // F5 hardening posture: only the "column doesn't exist" path is a + // non-fatal fall-through. + if (!isUndefinedColumnError(e, 'token_ttl')) throw e; + } + return undefined; + } + private async issueTokens( clientId: string, scopes: string[], resource: URL | undefined, includeRefresh: boolean, - ttlOverride?: number, ): Promise { const accessToken = generateToken('gbrain_at_'); const accessHash = hashToken(accessToken); const now = Math.floor(Date.now() / 1000); - const effectiveTtl = ttlOverride || this.tokenTtl; + // #2179: the per-client override lives here (not in individual grant + // handlers) so client_credentials, authorization_code AND refresh + // issuance all honor oauth_clients.token_ttl consistently. + const effectiveTtl = (await this.lookupClientTokenTtl(clientId)) || this.tokenTtl; const accessExpiry = now + effectiveTtl; await this.sql` diff --git a/test/e2e/serve-http-oauth.test.ts b/test/e2e/serve-http-oauth.test.ts index c3e8444ef..98970e1d2 100644 --- a/test/e2e/serve-http-oauth.test.ts +++ b/test/e2e/serve-http-oauth.test.ts @@ -675,6 +675,54 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2 + v0.26.3)', () => { } }, 15_000); + // ========================================================================= + // #2179: DCR token_ttl_seconds — wire-level clamp + echo + // ========================================================================= + // + // The unit tests in test/oauth-dcr-ttl.test.ts prove the store-level clamp; + // this is the HTTP seam: the MCP SDK's /register handler STRIPS unknown + // body members, so the field only works if serve-http's middleware carries + // it through dcrRegistrationContext. A request above the default max (7d) + // must come back clamped, not rejected — and the minted token must match. + + test('DCR /register accepts token_ttl_seconds, clamps to policy, echoes effective value (#2179)', async () => { + const res = await fetch(`${BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'e2e-dcr-ttl', + redirect_uris: ['https://example.com/cb'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'client_secret_basic', + scope: 'read', + token_ttl_seconds: 365 * 24 * 3600, // way above the 7d default max + }), + }); + expect(res.ok).toBe(true); + const body = await res.json() as any; + if (body.client_id) dcrClientIds.push(body.client_id); + + // Echoed effective value = clamped to the default max (7 days). + expect(body.token_ttl_seconds).toBe(7 * 24 * 3600); + + // And a client that omits the field gets no echo (backward compatible). + const res2 = await fetch(`${BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'e2e-dcr-no-ttl', + redirect_uris: ['https://example.com/cb'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'client_secret_basic', + scope: 'read', + }), + }); + expect(res2.ok).toBe(true); + const body2 = await res2.json() as any; + if (body2.client_id) dcrClientIds.push(body2.client_id); + expect(body2.token_ttl_seconds).toBeUndefined(); + }, 15_000); + // ========================================================================= // v0.26.2: revoke-client CLI subprocess test // ========================================================================= diff --git a/test/oauth-dcr-ttl.test.ts b/test/oauth-dcr-ttl.test.ts new file mode 100644 index 000000000..2076f69ee --- /dev/null +++ b/test/oauth-dcr-ttl.test.ts @@ -0,0 +1,209 @@ +/** + * #2179 — DCR `token_ttl_seconds`: clamp boundaries, persistence, response + * echo, and per-client TTL enforcement across grant paths. + * + * The wire path (serve-http /register middleware → SDK handler → store) is + * exercised at the store boundary here: the middleware's only job is to put + * the parsed number into `dcrRegistrationContext`, which these tests do + * directly. Setup mirrors test/oauth.test.ts (in-memory PGLite). + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { PGlite } from '@electric-sql/pglite'; +import { vector } from '@electric-sql/pglite/vector'; +import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; +import { + GBrainOAuthProvider, + clampDcrTokenTtl, + dcrRegistrationContext, + DEFAULT_DCR_TTL_MIN_SECONDS, + DEFAULT_DCR_TTL_MAX_SECONDS, +} from '../src/core/oauth-provider.ts'; +import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts'; + +let db: PGlite; +let sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise; + +beforeAll(async () => { + db = new PGlite({ extensions: { vector, pg_trgm } }); + await db.exec(PGLITE_SCHEMA_SQL); + sql = async (strings: TemplateStringsArray, ...values: unknown[]) => { + const query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? `$${i + 1}` : ''), ''); + const result = await db.query(query, values as any[]); + return result.rows; + }; +}, 30_000); + +afterAll(async () => { + if (db) await db.close(); +}, 15_000); + +// --------------------------------------------------------------------------- +// clampDcrTokenTtl — pure clamp boundaries +// --------------------------------------------------------------------------- + +describe('clampDcrTokenTtl', () => { + test('below min clamps up to min', () => { + expect(clampDcrTokenTtl(10, 300, 600)).toBe(300); + }); + + test('exactly min passes through', () => { + expect(clampDcrTokenTtl(300, 300, 600)).toBe(300); + }); + + test('in-range passes through', () => { + expect(clampDcrTokenTtl(450, 300, 600)).toBe(450); + }); + + test('exactly max passes through', () => { + expect(clampDcrTokenTtl(600, 300, 600)).toBe(600); + }); + + test('above max clamps down to max', () => { + expect(clampDcrTokenTtl(999_999, 300, 600)).toBe(600); + }); + + test('zero and negative clamp up to min (never reject)', () => { + expect(clampDcrTokenTtl(0, 300, 600)).toBe(300); + expect(clampDcrTokenTtl(-5, 300, 600)).toBe(300); + }); + + test('non-integer request floors before clamping', () => { + expect(clampDcrTokenTtl(450.9, 300, 600)).toBe(450); + }); + + test('inverted window collapses to min bound', () => { + expect(clampDcrTokenTtl(500, 600, 300)).toBe(600); + }); + + test('non-positive min is floored to 1', () => { + expect(clampDcrTokenTtl(0, 0, 600)).toBe(1); + }); + + test('defaults are 300s..7d', () => { + expect(clampDcrTokenTtl(1)).toBe(DEFAULT_DCR_TTL_MIN_SECONDS); + expect(clampDcrTokenTtl(Number.MAX_SAFE_INTEGER)).toBe(DEFAULT_DCR_TTL_MAX_SECONDS); + expect(DEFAULT_DCR_TTL_MIN_SECONDS).toBe(300); + expect(DEFAULT_DCR_TTL_MAX_SECONDS).toBe(7 * 24 * 3600); + }); +}); + +// --------------------------------------------------------------------------- +// registerClient — persistence + response echo through dcrRegistrationContext +// --------------------------------------------------------------------------- + +function makeProvider(bounds?: { min?: number; max?: number }) { + return new GBrainOAuthProvider({ + sql, + tokenTtl: 60, + allowClientCredentialsDcr: true, + dcrTtlMinSeconds: bounds?.min, + dcrTtlMaxSeconds: bounds?.max, + }); +} + +const DCR_METADATA = { + client_name: 'dcr-ttl-test', + redirect_uris: [], + grant_types: ['client_credentials'], + scope: 'read', + token_endpoint_auth_method: 'client_secret_post', +} as any; + +function registerWithTtl(provider: GBrainOAuthProvider, tokenTtlSeconds?: number) { + return dcrRegistrationContext.run({ tokenTtlSeconds }, () => + provider.clientsStore.registerClient!({ ...DCR_METADATA }), + ); +} + +describe('DCR registration with token_ttl_seconds (#2179)', () => { + test('in-range request persists and echoes verbatim; /token honors it', async () => { + const provider = makeProvider({ min: 120, max: 600 }); + const info = await registerWithTtl(provider, 300); + expect((info as any).token_ttl_seconds).toBe(300); + + const [row] = await sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${info.client_id}`; + expect(Number(row.token_ttl)).toBe(300); + + const tokens = await provider.exchangeClientCredentials(info.client_id, info.client_secret!, 'read'); + expect(tokens.expires_in).toBe(300); + }); + + test('below-min request clamps up, is echoed clamped, never rejected', async () => { + const provider = makeProvider({ min: 120, max: 600 }); + const info = await registerWithTtl(provider, 10); + expect((info as any).token_ttl_seconds).toBe(120); + + const [row] = await sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${info.client_id}`; + expect(Number(row.token_ttl)).toBe(120); + }); + + test('above-max request clamps down, is echoed clamped, never rejected', async () => { + const provider = makeProvider({ min: 120, max: 600 }); + const info = await registerWithTtl(provider, 86_400); + expect((info as any).token_ttl_seconds).toBe(600); + + const tokens = await provider.exchangeClientCredentials(info.client_id, info.client_secret!, 'read'); + expect(tokens.expires_in).toBe(600); + }); + + test('absent request → no echo, server default TTL applies', async () => { + const provider = makeProvider({ min: 120, max: 600 }); + const info = await registerWithTtl(provider, undefined); + expect((info as any).token_ttl_seconds).toBeUndefined(); + + const [row] = await sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${info.client_id}`; + expect(row.token_ttl).toBeNull(); + + const tokens = await provider.exchangeClientCredentials(info.client_id, info.client_secret!, 'read'); + expect(tokens.expires_in).toBe(60); // provider default + }); + + test('registration outside any DCR context behaves exactly as before', async () => { + const provider = makeProvider({ min: 120, max: 600 }); + const info = await provider.clientsStore.registerClient!({ ...DCR_METADATA }); + expect((info as any).token_ttl_seconds).toBeUndefined(); + const [row] = await sql`SELECT token_ttl FROM oauth_clients WHERE client_id = ${info.client_id}`; + expect(row.token_ttl).toBeNull(); + }); + + test('unset bounds use the exported defaults', async () => { + const provider = makeProvider(); + const info = await registerWithTtl(provider, 1); + expect((info as any).token_ttl_seconds).toBe(DEFAULT_DCR_TTL_MIN_SECONDS); + }); +}); + +// --------------------------------------------------------------------------- +// issueTokens — per-client token_ttl honored on the authorization_code path +// (DCR clients default to authorization_code, so the override must not be +// client_credentials-only) +// --------------------------------------------------------------------------- + +describe('per-client token_ttl across grant paths (#2179)', () => { + test('authorization_code exchange honors oauth_clients.token_ttl', async () => { + const provider = makeProvider(); + const { clientId } = await provider.registerClientManual( + 'dcr-ttl-authcode', ['authorization_code'], 'read', + ['http://localhost:3000/callback'], + ); + await sql`UPDATE oauth_clients SET token_ttl = ${222} WHERE client_id = ${clientId}`; + const client = (await provider.clientsStore.getClient(clientId))!; + + let redirectUrl = ''; + const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any; + await provider.authorize(client, { + codeChallenge: 'test-challenge-hash', + redirectUri: 'http://localhost:3000/callback', + scopes: ['read'], + state: 'ttl-state', + }, mockRes); + const code = new URL(redirectUrl).searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode(client, code); + expect(tokens.expires_in).toBe(222); + + // Refresh issuance honors it too. + const refreshed = await provider.exchangeRefreshToken(client, tokens.refresh_token!); + expect(refreshed.expires_in).toBe(222); + }); +});