Skip to content

Commit ffa0aca

Browse files
committed
2 parents 0784dad + ffdd88d commit ffa0aca

4 files changed

Lines changed: 74 additions & 15 deletions

File tree

packages/core/src/security/api-key.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,22 @@ describe('core api-key primitives', () => {
3939
expect(k.raw.startsWith(k.prefix)).toBe(true);
4040
});
4141

42-
it('extractApiKey: x-api-key / ApiKey scheme, not Bearer', () => {
42+
it('extractApiKey: x-api-key / ApiKey, and Bearer only for osk_-prefixed keys', () => {
4343
expect(extractApiKey({ 'x-api-key': 'k' })).toBe('k');
4444
expect(extractApiKey({ authorization: 'ApiKey k' })).toBe('k');
45+
// A bare Bearer (e.g. a better-auth session token) is NOT an api-key.
4546
expect(extractApiKey({ authorization: 'Bearer k' })).toBeUndefined();
47+
// A Bearer carrying the api-key prefix IS accepted — remote MCP clients
48+
// (Claude Desktop / Cursor / Claude Code) send the key this way.
49+
expect(extractApiKey({ authorization: 'Bearer osk_abc123' })).toBe('osk_abc123');
50+
expect(extractApiKey({ authorization: 'bearer osk_abc123' })).toBe('osk_abc123'); // scheme is case-insensitive
51+
});
52+
53+
it('resolveApiKeyPrincipal resolves a Bearer osk_ key (remote MCP client path)', async () => {
54+
const raw = 'osk_' + 'b'.repeat(24);
55+
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
56+
const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` });
57+
expect(p?.userId).toBe('u1');
4658
});
4759

4860
it('parseScopes + isExpired basics', () => {

packages/core/src/security/api-key.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,27 @@ export function generateApiKey(prefix: string = API_KEY_PREFIX): GeneratedApiKey
6969
}
7070

7171
/**
72-
* Extract an API key from request headers. Accepts `X-API-Key: <token>` or
73-
* `Authorization: ApiKey <token>` (case-insensitive scheme). Bearer tokens are
74-
* deliberately NOT treated as API keys — those flow through the session path.
72+
* Extract an API key from request headers. Accepts, in order:
73+
* - `X-API-Key: <token>`
74+
* - `Authorization: ApiKey <token>` (case-insensitive scheme)
75+
* - `Authorization: Bearer <token>` ONLY when `<token>` carries the ObjectStack
76+
* api-key prefix (`osk_`). Remote MCP clients (Claude Desktop / Cursor /
77+
* Claude Code) authenticate to `/api/v1/mcp` with the key as a Bearer per the
78+
* MCP spec, so rejecting Bearer outright made every standard MCP client fail.
79+
* A better-auth *session* token never starts with `osk_`, so a session Bearer
80+
* still falls through to the session path — this can't shadow it.
7581
*/
7682
export function extractApiKey(headers: any): string | undefined {
7783
const x = readHeader(headers, 'x-api-key');
7884
if (x && x.trim()) return x.trim();
7985
const auth = readHeader(headers, 'authorization');
8086
if (!auth) return undefined;
81-
const m = auth.match(/^ApiKey\s+(.+)$/i);
82-
const token = m?.[1]?.trim();
83-
return token || undefined;
87+
const apiKeyScheme = auth.match(/^ApiKey\s+(\S.*)$/i);
88+
if (apiKeyScheme?.[1]?.trim()) return apiKeyScheme[1].trim();
89+
// Bearer is accepted only for prefixed api-keys (never for session tokens).
90+
const bearer = auth.match(/^Bearer\s+(\S.*)$/i)?.[1]?.trim();
91+
if (bearer && bearer.startsWith(API_KEY_PREFIX)) return bearer;
92+
return undefined;
8493
}
8594

8695
/** Parse a `scopes` value that may be a JSON-string textarea or a real array. */

packages/runtime/src/http-dispatcher.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,16 @@ export class HttpDispatcher {
292292
};
293293

294294
if (action === 'create') {
295-
if (ql) {
295+
// Prefer the protocol service (validations + RLS + audit), mirroring
296+
// the read paths below. The MCP bridge passes `context.dataDriver` as
297+
// `ql`, which in the multi-env runtime is a RAW db driver with no ORM
298+
// `insert` — so going straight to `ql.insert` broke MCP create_record
299+
// ("ql.insert is not a function") while REST (which uses `createData`)
300+
// worked. Routing writes through the protocol keeps them aligned.
301+
if (protocol && typeof protocol.createData === 'function') {
302+
return await protocol.createData({ object: params.object, data: params.data, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
303+
}
304+
if (ql && typeof ql.insert === 'function') {
296305
const res = await ql.insert(params.object, params.data, qlOpts);
297306
const record = { ...params.data, ...res };
298307
return { object: params.object, id: record.id, record };
@@ -315,7 +324,10 @@ export class HttpDispatcher {
315324
}
316325

317326
if (action === 'update') {
318-
if (ql && params.id) {
327+
if (protocol && typeof protocol.updateData === 'function') {
328+
return await protocol.updateData({ object: params.object, id: params.id, data: params.data, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
329+
}
330+
if (ql && params.id && typeof ql.update === 'function') {
319331
let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));
320332
if (all && (all as any).value) all = (all as any).value;
321333
if (!all) all = [];
@@ -328,7 +340,10 @@ export class HttpDispatcher {
328340
}
329341

330342
if (action === 'delete') {
331-
if (ql) {
343+
if (protocol && typeof protocol.deleteData === 'function') {
344+
return await protocol.deleteData({ object: params.object, id: params.id, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
345+
}
346+
if (ql && typeof ql.delete === 'function') {
332347
await ql.delete(params.object, findOpts({ where: { id: params.id } }));
333348
return { object: params.object, id: params.id, deleted: true };
334349
}
@@ -3499,7 +3514,23 @@ export class HttpDispatcher {
34993514
try {
35003515
context.executionContext = await resolveExecutionContext({
35013516
getService: (n: string) => this.resolveService(n, context.environmentId),
3502-
getQl: () => Promise.resolve(this.getObjectQLService(context.environmentId)),
3517+
// Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
3518+
// `resolveService('objectql', envId)` factory can return a different
3519+
// instance that doesn't see THIS env's rows (the gotcha
3520+
// `handleActions` works around) — which made the api-key lookup miss
3521+
// `sys_api_key` on the MCP path and reject valid keys with 401, while
3522+
// REST accepted them (rest-server resolves identity via
3523+
// `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel`
3524+
// keeps REST + MCP identity resolution aligned; falls back to the
3525+
// scoped path when the kernel can't hand back an objectql directly.
3526+
getQl: async () => {
3527+
const k: any = this.kernel;
3528+
if (k && typeof k.getServiceAsync === 'function') {
3529+
const ql = await k.getServiceAsync('objectql').catch(() => undefined);
3530+
if (ql && (ql.registry || typeof ql.find === 'function')) return ql;
3531+
}
3532+
return this.getObjectQLService(context.environmentId);
3533+
},
35033534
request: context.request,
35043535
});
35053536
} catch {

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,21 @@ describe('resolveExecutionContext — API key verify path', () => {
131131
expect(ctx.permissions).toEqual([]);
132132
});
133133

134-
it('ignores Bearer tokens on the API-key path (no key resolution)', async () => {
134+
it('resolves a Bearer api-key (osk_ prefix) but not a bare session Bearer', async () => {
135135
const raw = 'osk_valid';
136136
const rows = [{ id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u1' }];
137-
// Bearer is a session token, not an API key — must not resolve here.
138-
const ctx = await resolveExecutionContext(
137+
// A Bearer carrying the osk_ api-key prefix DOES resolve — remote MCP clients
138+
// (Claude Desktop / Cursor / Claude Code) send the key as a Bearer.
139+
const keyed = await resolveExecutionContext(
139140
makeOpts(rows, { authorization: `Bearer ${raw}` }),
140141
);
141-
expect(ctx.userId).toBeUndefined();
142+
expect(keyed.userId).toBe('u1');
143+
// A bare Bearer (a better-auth session token) is NOT an api-key — no key
144+
// resolution here; it falls through to the session path.
145+
const session = await resolveExecutionContext(
146+
makeOpts(rows, { authorization: 'Bearer some-session-token' }),
147+
);
148+
expect(session.userId).toBeUndefined();
142149
});
143150
});
144151

0 commit comments

Comments
 (0)