1/// <reference types="@cloudflare/workers-types" />2/**3 * Authentication: shoo OIDC session cookies, API bearer tokens, dev bypass.4 *5 * Integration surface (imported by the MCP transport):6 * authenticateBearer(request, env): Promise<AuthedUser | null>7 * authenticate(request, env): Promise<AuthedUser | null> (bearer first, then cookie)8 *9 * Cookie format: base64url(JSON{u:userId, exp:epochSec}) + '.' + base64url(HMAC-SHA256).10 * Secret: env.SESSION_SECRET, with fixed fallback 'dev-secret' only when DEV_AUTH=1.11 *12 * Dev bypass (env.DEV_AUTH === '1' only):13 * - POST /api/session {local: "name"} or GET /api/session?login=local:name14 * - `Authorization: Bearer local:<name>` accepted directly as that user.15 */16import { createRemoteJWKSet, jwtVerify } from 'jose';17import type { AuthedUser } from '@tribes/protocol';18import type { Env } from './env';19import { getTokenUser, getUserById, upsertUser } from './db';20import { base64UrlToBytes, bytesToBase64Url, nowSec, sha256Hex } from './util';2122export const SESSION_COOKIE = 'tribes_session';23export const SESSION_TTL_SECONDS = 7 * 24 * 3600;2425const SHOO_ISSUER = 'https://shoo.dev';26// Module-scope on purpose: jose caches fetched keys across requests (config, not request state).27const SHOO_JWKS = createRemoteJWKSet(new URL('https://shoo.dev/.well-known/jwks.json'));2829// ---------------------------------------------------------------------------30// shoo id_token verification31// ---------------------------------------------------------------------------3233/**34 * Verifies a shoo id_token for this deployment's origin and returns the35 * pairwise subject. Throws on any verification failure.36 */37export async function verifyShooIdToken(idToken: string, origin: string): Promise<string> {38 const { payload } = await jwtVerify(idToken, SHOO_JWKS, {39 issuer: SHOO_ISSUER,40 audience: `origin:${origin}`,41 });42 const sub = payload['pairwise_sub'];43 if (typeof sub !== 'string' || sub.length === 0) {44 throw new Error('id_token missing pairwise_sub');45 }46 return sub;47}4849// ---------------------------------------------------------------------------50// Session cookie (HMAC-signed, HttpOnly)51// ---------------------------------------------------------------------------5253export function sessionSecret(env: Env): string {54 if (env.SESSION_SECRET) return env.SESSION_SECRET;55 if (env.DEV_AUTH === '1') return 'dev-secret';56 throw new Error('SESSION_SECRET is not configured');57}5859async function hmacKey(secret: string, usage: 'sign' | 'verify'): Promise<CryptoKey> {60 return crypto.subtle.importKey(61 'raw',62 new TextEncoder().encode(secret),63 { name: 'HMAC', hash: 'SHA-256' },64 false,65 [usage],66 );67}6869export async function signSession(70 secret: string,71 userId: string,72 expSec: number,73): Promise<string> {74 const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ u: userId, exp: expSec })));75 const key = await hmacKey(secret, 'sign');76 const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));77 return `${payload}.${bytesToBase64Url(new Uint8Array(sig))}`;78}7980/** Returns the userId, or null for any malformed/tampered/expired value. */81export async function verifySession(secret: string, value: string): Promise<string | null> {82 try {83 const dot = value.indexOf('.');84 if (dot < 0) return null;85 const payload = value.slice(0, dot);86 const sig = base64UrlToBytes(value.slice(dot + 1));87 const key = await hmacKey(secret, 'verify');88 // crypto.subtle.verify is constant-time — never compare signatures with ===.89 const ok = await crypto.subtle.verify('HMAC', key, sig, new TextEncoder().encode(payload));90 if (!ok) return null;91 const parsed: unknown = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload)));92 if (typeof parsed !== 'object' || parsed === null) return null;93 const { u, exp } = parsed as { u?: unknown; exp?: unknown };94 if (typeof u !== 'string' || typeof exp !== 'number') return null;95 if (exp <= nowSec()) return null;96 return u;97 } catch {98 return null;99 }100}101102export function sessionCookieHeader(103 value: string,104 opts: { secure: boolean; maxAge: number; domain?: string },105): string {106 const parts = [107 `${SESSION_COOKIE}=${value}`,108 'Path=/',109 'HttpOnly',110 'SameSite=Lax',111 `Max-Age=${opts.maxAge}`,112 ];113 // Prod sets COOKIE_DOMAIN=.tribes.pub so one sign-in (on the canonical114 // apex origin) covers dev-independent versioned release hosts too.115 if (opts.domain) parts.push(`Domain=${opts.domain}`);116 if (opts.secure) parts.push('Secure');117 return parts.join('; ');118}119120export function clearSessionCookieHeader(secure: boolean, domain?: string): string {121 return sessionCookieHeader('', { secure, maxAge: 0, domain });122}123124export function parseCookies(header: string | null): Map<string, string> {125 const out = new Map<string, string>();126 if (!header) return out;127 for (const part of header.split(';')) {128 const eq = part.indexOf('=');129 if (eq < 0) continue;130 out.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());131 }132 return out;133}134135// ---------------------------------------------------------------------------136// API tokens137// ---------------------------------------------------------------------------138139/** 'trb_' + exactly 32 base64url chars (24 random bytes). Full value shown once. */140export function mintTokenValue(): string {141 return 'trb_' + bytesToBase64Url(crypto.getRandomValues(new Uint8Array(24)));142}143144/** sha-256 hex of the full token value — the only thing stored server-side. */145export function hashToken(token: string): Promise<string> {146 return sha256Hex(token);147}148149// ---------------------------------------------------------------------------150// Request authentication151// ---------------------------------------------------------------------------152153/** Bearer auth: `trb_...` API tokens, plus `local:<name>` when DEV_AUTH=1. */154export async function authenticateBearer(request: Request, env: Env): Promise<AuthedUser | null> {155 const header = request.headers.get('authorization');156 if (!header) return null;157 const match = /^Bearer\s+(.+)$/i.exec(header);158 if (!match || !match[1]) return null;159 const token = match[1].trim();160161 if (env.DEV_AUTH === '1' && token.startsWith('local:')) {162 const user = await upsertUser(env.DB, token, null);163 return { userId: user.id, externalId: user.external_id };164 }165 if (!token.startsWith('trb_')) return null;166 const row = await getTokenUser(env.DB, await hashToken(token));167 return row ? { userId: row.user_id, externalId: row.external_id } : null;168}169170export async function authenticateCookie(request: Request, env: Env): Promise<AuthedUser | null> {171 const value = parseCookies(request.headers.get('cookie')).get(SESSION_COOKIE);172 if (!value) return null;173 let secret: string;174 try {175 secret = sessionSecret(env);176 } catch {177 return null;178 }179 const userId = await verifySession(secret, value);180 if (!userId) return null;181 const user = await getUserById(env.DB, userId);182 return user ? { userId: user.id, externalId: user.external_id } : null;183}184185/** Bearer first (agents), then session cookie (web). */186export async function authenticate(request: Request, env: Env): Promise<AuthedUser | null> {187 return (await authenticateBearer(request, env)) ?? (await authenticateCookie(request, env));188}