mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Adds the machine-to-machine auth layer the public REST API needs so external scripts and automations can drive wacrm (issue #245). This is the groundwork release; data endpoints land one at a time on top of it. - Migration 026: account-scoped `api_keys` table. SHA-256 hash at rest (plaintext shown once), display `key_prefix`, `scopes[]`, RLS via `is_account_member` (members read, admin+ manage). - `src/lib/api-keys/`: key gen/hash (wacrm_live_ prefix, timing-safe), scope vocabulary, service-role auth-path store. - `requireApiKey(request, scope?)` (src/lib/auth/api-context.ts): bearer parse -> hash lookup -> liveness -> per-key rate limit -> scopes-only authz. Returns a service-role-scoped account context. - `/api/v1` envelope ({data} / {error:{code,message}}) + GET /api/v1/me reference endpoint that exercises the whole stack. - Dashboard: admin+ key management routes + Settings -> API keys tab. - Docs (docs/public-api.md), CHANGELOG, README, .env note. Checks: typecheck clean, lint 0 errors, full suite green (+27 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
2.3 KiB
TypeScript
72 lines
2.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
API_KEY_PREFIX,
|
|
generateApiKey,
|
|
hashApiKey,
|
|
looksLikeApiKey,
|
|
timingSafeHexEqual,
|
|
} from './keys';
|
|
|
|
describe('generateApiKey', () => {
|
|
it('returns a prefixed plaintext, a hash, and a display prefix', () => {
|
|
const { plaintext, hash, prefix } = generateApiKey();
|
|
expect(plaintext.startsWith(API_KEY_PREFIX)).toBe(true);
|
|
expect(plaintext.length).toBeGreaterThan(API_KEY_PREFIX.length + 20);
|
|
// SHA-256 hex is 64 chars.
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
// Display prefix is the literal prefix + 8 body chars.
|
|
expect(prefix.startsWith(API_KEY_PREFIX)).toBe(true);
|
|
expect(prefix.length).toBe(API_KEY_PREFIX.length + 8);
|
|
// The display prefix is a true prefix of the plaintext.
|
|
expect(plaintext.startsWith(prefix)).toBe(true);
|
|
});
|
|
|
|
it('never repeats a key (entropy sanity check)', () => {
|
|
const seen = new Set<string>();
|
|
for (let i = 0; i < 200; i++) seen.add(generateApiKey().plaintext);
|
|
expect(seen.size).toBe(200);
|
|
});
|
|
|
|
it('hash matches an independent hashApiKey of the plaintext', () => {
|
|
const { plaintext, hash } = generateApiKey();
|
|
expect(hashApiKey(plaintext)).toBe(hash);
|
|
});
|
|
});
|
|
|
|
describe('hashApiKey', () => {
|
|
it('is deterministic', () => {
|
|
expect(hashApiKey('wacrm_live_abc')).toBe(hashApiKey('wacrm_live_abc'));
|
|
});
|
|
|
|
it('differs for different inputs', () => {
|
|
expect(hashApiKey('wacrm_live_abc')).not.toBe(hashApiKey('wacrm_live_abd'));
|
|
});
|
|
});
|
|
|
|
describe('looksLikeApiKey', () => {
|
|
it('accepts a well-formed key', () => {
|
|
expect(looksLikeApiKey(generateApiKey().plaintext)).toBe(true);
|
|
});
|
|
|
|
it('rejects the bare prefix, empty, and foreign tokens', () => {
|
|
expect(looksLikeApiKey(API_KEY_PREFIX)).toBe(false);
|
|
expect(looksLikeApiKey('')).toBe(false);
|
|
expect(looksLikeApiKey('some-invite-token')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('timingSafeHexEqual', () => {
|
|
it('is true for identical digests', () => {
|
|
const h = hashApiKey('wacrm_live_xyz');
|
|
expect(timingSafeHexEqual(h, h)).toBe(true);
|
|
});
|
|
|
|
it('is false for different digests', () => {
|
|
expect(timingSafeHexEqual(hashApiKey('a'), hashApiKey('b'))).toBe(false);
|
|
});
|
|
|
|
it('is false (not throwing) on length mismatch', () => {
|
|
expect(timingSafeHexEqual('ab', 'abcd')).toBe(false);
|
|
});
|
|
});
|