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>
This commit is contained in:
test
2026-08-13 12:18:13 -07:00
committed by Sina Matian
co-authored by Time Attakc
parent 735dec83b7
commit 189bf856ee
7 changed files with 497 additions and 18 deletions
+16
View File
@@ -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`.
+16
View File
@@ -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`.
+50 -1
View File
@@ -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<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
+5
View File
@@ -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',
+123 -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,
@@ -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<OAuthClientInformationFull | undefined> {
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<string, unknown>).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<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`
+52
View File
@@ -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
// =========================================================================
+235
View File
@@ -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<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('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);
});
});