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
173 changes: 173 additions & 0 deletions packages/runtime/src/security/api-key.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 <token> (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);
});
});
137 changes: 137 additions & 0 deletions packages/runtime/src/security/api-key.ts
Original file line number Diff line number Diff line change
@@ -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');

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
a call to extractApiKey
is hashed insecurely.
Password from
an access to apiKey
is hashed insecurely.
}

/** 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: <token>` or
* `Authorization: ApiKey <token>` (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);

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with 'apikey\t' and with many repetitions of '\t\t'.
This
regular expression
that depends on
library input
may run slow on strings starting with 'apikey\t' and with many repetitions of '\t\t'.
This
regular expression
that depends on
library input
may run slow on strings starting with 'apikey\t' and with many repetitions of '\t\t'.
This
regular expression
that depends on
library input
may run slow on strings starting with 'apikey\t' and with many repetitions of '\t\t'.
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<unknown>(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<T>(s: string, fallback: T): T {
try {
return JSON.parse(s) as T;
} catch {
return fallback;
}
}
9 changes: 9 additions & 0 deletions packages/runtime/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading