From 189bf856eee97137d88a62359cc5ae31af5956d5 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 13 Aug 2026 10:37:02 -0700 Subject: [PATCH] feat(oauth): accept token_ttl_seconds at DCR, clamped to admin policy (#2179) (#3456) Wave-assembled from PR #3456 by @time-attack. Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com> --- docs/mcp/DEPLOY.md | 16 ++ llms-full.txt | 16 ++ src/commands/serve-http.ts | 51 ++++++- src/core/config.ts | 5 + src/core/oauth-provider.ts | 140 +++++++++++++++--- test/e2e/serve-http-oauth.test.ts | 52 +++++++ test/oauth-dcr-ttl.test.ts | 235 ++++++++++++++++++++++++++++++ 7 files changed, 497 insertions(+), 18 deletions(-) create mode 100644 test/oauth-dcr-ttl.test.ts diff --git a/docs/mcp/DEPLOY.md b/docs/mcp/DEPLOY.md index 8e6ff5634..a79b7e420 100644 --- a/docs/mcp/DEPLOY.md +++ b/docs/mcp/DEPLOY.md @@ -163,6 +163,22 @@ 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`). The window defaults fail-closed: min +300 seconds, max bounded by your `--token-ttl` — a self-registering client +cannot request a longer-lived token than the server default unless you +explicitly widen the window: + +```bash +gbrain config set oauth.dcr_ttl_min_seconds 600 +gbrain config set oauth.dcr_ttl_max_seconds 86400 +``` + ### 3. Expose the server **Bind explicitly.** `gbrain serve --http` defaults to `127.0.0.1`. diff --git a/llms-full.txt b/llms-full.txt index 8384e71aa..1abf3ef69 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4125,6 +4125,22 @@ 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`). The window defaults fail-closed: min +300 seconds, max bounded by your `--token-ttl` — a self-registering client +cannot request a longer-lived token than the server default unless you +explicitly widen the window: + +```bash +gbrain config set oauth.dcr_ttl_min_seconds 600 +gbrain config set oauth.dcr_ttl_max_seconds 86400 +``` + ### 3. Expose the server **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 6bf57eecc..3521d74a2 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -27,7 +27,12 @@ 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, +} from '../core/oauth-provider.ts'; import type { SqlQuery } from '../core/oauth-provider.ts'; import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts'; import { normalizeSourceInput, normalizeFederatedReadInput } from '../core/source-id.ts'; @@ -702,11 +707,41 @@ 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 ...`). + // FAIL-CLOSED defaults: an unset/invalid max is bounded by the operator's + // own --token-ttl (never a fixed permissive ceiling), and an inverted + // window collapses to the min bound — the same direction clampDcrTokenTtl + // itself resolves. A bad config narrows the window; it never widens it. + 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 = Math.max(tokenTtl, dcrTtlMinSeconds); + 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'), Math.max(tokenTtl, dcrTtlMinSeconds)); + } catch { + // Config read is best-effort; the fail-closed defaults stand. + dcrTtlMaxSeconds = Math.max(tokenTtl, dcrTtlMinSeconds); + } + if (dcrTtlMinSeconds > dcrTtlMaxSeconds) { + console.error( + `[serve-http] WARNING: oauth.dcr_ttl_min_seconds (${dcrTtlMinSeconds}) exceeds ` + + `oauth.dcr_ttl_max_seconds (${dcrTtlMaxSeconds}); collapsing the window to ` + + `the min bound (${dcrTtlMinSeconds}).`, + ); + dcrTtlMaxSeconds = dcrTtlMinSeconds; + } + 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 @@ -820,6 +855,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 5bfb174ea..d2e8f083c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1123,6 +1123,11 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [ 'orphans.exclude_slugs', '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; unset min defaults to 300s, unset max + // defaults fail-closed to max(--token-ttl, min). + '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 656946959..b2c771697 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, @@ -235,14 +236,75 @@ 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. Unset defaults FAIL-CLOSED to + * max(tokenTtl, dcrTtlMinSeconds): an anonymous DCR registrant can never + * elect a longer-lived token than the operator's own --token-ttl unless + * the admin explicitly widened the window. + */ + dcrTtlMaxSeconds?: number; } +// --------------------------------------------------------------------------- +// DCR token TTL (#2179) +// --------------------------------------------------------------------------- + +/** + * Default lower clamp bound 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. There is + * deliberately NO fixed default max: an unset max derives fail-closed from + * the operator's --token-ttl (`max(tokenTtl, min)`), so a self-registering + * client can never out-live the server default without explicit admin opt-in. + */ +export const DEFAULT_DCR_TTL_MIN_SECONDS = 300; // 5 minutes + +/** + * Clamp a DCR-requested token TTL into the admin-configured [min, max] + * window. Bounds are REQUIRED — callers resolve them (fail-closed) first. + * Never rejects (#2179): out-of-range values clamp to the nearest bound. + * Non-integer requests floor; an inverted window collapses to the min bound. + */ +export function clampDcrTokenTtl( + requested: number, + min: number, + max: number, +): 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) {} + // #2179: DCR TTL bounds are required — the provider resolves fail-closed + // defaults (max bounded by tokenTtl); no permissive fallback lives here. + constructor( + private sql: SqlQuery, + private allowClientCredentialsDcr: boolean, + private dcrTtlMin: number, + private dcrTtlMax: number, + ) {} async getClient(clientId: string): Promise { const rows = await this.sql` @@ -392,6 +454,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 @@ -403,6 +486,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; } } @@ -420,10 +506,21 @@ export class GBrainOAuthProvider implements OAuthServerProvider { constructor(options: GBrainOAuthProviderOptions) { this.sql = options.sql; - this._clientsStore = new GBrainClientsStore(this.sql, options.allowClientCredentialsDcr === true); this.dcrDisabled = options.dcrDisabled === true; this.tokenTtl = options.tokenTtl || 3600; this.refreshTtl = options.refreshTtl || 30 * 24 * 3600; + // #2179 fail-closed: an unset DCR max is bounded by the operator's own + // token TTL — never a fixed permissive ceiling — so a self-registering + // client cannot elect a longer-lived token than the server default + // unless the admin explicitly configured a wider window. + const dcrTtlMin = options.dcrTtlMinSeconds ?? DEFAULT_DCR_TTL_MIN_SECONDS; + const dcrTtlMax = options.dcrTtlMaxSeconds ?? Math.max(this.tokenTtl, dcrTtlMin); + this._clientsStore = new GBrainClientsStore( + this.sql, + options.allowClientCredentialsDcr === true, + dcrTtlMin, + dcrTtlMax, + ); } get clientsStore(): OAuthRegisteredClientsStore { @@ -931,20 +1028,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); } // ------------------------------------------------------------------------- @@ -1204,17 +1291,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..f27be05aa 100644 --- a/test/e2e/serve-http-oauth.test.ts +++ b/test/e2e/serve-http-oauth.test.ts @@ -675,6 +675,58 @@ 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. With the clamp window unset, the max + // derives fail-closed from the server's --token-ttl (default 3600) — a + // huge request must come back clamped to that, 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 any sane 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 fail-closed to the server's + // --token-ttl (3600, the default — the e2e server sets no flag and no + // oauth.dcr_ttl_max_seconds config). + expect(body.token_ttl_seconds).toBe(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..bc2690a03 --- /dev/null +++ b/test/oauth-dcr-ttl.test.ts @@ -0,0 +1,235 @@ +/** + * #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, +} 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('default min is 300s; bounds are otherwise explicit (no permissive default max)', () => { + expect(DEFAULT_DCR_TTL_MIN_SECONDS).toBe(300); + expect(clampDcrTokenTtl(1, DEFAULT_DCR_TTL_MIN_SECONDS, 3600)).toBe(300); + expect(clampDcrTokenTtl(Number.MAX_SAFE_INTEGER, DEFAULT_DCR_TTL_MIN_SECONDS, 3600)).toBe(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: min defaults to 300s', async () => { + const provider = makeProvider(); + const info = await registerWithTtl(provider, 1); + expect((info as any).token_ttl_seconds).toBe(DEFAULT_DCR_TTL_MIN_SECONDS); + }); + + // #2179 fail-closed pin: with oauth.dcr_ttl_max_seconds UNSET, a DCR + // registrant can never elect a token that out-lives the operator's own + // --token-ttl. This is the security property that keeps a default + // --enable-dcr server from handing anonymous registrants 7-day tokens. + test('unset max cannot exceed the server --token-ttl (fail-closed)', async () => { + const provider = new GBrainOAuthProvider({ + sql, + tokenTtl: 3600, + allowClientCredentialsDcr: true, + }); + const info = await registerWithTtl(provider, 7 * 24 * 3600); + expect((info as any).token_ttl_seconds).toBe(3600); + + const tokens = await provider.exchangeClientCredentials(info.client_id, info.client_secret!, 'read'); + expect(tokens.expires_in).toBe(3600); + }); + + test('explicitly configured max above --token-ttl is honored (admin opt-in)', async () => { + const provider = new GBrainOAuthProvider({ + sql, + tokenTtl: 3600, + allowClientCredentialsDcr: true, + dcrTtlMaxSeconds: 86_400, + }); + const info = await registerWithTtl(provider, 999_999); + expect((info as any).token_ttl_seconds).toBe(86_400); + }); +}); + +// --------------------------------------------------------------------------- +// 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); + }); +});