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
160 changes: 160 additions & 0 deletions controlplane/src/auth/api-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary

// REVISION: api-token-v1-pat

/**
* Personal access tokens (PATs) for CLI / external tools (`orcabot push`/`pull`).
*
* Issuance sits behind the normal user auth (Cloudflare Access in prod, dev-auth
* locally) — see the routes in index.ts. The plaintext token is shown exactly
* once at creation; only its SHA-256 hash is stored. Bearer presentation is
* validated in auth/middleware.ts (prefix-gated so it never collides with the
* PTY/gateway JWT bearer tokens, which are verified elsewhere).
*
* A PAT inherits the owning user's identity — and therefore their subscription
* status — so the paywall applies to CLI writes exactly as it does in the UI.
*/

import type { Env, User } from '../types';

export const PAT_PREFIX = 'orca_pat_';

/** SHA-256 hex of the given string. */
export async function hashToken(token: string): Promise<string> {
const data = new TextEncoder().encode(token);
const digest = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

function randomToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const body = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `${PAT_PREFIX}${body}`;
}

export interface ApiTokenMeta {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
}

/** Create a PAT for a user. Returns the plaintext token (shown once) + metadata. */
export async function createApiToken(
env: Env,
userId: string,
name: string,
ttlDays?: number
): Promise<{ token: string; meta: ApiTokenMeta }> {
const token = randomToken();
const tokenHash = await hashToken(token);
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
const expiresAt =
ttlDays && ttlDays > 0
? new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString()
: null;

await env.DB.prepare(
`INSERT INTO api_tokens (id, user_id, token_hash, name, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(id, userId, tokenHash, name || 'cli', createdAt, expiresAt)
.run();

return {
token,
meta: { id, name: name || 'cli', createdAt, lastUsedAt: null, expiresAt },
};
}

/** List a user's PATs (metadata only — never the hash or plaintext). */
export async function listApiTokens(env: Env, userId: string): Promise<ApiTokenMeta[]> {
const rows = await env.DB.prepare(
`SELECT id, name, created_at, last_used_at, expires_at
FROM api_tokens
WHERE user_id = ? AND revoked_at IS NULL
ORDER BY created_at DESC`
)
.bind(userId)
.all<{
id: string;
name: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
}>();
return (rows.results || []).map((r) => ({
id: r.id,
name: r.name,
createdAt: r.created_at,
lastUsedAt: r.last_used_at,
expiresAt: r.expires_at,
}));
}

/** Revoke a PAT by id (must belong to the user). Returns true if a row was revoked. */
export async function revokeApiToken(env: Env, userId: string, id: string): Promise<boolean> {
const res = await env.DB.prepare(
`UPDATE api_tokens SET revoked_at = datetime('now')
WHERE id = ? AND user_id = ? AND revoked_at IS NULL`
)
.bind(id, userId)
.run();
return (res.meta?.changes ?? 0) > 0;
}

/**
* Resolve a bearer PAT to a user. Returns null if the token is malformed,
* unknown, revoked, or expired. Fail-closed. Updates last_used_at on success.
*/
export async function getUserForApiToken(env: Env, token: string): Promise<User | null> {
if (!token.startsWith(PAT_PREFIX)) return null;
const tokenHash = await hashToken(token);
const row = await env.DB.prepare(
`SELECT t.id as token_id, t.expires_at as expires_at, t.revoked_at as revoked_at,
u.id as id, u.email as email, u.name as name,
u.created_at as created_at, u.trial_started_at as trial_started_at
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ?`
)
.bind(tokenHash)
.first<{
token_id: string;
expires_at: string | null;
revoked_at: string | null;
id: string;
email: string;
name: string;
created_at: string;
trial_started_at: string | null;
}>();

if (!row) return null;
if (row.revoked_at) return null;
if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) return null;

// Best-effort last-used stamp (don't fail auth if it errors).
try {
await env.DB.prepare(`UPDATE api_tokens SET last_used_at = datetime('now') WHERE id = ?`)
.bind(row.token_id)
.run();
} catch {
// ignore
}

return {
id: row.id,
email: row.email,
name: row.name,
createdAt: row.trial_started_at || row.created_at,
};
}
41 changes: 39 additions & 2 deletions controlplane/src/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary

// REVISION: auth-middleware-v1-email-fallback
// REVISION: auth-middleware-v2-pat-method-gate

/**
* Auth Middleware
Expand All @@ -14,10 +14,17 @@
import type { Env, User } from '../types';
import { validateCfAccessTоken, cfAccessUserIdFrоmSub } from './cf-access';
import { getUserForSession } from './sessions';
import { getUserForApiToken, PAT_PREFIX } from './api-token';

/** How the request authenticated. Used to gate sensitive routes (e.g. token
* management must not be reachable with a PAT — a leaked PAT could otherwise mint
* a replacement and survive revocation). */
export type AuthMethod = 'session' | 'pat' | 'cf-access' | 'dev';

export interface AuthContext {
user: User | null;
isAuthenticated: boolean;
method?: AuthMethod;
}

// Extract user from request
Expand All @@ -27,7 +34,22 @@ export async function authenticate(
): Promise<AuthContext> {
const sessionUser = await getUserForSession(request, env);
if (sessionUser) {
return { user: sessionUser, isAuthenticated: true };
return { user: sessionUser, isAuthenticated: true, method: 'session' };
}

// Personal access token (CLI / external tools). Prefix-gated so it never
// collides with the PTY/gateway JWT bearer tokens (verified elsewhere).
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const bearer = authHeader.slice('Bearer '.length).trim();
if (bearer.startsWith(PAT_PREFIX)) {
const patUser = await getUserForApiToken(env, bearer);
if (patUser) {
return { user: patUser, isAuthenticated: true, method: 'pat' };
}
// Malformed/expired/revoked PAT: fail closed (don't fall through to other modes).
return { user: null, isAuthenticated: false };
}
}

// Try Cloudflare Access first (production mode)
Expand Down Expand Up @@ -96,6 +118,7 @@ async function authenticateWithCfAccеss(
return {
user,
isAuthenticated: true,
method: 'cf-access',
};
}

Expand Down Expand Up @@ -179,6 +202,7 @@ async function authenticateDevMоde(
return {
user,
isAuthenticated: true,
method: 'dev',
};
}

Expand All @@ -193,6 +217,19 @@ export function requireAuth(ctx: AuthContext): Response | null {
return null;
}

// Reject requests authenticated by a PAT. Token management (mint/list/revoke)
// must be done with a real session (browser/CF Access) or dev auth — never with a
// PAT, or a leaked token could mint a replacement and survive its own revocation.
export function rejectPatAuth(ctx: AuthContext): Response | null {
if (ctx.method === 'pat') {
return Response.json(
{ error: 'E79411: Personal access tokens cannot manage tokens — use the web app' },
{ status: 403 }
);
}
return null;
}

// Validate internal API token for service-to-service calls
export function requireInternalAuth(
request: Request,
Expand Down
18 changes: 17 additions & 1 deletion controlplane/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,22 @@ CREATE TABLE IF NOT EXISTS user_sessions (
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at);

-- Personal access tokens for CLI / external tools (orcabot push/pull).
-- Only the SHA-256 hash of the token is stored; the plaintext is shown once at creation.
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT 'cli',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
expires_at TEXT,
revoked_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);

-- OAuth login states (short-lived)
CREATE TABLE IF NOT EXISTS auth_states (
state TEXT PRIMARY KEY,
Expand Down Expand Up @@ -1127,7 +1143,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_link_edge_b ON link_edge_map(link_id, edge
`;

// Initialize the database
const SCHEMA_REVISION = "schema-v14-egress-blocklist";
const SCHEMA_REVISION = "schema-v15-api-tokens";

export async function initializeDatabase(db: D1Database): Promise<void> {
console.log(`[schema] REVISION: ${SCHEMA_REVISION} loaded at ${new Date().toISOString()}`);
Expand Down
Loading
Loading