mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(oauth): add coerceTimestamp helper + fix BIGINT-as-string bug class
Postgres-js with prepare:false (auto-detected on Supabase pooler / port
6543) returns BIGINT columns as strings. Two surfaces broke on this:
(1) MCP SDK's bearerAuth checks typeof === 'number' and rejected
strings — fixed in v0.26.1 only at line 303 of oauth-provider.ts;
(2) RFC 7591 §3.2.1 requires client_id_issued_at and
client_secret_expires_at to be JSON numbers in DCR responses, not
strings — latent until v0.26.2.
Adds module-private coerceTimestamp() at the SELECT-row → JS-number
boundary. Throws on non-finite (corrupt rows fail loud, not as
fake-valid expiresAt: NaN flowing into the SDK). Returns undefined for
SQL NULL — schema permits NULL on oauth_tokens.expires_at, callers
treat NULL as expired (fail-closed) at comparison sites and preserve
undefined in DCR getClient response per RFC 7591.
Refactors 5 sites:
- L112,113 (getClient) — DCR response numeric-shape compliance.
- L274 (exchangeRefreshToken) — NULL→expired fail-closed contract.
- L296,303 (verifyAccessToken) — single guard, narrowed return.
No `!` non-null assertions: all 5 sites read nullable BIGINT columns
per src/schema.sql:362,363,372. The L296/L303 cleanup also folds in
v0.26.1's inline Number(...) at L303.
* feat(auth): add gbrain auth revoke-client subcommand
Hard-deletes the matching oauth_clients row via atomic
DELETE ... RETURNING. Schema-level FK CASCADE on oauth_tokens.client_id
and oauth_codes.client_id (src/schema.sql:370,382) purges all dependent
rows in the same transaction. No manual delete of dependents needed.
Exit 1 on no-such-client (idempotent: re-running on the same id
produces the same error). Operator-friendly output: prints the client
name + cascade confirmation, no race-prone pre-delete count.
Closes the v0.26.1 process miss where test/e2e/serve-http-oauth.test.ts
afterAll already called this subcommand — silently failing because the
subcommand didn't exist. With this fix, E2E cleanup actually purges
test clients.
* test(oauth): v0.26.2 regression coverage + bun execSync env fix
Unit additions in test/oauth.test.ts:
- 5 cases pinning coerceTimestamp contract (null/undef/string/number/
throws-on-NaN). The throws-on-NaN case is load-bearing: pre-v0.26.2
Number(corrupt) → NaN, NaN < now is false → expired check skipped,
fake-valid expiresAt:NaN flowed to SDK. Now fail-closed.
- NULL expires_at on oauth_tokens insert → verifyAccessToken throws
"Token expired". Schema permits NULL; pre-v0.26.2 hand-modified rows
could ride past validation.
- Cascade-deleted client → previously-minted token fails
verifyAccessToken with "Invalid token" (not "expired"). Pins the
cascade contract independently of the CLI subprocess path.
E2E additions in test/e2e/serve-http-oauth.test.ts:
- DCR /register HTTP-level response-shape test. Spawns server with
--enable-dcr, POSTs a client manifest, asserts typeof === 'number'
on client_id_issued_at and (when present) client_secret_expires_at
per RFC 7591 §3.2.1. Replaces the v0.26.1 plan's internal-store-only
test that Codex flagged as the wrong seam.
- Real CLI subprocess test for revoke-client: register → mint token →
revoke via execSync → assert token rejected at /mcp + cascade
invalidation visible + re-run exits 1 with "No client found".
- afterAll guards on clientId so pre-registration beforeAll failures
surface cleanly instead of throwing on undefined during cleanup.
Also tracks DCR-registered clients alongside the manual one.
- Server fixture: --enable-dcr added so /register is reachable.
- Health endpoint: page_count assertion loosened from > 0 to >= 0
+ typeof number — pre-v0.26.2 broke on fresh-schema E2E runs.
bun execSync env-inheritance fix (the load-bearing infrastructure
fix that unbroke v0.26.2's full-suite test):
- bun's child_process.execSync does NOT inherit env mutations done
via process.env.X = ...; only OS-level env from before bun started.
- helpers.ts loads .env.testing and sets DATABASE_URL via process.env
mutation, invisible to subprocesses unless env: { ...process.env }
is passed explicitly.
- All 4 execSync calls in this file (beforeAll register-client,
afterAll revoke-client, in-test register-client, in-test
revoke-client x2) now pass env: { ...process.env }.
- Without this, full bun test suite OAuth E2E fails with "Set
DATABASE_URL or GBRAIN_DATABASE_URL environment variable" even when
isolated test/e2e/serve-http-oauth.test.ts runs pass. Pattern is
documented inline as a reference for other E2E test fixes (see
TODOS.md "test infra (v0.26.2 follow-up)" for the 22-test backlog).
* build: commit admin/dist + remove gitignore exclusion
CLAUDE.md (admin/ section, v0.26.0 release notes) states:
"output at admin/dist/ is committed for self-contained binaries"
But .gitignore excluded admin/dist/, so the bun --compile binary that
embeds the admin SPA via `import path from '...' with { type: 'file' }`
couldn't resolve in fresh clones. PR #577 (v0.26.1) didn't catch this
because admin tests pass when admin/dist exists locally.
Removes the .gitignore line + commits the current 220KB build:
- index.html (0.7KB)
- assets/index-{hash}.js (210KB / 65KB gzip)
- assets/index-{hash}.css (6.3KB / 1.8KB gzip)
Now `bun build --compile --outfile bin/gbrain src/cli.ts` works on a
fresh clone without a separate `cd admin && bun install && bun run
build` step in CI.
* docs: capturing test output rule + regen llms-full.txt
Adds a CLAUDE.md section "Capturing test output (NEVER pipe through
tail / head)" documenting the iron rule that bit v0.26.2's ship:
bun test 2>&1 | tail -10 → exit code = tail's (always 0),
failures truncated, ship gates fail open
The pipe form silently breaks /ship Step T1 (test failure ownership
triage) because $? after a pipe is the LAST command's exit code, and
bun prints failure details before the summary line so tail -N drops
them. v0.26.2's first ship attempt reported "3911 pass / 23 fail" but
no failure details survived, forcing a 23-minute re-run to triage.
Right pattern: redirect to a file first, then tail the file separately.
Regenerates llms-full.txt to match the new CLAUDE.md content (drift
guard at test/build-llms.test.ts enforces this).
* docs: P0 TODO for 22 pre-existing test failures unrelated to OAuth
Captures the test-infra backlog uncovered by v0.26.2's full bun test
run. None of the 22 failing cases touch the OAuth diff:
- 12 Git-to-DB Sync Pipeline cases (state-machine drift)
- 3 multi-source cascade + sync routing cases
- E2E sync-parallel, sync --skip-failed, doctor, dream, runCycle,
claw-test fresh-install, BrainRegistry lazy init
Likely root causes for several: same bun execSync env-inheritance
pattern fixed in test/e2e/serve-http-oauth.test.ts during v0.26.2
(documented in the TODO + the inline test comment for the next
maintainer to find).
Separating from v0.26.2 keeps the OAuth ship focused on the bug
class it was scoped for. Fix-wave deserves its own PR.
* chore: bump to v0.26.2 + CHANGELOG
VERSION 0.26.0 → 0.26.2. Includes a retroactive v0.26.1 entry above
v0.26.0 because PR #577 shipped its three fixes (oauth-provider:303
Number cast, OAuth metadata interceptor, Express 5 trust proxy +
admin wildcard) without bumping VERSION/package.json/CHANGELOG —
this branch catches the changelog up to commit history.
v0.26.2 release-summary covers the OAuth string-vs-number bug class
fix (5 sites + coerceTimestamp helper), the gbrain auth revoke-client
subcommand landing as a real CLI, and the bun execSync env-inheritance
fix that unblocked full-suite E2E OAuth tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: post-ship updates for v0.26.2
- CLAUDE.md src/core/oauth-provider.ts: append v0.26.2 coerceTimestamp boundary helper note (5 call sites, NULL semantics, throw-on-NaN posture, intentionally module-private)
- CLAUDE.md src/commands/auth.ts: add v0.26.2 revoke-client subcommand with FK CASCADE cleanup
- CLAUDE.md test/oauth.test.ts: bump v0.26.2 case additions (5 coerceTimestamp + NULL-expires_at + cascade-delete contract)
- CLAUDE.md test/e2e/serve-http-oauth.test.ts: new entry covering v0.26.0 + v0.26.2 expansion (DCR HTTP-level test, CLI subprocess revoke-client test, bun execSync env-inheritance fix as reference for sibling E2Es)
- README.md: add gbrain auth revoke-client to command list
- llms-full.txt: regenerate after CLAUDE.md edits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
617 lines
25 KiB
TypeScript
617 lines
25 KiB
TypeScript
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, coerceTimestamp } from '../src/core/oauth-provider.ts';
|
|
import { hashToken, generateToken } from '../src/core/utils.ts';
|
|
import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test setup: in-memory PGLite with OAuth tables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let db: PGlite;
|
|
let sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<any>;
|
|
let provider: GBrainOAuthProvider;
|
|
|
|
beforeAll(async () => {
|
|
db = new PGlite({ extensions: { vector, pg_trgm } });
|
|
await db.exec(PGLITE_SCHEMA_SQL);
|
|
|
|
// Create a tagged template wrapper for PGLite
|
|
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;
|
|
};
|
|
|
|
provider = new GBrainOAuthProvider({ sql, tokenTtl: 60, refreshTtl: 300 });
|
|
}, 30_000); // PGLITE_SCHEMA_SQL execution under full-suite load can exceed default 5s
|
|
|
|
afterAll(async () => {
|
|
if (db) await db.close();
|
|
}, 15_000);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// hashToken + generateToken utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('hashToken', () => {
|
|
test('produces consistent SHA-256 hex', () => {
|
|
const hash = hashToken('test-token');
|
|
expect(hash).toHaveLength(64);
|
|
expect(hashToken('test-token')).toBe(hash); // deterministic
|
|
});
|
|
|
|
test('different inputs produce different hashes', () => {
|
|
expect(hashToken('a')).not.toBe(hashToken('b'));
|
|
});
|
|
});
|
|
|
|
describe('generateToken', () => {
|
|
test('produces prefixed random hex', () => {
|
|
const token = generateToken('gbrain_cl_');
|
|
expect(token).toStartWith('gbrain_cl_');
|
|
expect(token).toHaveLength('gbrain_cl_'.length + 64); // 32 bytes = 64 hex chars
|
|
});
|
|
|
|
test('tokens are unique', () => {
|
|
const a = generateToken('test_');
|
|
const b = generateToken('test_');
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// coerceTimestamp — postgres BIGINT-as-string boundary helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('coerceTimestamp', () => {
|
|
test('null returns undefined', () => {
|
|
expect(coerceTimestamp(null)).toBeUndefined();
|
|
});
|
|
|
|
test('undefined returns undefined', () => {
|
|
expect(coerceTimestamp(undefined)).toBeUndefined();
|
|
});
|
|
|
|
test('numeric string coerces to number', () => {
|
|
// The actual production path: postgres-js with prepare:false returns
|
|
// BIGINT columns as strings.
|
|
expect(coerceTimestamp('12345')).toBe(12345);
|
|
expect(coerceTimestamp('1735689600')).toBe(1735689600);
|
|
});
|
|
|
|
test('native number passes through', () => {
|
|
// Direct-PG users on prepare:true get native numbers.
|
|
expect(coerceTimestamp(12345)).toBe(12345);
|
|
expect(coerceTimestamp(0)).toBe(0);
|
|
});
|
|
|
|
test('non-finite input throws (fail-closed contract)', () => {
|
|
// The load-bearing change vs Number(): corrupt rows fail loud at the
|
|
// boundary instead of letting NaN flow through to the SDK as a
|
|
// fake-valid `expiresAt`.
|
|
expect(() => coerceTimestamp('not-a-number')).toThrow(/non-finite/);
|
|
expect(() => coerceTimestamp(NaN)).toThrow(/non-finite/);
|
|
expect(() => coerceTimestamp(Infinity)).toThrow(/non-finite/);
|
|
expect(() => coerceTimestamp(-Infinity)).toThrow(/non-finite/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client Registration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('client registration', () => {
|
|
test('registerClientManual creates a client', async () => {
|
|
const { clientId, clientSecret } = await provider.registerClientManual(
|
|
'test-agent', ['client_credentials'], 'read write',
|
|
);
|
|
expect(clientId).toStartWith('gbrain_cl_');
|
|
expect(clientSecret).toStartWith('gbrain_cs_');
|
|
|
|
// Verify client exists in DB
|
|
const client = await provider.clientsStore.getClient(clientId);
|
|
expect(client).toBeDefined();
|
|
expect(client!.client_name).toBe('test-agent');
|
|
});
|
|
|
|
test('getClient returns undefined for unknown client', async () => {
|
|
const client = await provider.clientsStore.getClient('nonexistent');
|
|
expect(client).toBeUndefined();
|
|
});
|
|
|
|
test('duplicate client_id is rejected', async () => {
|
|
const { clientId } = await provider.registerClientManual(
|
|
'dup-test', ['client_credentials'], 'read',
|
|
);
|
|
// Try to insert same client_id directly
|
|
await expect(
|
|
sql`INSERT INTO oauth_clients (client_id, client_name, scope) VALUES (${clientId}, ${'dup'}, ${'read'})`,
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client Credentials Exchange
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('client credentials', () => {
|
|
let clientId: string;
|
|
let clientSecret: string;
|
|
|
|
beforeAll(async () => {
|
|
const result = await provider.registerClientManual(
|
|
'cc-test-agent', ['client_credentials'], 'read write',
|
|
);
|
|
clientId = result.clientId;
|
|
clientSecret = result.clientSecret;
|
|
});
|
|
|
|
test('valid exchange returns access token', async () => {
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
|
expect(tokens.access_token).toStartWith('gbrain_at_');
|
|
expect(tokens.token_type).toBe('bearer');
|
|
expect(tokens.expires_in).toBe(60);
|
|
expect(tokens.scope).toBe('read');
|
|
});
|
|
|
|
test('no refresh token issued for CC grant', async () => {
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
|
expect(tokens.refresh_token).toBeUndefined();
|
|
});
|
|
|
|
test('wrong secret is rejected', async () => {
|
|
await expect(
|
|
provider.exchangeClientCredentials(clientId, 'wrong-secret', 'read'),
|
|
).rejects.toThrow('Invalid client secret');
|
|
});
|
|
|
|
test('client without CC grant is rejected', async () => {
|
|
const { clientId: noCC } = await provider.registerClientManual(
|
|
'no-cc-agent', ['authorization_code'], 'read',
|
|
);
|
|
await expect(
|
|
provider.exchangeClientCredentials(noCC, 'any-secret', 'read'),
|
|
).rejects.toThrow('not authorized');
|
|
});
|
|
|
|
test('scope is filtered to allowed scopes', async () => {
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read write admin');
|
|
// Client only has 'read write', admin should be filtered out
|
|
expect(tokens.scope).not.toContain('admin');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token Verification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('verifyAccessToken', () => {
|
|
test('valid token returns auth info', async () => {
|
|
const { clientId, clientSecret } = await provider.registerClientManual(
|
|
'verify-test', ['client_credentials'], 'read write',
|
|
);
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
|
const authInfo = await provider.verifyAccessToken(tokens.access_token);
|
|
|
|
expect(authInfo.clientId).toBe(clientId);
|
|
expect(authInfo.scopes).toContain('read');
|
|
expect(authInfo.token).toBe(tokens.access_token);
|
|
});
|
|
|
|
test('expired token is rejected', async () => {
|
|
// Insert a token that's already expired
|
|
const expiredToken = generateToken('gbrain_at_');
|
|
const hash = hashToken(expiredToken);
|
|
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
|
await sql`
|
|
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
|
VALUES (${hash}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${Math.floor(Date.now() / 1000) - 100})
|
|
`;
|
|
await expect(provider.verifyAccessToken(expiredToken)).rejects.toThrow('expired');
|
|
});
|
|
|
|
test('unknown token is rejected', async () => {
|
|
await expect(provider.verifyAccessToken('nonexistent-token')).rejects.toThrow('Invalid token');
|
|
});
|
|
|
|
test('NULL expires_at is treated as expired (fail-closed)', async () => {
|
|
// Schema declares oauth_tokens.expires_at as nullable BIGINT (schema.sql:372).
|
|
// Hand-modified or corrupt rows could land with NULL; verifyAccessToken must
|
|
// fail-closed, not return an undefined-bearing AuthInfo that the SDK accepts.
|
|
const nullExpiryToken = generateToken('gbrain_at_');
|
|
const hash = hashToken(nullExpiryToken);
|
|
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
|
await sql`
|
|
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
|
VALUES (${hash}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${null})
|
|
`;
|
|
await expect(provider.verifyAccessToken(nullExpiryToken)).rejects.toThrow('expired');
|
|
});
|
|
|
|
test('cascade-deleted client invalidates its tokens (Invalid token, not Expired)', async () => {
|
|
// revoke-client does DELETE FROM oauth_clients WHERE client_id = ...
|
|
// The schema-level FK cascade (schema.sql:370) wipes oauth_tokens too.
|
|
// verifyAccessToken on a previously-minted token from that client must
|
|
// fail with "Invalid token" (cascade purged the row) — distinct from
|
|
// "Token expired" so logs distinguish the failure modes.
|
|
const { clientId, clientSecret } = await provider.registerClientManual(
|
|
'cascade-test', ['client_credentials'], 'read',
|
|
);
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
|
await sql`DELETE FROM oauth_clients WHERE client_id = ${clientId}`;
|
|
await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow('Invalid token');
|
|
});
|
|
|
|
test('expiresAt is always a number (not string) — SDK bearerAuth compat', async () => {
|
|
// Regression: postgres driver with prepare:false returns integers as strings.
|
|
// MCP SDK's bearerAuth middleware checks typeof === 'number' and rejects strings.
|
|
// verifyAccessToken must cast to Number() before returning.
|
|
const { clientId, clientSecret } = await provider.registerClientManual(
|
|
'typeof-test', ['client_credentials'], 'read',
|
|
);
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
|
const authInfo = await provider.verifyAccessToken(tokens.access_token);
|
|
|
|
expect(typeof authInfo.expiresAt).toBe('number');
|
|
expect(Number.isNaN(authInfo.expiresAt)).toBe(false);
|
|
expect(authInfo.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
|
});
|
|
|
|
test('legacy access_tokens fallback works', async () => {
|
|
// Insert a legacy bearer token
|
|
const legacyToken = generateToken('gbrain_');
|
|
const hash = hashToken(legacyToken);
|
|
await sql`
|
|
INSERT INTO access_tokens (id, name, token_hash)
|
|
VALUES (${crypto.randomUUID()}, ${'legacy-agent'}, ${hash})
|
|
`;
|
|
|
|
const authInfo = await provider.verifyAccessToken(legacyToken);
|
|
expect(authInfo.clientId).toBe('legacy-agent');
|
|
expect(authInfo.scopes).toEqual(['read', 'write', 'admin']); // grandfathered full access
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token Revocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('revokeToken', () => {
|
|
test('revoked token no longer verifies', async () => {
|
|
const { clientId, clientSecret } = await provider.registerClientManual(
|
|
'revoke-test', ['client_credentials'], 'read',
|
|
);
|
|
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
|
|
|
// Verify token works
|
|
const authInfo = await provider.verifyAccessToken(tokens.access_token);
|
|
expect(authInfo.clientId).toBe(clientId);
|
|
|
|
// Revoke it
|
|
const client = (await provider.clientsStore.getClient(clientId))!;
|
|
await provider.revokeToken!(client, { token: tokens.access_token });
|
|
|
|
// Should no longer verify
|
|
await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow();
|
|
});
|
|
|
|
test('revoking already-revoked token is a no-op', async () => {
|
|
// This should not throw
|
|
const client = (await provider.clientsStore.getClient(
|
|
(await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0].client_id as string,
|
|
))!;
|
|
await provider.revokeToken!(client, { token: 'already-gone' });
|
|
// No error = pass
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Authorization Code Flow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('authorization code flow', () => {
|
|
test('code issuance and exchange', async () => {
|
|
const { clientId } = await provider.registerClientManual(
|
|
'authcode-test', ['authorization_code'], 'read write',
|
|
['http://localhost:3000/callback'],
|
|
);
|
|
const client = (await provider.clientsStore.getClient(clientId))!;
|
|
|
|
// Mock Express response for authorize
|
|
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', 'write'],
|
|
state: 'test-state',
|
|
}, mockRes);
|
|
|
|
expect(redirectUrl).toContain('code=gbrain_code_');
|
|
expect(redirectUrl).toContain('state=test-state');
|
|
|
|
// Extract code from redirect URL
|
|
const url = new URL(redirectUrl);
|
|
const code = url.searchParams.get('code')!;
|
|
|
|
// Exchange code for tokens
|
|
const tokens = await provider.exchangeAuthorizationCode(client, code);
|
|
expect(tokens.access_token).toStartWith('gbrain_at_');
|
|
expect(tokens.refresh_token).toBeDefined(); // Auth code flow includes refresh
|
|
});
|
|
|
|
test('code is single-use', async () => {
|
|
const { clientId } = await provider.registerClientManual(
|
|
'single-use-test', ['authorization_code'], 'read',
|
|
['http://localhost:3000/callback'],
|
|
);
|
|
const client = (await provider.clientsStore.getClient(clientId))!;
|
|
|
|
let redirectUrl = '';
|
|
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
|
|
|
await provider.authorize(client, {
|
|
codeChallenge: 'challenge',
|
|
redirectUri: 'http://localhost:3000/callback',
|
|
scopes: ['read'],
|
|
}, mockRes);
|
|
|
|
const code = new URL(redirectUrl).searchParams.get('code')!;
|
|
|
|
// First exchange works
|
|
await provider.exchangeAuthorizationCode(client, code);
|
|
|
|
// Second exchange fails (code consumed)
|
|
await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow();
|
|
});
|
|
|
|
test('expired code is rejected', async () => {
|
|
// Insert an already-expired code
|
|
const expiredCode = generateToken('gbrain_code_');
|
|
const hash = hashToken(expiredCode);
|
|
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
|
|
|
await sql`
|
|
INSERT INTO oauth_codes (code_hash, client_id, scopes, code_challenge,
|
|
redirect_uri, expires_at)
|
|
VALUES (${hash}, ${firstClient.client_id as string}, ${'{read}'},
|
|
${'challenge'}, ${'http://localhost/cb'}, ${Math.floor(Date.now() / 1000) - 100})
|
|
`;
|
|
|
|
const client = (await provider.clientsStore.getClient(firstClient.client_id as string))!;
|
|
await expect(provider.exchangeAuthorizationCode(client, expiredCode)).rejects.toThrow();
|
|
});
|
|
|
|
// CSO finding #2 regression. The pre-fix SELECT-then-DELETE pattern let two
|
|
// concurrent token requests with the same code both pass the SELECT, both
|
|
// running DELETE (no-op on second) and both calling issueTokens. The fix is
|
|
// DELETE...RETURNING in one statement; this test fires N=10 concurrent
|
|
// exchanges and asserts exactly one succeeds.
|
|
test('concurrent exchange requests: only one succeeds (TOCTOU race)', async () => {
|
|
const { clientId } = await provider.registerClientManual(
|
|
'toctou-code-test', ['authorization_code'], 'read',
|
|
['http://localhost:3000/callback'],
|
|
);
|
|
const client = (await provider.clientsStore.getClient(clientId))!;
|
|
|
|
let redirectUrl = '';
|
|
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
|
await provider.authorize(client, {
|
|
codeChallenge: 'challenge',
|
|
redirectUri: 'http://localhost:3000/callback',
|
|
scopes: ['read'],
|
|
}, mockRes);
|
|
const code = new URL(redirectUrl).searchParams.get('code')!;
|
|
|
|
const N = 10;
|
|
const results = await Promise.allSettled(
|
|
Array.from({ length: N }, () => provider.exchangeAuthorizationCode(client, code)),
|
|
);
|
|
const successes = results.filter(r => r.status === 'fulfilled');
|
|
const failures = results.filter(r => r.status === 'rejected');
|
|
expect(successes.length).toBe(1);
|
|
expect(failures.length).toBe(N - 1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Refresh Token
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('refresh token', () => {
|
|
test('valid refresh rotates tokens', async () => {
|
|
const { clientId } = await provider.registerClientManual(
|
|
'refresh-test', ['authorization_code'], 'read write',
|
|
['http://localhost:3000/callback'],
|
|
);
|
|
const client = (await provider.clientsStore.getClient(clientId))!;
|
|
|
|
let redirectUrl = '';
|
|
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
|
|
|
await provider.authorize(client, {
|
|
codeChallenge: 'challenge',
|
|
redirectUri: 'http://localhost:3000/callback',
|
|
scopes: ['read', 'write'],
|
|
}, mockRes);
|
|
|
|
const code = new URL(redirectUrl).searchParams.get('code')!;
|
|
const tokens = await provider.exchangeAuthorizationCode(client, code);
|
|
|
|
// Refresh
|
|
const newTokens = await provider.exchangeRefreshToken(client, tokens.refresh_token!, ['read']);
|
|
expect(newTokens.access_token).not.toBe(tokens.access_token);
|
|
expect(newTokens.refresh_token).toBeDefined();
|
|
expect(newTokens.refresh_token).not.toBe(tokens.refresh_token); // rotated
|
|
|
|
// Old refresh token should no longer work
|
|
await expect(provider.exchangeRefreshToken(client, tokens.refresh_token!)).rejects.toThrow();
|
|
});
|
|
|
|
// CSO finding #3 regression. Same TOCTOU pattern as auth code; the fix is
|
|
// DELETE...RETURNING. Detection of stolen refresh tokens (RFC 6749 §10.4)
|
|
// depends on second-use failure, so two concurrent succeed = no detection.
|
|
test('concurrent refresh requests: only one succeeds (TOCTOU race)', async () => {
|
|
const { clientId } = await provider.registerClientManual(
|
|
'toctou-refresh-test', ['authorization_code'], 'read',
|
|
['http://localhost:3000/callback'],
|
|
);
|
|
const client = (await provider.clientsStore.getClient(clientId))!;
|
|
|
|
let redirectUrl = '';
|
|
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
|
await provider.authorize(client, {
|
|
codeChallenge: 'challenge',
|
|
redirectUri: 'http://localhost:3000/callback',
|
|
scopes: ['read'],
|
|
}, mockRes);
|
|
const code = new URL(redirectUrl).searchParams.get('code')!;
|
|
const tokens = await provider.exchangeAuthorizationCode(client, code);
|
|
|
|
const N = 10;
|
|
const results = await Promise.allSettled(
|
|
Array.from({ length: N }, () => provider.exchangeRefreshToken(client, tokens.refresh_token!)),
|
|
);
|
|
const successes = results.filter(r => r.status === 'fulfilled');
|
|
expect(successes.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token Sweep
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('sweepExpiredTokens', () => {
|
|
test('removes expired tokens', async () => {
|
|
// Insert some expired tokens
|
|
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
|
const expired1 = hashToken(generateToken('sweep_'));
|
|
const expired2 = hashToken(generateToken('sweep_'));
|
|
|
|
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
|
VALUES (${expired1}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${1})`;
|
|
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
|
VALUES (${expired2}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${2})`;
|
|
|
|
await provider.sweepExpiredTokens();
|
|
|
|
// Verify they're gone
|
|
const remaining = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE expires_at < 100`;
|
|
expect(remaining[0].count).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scope Annotations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('operation scope annotations', () => {
|
|
test('all operations have a scope', () => {
|
|
const { operations } = require('../src/core/operations.ts');
|
|
for (const op of operations) {
|
|
expect(op.scope, `${op.name} missing scope`).toBeDefined();
|
|
expect(['read', 'write', 'admin']).toContain(op.scope);
|
|
}
|
|
});
|
|
|
|
test('mutating operations are write or admin scoped', () => {
|
|
const { operations } = require('../src/core/operations.ts');
|
|
for (const op of operations) {
|
|
if (op.mutating) {
|
|
expect(['write', 'admin'], `${op.name} is mutating but not write/admin`).toContain(op.scope);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('sync_brain and file_upload are localOnly', () => {
|
|
const { operationsByName } = require('../src/core/operations.ts');
|
|
expect(operationsByName.sync_brain.localOnly).toBe(true);
|
|
expect(operationsByName.file_upload.localOnly).toBe(true);
|
|
});
|
|
|
|
test('file_list and file_url are localOnly', () => {
|
|
const { operationsByName } = require('../src/core/operations.ts');
|
|
expect(operationsByName.file_list.localOnly).toBe(true);
|
|
expect(operationsByName.file_url.localOnly).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSO finding #5 — pgArray escape + DCR redirect_uri validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('redirect_uri validation (DCR)', () => {
|
|
test('http://localhost is allowed (loopback exception)', async () => {
|
|
const result = await provider.clientsStore.registerClient!({
|
|
client_name: 'localhost-ok',
|
|
redirect_uris: ['http://localhost:3000/callback'],
|
|
grant_types: ['authorization_code'],
|
|
scope: 'read',
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
});
|
|
expect(result.client_id).toStartWith('gbrain_cl_');
|
|
});
|
|
|
|
test('https:// is allowed', async () => {
|
|
const result = await provider.clientsStore.registerClient!({
|
|
client_name: 'https-ok',
|
|
redirect_uris: ['https://example.com/callback'],
|
|
grant_types: ['authorization_code'],
|
|
scope: 'read',
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
});
|
|
expect(result.client_id).toStartWith('gbrain_cl_');
|
|
});
|
|
|
|
test('plaintext http:// (non-loopback) is rejected', async () => {
|
|
await expect(
|
|
provider.clientsStore.registerClient!({
|
|
client_name: 'http-rejected',
|
|
redirect_uris: ['http://example.com/callback'],
|
|
grant_types: ['authorization_code'],
|
|
scope: 'read',
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
}),
|
|
).rejects.toThrow(/https/);
|
|
});
|
|
|
|
test('non-URL string is rejected', async () => {
|
|
await expect(
|
|
provider.clientsStore.registerClient!({
|
|
client_name: 'garbage',
|
|
redirect_uris: ['not-a-url'],
|
|
grant_types: ['authorization_code'],
|
|
scope: 'read',
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
// pgArray escape regression: an element containing a comma must be stored
|
|
// as ONE element, not parsed by Postgres as TWO. Without the fix, the
|
|
// comma would smuggle a second redirect_uri into the registered list.
|
|
test('redirect_uri with embedded comma stored as single element', async () => {
|
|
// Use a localhost URI with comma in the path so it passes HTTPS validation.
|
|
const trickyUri = 'http://localhost:3000/cb,evil';
|
|
const result = await provider.clientsStore.registerClient!({
|
|
client_name: 'comma-test',
|
|
redirect_uris: [trickyUri],
|
|
grant_types: ['authorization_code'],
|
|
scope: 'read',
|
|
token_endpoint_auth_method: 'client_secret_post',
|
|
});
|
|
|
|
// Read back from the DB and confirm exactly one element.
|
|
const stored = await provider.clientsStore.getClient(result.client_id);
|
|
expect(stored).toBeDefined();
|
|
expect(stored!.redirect_uris).toHaveLength(1);
|
|
expect(stored!.redirect_uris[0]).toBe(trickyUri);
|
|
});
|
|
});
|