mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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:
co-authored by
Claude Fable 5
parent
032af6e5f7
commit
d2a432dfd7
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user