Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/core/src/security/api-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,22 @@ describe('core api-key primitives', () => {
expect(k.raw.startsWith(k.prefix)).toBe(true);
});

it('extractApiKey: x-api-key / ApiKey scheme, not Bearer', () => {
it('extractApiKey: x-api-key / ApiKey, and Bearer only for osk_-prefixed keys', () => {
expect(extractApiKey({ 'x-api-key': 'k' })).toBe('k');
expect(extractApiKey({ authorization: 'ApiKey k' })).toBe('k');
// A bare Bearer (e.g. a better-auth session token) is NOT an api-key.
expect(extractApiKey({ authorization: 'Bearer k' })).toBeUndefined();
// A Bearer carrying the api-key prefix IS accepted — remote MCP clients
// (Claude Desktop / Cursor / Claude Code) send the key this way.
expect(extractApiKey({ authorization: 'Bearer osk_abc123' })).toBe('osk_abc123');
expect(extractApiKey({ authorization: 'bearer osk_abc123' })).toBe('osk_abc123'); // scheme is case-insensitive
});

it('resolveApiKeyPrincipal resolves a Bearer osk_ key (remote MCP client path)', async () => {
const raw = 'osk_' + 'b'.repeat(24);
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` });
expect(p?.userId).toBe('u1');
});

it('parseScopes + isExpired basics', () => {
Expand Down
21 changes: 15 additions & 6 deletions packages/core/src/security/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
* cannot recover the raw key by probing for partial matches.
*/
export function hashApiKey(raw: string): string {
return createHash('sha256').update(raw, 'utf8').digest('hex');

Check failure

Code scanning / CodeQL

Use of password hash with insufficient computational effort High

Password from
a call to generateApiKey
is hashed insecurely.
Password from an access to API_KEY_PREFIX is hashed insecurely.
Password from a call to readHeader is hashed insecurely.
Password from an access to apiKeyScheme is hashed insecurely.
Password from a call to extractApiKey is hashed insecurely.
Password from an access to apiKey is hashed insecurely.
Password from an access to RAW_API_KEY is hashed insecurely.
Password from a call to generateApiKey is hashed insecurely.
}

/** Result of {@link generateApiKey}. `raw` is shown to the user only once. */
Expand Down Expand Up @@ -69,18 +69,27 @@
}

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

/** Parse a `scopes` value that may be a JSON-string textarea or a real array. */
Expand Down
39 changes: 35 additions & 4 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,16 @@ export class HttpDispatcher {
};

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

if (action === 'update') {
if (ql && params.id) {
if (protocol && typeof protocol.updateData === 'function') {
return await protocol.updateData({ object: params.object, id: params.id, data: params.data, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
}
if (ql && params.id && typeof ql.update === 'function') {
let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));
if (all && (all as any).value) all = (all as any).value;
if (!all) all = [];
Expand All @@ -323,7 +335,10 @@ export class HttpDispatcher {
}

if (action === 'delete') {
if (ql) {
if (protocol && typeof protocol.deleteData === 'function') {
return await protocol.deleteData({ object: params.object, id: params.id, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
}
if (ql && typeof ql.delete === 'function') {
await ql.delete(params.object, findOpts({ where: { id: params.id } }));
return { object: params.object, id: params.id, deleted: true };
}
Expand Down Expand Up @@ -3192,7 +3207,23 @@ export class HttpDispatcher {
try {
context.executionContext = await resolveExecutionContext({
getService: (n: string) => this.resolveService(n, context.environmentId),
getQl: () => Promise.resolve(this.getObjectQLService(context.environmentId)),
// Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
// `resolveService('objectql', envId)` factory can return a different
// instance that doesn't see THIS env's rows (the gotcha
// `handleActions` works around) — which made the api-key lookup miss
// `sys_api_key` on the MCP path and reject valid keys with 401, while
// REST accepted them (rest-server resolves identity via
// `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel`
// keeps REST + MCP identity resolution aligned; falls back to the
// scoped path when the kernel can't hand back an objectql directly.
getQl: async () => {
const k: any = this.kernel;
if (k && typeof k.getServiceAsync === 'function') {
const ql = await k.getServiceAsync('objectql').catch(() => undefined);
if (ql && (ql.registry || typeof ql.find === 'function')) return ql;
}
return this.getObjectQLService(context.environmentId);
},
request: context.request,
});
} catch {
Expand Down
15 changes: 11 additions & 4 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,21 @@ describe('resolveExecutionContext — API key verify path', () => {
expect(ctx.permissions).toEqual([]);
});

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

Expand Down
Loading