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
+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);
});
});