feat(oauth): accept token_ttl_seconds at Dynamic Client Registration, clamped to admin policy (#2179)

POST /register now accepts an optional token_ttl_seconds field (RFC 7591
extension). The server clamps it into an admin-configured window
(oauth.dcr_ttl_min_seconds / oauth.dcr_ttl_max_seconds config keys;
defaults 300s..7d), persists it as the client's per-client TTL override
(oauth_clients.token_ttl), and echoes the EFFECTIVE value back as
token_ttl_seconds in the registration response. Fail-safe posture:
absent/malformed -> server default; out-of-range -> clamped, never
rejected; pre-migration schemas without the token_ttl column keep
registering (no echo).

The MCP SDK's /register handler strips unknown body members before they
reach the clients store, so serve-http parses the raw body in a
middleware and carries the value through an AsyncLocalStorage context.

Root-cause follow-through: the per-client token_ttl lookup moved from
exchangeClientCredentials into issueTokens, so authorization_code (the
DCR default grant) and refresh issuance honor the override too —
previously only client_credentials did.

Tests: clamp boundaries (below/at/in/at/above, floor, inverted window),
store-level persistence + echo + no-context back-compat, cross-grant TTL
enforcement (test/oauth-dcr-ttl.test.ts), and a DB-gated wire-level e2e
in test/e2e/serve-http-oauth.test.ts. Docs: docs/mcp/DEPLOY.md.

Reported-by: @asabirov (#2179)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-27 16:35:21 -07:00
co-authored by Claude Fable 5
parent 032af6e5f7
commit d2a432dfd7
6 changed files with 434 additions and 18 deletions
+14
View File
@@ -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`.
+49 -1
View File
@@ -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<string, unknown> | 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
+4
View File
@@ -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',
+110 -17
View File
@@ -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<OAuthClientInformationFull | undefined> {
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<string, unknown>).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<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) 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<OAuthTokens> {
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`
+48
View File
@@ -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
// =========================================================================
+209
View File
@@ -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<any>;
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);
});
});