mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
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:
committed by
Sina Matian
co-authored by
Time Attakc
parent
735dec83b7
commit
189bf856ee
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user