From 6b9b04d1d057d45d4f1a468f5e3a14da3ed0acb6 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 17:59:02 +0800 Subject: [PATCH] Improve API token format and enhance auth status command - Change token format to reviq_ prefix instead of raw hex - Add me.authStatus API endpoint for detailed auth information - Enhance CLI `reviq auth status` to show token details from API - Add comprehensive tests for token generation (18 tests) - Extract bootstrap logic to @reviq/db for reusability and testing - Remove default db export; callers must use createDb() directly Token changes: - New format: reviq_ - Added parseToken() for validation - Added isValidTokenFormat() helper Auth status endpoint returns: - User profile information - Auth method (api_token or session) - Token/session details (name, expiration, last used) Co-Authored-By: Claude Opus 4.5 --- apps/api-server/package.json | 1 + .../src/__tests__/e2e/webauthn.test.ts | 9 +- apps/api-server/src/context.ts | 29 +++ apps/api-server/src/middleware/auth.ts | 19 ++ apps/api-server/src/procedures/base.ts | 19 ++ apps/api-server/src/router.ts | 35 ++++ apps/api-server/src/utils/crypto.ts | 35 ++++ apps/cli/package.json | 10 +- apps/cli/src/routes/auth/status.ts | 119 +++++++++++- apps/cli/src/routes/bootstrap.ts | 97 ++-------- apps/cli/src/utils/token.test.ts | 170 ++++++++++++++++++ apps/cli/src/utils/token.ts | 26 +-- bun.lock | 6 + packages/api-contract/src/contract.ts | 4 + packages/api-contract/src/schemas/user.ts | 34 ++++ packages/db/package.json | 2 + packages/db/src/helpers/execute-bootstrap.ts | 168 +++++++++++++++++ packages/db/src/helpers/password.ts | 22 +++ packages/db/src/helpers/token.ts | 51 ++++++ packages/db/src/index.ts | 33 ++-- 20 files changed, 764 insertions(+), 125 deletions(-) create mode 100644 apps/cli/src/utils/token.test.ts create mode 100644 packages/db/src/helpers/execute-bootstrap.ts create mode 100644 packages/db/src/helpers/password.ts create mode 100644 packages/db/src/helpers/token.ts diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 6ced645..efffc4d 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -19,6 +19,7 @@ "@reviq/api-contract": "workspace:*", "@reviq/db": "workspace:*", "@reviq/db-schema": "workspace:*", + "@scure/base": "^2.0.0", "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index f3358e9..0102999 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -61,6 +61,7 @@ function createAuthenticatedContext( userId: number, email: string, ): AuthenticatedContext { + const now = new Date(); return { ...createAPIContext(), user: { @@ -73,7 +74,13 @@ function createAuthenticatedContext( session: { id: "1", trustedMode: false, - createdAt: new Date(), + createdAt: now, + }, + auth: { + method: "session", + sessionId: "1", + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + createdAt: now, }, }; } diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts index aee5056..4e4458b 100644 --- a/apps/api-server/src/context.ts +++ b/apps/api-server/src/context.ts @@ -44,6 +44,33 @@ export interface Session { createdAt: Date; } +/** + * API token authentication info + */ +export interface ApiTokenAuth { + method: "api_token"; + tokenId: string; + tokenName: string; + expiresAt: Date; + lastUsedAt: Date | null; + createdAt: Date; +} + +/** + * Session authentication info + */ +export interface SessionAuth { + method: "session"; + sessionId: string; + expiresAt: Date; + createdAt: Date; +} + +/** + * Union type for authentication method info + */ +export type AuthInfo = ApiTokenAuth | SessionAuth; + /** * Authenticated API context for protected handlers */ @@ -52,6 +79,8 @@ export interface AuthenticatedContext extends APIContext { user: SessionUser; /** Current session */ session: Session; + /** Authentication method and details */ + auth: AuthInfo; } /** diff --git a/apps/api-server/src/middleware/auth.ts b/apps/api-server/src/middleware/auth.ts index c8deee8..04c9209 100644 --- a/apps/api-server/src/middleware/auth.ts +++ b/apps/api-server/src/middleware/auth.ts @@ -9,6 +9,7 @@ import type { APIContext, AuthenticatedContext, + AuthInfo, Session, SessionUser, } from "../context.js"; @@ -125,10 +126,28 @@ export const createAuthMiddleware = () => { createdAt: apiToken?.created_at ?? new Date(), }; + // Build auth info based on authentication method + const authInfo: AuthInfo = session + ? { + method: "session", + sessionId: session.id, + expiresAt: session.expires_at, + createdAt: session.created_at, + } + : { + method: "api_token", + tokenId: apiToken?.id, + tokenName: apiToken?.name, + expiresAt: apiToken?.expires_at, + lastUsedAt: apiToken?.last_used_at, + createdAt: apiToken?.created_at, + }; + return next({ context: { user: sessionUser, session: sessionInfo, + auth: authInfo, }, }); }; diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts index f8527df..122bb9d 100644 --- a/apps/api-server/src/procedures/base.ts +++ b/apps/api-server/src/procedures/base.ts @@ -8,6 +8,7 @@ import type { APIContext, AuthenticatedContext, + AuthInfo, LoginRequestContext, Session, SessionUser, @@ -122,10 +123,28 @@ export const authMiddleware = os.middleware(async ({ context, next }) => { createdAt: apiToken?.created_at ?? new Date(), }; + // Build auth info based on authentication method + const authInfo: AuthInfo = session + ? { + method: "session", + sessionId: session.id, + expiresAt: session.expires_at, + createdAt: session.created_at, + } + : { + method: "api_token", + tokenId: apiToken?.id, + tokenName: apiToken?.name, + expiresAt: apiToken?.expires_at, + lastUsedAt: apiToken?.last_used_at, + createdAt: apiToken?.created_at, + }; + return next({ context: { user: sessionUser, session: sessionInfo, + auth: authInfo, }, }); }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 0406a26..428178c 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -178,6 +178,40 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => { }; }); +const meAuthStatus = os.me.authStatus + .use(authMiddleware) + .handler(async ({ context }) => { + const user = await context.db + .selectFrom("users") + .select([ + "id", + "email", + "display_name", + "full_name", + "phone_number", + "avatar_url", + "email_verified_at", + "is_superuser", + ]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + return { + user: { + id: user.id, + email: user.email, + displayName: user.display_name, + fullName: user.full_name, + phoneNumber: user.phone_number, + avatarUrl: user.avatar_url, + emailVerified: user.email_verified_at !== null, + needsSetup: user.display_name === null, + isSuperuser: user.is_superuser, + }, + auth: context.auth, + }; + }); + const setupProfile = os.me.setupProfile .use(authMiddleware) .handler(async ({ input, context }) => { @@ -314,6 +348,7 @@ export const router = os.router({ }, me: { get: meGet, + authStatus: meAuthStatus, setupProfile, updateProfile, delete: meDelete, diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts index 3528a83..12da4c4 100644 --- a/apps/api-server/src/utils/crypto.ts +++ b/apps/api-server/src/utils/crypto.ts @@ -1,3 +1,10 @@ +import { base58 } from "@scure/base"; + +/** + * Token prefix for all RevIQ API tokens + */ +export const TOKEN_PREFIX = "reviq_"; + /** * Hash a token with SHA-256 for storage in database * Never store raw tokens - always hash first @@ -13,6 +20,34 @@ export const hashToken = async (token: string): Promise => { .join(""); }; +/** + * Validate that a token has the correct format + * Returns the raw bytes if valid, null if invalid + */ +export const parseToken = (token: string): Uint8Array | null => { + if (!token.startsWith(TOKEN_PREFIX)) { + return null; + } + const encoded = token.slice(TOKEN_PREFIX.length); + try { + const bytes = base58.decode(encoded); + // Expect 32 bytes of entropy + if (bytes.length !== 32) { + return null; + } + return bytes; + } catch { + return null; + } +}; + +/** + * Check if a token has the valid reviq_ prefix format + */ +export const isValidTokenFormat = (token: string): boolean => { + return parseToken(token) !== null; +}; + /** * Generate a session token (UUID v4) */ diff --git a/apps/cli/package.json b/apps/cli/package.json index f9004f0..d8e1cce 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -12,13 +12,15 @@ "cli": "bun run src/bin/reviq.ts", "typecheck": "tsc --noEmit", "lint": "eslint . --cache", - "clean": "rm -rf dist .eslintcache" + "clean": "rm -rf dist .eslintcache", + "test": "bun test" }, "dependencies": { - "@stricli/core": "^1.2.5", - "@stricli/auto-complete": "^1.0.0", + "@noble/hashes": "^2.0.1", "@reviq/db": "workspace:*", - "@noble/hashes": "^2.0.1" + "@scure/base": "^2.0.0", + "@stricli/auto-complete": "^1.0.0", + "@stricli/core": "^1.2.5" }, "devDependencies": { "@macalinao/eslint-config": "catalog:", diff --git a/apps/cli/src/routes/auth/status.ts b/apps/cli/src/routes/auth/status.ts index 3ad9869..55150ae 100644 --- a/apps/cli/src/routes/auth/status.ts +++ b/apps/cli/src/routes/auth/status.ts @@ -1,6 +1,61 @@ import type { LocalContext } from "../../context.js"; import { buildCommand } from "@stricli/core"; +import { createApiClient } from "../../utils/api-client.js"; import { getConfigPath, readConfig } from "../../utils/config.js"; +import { TOKEN_PREFIX } from "../../utils/token.js"; + +interface AuthStatusResponse { + user: { + id: number; + email: string; + displayName: string | null; + fullName: string | null; + isSuperuser: boolean; + emailVerified: boolean; + }; + auth: + | { + method: "api_token"; + tokenId: string; + tokenName: string; + expiresAt: string; + lastUsedAt: string | null; + createdAt: string; + } + | { + method: "session"; + sessionId: string; + expiresAt: string; + createdAt: string; + }; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleString(); +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return `${String(Math.abs(diffDays))} days ago`; + } + if (diffDays === 0) { + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + if (diffHours <= 0) { + return "expired"; + } + return `in ${String(diffHours)} hours`; + } + if (diffDays === 1) { + return "tomorrow"; + } + return `in ${String(diffDays)} days`; +} async function status(this: LocalContext): Promise { const config = await readConfig(); @@ -16,10 +71,66 @@ async function status(this: LocalContext): Promise { console.log("Authentication Status"); console.log("=====================\n"); - console.log(`Email: ${config.email}`); - console.log(`API URL: ${config.apiUrl}`); - console.log(`Config file: ${getConfigPath()}`); - console.log("Token: [configured]"); + + // Show local config info + console.log("Local Configuration:"); + console.log(` Config file: ${getConfigPath()}`); + console.log(` API URL: ${config.apiUrl}`); + + // Show token format info + const hasNewFormat = config.token.startsWith(TOKEN_PREFIX); + console.log( + ` Token format: ${hasNewFormat ? "reviq_" : "legacy (hex)"}`, + ); + + // Try to fetch status from API + console.log("\nAPI Status:"); + try { + const client = await createApiClient(); + const response = await client.call("me.authStatus"); + + // User info + console.log("\n User:"); + console.log(` Email: ${response.user.email}`); + if (response.user.displayName) { + console.log(` Display name: ${response.user.displayName}`); + } + if (response.user.fullName) { + console.log(` Full name: ${response.user.fullName}`); + } + console.log( + ` Email verified: ${response.user.emailVerified ? "yes" : "no"}`, + ); + console.log(` Superuser: ${response.user.isSuperuser ? "yes" : "no"}`); + + // Auth method info + if (response.auth.method === "api_token") { + console.log("\n API Token:"); + console.log(` Name: ${response.auth.tokenName}`); + console.log(` Token ID: ${response.auth.tokenId}`); + console.log(` Created: ${formatDate(response.auth.createdAt)}`); + console.log( + ` Expires: ${formatDate(response.auth.expiresAt)} (${formatRelativeTime(response.auth.expiresAt)})`, + ); + if (response.auth.lastUsedAt) { + console.log(` Last used: ${formatDate(response.auth.lastUsedAt)}`); + } + } else { + console.log("\n Session:"); + console.log(` Session ID: ${response.auth.sessionId}`); + console.log(` Created: ${formatDate(response.auth.createdAt)}`); + console.log( + ` Expires: ${formatDate(response.auth.expiresAt)} (${formatRelativeTime(response.auth.expiresAt)})`, + ); + } + } catch (error) { + console.log( + ` Error: ${error instanceof Error ? error.message : String(error)}`, + ); + console.log( + "\n Unable to connect to API. Local credentials may be invalid.", + ); + } } export const statusCommand = buildCommand({ diff --git a/apps/cli/src/routes/bootstrap.ts b/apps/cli/src/routes/bootstrap.ts index b3f6456..27be6bb 100644 --- a/apps/cli/src/routes/bootstrap.ts +++ b/apps/cli/src/routes/bootstrap.ts @@ -1,9 +1,7 @@ import type { LocalContext } from "../context.js"; -import { createDb } from "@reviq/db"; +import { createDb, executeBootstrap } from "@reviq/db"; import { buildCommand } from "@stricli/core"; import { writeConfig } from "../utils/config.js"; -import { hashPassword } from "../utils/password.js"; -import { generateToken, hashToken } from "../utils/token.js"; interface BootstrapFlags { email: string; @@ -17,98 +15,23 @@ async function bootstrap( console.log("RevIQ Bootstrap - Create Superuser"); console.log("===================================\n"); - // Validate password length - if (flags.password.length < 8) { - console.error("Error: Password must be at least 8 characters"); - this.process.exit(1); - } - const db = createDb(); try { - // Check if user already exists - const existing = await db - .selectFrom("users") - .where("email", "=", flags.email.toLowerCase()) - .select("id") - .executeTakeFirst(); + // Execute the bootstrap operation + const result = await executeBootstrap(db, { + email: flags.email, + password: flags.password, + }); - if (existing) { - console.error(`Error: User with email ${flags.email} already exists`); - await db.destroy(); - this.process.exit(1); - } - - // Hash the password - const passwordHash = hashPassword(flags.password); - - // Create superuser - const [user] = await db - .insertInto("users") - .values({ - email: flags.email.toLowerCase(), - password_hash: passwordHash, - is_superuser: true, - email_verified_at: new Date(), - }) - .returning(["id", "email"]) - .execute(); - - if (!user) { - console.error("Error: Failed to create user"); - await db.destroy(); - this.process.exit(1); - } - - console.log(`Created superuser: ${user.email}`); - - // Create "reviq" org - const [org] = await db - .insertInto("orgs") - .values({ - slug: "reviq", - display_name: "RevIQ", - }) - .returning(["id", "slug"]) - .execute(); - - if (!org) { - console.error("Error: Failed to create org"); - await db.destroy(); - this.process.exit(1); - } - - // Add user as owner of the org - await db - .insertInto("org_members") - .values({ - org_id: org.id, - user_id: user.id, - role: "owner", - }) - .execute(); - - console.log(`Created org: ${org.slug}`); - - // Generate API token - const token = generateToken(); - const tokenHashValue = hashToken(token); - - await db - .insertInto("api_tokens") - .values({ - user_id: user.id, - token_hash: tokenHashValue, - name: "CLI bootstrap token", - expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year - }) - .execute(); + console.log(`Created superuser: ${result.user.email}`); + console.log(`Created org: ${result.org.slug}`); // Save to config await writeConfig({ apiUrl: Bun.env.API_URL ?? "http://localhost:9861", - token, - email: user.email, + token: result.token, + email: result.user.email, }); console.log("Saved credentials to ~/.config/reviq/credentials.json"); diff --git a/apps/cli/src/utils/token.test.ts b/apps/cli/src/utils/token.test.ts new file mode 100644 index 0000000..9b4c30a --- /dev/null +++ b/apps/cli/src/utils/token.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for token generation and parsing utilities + */ + +import { describe, expect, test } from "bun:test"; +import { generateToken, hashToken, parseToken, TOKEN_PREFIX } from "./token.js"; + +describe("TOKEN_PREFIX", () => { + test("should be 'reviq_'", () => { + expect(TOKEN_PREFIX).toBe("reviq_"); + }); +}); + +describe("generateToken", () => { + test("should generate a token with the reviq_ prefix", () => { + const token = generateToken(); + expect(token.startsWith(TOKEN_PREFIX)).toBe(true); + }); + + test("should generate tokens of consistent length", () => { + const token1 = generateToken(); + const token2 = generateToken(); + const token3 = generateToken(); + + // Base58 encoding of 32 bytes produces 43-44 characters + // Plus 6 chars for "reviq_" prefix = 49-50 total + expect(token1.length).toBeGreaterThanOrEqual(49); + expect(token1.length).toBeLessThanOrEqual(50); + expect(token2.length).toBeGreaterThanOrEqual(49); + expect(token2.length).toBeLessThanOrEqual(50); + expect(token3.length).toBeGreaterThanOrEqual(49); + expect(token3.length).toBeLessThanOrEqual(50); + }); + + test("should generate unique tokens", () => { + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateToken()); + } + expect(tokens.size).toBe(100); + }); + + test("should only contain valid base58 characters after prefix", () => { + const token = generateToken(); + const base58Part = token.slice(TOKEN_PREFIX.length); + // Base58 alphabet: 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz + // (excludes 0, O, I, l) + const base58Regex = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; + expect(base58Regex.test(base58Part)).toBe(true); + }); +}); + +describe("parseToken", () => { + test("should parse a valid token and return 32 bytes", () => { + const token = generateToken(); + const bytes = parseToken(token); + expect(bytes).not.toBeNull(); + expect(bytes?.length).toBe(32); + }); + + test("should return null for tokens without the prefix", () => { + const bytes = parseToken("invalid_token"); + expect(bytes).toBeNull(); + }); + + test("should return null for tokens with wrong prefix", () => { + const bytes = parseToken( + "wrong_5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ", + ); + expect(bytes).toBeNull(); + }); + + test("should return null for tokens with invalid base58 characters", () => { + // 'O', '0', 'I', 'l' are not valid base58 characters + const bytes = parseToken("reviq_0InvalidBase58"); + expect(bytes).toBeNull(); + }); + + test("should return null for tokens with incorrect byte length", () => { + // A shorter base58 string (less than 32 bytes) + const bytes = parseToken("reviq_abc123"); + expect(bytes).toBeNull(); + }); + + test("should round-trip correctly", () => { + const originalToken = generateToken(); + const bytes = parseToken(originalToken); + expect(bytes).not.toBeNull(); + + // Verify the bytes are the same entropy + expect(bytes?.length).toBe(32); + }); +}); + +describe("hashToken", () => { + test("should produce a 64-character hex string", () => { + const token = generateToken(); + const hash = hashToken(token); + expect(hash.length).toBe(64); + }); + + test("should produce consistent hashes for the same token", () => { + const token = generateToken(); + const hash1 = hashToken(token); + const hash2 = hashToken(token); + expect(hash1).toBe(hash2); + }); + + test("should produce different hashes for different tokens", () => { + const token1 = generateToken(); + const token2 = generateToken(); + const hash1 = hashToken(token1); + const hash2 = hashToken(token2); + expect(hash1).not.toBe(hash2); + }); + + test("should only contain hex characters", () => { + const token = generateToken(); + const hash = hashToken(token); + const hexRegex = /^[0-9a-f]+$/; + expect(hexRegex.test(hash)).toBe(true); + }); + + test("should hash legacy tokens correctly", () => { + // Test with a legacy hex token (pre-reviq_ format) + const legacyToken = "a".repeat(64); + const hash = hashToken(legacyToken); + expect(hash.length).toBe(64); + expect(hash).not.toBe(legacyToken); + }); +}); + +describe("token security properties", () => { + test("should have sufficient entropy (32 bytes = 256 bits)", () => { + const token = generateToken(); + const bytes = parseToken(token); + expect(bytes).not.toBeNull(); + expect(bytes?.length).toBe(32); + // 32 bytes * 8 bits = 256 bits of entropy + }); + + test("should be cryptographically random (statistical test)", () => { + // Generate many tokens and check that byte distribution is roughly uniform + const byteCounts: number[] = new Array(256).fill(0); + const numTokens = 1000; + + for (let i = 0; i < numTokens; i++) { + const token = generateToken(); + const bytes = parseToken(token); + if (bytes) { + for (const byte of bytes) { + // byte is always 0-255, and byteCounts has 256 elements initialized to 0 + byteCounts[byte] = (byteCounts[byte] ?? 0) + 1; + } + } + } + + // Each byte value should appear roughly (numTokens * 32) / 256 times + const expectedCount = (numTokens * 32) / 256; + const tolerance = expectedCount * 0.5; // Allow 50% variance + + // Check that no byte value is extremely over- or under-represented + for (let i = 0; i < 256; i++) { + const count: number = byteCounts[i] ?? 0; + expect(count).toBeGreaterThan(expectedCount - tolerance); + expect(count).toBeLessThan(expectedCount + tolerance); + } + }); +}); diff --git a/apps/cli/src/utils/token.ts b/apps/cli/src/utils/token.ts index 64c46fe..ba3c6c7 100644 --- a/apps/cli/src/utils/token.ts +++ b/apps/cli/src/utils/token.ts @@ -1,22 +1,12 @@ /** * Token generation and hashing utilities + * + * Re-exports from @reviq/db for convenience */ -import { sha256 } from "@noble/hashes/sha2.js"; -import { randomBytes } from "@noble/hashes/utils.js"; - -/** - * Generate a cryptographically secure random token - * Returns a 32-byte hex string (64 characters) - */ -export const generateToken = (): string => { - return Buffer.from(randomBytes(32)).toString("hex"); -}; - -/** - * Hash a token using SHA-256 - * Returns a hex string - */ -export const hashToken = (token: string): string => { - return Buffer.from(sha256(Buffer.from(token))).toString("hex"); -}; +export { + generateToken, + hashToken, + parseToken, + TOKEN_PREFIX, +} from "@reviq/db"; diff --git a/bun.lock b/bun.lock index 173e447..bfd2fd8 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "@reviq/api-contract": "workspace:*", "@reviq/db": "workspace:*", "@reviq/db-schema": "workspace:*", + "@scure/base": "^2.0.0", "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", @@ -49,6 +50,7 @@ "dependencies": { "@noble/hashes": "^2.0.1", "@reviq/db": "workspace:*", + "@scure/base": "^2.0.0", "@stricli/auto-complete": "^1.0.0", "@stricli/core": "^1.2.5", }, @@ -120,7 +122,9 @@ "name": "@reviq/db", "version": "0.0.1", "dependencies": { + "@noble/hashes": "^2.0.1", "@reviq/db-schema": "workspace:*", + "@scure/base": "^2.0.0", "kysely": "^0.28.9", "pg": "^8.13.1", }, @@ -426,6 +430,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index a1db90a..1fc486a 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -33,6 +33,7 @@ import { updateMemberRoleInputSchema, } from "./schemas/org.js"; import { + authStatusOutputSchema, deviceOutputSchema, passkeyOutputSchema, sessionOutputSchema, @@ -113,6 +114,9 @@ export const contract = oc.router({ updateProfile: oc.input(updateProfileInputSchema).output(z.void()), delete: oc.input(z.object({ password: z.string() })).output(z.void()), + // Auth status (for CLI and debugging) + authStatus: oc.output(authStatusOutputSchema), + // Authentication settings setPassword: oc.input(setPasswordInputSchema).output(z.void()), diff --git a/packages/api-contract/src/schemas/user.ts b/packages/api-contract/src/schemas/user.ts index d2dfb95..1158c88 100644 --- a/packages/api-contract/src/schemas/user.ts +++ b/packages/api-contract/src/schemas/user.ts @@ -97,3 +97,37 @@ export const deviceOutputSchema = z.object({ export const trustDeviceInputSchema = z.object({ name: nonEmptyString(100), }); + +/** + * Auth status output schema for API token authentication + */ +export const apiTokenAuthStatusSchema = z.object({ + method: z.literal("api_token"), + tokenId: z.string(), + tokenName: z.string(), + expiresAt: z.date(), + lastUsedAt: z.date().nullable(), + createdAt: z.date(), +}); + +/** + * Auth status output schema for session authentication + */ +export const sessionAuthStatusSchema = z.object({ + method: z.literal("session"), + sessionId: z.string(), + expiresAt: z.date(), + createdAt: z.date(), +}); + +/** + * Auth status output schema + * Returns information about the current authentication method + */ +export const authStatusOutputSchema = z.object({ + user: userProfileSchema, + auth: z.discriminatedUnion("method", [ + apiTokenAuthStatusSchema, + sessionAuthStatusSchema, + ]), +}); diff --git a/packages/db/package.json b/packages/db/package.json index f22d7a8..f6f2922 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,7 +12,9 @@ "lint": "eslint . --cache" }, "dependencies": { + "@noble/hashes": "^2.0.1", "@reviq/db-schema": "workspace:*", + "@scure/base": "^2.0.0", "kysely": "^0.28.9", "pg": "^8.13.1" }, diff --git a/packages/db/src/helpers/execute-bootstrap.ts b/packages/db/src/helpers/execute-bootstrap.ts new file mode 100644 index 0000000..f9e74d5 --- /dev/null +++ b/packages/db/src/helpers/execute-bootstrap.ts @@ -0,0 +1,168 @@ +/** + * Core bootstrap logic for creating a superuser and initial organization + * + * This is extracted from the CLI bootstrap command to make it reusable + * and testable. It operates on a database transaction. + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely, Transaction } from "kysely"; +import { hashPassword } from "./password.js"; +import { generateToken, hashToken } from "./token.js"; + +/** + * Input for the bootstrap operation + */ +export interface BootstrapInput { + /** Email address for the superuser */ + email: string; + /** Password for the superuser */ + password: string; + /** Optional organization slug (defaults to "reviq") */ + orgSlug?: string; + /** Optional organization display name (defaults to "RevIQ") */ + orgDisplayName?: string; + /** Optional token name (defaults to "CLI bootstrap token") */ + tokenName?: string; + /** Optional token expiration in days (defaults to 365) */ + tokenExpirationDays?: number; +} + +/** + * Result of the bootstrap operation + */ +export interface BootstrapResult { + /** The created user */ + user: { + id: number; + email: string; + }; + /** The created organization */ + org: { + id: number; + slug: string; + }; + /** The created API token (raw token, not hashed) */ + token: string; +} + +/** + * Execute the bootstrap operation within a transaction + * + * Creates: + * - A superuser with the given email and password + * - An organization with the superuser as owner + * - An API token for the superuser + * + * @param trx - Database transaction (use db.transaction() or pass a Transaction) + * @param input - Bootstrap configuration + * @returns The created user, org, and API token + * @throws Error if user already exists or validation fails + */ +export const executeBootstrap = async ( + trx: Kysely | Transaction, + input: BootstrapInput, +): Promise => { + const { + email, + password, + orgSlug = "reviq", + orgDisplayName = "RevIQ", + tokenName = "CLI bootstrap token", + tokenExpirationDays = 365, + } = input; + + // Validate password length + if (password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + + // Validate email format (basic check) + if (!email.includes("@")) { + throw new Error("Invalid email address"); + } + + const normalizedEmail = email.toLowerCase(); + + // Check if user already exists + const existing = await trx + .selectFrom("users") + .where("email", "=", normalizedEmail) + .select("id") + .executeTakeFirst(); + + if (existing) { + throw new Error(`User with email ${email} already exists`); + } + + // Hash the password + const passwordHash = hashPassword(password); + + // Create superuser + const [user] = await trx + .insertInto("users") + .values({ + email: normalizedEmail, + password_hash: passwordHash, + is_superuser: true, + email_verified_at: new Date(), + }) + .returning(["id", "email"]) + .execute(); + + if (!user) { + throw new Error("Failed to create user"); + } + + // Create organization + const [org] = await trx + .insertInto("orgs") + .values({ + slug: orgSlug, + display_name: orgDisplayName, + }) + .returning(["id", "slug"]) + .execute(); + + if (!org) { + throw new Error("Failed to create organization"); + } + + // Add user as owner of the org + await trx + .insertInto("org_members") + .values({ + org_id: org.id, + user_id: user.id, + role: "owner", + }) + .execute(); + + // Generate API token + const token = generateToken(); + const tokenHashValue = hashToken(token); + + await trx + .insertInto("api_tokens") + .values({ + user_id: user.id, + token_hash: tokenHashValue, + name: tokenName, + expires_at: new Date( + Date.now() + tokenExpirationDays * 24 * 60 * 60 * 1000, + ), + }) + .execute(); + + return { + user: { + id: user.id, + email: user.email, + }, + org: { + id: org.id, + slug: org.slug, + }, + token, + }; +}; diff --git a/packages/db/src/helpers/password.ts b/packages/db/src/helpers/password.ts new file mode 100644 index 0000000..f13c2d7 --- /dev/null +++ b/packages/db/src/helpers/password.ts @@ -0,0 +1,22 @@ +/** + * Password hashing utilities using scrypt from @noble/hashes + */ + +import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js"; +import { randomBytes } from "@noble/hashes/utils.js"; + +// scrypt parameters: N=2^17, r=8, p=1, dkLen=32 +const N = 131072; +const r = 8; +const p = 1; +const dkLen = 32; + +/** + * Hash a password using scrypt + * Format: scrypt$17$8$1$$ + */ +export const hashPassword = (password: string): string => { + const salt = randomBytes(16); + const hash = nobleScrypt(password, salt, { N, r, p, dkLen }); + return `scrypt$17$8$1$${Buffer.from(salt).toString("base64")}$${Buffer.from(hash).toString("base64")}`; +}; diff --git a/packages/db/src/helpers/token.ts b/packages/db/src/helpers/token.ts new file mode 100644 index 0000000..5bed40e --- /dev/null +++ b/packages/db/src/helpers/token.ts @@ -0,0 +1,51 @@ +/** + * Token generation and hashing utilities + */ + +import { sha256 } from "@noble/hashes/sha2.js"; +import { randomBytes } from "@noble/hashes/utils.js"; +import { base58 } from "@scure/base"; + +/** + * Token prefix for all RevIQ API tokens + */ +export const TOKEN_PREFIX = "reviq_"; + +/** + * Generate a cryptographically secure random token + * Format: reviq_ + * Example: reviq_5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ + */ +export const generateToken = (): string => { + const bytes = randomBytes(32); + return TOKEN_PREFIX + base58.encode(bytes); +}; + +/** + * Validate that a token has the correct format + * Returns the raw bytes if valid, null if invalid + */ +export const parseToken = (token: string): Uint8Array | null => { + if (!token.startsWith(TOKEN_PREFIX)) { + return null; + } + const encoded = token.slice(TOKEN_PREFIX.length); + try { + const bytes = base58.decode(encoded); + // Expect 32 bytes of entropy + if (bytes.length !== 32) { + return null; + } + return bytes; + } catch { + return null; + } +}; + +/** + * Hash a token using SHA-256 + * Returns a hex string + */ +export const hashToken = (token: string): string => { + return Buffer.from(sha256(Buffer.from(token))).toString("hex"); +}; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b57e9e8..02a620d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,24 +2,35 @@ * Database client for RevIQ Publisher Dashboard * * @module @reviq/db - */ - -import type { Database } from "@reviq/db-schema"; -import type { Kysely } from "kysely"; -import { createDb } from "./client.js"; - -/** - * Default database instance * - * Uses DATABASE_URL environment variable for connection + * Usage: + * import { createDb } from "@reviq/db"; + * const db = createDb(); // Uses DATABASE_URL env var + * // ... use db ... + * await db.destroy(); */ -export const db: Kysely = createDb(); /** * Re-export database types from db-schema */ export type { Database } from "@reviq/db-schema"; /** - * Export createDb for creating custom database instances + * Export createDb for creating database instances + * Callers should create their own database instance and pass it to functions */ export { createDb } from "./client.js"; +/** + * Export helper functions + */ +export { + type BootstrapInput, + type BootstrapResult, + executeBootstrap, +} from "./helpers/execute-bootstrap.js"; +export { hashPassword } from "./helpers/password.js"; +export { + generateToken, + hashToken, + parseToken, + TOKEN_PREFIX, +} from "./helpers/token.js";