mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
fix: honor legacy token source grants in oauth
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
+44
-15
@@ -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<AuthInfo> {
|
||||
async verifyAccessToken(token: string): Promise<SdkAuthInfo> {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>).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');
|
||||
|
||||
@@ -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<string | null> {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user