From 80ab6f445e6eee532964c09611094821d7eecffc Mon Sep 17 00:00:00 2001 From: maxpetrusenkoagent Date: Wed, 10 Jun 2026 17:30:25 -0400 Subject: [PATCH] fix: honor legacy token source grants in oauth --- src/core/legacy-token-scope.ts | 22 +++++++++++++ src/core/oauth-provider.ts | 59 +++++++++++++++++++++++++--------- src/mcp/http-transport.ts | 26 +++------------ test/oauth.test.ts | 28 ++++++++++++++++ 4 files changed, 98 insertions(+), 37 deletions(-) create mode 100644 src/core/legacy-token-scope.ts diff --git a/src/core/legacy-token-scope.ts b/src/core/legacy-token-scope.ts new file mode 100644 index 000000000..5e616c280 --- /dev/null +++ b/src/core/legacy-token-scope.ts @@ -0,0 +1,22 @@ +/** + * Derive a legacy bearer token's source scope from its stored + * `access_tokens.permissions.source_id` grant. + * + * ARRAY = federated read grant, exposed through `allowedSources` with the + * first granted source as the scalar write floor. STRING = scalar source. + * Missing, empty, or garbage values fail closed to the historical `default` + * floor and NEVER widen to all sources. + */ +export function parseLegacyTokenScope(rawSource: unknown): { sourceId: string; allowedSources?: string[] } { + if (Array.isArray(rawSource)) { + const allowedSources = (rawSource as unknown[]).filter(s => typeof s === 'string' && s.length > 0) as string[]; + if (allowedSources.length > 0) { + return { sourceId: allowedSources[0], allowedSources }; + } + return { sourceId: 'default' }; + } + if (typeof rawSource === 'string' && rawSource.length > 0) { + return { sourceId: rawSource }; + } + return { sourceId: 'default' }; +} diff --git a/src/core/oauth-provider.ts b/src/core/oauth-provider.ts index 8b4ac91d3..cd32d0dcb 100644 --- a/src/core/oauth-provider.ts +++ b/src/core/oauth-provider.ts @@ -21,10 +21,12 @@ import type { } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; -import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import type { AuthInfo as SdkAuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts'; import { hasScope, assertAllowedScopes, parseScopeString, InvalidScopeError } from './scope.ts'; +import type { AuthInfo as CoreAuthInfo } from './operations.ts'; +import { parseLegacyTokenScope } from './legacy-token-scope.ts'; import type { SqlQuery, SqlValue } from './sql-query.ts'; export type { SqlQuery, SqlValue }; @@ -539,7 +541,7 @@ export class GBrainOAuthProvider implements OAuthServerProvider { // Token Verification // ------------------------------------------------------------------------- - async verifyAccessToken(token: string): Promise { + async verifyAccessToken(token: string): Promise { const tokenHash = hashToken(token); const now = Math.floor(Date.now() / 1000); @@ -629,14 +631,29 @@ export class GBrainOAuthProvider implements OAuthServerProvider { // operations.ts prefers this array over scalar sourceId when set // and non-empty. allowedSources, - } as AuthInfo; + } as CoreAuthInfo as SdkAuthInfo; } - // Fallback: legacy access_tokens table (backward compat) - const legacyRows = await this.sql` - SELECT name FROM access_tokens - WHERE token_hash = ${tokenHash} AND revoked_at IS NULL - `; + // Fallback: legacy access_tokens table (backward compat). Modern legacy + // rows may carry permissions.source_id from the pre-OAuth bearer-token + // path; OAuth transport must preserve that same source grant instead of + // pinning every legacy token to `default`. + let legacyRows: Record[]; + try { + legacyRows = await this.sql` + SELECT name, permissions FROM access_tokens + WHERE token_hash = ${tokenHash} AND revoked_at IS NULL + `; + } catch (err) { + if (isUndefinedColumnError(err, 'permissions')) { + legacyRows = await this.sql` + SELECT name FROM access_tokens + WHERE token_hash = ${tokenHash} AND revoked_at IS NULL + `; + } else { + throw err; + } + } if (legacyRows.length > 0) { // Legacy tokens get full admin access (grandfather in). @@ -646,19 +663,31 @@ export class GBrainOAuthProvider implements OAuthServerProvider { UPDATE access_tokens SET last_used_at = now() WHERE token_hash = ${tokenHash} `; const name = legacyRows[0].name as string; + const permissionsRaw = legacyRows[0].permissions; + let permissions: unknown = permissionsRaw; + if (typeof permissionsRaw === 'string') { + try { + permissions = JSON.parse(permissionsRaw); + } catch { + permissions = undefined; + } + } + const sourceGrant = permissions && typeof permissions === 'object' + ? (permissions as Record).source_id + : undefined; + const { sourceId, allowedSources } = parseLegacyTokenScope(sourceGrant); return { token, clientId: name, clientName: name, scopes: ['read', 'write', 'admin'], expiresAt: Math.floor(Date.now() / 1000) + 365 * 24 * 3600, // Legacy tokens never expire — set 1yr future - // v0.34.1 (#861, D13): legacy bearer tokens default to 'default' - // source — matches the pre-v0.34 effective behavior where the - // serve-http transport fell back to GBRAIN_SOURCE/'default' for - // any caller without explicit scope. Operators who want a - // narrower scope for legacy tokens migrate to OAuth. - sourceId: 'default', - } as AuthInfo; + // Legacy tokens without an explicit permissions.source_id grant keep + // the historical 'default' source floor. Array grants become + // allowedSources for federated reads, matching legacy HTTP transport. + sourceId, + allowedSources, + } as CoreAuthInfo as SdkAuthInfo; } throw new InvalidTokenError('Invalid token'); diff --git a/src/mcp/http-transport.ts b/src/mcp/http-transport.ts index c1ccb3152..c3ff28c27 100644 --- a/src/mcp/http-transport.ts +++ b/src/mcp/http-transport.ts @@ -34,6 +34,8 @@ import { VERSION } from '../version.ts'; import { dispatchToolCall } from './dispatch.ts'; import { buildDefaultLimiters, type RateLimiter } from './rate-limit.ts'; import { sqlQueryForEngine } from '../core/sql-query.ts'; +import { parseLegacyTokenScope } from '../core/legacy-token-scope.ts'; +export { parseLegacyTokenScope }; const DEFAULT_BODY_CAP = 1024 * 1024; // 1 MiB @@ -84,28 +86,8 @@ interface AuthResult { auth?: AuthInfo; } -/** - * #1336: derive a legacy bearer token's source scope from its stored - * `permissions.source_id`. An ARRAY value is a federated_read grant → - * `allowedSources` (scoped reads across exactly those sources). A STRING value - * scopes the scalar floor. Anything else → 'default' (preserves pre-v0.34 - * behavior). NEVER widened to "all": an empty/garbage value keeps the 'default' - * floor and no federated grant. - */ -export function parseLegacyTokenScope(rawSource: unknown): { sourceId: string; allowedSources?: string[] } { - if (Array.isArray(rawSource)) { - const allowedSources = (rawSource as unknown[]).filter(s => typeof s === 'string' && s.length > 0) as string[]; - if (allowedSources.length > 0) { - // Scalar floor: the first granted source (write authority); reads span the array. - return { sourceId: allowedSources[0], allowedSources }; - } - return { sourceId: 'default' }; - } - if (typeof rawSource === 'string' && rawSource.length > 0) { - return { sourceId: rawSource }; - } - return { sourceId: 'default' }; -} +/* Legacy token source-scope parsing lives in core/legacy-token-scope.ts and is + * re-exported above so the legacy HTTP transport and OAuth provider cannot drift. */ /** Read up to `cap` bytes off req.body. Returns null if cap exceeded. */ async function readBodyWithCap(req: Request, cap: number): Promise { diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 257ebb2d7..3c466ec5b 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -12,6 +12,7 @@ import { import { hashToken, generateToken } from '../src/core/utils.ts'; import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts'; import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import type { AuthInfo as CoreAuthInfo } from '../src/core/operations.ts'; // --------------------------------------------------------------------------- // Test setup: in-memory PGLite with OAuth tables @@ -310,6 +311,33 @@ describe('verifyAccessToken', () => { expect(authInfo.clientId).toBe('legacy-agent'); expect(authInfo.scopes).toEqual(['read', 'write', 'admin']); // grandfathered full access }); + + test('legacy access_tokens fallback honors permissions.source_id array grants', async () => { + // oauth.test.ts initializes the static PGLite schema blob, not the full + // migration stack. Add the v38 permissions column here so the row matches + // a modern brain carrying a legacy-token source grant. + await sql` + ALTER TABLE access_tokens + ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb + `; + + const legacyToken = generateToken('gbrain_'); + const hash = hashToken(legacyToken); + await sql` + INSERT INTO access_tokens (id, name, token_hash, permissions) + VALUES ( + ${crypto.randomUUID()}, + ${'legacy-federated-agent'}, + ${hash}, + ${JSON.stringify({ source_id: ['default', 'src-a', 'src-b'] })}::jsonb + ) + `; + + const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo; + expect(authInfo.clientId).toBe('legacy-federated-agent'); + expect(authInfo.sourceId).toBe('default'); + expect(authInfo.allowedSources).toEqual(['default', 'src-a', 'src-b']); + }); }); // ---------------------------------------------------------------------------