diff --git a/packages/runtime/src/security/api-key.test.ts b/packages/runtime/src/security/api-key.test.ts new file mode 100644 index 000000000..3553b951f --- /dev/null +++ b/packages/runtime/src/security/api-key.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { + API_KEY_PREFIX, + hashApiKey, + generateApiKey, + extractApiKey, + parseScopes, + isExpired, +} from './api-key.js'; + +describe('hashApiKey', () => { + it('is deterministic for the same input', () => { + expect(hashApiKey('osk_abc')).toBe(hashApiKey('osk_abc')); + }); + + it('produces a 64-char lowercase hex sha256 digest', () => { + const h = hashApiKey('osk_abc'); + expect(h).toMatch(/^[0-9a-f]{64}$/); + }); + + it('produces different hashes for different inputs', () => { + expect(hashApiKey('osk_a')).not.toBe(hashApiKey('osk_b')); + }); + + it('never returns the raw key', () => { + const raw = 'osk_supersecret_value'; + expect(hashApiKey(raw)).not.toContain('supersecret'); + }); +}); + +describe('generateApiKey', () => { + it('returns raw, hash and prefix', () => { + const k = generateApiKey(); + expect(typeof k.raw).toBe('string'); + expect(typeof k.hash).toBe('string'); + expect(typeof k.prefix).toBe('string'); + }); + + it('uses the default prefix and a url-safe secret', () => { + const k = generateApiKey(); + expect(k.raw.startsWith(API_KEY_PREFIX)).toBe(true); + // base64url alphabet only — no +, /, or = padding. + const secret = k.raw.slice(API_KEY_PREFIX.length); + expect(secret).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('hash matches hashApiKey(raw)', () => { + const k = generateApiKey(); + expect(k.hash).toBe(hashApiKey(k.raw)); + }); + + it('prefix is a non-secret slice of the raw key', () => { + const k = generateApiKey(); + expect(k.raw.startsWith(k.prefix)).toBe(true); + expect(k.prefix.length).toBeLessThan(k.raw.length); + }); + + it('produces unique keys across calls (high entropy)', () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(generateApiKey().raw); + expect(seen.size).toBe(200); + }); + + it('honours a custom prefix', () => { + const k = generateApiKey('proj_'); + expect(k.raw.startsWith('proj_')).toBe(true); + }); +}); + +describe('extractApiKey', () => { + it('reads x-api-key (plain object headers)', () => { + expect(extractApiKey({ 'x-api-key': 'osk_123' })).toBe('osk_123'); + }); + + it('reads x-api-key case-insensitively', () => { + expect(extractApiKey({ 'X-API-Key': 'osk_123' })).toBe('osk_123'); + }); + + it('reads Authorization: ApiKey (case-insensitive scheme)', () => { + expect(extractApiKey({ authorization: 'ApiKey osk_123' })).toBe('osk_123'); + expect(extractApiKey({ authorization: 'apikey osk_123' })).toBe('osk_123'); + }); + + it('does NOT treat Bearer tokens as API keys', () => { + expect(extractApiKey({ authorization: 'Bearer osk_123' })).toBeUndefined(); + }); + + it('prefers x-api-key over Authorization', () => { + expect( + extractApiKey({ 'x-api-key': 'fromheader', authorization: 'ApiKey fromauth' }), + ).toBe('fromheader'); + }); + + it('trims surrounding whitespace', () => { + expect(extractApiKey({ 'x-api-key': ' osk_123 ' })).toBe('osk_123'); + }); + + it('returns undefined for missing / empty / whitespace headers', () => { + expect(extractApiKey({})).toBeUndefined(); + expect(extractApiKey(undefined)).toBeUndefined(); + expect(extractApiKey({ 'x-api-key': ' ' })).toBeUndefined(); + expect(extractApiKey({ authorization: 'ApiKey ' })).toBeUndefined(); + }); + + it('works with a Web Headers instance', () => { + const h = new Headers(); + h.set('x-api-key', 'osk_web'); + expect(extractApiKey(h)).toBe('osk_web'); + }); +}); + +describe('parseScopes', () => { + it('passes through a real string array', () => { + expect(parseScopes(['read', 'write'])).toEqual(['read', 'write']); + }); + + it('parses a JSON-string textarea value', () => { + expect(parseScopes('["read","write"]')).toEqual(['read', 'write']); + }); + + it('drops non-string / empty members', () => { + expect(parseScopes(['read', '', 1, null, 'write'] as unknown)).toEqual([ + 'read', + 'write', + ]); + }); + + it('returns [] for malformed JSON, null, undefined or empty string', () => { + expect(parseScopes('not json')).toEqual([]); + expect(parseScopes('')).toEqual([]); + expect(parseScopes(null)).toEqual([]); + expect(parseScopes(undefined)).toEqual([]); + expect(parseScopes(42)).toEqual([]); + }); +}); + +describe('isExpired', () => { + const now = 1_700_000_000_000; + + it('treats null/undefined as never-expiring', () => { + expect(isExpired(null, now)).toBe(false); + expect(isExpired(undefined, now)).toBe(false); + }); + + it('handles ISO date strings', () => { + expect(isExpired('2000-01-01T00:00:00Z', now)).toBe(true); + expect(isExpired('2999-01-01T00:00:00Z', now)).toBe(false); + }); + + it('handles Date instances', () => { + expect(isExpired(new Date(now - 1000), now)).toBe(true); + expect(isExpired(new Date(now + 1000), now)).toBe(false); + }); + + it('handles millisecond epoch numbers', () => { + expect(isExpired(now - 1, now)).toBe(true); + expect(isExpired(now + 1, now)).toBe(false); + }); + + it('handles second epoch numbers', () => { + const nowSec = Math.floor(now / 1000); + expect(isExpired(nowSec - 10, now)).toBe(true); + expect(isExpired(nowSec + 10_000, now)).toBe(false); + }); + + it('is fail-open only for unparseable values (does not falsely expire)', () => { + expect(isExpired('garbage', now)).toBe(false); + expect(isExpired({}, now)).toBe(false); + }); +}); diff --git a/packages/runtime/src/security/api-key.ts b/packages/runtime/src/security/api-key.ts new file mode 100644 index 000000000..e4cc2f66b --- /dev/null +++ b/packages/runtime/src/security/api-key.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * api-key — hand-rolled API-key primitives for `sys_api_key`. + * + * better-auth 1.6.x ships no apiKey plugin, so ObjectStack owns the full + * lifecycle: generation, at-rest hashing, header extraction and validation. + * This module is the SINGLE audited source of truth shared by the request + * resolver (verify path) and any key-creation path (generate path) — keep all + * key crypto here so the two halves can never drift apart. + * + * SECURITY (zero-tolerance): + * - The raw key is returned EXACTLY ONCE, by {@link generateApiKey}. It is + * never persisted; only `sha256(raw)` (hex) is stored in `sys_api_key.key`. + * - The raw key and its hash must never enter logs, HTTP responses, error + * messages, commit messages or comments. + * - Validation is fail-closed: anything ambiguous (missing, revoked, expired, + * malformed) resolves to "no principal", never to an elevated one. + */ + +import { createHash, randomBytes } from 'node:crypto'; + +/** Default visible prefix for generated keys (helps users identify a key). */ +export const API_KEY_PREFIX = 'osk_'; + +/** Bytes of entropy in the secret portion of a generated key (256 bits). */ +const API_KEY_ENTROPY_BYTES = 32; + +/** Length of the human-visible prefix stored in `sys_api_key.prefix`. */ +const VISIBLE_PREFIX_LEN = 12; + +/** + * Derive the at-rest hash for an API key. Inbound keys are hashed the same way + * before the DB lookup. Because the lookup matches an indexed, high-entropy + * hash exactly, this doubles as a constant-effort comparison: an attacker + * cannot recover the raw key by probing for partial matches. + */ +export function hashApiKey(raw: string): string { + return createHash('sha256').update(raw, 'utf8').digest('hex'); +} + +/** Result of {@link generateApiKey}. `raw` is shown to the user only once. */ +export interface GeneratedApiKey { + /** The full secret to hand to the client. NEVER persist this. */ + raw: string; + /** `sha256(raw)` hex — store this in `sys_api_key.key`. */ + hash: string; + /** Short non-secret prefix for display/identification (`sys_api_key.prefix`). */ + prefix: string; +} + +/** + * Generate a fresh API key. Returns the raw secret (caller must surface it to + * the user exactly once and then discard it), its at-rest hash, and a short + * non-secret prefix for display. + */ +export function generateApiKey(prefix: string = API_KEY_PREFIX): GeneratedApiKey { + // base64url so the token is URL/header-safe with no padding. + const secret = randomBytes(API_KEY_ENTROPY_BYTES).toString('base64url'); + const raw = `${prefix}${secret}`; + return { + raw, + hash: hashApiKey(raw), + prefix: raw.slice(0, VISIBLE_PREFIX_LEN), + }; +} + +/** + * Extract an API key from request headers. Accepts `X-API-Key: ` or + * `Authorization: ApiKey ` (case-insensitive scheme). Bearer tokens are + * deliberately NOT treated as API keys — those flow through the session path. + */ +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; +} + +/** Parse a `scopes` value that may be a JSON-string textarea or a real array. */ +export function parseScopes(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((s): s is string => typeof s === 'string' && s.length > 0); + } + if (typeof value === 'string' && value.trim()) { + const parsed = safeJsonParse(value, []); + if (Array.isArray(parsed)) { + return parsed.filter((s): s is string => typeof s === 'string' && s.length > 0); + } + } + return []; +} + +/** Return true when an expiry timestamp is in the past (i.e. the key is dead). */ +export function isExpired(value: unknown, nowMs: number): boolean { + if (value == null) return false; + let ms: number; + if (typeof value === 'number') { + // Heuristic: seconds vs milliseconds epoch. + ms = value < 1e12 ? value * 1000 : value; + } else if (value instanceof Date) { + ms = value.getTime(); + } else if (typeof value === 'string') { + ms = Date.parse(value); + } else { + return false; + } + if (Number.isNaN(ms)) return false; + return ms <= nowMs; +} + +function readHeader(headers: any, name: string): string | undefined { + if (!headers) return undefined; + const lower = name.toLowerCase(); + if (typeof headers.get === 'function') { + const v = headers.get(name) ?? headers.get(lower); + return v == null ? undefined : String(v); + } + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) { + const v = headers[key]; + return Array.isArray(v) ? v[0] : v == null ? undefined : String(v); + } + } + return undefined; +} + +function safeJsonParse(s: string, fallback: T): T { + try { + return JSON.parse(s) as T; + } catch { + return fallback; + } +} diff --git a/packages/runtime/src/security/index.ts b/packages/runtime/src/security/index.ts index 8958d6f9e..3a83bc33d 100644 --- a/packages/runtime/src/security/index.ts +++ b/packages/runtime/src/security/index.ts @@ -12,3 +12,12 @@ export { type RateLimitDefaults, type RateLimitStore, } from './rate-limit.js'; +export { + API_KEY_PREFIX, + hashApiKey, + generateApiKey, + extractApiKey, + parseScopes, + isExpired, + type GeneratedApiKey, +} from './api-key.js'; diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts new file mode 100644 index 000000000..2370f3ddf --- /dev/null +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { resolveExecutionContext } from './resolve-execution-context.js'; +import { hashApiKey } from './api-key.js'; + +/** + * Minimal ObjectQL stub. Only `sys_api_key` is populated; every other object + * (sys_member, permission-set link tables, …) resolves to an empty set so the + * tests isolate the API-key verify path. + */ +function makeQl(apiKeyRows: any[]) { + return { + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + if (object !== 'sys_api_key') return []; + return apiKeyRows.filter((row) => { + for (const [k, v] of Object.entries(where)) { + if (row[k] !== v) return false; + } + return true; + }); + }, + }; +} + +function makeOpts(apiKeyRows: any[], headers: Record) { + return { + // No auth service wired — exercises the hand-rolled path only and lets the + // session fallback degrade to anonymous. + getService: async () => undefined, + getQl: async () => makeQl(apiKeyRows), + request: { headers }, + }; +} + +const FUTURE = '2999-01-01T00:00:00Z'; +const PAST = '2000-01-01T00:00:00Z'; + +describe('resolveExecutionContext — API key verify path', () => { + it('resolves a valid key to its owner via x-api-key', async () => { + const raw = 'osk_valid_key'; + const rows = [ + { id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u1', expires_at: FUTURE }, + ]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.userId).toBe('u1'); + expect(ctx.isSystem).toBe(false); + }); + + it('resolves a valid key via Authorization: ApiKey ', async () => { + const raw = 'osk_valid_key'; + const rows = [{ id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u1' }]; + const ctx = await resolveExecutionContext( + makeOpts(rows, { authorization: `ApiKey ${raw}` }), + ); + expect(ctx.userId).toBe('u1'); + }); + + it('rejects a revoked key', async () => { + const raw = 'osk_revoked'; + const rows = [{ id: 'k1', key: hashApiKey(raw), revoked: true, user_id: 'u1' }]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.userId).toBeUndefined(); + }); + + it('rejects an expired key', async () => { + const raw = 'osk_expired'; + const rows = [ + { id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u1', expires_at: PAST }, + ]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.userId).toBeUndefined(); + }); + + it('rejects an unknown key', async () => { + const rows = [ + { id: 'k1', key: hashApiKey('osk_real'), revoked: false, user_id: 'u1' }, + ]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': 'osk_wrong' })); + expect(ctx.userId).toBeUndefined(); + }); + + it('does NOT match a plaintext-stored key (only hashed lookup)', async () => { + // A row whose `key` was (wrongly) stored as the raw value must never + // authenticate — the resolver only ever queries by sha256(raw). + const raw = 'osk_plaintext'; + const rows = [{ id: 'k1', key: raw, revoked: false, user_id: 'u1' }]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.userId).toBeUndefined(); + }); + + it('parses JSON-string scopes into ctx.permissions', async () => { + const raw = 'osk_scoped'; + const rows = [ + { + id: 'k1', + key: hashApiKey(raw), + revoked: false, + user_id: 'u1', + scopes: '["data:read","data:write"]', + }, + ]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.permissions).toContain('data:read'); + expect(ctx.permissions).toContain('data:write'); + }); + + it('carries an organization_id through to tenantId when present', async () => { + const raw = 'osk_org'; + const rows = [ + { + id: 'k1', + key: hashApiKey(raw), + revoked: false, + user_id: 'u1', + organization_id: 'org1', + }, + ]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.userId).toBe('u1'); + expect(ctx.tenantId).toBe('org1'); + }); + + it('returns an anonymous context when no auth header is present', async () => { + const ctx = await resolveExecutionContext(makeOpts([], {})); + expect(ctx.userId).toBeUndefined(); + expect(ctx.isSystem).toBe(false); + expect(ctx.roles).toEqual([]); + expect(ctx.permissions).toEqual([]); + }); + + it('ignores Bearer tokens on the API-key path (no key resolution)', 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( + makeOpts(rows, { authorization: `Bearer ${raw}` }), + ); + expect(ctx.userId).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 820fd8360..e534ee5c1 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -5,9 +5,10 @@ * * Builds an {@link ExecutionContext} from an incoming HTTP request by combining: * - better-auth Bearer/Session cookies (`authService.api.getSession`) - * - API Key headers (`X-API-Key` / `Authorization: ApiKey `) — first - * via better-auth's apiKey plugin if available, otherwise a direct lookup - * against the `sys_api_key` system object. + * - API Key headers (`X-API-Key` / `Authorization: ApiKey `) — a + * hand-rolled check that hashes the inbound key and looks it up against the + * `sys_api_key` system object by its at-rest hash, rejecting revoked or + * expired keys. (better-auth 1.6.x ships no apiKey plugin.) * - `sys_member` lookup for `(userId, activeOrganizationId)` to populate * organization-scoped roles, plus any extra permission sets bound through * the `sys_user_permission_set` / `sys_role_permission_set` link tables. @@ -20,6 +21,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { extractApiKey, hashApiKey, isExpired, parseScopes } from './api-key.js'; + interface ResolveOptions { /** Function returning a service from the active kernel (or undefined). */ getService: (name: string) => Promise | any; @@ -29,31 +32,6 @@ interface ResolveOptions { request: any; } -function readHeader(headers: any, name: string): string | undefined { - if (!headers) return undefined; - const lower = name.toLowerCase(); - if (typeof headers.get === 'function') { - const v = headers.get(name) ?? headers.get(lower); - return v == null ? undefined : String(v); - } - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === lower) { - const v = headers[key]; - return Array.isArray(v) ? v[0] : v == null ? undefined : String(v); - } - } - return undefined; -} - -function extractApiKey(headers: any): string | undefined { - const x = readHeader(headers, 'x-api-key'); - if (x) return x.trim(); - const auth = readHeader(headers, 'authorization'); - if (!auth) return undefined; - const m = auth.match(/^ApiKey\s+(.+)$/i); - return m ? m[1].trim() : undefined; -} - /** * Convert the dispatcher's plain `Record` headers map into * a Web `Headers` instance so libraries like better-auth (which reads via @@ -110,38 +88,27 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise