diff --git a/apps/api/package.json b/apps/api/package.json index 2797f88..806103a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "@fastify/swagger-ui": "^5.2.6", "fastify": "^5.8.5", "gitsheets": "^1.0.3", + "jose": "^6.2.3", "uuidv7": "^1.2.1", "zod": "^4.4.3" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 460a55c..c1988ca 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -30,7 +30,9 @@ import traceIdPlugin from './plugins/trace-id.js'; import storePlugin from './plugins/store.js'; import rateLimitPlugin from './plugins/rate-limit.js'; import idempotencyPlugin from './plugins/idempotency.js'; +import sessionMiddlewarePlugin from './auth/middleware.js'; import { healthRoutes } from './routes/health.js'; +import { authRoutes } from './routes/auth.js'; declare module 'fastify' { interface FastifyInstance { @@ -97,6 +99,9 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { + const { access, refresh, accessJti, refreshJti } = await issueSession(personId, accountLevel, signingKey); + return { accessToken: access, refreshToken: refresh, accessJti, refreshJti }; +} diff --git a/apps/api/src/auth/jwt.ts b/apps/api/src/auth/jwt.ts new file mode 100644 index 0000000..065efd0 --- /dev/null +++ b/apps/api/src/auth/jwt.ts @@ -0,0 +1,179 @@ +/** + * JWT primitives for session management. + * + * Three token types with distinct cookies, paths, and TTLs: + * - access (cfp_session, path /, 15m) — { sub: personId, jti, accountLevel, scope: 'session' } + * - refresh (cfp_refresh, path /api/auth/refresh, 30d) — { sub: personId, jti, scope: 'refresh' } + * - claim (cfp_claim, path /api/account-claim, 5m) — { sub: ghId, scope: 'claim', ... } + * + * HS256 with CFP_JWT_SIGNING_KEY. Clock skew tolerance ±60s. + */ +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; +import { uuidv7 } from 'uuidv7'; + +export type AccountLevel = 'anonymous' | 'user' | 'staff' | 'administrator'; + +export interface AccessClaims { + readonly sub: string; // personId + readonly jti: string; + readonly accountLevel: AccountLevel; + readonly exp: number; + readonly iat: number; +} + +export interface RefreshClaims { + readonly sub: string; // personId + readonly jti: string; + readonly exp: number; + readonly iat: number; +} + +export interface GhIdentitySnapshot { + readonly ghId: string; + readonly ghLogin: string; + readonly ghName: string | null; + readonly ghEmails: string[]; +} + +export interface ClaimPendingClaims { + readonly sub: string; // ghId + readonly jti: string; + readonly scope: 'claim'; + readonly ghLogin: string; + readonly ghName: string | null; + readonly ghEmails: string[]; + readonly candidates: string[]; // personId candidates + readonly exp: number; + readonly iat: number; +} + +const CLOCK_SKEW_SECONDS = 60; +const ACCESS_TTL_SECONDS = 15 * 60; +const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60; +const CLAIM_TTL_SECONDS = 5 * 60; + +function keyBytes(signingKey: string): Uint8Array { + return new TextEncoder().encode(signingKey); +} + +export async function issueSession( + personId: string, + accountLevel: AccountLevel, + signingKey: string, +): Promise<{ access: string; refresh: string; accessJti: string; refreshJti: string }> { + const accessJti = uuidv7(); + const refreshJti = uuidv7(); + const now = Math.floor(Date.now() / 1000); + const key = keyBytes(signingKey); + + const access = await new SignJWT({ + sub: personId, + jti: accessJti, + accountLevel, + scope: 'session', + } satisfies Partial & { accountLevel: AccountLevel; scope: string }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(now + ACCESS_TTL_SECONDS) + .sign(key); + + const refresh = await new SignJWT({ + sub: personId, + jti: refreshJti, + scope: 'refresh', + } satisfies Partial & { scope: string }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(now + REFRESH_TTL_SECONDS) + .sign(key); + + return { access, refresh, accessJti, refreshJti }; +} + +export async function verifyAccess(token: string, signingKey: string): Promise { + const { payload } = await jwtVerify(token, keyBytes(signingKey), { + algorithms: ['HS256'], + clockTolerance: CLOCK_SKEW_SECONDS, + }); + + if (payload['scope'] !== 'session') { + throw new Error('Token scope mismatch: expected session'); + } + + return { + sub: payload.sub!, + jti: payload.jti!, + accountLevel: payload['accountLevel'] as AccountLevel, + exp: payload.exp!, + iat: payload.iat!, + }; +} + +export async function verifyRefresh(token: string, signingKey: string): Promise { + const { payload } = await jwtVerify(token, keyBytes(signingKey), { + algorithms: ['HS256'], + clockTolerance: CLOCK_SKEW_SECONDS, + }); + + if (payload['scope'] !== 'refresh') { + throw new Error('Token scope mismatch: expected refresh'); + } + + return { + sub: payload.sub!, + jti: payload.jti!, + exp: payload.exp!, + iat: payload.iat!, + }; +} + +export async function issueClaimPending( + ghIdentity: GhIdentitySnapshot, + candidates: string[], + signingKey: string, +): Promise { + const now = Math.floor(Date.now() / 1000); + + return new SignJWT({ + sub: ghIdentity.ghId, + jti: uuidv7(), + scope: 'claim', + ghLogin: ghIdentity.ghLogin, + ghName: ghIdentity.ghName, + ghEmails: ghIdentity.ghEmails, + candidates, + } satisfies Partial & { + scope: string; + ghLogin: string; + ghName: string | null; + ghEmails: string[]; + candidates: string[]; + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(now + CLAIM_TTL_SECONDS) + .sign(keyBytes(signingKey)); +} + +export async function verifyClaimPending(token: string, signingKey: string): Promise { + const { payload } = await jwtVerify(token, keyBytes(signingKey), { + algorithms: ['HS256'], + clockTolerance: CLOCK_SKEW_SECONDS, + }); + + if (payload['scope'] !== 'claim') { + throw new Error('Token scope mismatch: expected claim'); + } + + return { + sub: payload.sub!, + jti: payload.jti!, + scope: 'claim', + ghLogin: payload['ghLogin'] as string, + ghName: payload['ghName'] as string | null, + ghEmails: payload['ghEmails'] as string[], + candidates: payload['candidates'] as string[], + exp: payload.exp!, + iat: payload.iat!, + }; +} diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts new file mode 100644 index 0000000..2093bd3 --- /dev/null +++ b/apps/api/src/auth/middleware.ts @@ -0,0 +1,136 @@ +/** + * Session middleware — Fastify plugin. + * + * Decorates every request with `request.session: SessionContext`. + * Also decorates the Fastify instance with `fastify.revocations` and + * `fastify.sessionMetadata` for use by route handlers. + * + * Ordering: registered after store plugin (needs fastify.store + fastify.config). + * + * The cfp_claim cookie is intentionally not honored here — it's only valid + * on /api/account-claim/* routes and is never treated as a session. + */ +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; +import { errors as JoseErrors } from 'jose'; + +import type { AccountLevel, GhIdentitySnapshot } from './jwt.js'; +import { verifyAccess } from './jwt.js'; +import { InMemoryRevocationStore } from './revocation.js'; +import { SessionMetadataStore } from './session-metadata.js'; +import type { Person } from '@cfp/shared/schemas'; + +export interface SessionContext { + /** Full Person record, or null for anonymous/claim-pending sessions. */ + readonly person: Person | null; + readonly accountLevel: AccountLevel; + /** personId from the JWT claims — set even when person lookup hasn't run. */ + readonly personId?: string; + readonly jti?: string; + readonly isClaimPending?: boolean; + readonly ghIdentity?: GhIdentitySnapshot; +} + +const ANONYMOUS_SESSION: SessionContext = { + person: null, + accountLevel: 'anonymous', +}; + +declare module 'fastify' { + interface FastifyRequest { + session: SessionContext; + } + interface FastifyInstance { + revocations: InMemoryRevocationStore; + sessionMetadata: SessionMetadataStore; + } +} + +async function sessionMiddlewarePlugin(fastify: FastifyInstance): Promise { + const revocations = new InMemoryRevocationStore(); + const sessionMetadata = new SessionMetadataStore(); + + fastify.decorate('revocations', revocations); + fastify.decorate('sessionMetadata', sessionMetadata); + + // Load revocation state from gitsheets + session metadata from private store at boot + fastify.addHook('onReady', async () => { + const allRevocations: import('@cfp/shared/schemas').Revocation[] = []; + const revocationsSheet = fastify.store.public.revocations; + for await (const record of revocationsSheet.query()) { + allRevocations.push(record); + } + revocations.load(allRevocations); + + await sessionMetadata.load(fastify.store.private); + }); + + // Start the revocation sweeper — runs every 5 minutes + let sweepInterval: ReturnType | undefined; + fastify.addHook('onReady', () => { + sweepInterval = setInterval( + () => { + void revocations.sweep(fastify.store.public).catch((err) => { + fastify.log.error({ err }, 'revocation sweeper failed'); + }); + }, + 5 * 60 * 1000, + ); + }); + + fastify.addHook('onClose', () => { + if (sweepInterval) clearInterval(sweepInterval); + }); + + // Decorate the request prototype with a default session + fastify.decorateRequest('session', null as unknown as SessionContext); + + fastify.addHook('onRequest', async (request) => { + const token = request.cookies['cfp_session']; + if (!token) { + request.session = ANONYMOUS_SESSION; + return; + } + + try { + const claims = await verifyAccess(token, fastify.config.CFP_JWT_SIGNING_KEY); + + // Check revocation + if ( + revocations.isRevoked(claims.jti) || + revocations.isCoveredBySentinel(claims.sub, claims.iat) + ) { + request.session = ANONYMOUS_SESSION; + return; + } + + // Look up person from public store + const person = await fastify.store.public.people.queryFirst({ id: claims.sub } as Record); + + request.session = { + person: person ?? null, + accountLevel: claims.accountLevel, + personId: claims.sub, + jti: claims.jti, + }; + } catch (err) { + if ( + err instanceof JoseErrors.JWTExpired || + err instanceof JoseErrors.JWTInvalid || + err instanceof JoseErrors.JWSInvalid || + err instanceof JoseErrors.JWSSignatureVerificationFailed + ) { + // Expired or invalid token → anonymous, not an error + request.session = ANONYMOUS_SESSION; + return; + } + throw err; + } + }); +} + +export default fp(sessionMiddlewarePlugin, { + name: 'session-middleware', + fastify: '5.x', + dependencies: ['store'], +}); diff --git a/apps/api/src/auth/revocation.ts b/apps/api/src/auth/revocation.ts new file mode 100644 index 0000000..07274ee --- /dev/null +++ b/apps/api/src/auth/revocation.ts @@ -0,0 +1,145 @@ +/** + * In-memory revocation set + gitsheets persistence. + * + * Two-layer revocation per specs/behaviors/authorization.md: + * 1. In-memory Set for O(1) hot checks on every authenticated request. + * 2. Persisted `revocations` sheet (gitsheets) — rebuilt at boot, updated + * synchronously on every revoke. + * + * The sweeper removes expired revocations from both memory and the sheet. + * + * Sign-out-everywhere sentinel: jti='*' with personId causes the verifier to + * reject any JWT for that personId whose iat is before the sentinel's revokedAt. + * The sentinel is stored in the sheet under a unique key (sentinel:). + */ +import type { PublicStore } from '../store/public.js'; +import type { Revocation } from '@cfp/shared/schemas'; + +export interface RevocationStore { + /** Check if a specific jti is revoked. */ + isRevoked(jti: string): boolean; + /** + * Check if a person's token (with a given iat epoch seconds) is covered by + * a sign-out-everywhere sentinel for that personId. + */ + isCoveredBySentinel(personId: string, iat: number): boolean; + /** Add a revocation and persist to gitsheets. */ + revoke(opts: { jti: string; personId: string; expiresAt: string }, store: PublicStore): Promise; + /** Delete expired records from memory + the gitsheets sheet. */ + sweep(store: PublicStore): Promise; + /** All non-expired revocations for a given personId (not sentinel). */ + getForPerson(personId: string): Revocation[]; +} + +export class InMemoryRevocationStore implements RevocationStore { + /** jti → full Revocation record. */ + readonly #byJti = new Map(); + /** personId → sentinel Revocation (jti='*'). */ + readonly #sentinels = new Map(); + + /** + * Populate from an array of Revocation records loaded from gitsheets at boot. + * Expired records are skipped. Clears existing state first. + */ + load(records: Revocation[]): void { + this.#byJti.clear(); + this.#sentinels.clear(); + const now = new Date().toISOString(); + for (const r of records) { + if (r.expiresAt <= now) continue; + if (r.jti === '*') { + this.#sentinels.set(r.personId, r); + } else { + this.#byJti.set(r.jti, r); + } + } + } + + isRevoked(jti: string): boolean { + return this.#byJti.has(jti); + } + + isCoveredBySentinel(personId: string, iat: number): boolean { + const sentinel = this.#sentinels.get(personId); + if (!sentinel) return false; + const sentinelEpoch = Math.floor(new Date(sentinel.revokedAt).getTime() / 1000); + return iat < sentinelEpoch; + } + + async revoke( + opts: { jti: string; personId: string; expiresAt: string }, + store: PublicStore, + ): Promise { + const now = new Date().toISOString(); + const record: Revocation = { + jti: opts.jti, + personId: opts.personId, + revokedAt: now, + expiresAt: opts.expiresAt, + }; + + if (opts.jti === '*') { + this.#sentinels.set(opts.personId, record); + } else { + this.#byJti.set(opts.jti, record); + } + + await store.transact( + { + message: `auth: revoke token jti=${opts.jti} person=${opts.personId}`, + author: { name: 'cfp-api', email: 'api@codeforphilly.org' }, + }, + async (tx) => { + await tx.revocations.upsert(record); + }, + ); + } + + async sweep(store: PublicStore): Promise { + const now = new Date().toISOString(); + const expiredJtis: string[] = []; + + for (const [jti, r] of this.#byJti) { + if (r.expiresAt <= now) expiredJtis.push(jti); + } + const expiredSentinelPersonIds: string[] = []; + for (const [personId, r] of this.#sentinels) { + if (r.expiresAt <= now) expiredSentinelPersonIds.push(personId); + } + + if (expiredJtis.length === 0 && expiredSentinelPersonIds.length === 0) return; + + const allExpiredJtis = [...expiredJtis]; + // Sentinels are stored in the sheet under jti='*' — but since jti is the path + // template key, sentinels stored with jti='*' would collide. At load time, any + // record with jti='*' is treated as a sentinel keyed by personId in memory. + // For sweeping, we delete from sheet by the jti value we upserted with. + for (const personId of expiredSentinelPersonIds) { + const sentinel = this.#sentinels.get(personId); + if (sentinel) allExpiredJtis.push(sentinel.jti); + } + + await store.transact( + { + message: `auth: sweep ${allExpiredJtis.length} expired revocations`, + author: { name: 'cfp-api', email: 'api@codeforphilly.org' }, + }, + async (tx) => { + for (const jti of allExpiredJtis) { + await tx.revocations.delete(jti); + } + }, + ); + + for (const jti of expiredJtis) this.#byJti.delete(jti); + for (const personId of expiredSentinelPersonIds) this.#sentinels.delete(personId); + } + + getForPerson(personId: string): Revocation[] { + const result: Revocation[] = []; + for (const r of this.#byJti.values()) { + if (r.personId === personId) result.push(r); + } + return result; + } +} diff --git a/apps/api/src/auth/session-metadata.ts b/apps/api/src/auth/session-metadata.ts new file mode 100644 index 0000000..fd9913c --- /dev/null +++ b/apps/api/src/auth/session-metadata.ts @@ -0,0 +1,72 @@ +/** + * Session metadata store — UA + IP + timestamps per refresh-token jti. + * + * Stored as a single JSON blob in the private bucket so it survives restarts + * but is never in the public commit log (no PII in git per + * specs/behaviors/storage.md#pii-aware-redaction). + * + * In-memory map keyed by refreshJti; flushed to private store on each mutation. + */ +import type { PrivateStore } from '../store/private/index.js'; + +export interface SessionMeta { + readonly refreshJti: string; + readonly personId: string; + readonly userAgent: string; + readonly ipAddress: string; + readonly issuedAt: string; + readonly expiresAt: string; +} + +const STORAGE_KEY = 'session-metadata.json'; + +export class SessionMetadataStore { + readonly #map = new Map(); + + async load(privateStore: PrivateStore): Promise { + const raw = await privateStore.readBlob(STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as Record; + for (const [jti, meta] of Object.entries(parsed)) { + this.#map.set(jti, meta); + } + } catch { + // Corrupt metadata is non-fatal — start fresh + } + } + + private async flush(privateStore: PrivateStore): Promise { + const obj: Record = {}; + for (const [jti, meta] of this.#map) { + obj[jti] = meta; + } + await privateStore.writeBlob(STORAGE_KEY, JSON.stringify(obj)); + } + + async add(meta: SessionMeta, privateStore: PrivateStore): Promise { + this.#map.set(meta.refreshJti, meta); + await this.flush(privateStore); + } + + async remove(refreshJti: string, privateStore: PrivateStore): Promise { + this.#map.delete(refreshJti); + await this.flush(privateStore); + } + + getAll(personId: string): SessionMeta[] { + const result: SessionMeta[] = []; + for (const meta of this.#map.values()) { + if (meta.personId === personId) result.push(meta); + } + return result; + } + + get(refreshJti: string): SessionMeta | null { + return this.#map.get(refreshJti) ?? null; + } + + has(refreshJti: string): boolean { + return this.#map.has(refreshJti); + } +} diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 4fb7930..1bff9dc 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -45,17 +45,21 @@ export class ApiValidationError extends Error { /** Thrown when the caller is not authenticated (401). */ export class UnauthenticatedError extends Error { - constructor(message = 'Authentication required') { + readonly code: string; + constructor(message = 'Authentication required', code = 'unauthenticated') { super(message); this.name = 'UnauthenticatedError'; + this.code = code; } } /** Thrown when the caller is authenticated but not authorized (403). */ export class ForbiddenError extends Error { - constructor(message = 'Forbidden') { + readonly code: string; + constructor(message = 'Forbidden', code = 'forbidden') { super(message); this.name = 'ForbiddenError'; + this.code = code; } } @@ -69,9 +73,11 @@ export class ApiNotFoundError extends Error { /** Thrown on unique-constraint / slug conflicts (409). */ export class ConflictError extends Error { - constructor(message: string) { + readonly code: string; + constructor(message: string, code = 'conflict') { super(message); this.name = 'ConflictError'; + this.code = code; } } @@ -112,12 +118,12 @@ export function mapError( } if (err instanceof UnauthenticatedError) { - void reply.code(401).send(errorResponse('unauthenticated', err.message, traceId)); + void reply.code(401).send(errorResponse(err.code, err.message, traceId)); return; } if (err instanceof ForbiddenError) { - void reply.code(403).send(errorResponse('forbidden', err.message, traceId)); + void reply.code(403).send(errorResponse(err.code, err.message, traceId)); return; } @@ -127,7 +133,7 @@ export function mapError( } if (err instanceof ConflictError) { - void reply.code(409).send(errorResponse('conflict', err.message, traceId)); + void reply.code(409).send(errorResponse(err.code, err.message, traceId)); return; } diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts index 29b1ea0..27a1310 100644 --- a/apps/api/src/plugins/rate-limit.ts +++ b/apps/api/src/plugins/rate-limit.ts @@ -72,17 +72,26 @@ async function rateLimitPlugin(fastify: FastifyInstance): Promise { const isWrite = WRITE_METHODS.has(request.method); const isAuthEndpoint = request.url.startsWith(AUTH_PATH_PREFIX); + const personId = request.session?.person?.id; + if (isAuthEndpoint) { // Auth endpoints: 10 req / min / IP check(ipBuckets, `auth:${ip}`, 10); } else if (isWrite) { - // Writes: keyed by account if we have one, otherwise IP - // (Account ID is not available until auth lands; use IP for now) - check(ipBuckets, `write:${ip}`, 30); + if (personId) { + // Authenticated writes: 30 req / min / account + check(accountBuckets, `write-account:${personId}`, 30); + } else { + check(ipBuckets, `write:${ip}`, 30); + } } else { - // Reads: unauthenticated=60/min/IP, authenticated=300/min/account - // Account check will be wired by auth-jwt-substrate plan - check(ipBuckets, `read:${ip}`, 60); + if (personId) { + // Authenticated reads: 300 req / min / account + check(accountBuckets, `account:${personId}`, 300); + } else { + // Unauthenticated reads: 60 req / min / IP + check(ipBuckets, `read:${ip}`, 60); + } } } catch (err) { done(err as Error); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..d04f543 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,278 @@ +/** + * Auth routes — session management endpoints. + * + * Implements specs/api/auth.md: + * GET /api/auth/me + * POST /api/auth/refresh + * POST /api/auth/logout + * GET /api/auth/sessions + * POST /api/auth/sessions/:jti/revoke + * + * OAuth flow stubs (return 501 until github-oauth plan): + * GET /api/auth/github/start + * GET /api/auth/github/callback + */ +import type { FastifyInstance } from 'fastify'; +import { errors as JoseErrors } from 'jose'; +import { ok } from '../lib/response.js'; +import { UnauthenticatedError, ConflictError, ApiNotFoundError } from '../lib/errors.js'; +import { verifyRefresh, issueSession } from '../auth/jwt.js'; +import { setSessionCookies, clearSessionCookies } from '../auth/cookies.js'; +import { requireAuth } from '../auth/guards.js'; +import type { SessionMeta } from '../auth/session-metadata.js'; + +function clientIp(request: import('fastify').FastifyRequest): string { + const forwarded = request.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return (forwarded.split(',')[0] ?? '').trim(); + } + return request.socket?.remoteAddress ?? 'unknown'; +} + +export async function authRoutes(fastify: FastifyInstance): Promise { + + // --------------------------------------------------------------------------- + // OAuth stubs — return 501 until github-oauth plan is implemented + // --------------------------------------------------------------------------- + + fastify.get( + '/api/auth/github/start', + { schema: { tags: ['auth'], summary: 'Begin GitHub OAuth flow (not yet wired)' } }, + async (_request, reply) => { + return reply.code(501).send({ + success: false, + error: { code: 'oauth_not_yet_wired', message: 'GitHub OAuth flow is not yet implemented' }, + metadata: { timestamp: new Date().toISOString() }, + }); + }, + ); + + fastify.get( + '/api/auth/github/callback', + { schema: { tags: ['auth'], summary: 'GitHub OAuth callback (not yet wired)' } }, + async (_request, reply) => { + return reply.code(501).send({ + success: false, + error: { code: 'oauth_not_yet_wired', message: 'GitHub OAuth flow is not yet implemented' }, + metadata: { timestamp: new Date().toISOString() }, + }); + }, + ); + + // --------------------------------------------------------------------------- + // GET /api/auth/me — returns current person or anonymous + // --------------------------------------------------------------------------- + + fastify.get( + '/api/auth/me', + { + schema: { + tags: ['auth'], + summary: 'Return current session info', + }, + }, + async (request) => { + const { session } = request; + return ok({ + person: session.person ?? null, + accountLevel: session.accountLevel, + }); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/auth/refresh — mint new access+refresh pair from refresh cookie + // --------------------------------------------------------------------------- + + fastify.post( + '/api/auth/refresh', + { schema: { tags: ['auth'], summary: 'Refresh session tokens' } }, + async (request, reply) => { + const refreshToken = request.cookies['cfp_refresh']; + if (!refreshToken) { + throw new UnauthenticatedError('No refresh token', 'no_refresh_token'); + } + + let claims; + try { + claims = await verifyRefresh(refreshToken, fastify.config.CFP_JWT_SIGNING_KEY); + } catch (err) { + if (err instanceof JoseErrors.JWTExpired) { + throw new UnauthenticatedError('Refresh token expired', 'refresh_token_expired'); + } + throw new UnauthenticatedError('Refresh token invalid', 'refresh_token_invalid'); + } + + if ( + fastify.revocations.isRevoked(claims.jti) || + fastify.revocations.isCoveredBySentinel(claims.sub, claims.iat) + ) { + throw new UnauthenticatedError('Refresh token revoked', 'refresh_token_revoked'); + } + + // Look up person to get current accountLevel + const person = await fastify.store.public.people.queryFirst({ id: claims.sub }); + if (!person) { + throw new UnauthenticatedError('Person not found', 'refresh_token_revoked'); + } + + const newTokens = await issueSession( + claims.sub, + person.accountLevel, + fastify.config.CFP_JWT_SIGNING_KEY, + ); + + // Revoke the old refresh jti + const oldExpiresAt = new Date(claims.exp * 1000).toISOString(); + await fastify.revocations.revoke( + { jti: claims.jti, personId: claims.sub, expiresAt: oldExpiresAt }, + fastify.store.public, + ); + await fastify.sessionMetadata.remove(claims.jti, fastify.store.private); + + // Store metadata for new refresh token + const newExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const newMeta: SessionMeta = { + refreshJti: newTokens.refreshJti, + personId: claims.sub, + userAgent: String(request.headers['user-agent'] ?? ''), + ipAddress: clientIp(request), + issuedAt: new Date().toISOString(), + expiresAt: newExpiresAt, + }; + await fastify.sessionMetadata.add(newMeta, fastify.store.private); + + setSessionCookies(reply, { access: newTokens.access, refresh: newTokens.refresh }, fastify.config.NODE_ENV); + return reply.code(200).send(ok(null)); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/auth/logout — revoke current session + // --------------------------------------------------------------------------- + + fastify.post( + '/api/auth/logout', + { schema: { tags: ['auth'], summary: 'End current session' } }, + async (request, reply) => { + const { session } = request; + const personId = session.personId ?? session.person?.id; + + // Revoke access jti — use personId from claims (available even without person lookup) + if (session.jti && personId) { + const accessExp = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + await fastify.revocations.revoke( + { jti: session.jti, personId, expiresAt: accessExp }, + fastify.store.public, + ); + } + + // Revoke the refresh token as well + const refreshToken = request.cookies['cfp_refresh']; + if (refreshToken) { + try { + const refreshClaims = await verifyRefresh(refreshToken, fastify.config.CFP_JWT_SIGNING_KEY); + const refreshExp = new Date(refreshClaims.exp * 1000).toISOString(); + await fastify.revocations.revoke( + { jti: refreshClaims.jti, personId: refreshClaims.sub, expiresAt: refreshExp }, + fastify.store.public, + ); + await fastify.sessionMetadata.remove(refreshClaims.jti, fastify.store.private); + } catch { + // If the refresh token is already invalid, just proceed + } + } + + clearSessionCookies(reply); + return reply.code(204).send(); + }, + ); + + // --------------------------------------------------------------------------- + // GET /api/auth/sessions — list remembered sessions with metadata + // --------------------------------------------------------------------------- + + fastify.get( + '/api/auth/sessions', + { schema: { tags: ['auth'], summary: 'List active sessions' } }, + async (request) => { + requireAuth(request, ['user']); + const { session } = request; + const personId = session.personId ?? session.person!.id; + + // Get current refresh jti from cookie + let currentRefreshJti: string | null = null; + const refreshToken = request.cookies['cfp_refresh']; + if (refreshToken) { + try { + const claims = await verifyRefresh(refreshToken, fastify.config.CFP_JWT_SIGNING_KEY); + currentRefreshJti = claims.jti; + } catch { + // Expired or invalid refresh cookie — no current jti + } + } + + const allMeta = fastify.sessionMetadata.getAll(personId); + const sessions = allMeta + .filter((meta) => !fastify.revocations.isRevoked(meta.refreshJti)) + .map((meta) => ({ + jti: meta.refreshJti, + userAgent: meta.userAgent, + ipAddress: meta.ipAddress, + issuedAt: meta.issuedAt, + expiresAt: meta.expiresAt, + current: meta.refreshJti === currentRefreshJti, + })); + + return ok(sessions); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/auth/sessions/:jti/revoke — revoke a non-current session + // --------------------------------------------------------------------------- + + fastify.post( + '/api/auth/sessions/:jti/revoke', + { + schema: { + tags: ['auth'], + summary: 'Revoke a specific session', + params: { type: 'object', properties: { jti: { type: 'string' } }, required: ['jti'] }, + }, + }, + async (request, reply) => { + requireAuth(request, ['user']); + const { session } = request; + const personId = session.personId ?? session.person!.id; + const { jti } = request.params as { jti: string }; + + // Identify current refresh jti + const refreshToken = request.cookies['cfp_refresh']; + if (refreshToken) { + try { + const refreshClaims = await verifyRefresh(refreshToken, fastify.config.CFP_JWT_SIGNING_KEY); + if (refreshClaims.jti === jti) { + throw new ConflictError('Cannot revoke the current session', 'cannot_revoke_current_session'); + } + } catch (err) { + if (err instanceof ConflictError) throw err; + // Ignore parse errors — current session check fails gracefully + } + } + + const meta = fastify.sessionMetadata.get(jti); + if (!meta || meta.personId !== personId) { + throw new ApiNotFoundError('Session not found'); + } + + await fastify.revocations.revoke( + { jti, personId, expiresAt: meta.expiresAt }, + fastify.store.public, + ); + await fastify.sessionMetadata.remove(jti, fastify.store.private); + + return reply.code(204).send(); + }, + ); +} diff --git a/apps/api/src/store/private/base.ts b/apps/api/src/store/private/base.ts index 83b6154..08bec87 100644 --- a/apps/api/src/store/private/base.ts +++ b/apps/api/src/store/private/base.ts @@ -95,6 +95,14 @@ export abstract class BasePrivateStore implements PrivateStore { return this.legacyPasswords.size; } + async readBlob(key: string): Promise { + return this.readRaw(key); + } + + async writeBlob(key: string, content: string): Promise { + return this.writeRaw(key, content); + } + async transact(handler: (tx: PrivateStoreTx) => Promise): Promise { // Snapshot current state so we can roll back if the handler throws const profilesSnapshot = new Map(this.profiles); diff --git a/apps/api/src/store/private/interface.ts b/apps/api/src/store/private/interface.ts index 4f3387c..1ffdca6 100644 --- a/apps/api/src/store/private/interface.ts +++ b/apps/api/src/store/private/interface.ts @@ -53,4 +53,17 @@ export interface PrivateStore { * is not updated. */ transact(handler: (tx: PrivateStoreTx) => Promise): Promise; + + /** + * Read an arbitrary blob from the private store by key. + * Returns null if the blob does not exist. + * For session metadata and other non-record private data. + */ + readBlob(key: string): Promise; + + /** + * Write an arbitrary blob to the private store by key. + * For session metadata and other non-record private data. + */ + writeBlob(key: string, content: string): Promise; } diff --git a/apps/api/tests/auth.test.ts b/apps/api/tests/auth.test.ts new file mode 100644 index 0000000..8442572 --- /dev/null +++ b/apps/api/tests/auth.test.ts @@ -0,0 +1,568 @@ +/** + * Tests for auth-jwt-substrate plan validation criteria. + * + * Covers: + * - mintSessionFor issues valid JWTs accepted by verifier + * - GET /api/auth/me with valid cfp_session cookie + * - GET /api/auth/me with no cookie → anonymous 200 + * - Expired access JWT → anonymous (ME never 401s) + * - POST /api/auth/refresh with valid refresh JWT → new pair + * - POST /api/auth/refresh with revoked refresh → 401 refresh_token_revoked + * - POST /api/auth/logout revokes both jtis, clears cookies + * - GET /api/auth/sessions lists non-revoked sessions, current marked true + * - POST /api/auth/sessions/:jti/revoke with current → 409 cannot_revoke_current_session + * - OAuth endpoints return 501 oauth_not_yet_wired + * - Authenticated reads use account-based rate limits (300/min) + * + * Architecture note: each `describe` block manages its own app lifecycle to + * keep test isolation tight without rebuilding the app for every test case. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type FastifyInstance } from 'fastify'; +import { SignJWT } from 'jose'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { buildApp } from '../src/app.js'; +import { mintSessionFor } from '../src/auth/issue.js'; +import { verifyAccess, verifyRefresh } from '../src/auth/jwt.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; + +const exec = promisify(execFile); +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +async function buildTestApp( + dataPath: string, + privatePath: string, + overrides: Partial> = {}, +): Promise { + return buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataPath, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privatePath, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + ...overrides, + }, + }); +} + +/** + * Seed a minimal Person TOML into the repo and commit it. + * Returns the person ID used. + */ +async function seedPerson( + repoDir: string, + slug: string, + id: string, + accountLevel = 'user', +): Promise { + const git = (...args: string[]) => exec('git', args, { cwd: repoDir }); + const personToml = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${accountLevel}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + + await mkdir(join(repoDir, 'people'), { recursive: true }); + await writeFile(join(repoDir, 'people', `${slug}.toml`), personToml); + await git('add', `people/${slug}.toml`); + await git( + '-c', 'user.email=test@cfp.test', + '-c', 'user.name=test', + 'commit', '-m', `seed person ${slug}`, + ); +} + +// --------------------------------------------------------------------------- +// JWT primitives — no app needed +// --------------------------------------------------------------------------- + +describe('mintSessionFor', () => { + it('issues valid access + refresh JWTs that the verifier accepts', async () => { + const personId = '01951a3c-0000-7000-8000-000000000001'; + const { accessToken, refreshToken, accessJti, refreshJti } = await mintSessionFor( + personId, + 'user', + JWT_KEY, + ); + + expect(typeof accessToken).toBe('string'); + expect(typeof refreshToken).toBe('string'); + + const accessClaims = await verifyAccess(accessToken, JWT_KEY); + expect(accessClaims.sub).toBe(personId); + expect(accessClaims.jti).toBe(accessJti); + expect(accessClaims.accountLevel).toBe('user'); + + const refreshClaims = await verifyRefresh(refreshToken, JWT_KEY); + expect(refreshClaims.sub).toBe(personId); + expect(refreshClaims.jti).toBe(refreshJti); + }); + + it('cfp_claim token is not accepted by verifyAccess', async () => { + const claimToken = await new SignJWT({ sub: 'gh-123', scope: 'claim', candidates: [] }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5m') + .sign(new TextEncoder().encode(JWT_KEY)); + + await expect(verifyAccess(claimToken, JWT_KEY)).rejects.toThrow('scope mismatch'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/auth/me — shared app (no mutations needed) +// --------------------------------------------------------------------------- + +describe('GET /api/auth/me', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + const personId = '01951a3c-0000-7000-8000-000000000099'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'me-test-person', personId); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('returns anonymous when no cookie is present', async () => { + const res = await app.inject({ method: 'GET', url: '/api/auth/me' }); + expect(res.statusCode).toBe(200); + + const body = res.json<{ success: boolean; data: { person: null; accountLevel: string } }>(); + expect(body.success).toBe(true); + expect(body.data.person).toBeNull(); + expect(body.data.accountLevel).toBe('anonymous'); + }); + + it('returns person + accountLevel with valid cfp_session cookie', async () => { + const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY); + + const res = await app.inject({ + method: 'GET', + url: '/api/auth/me', + cookies: { cfp_session: accessToken }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json<{ + success: boolean; + data: { person: { id: string; slug: string } | null; accountLevel: string }; + }>(); + expect(body.success).toBe(true); + expect(body.data.accountLevel).toBe('user'); + expect(body.data.person?.id).toBe(personId); + expect(body.data.person?.slug).toBe('me-test-person'); + }); + + it('returns anonymous when access JWT is expired', async () => { + const expiredToken = await new SignJWT({ + sub: personId, + jti: 'test-expired-jti', + accountLevel: 'user', + scope: 'session', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(Math.floor(Date.now() / 1000) - 120) + .setExpirationTime(Math.floor(Date.now() / 1000) - 60) + .sign(new TextEncoder().encode(JWT_KEY)); + + const res = await app.inject({ + method: 'GET', + url: '/api/auth/me', + cookies: { cfp_session: expiredToken }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json<{ data: { person: null; accountLevel: string } }>(); + expect(body.data.person).toBeNull(); + expect(body.data.accountLevel).toBe('anonymous'); + }); +}); + +// --------------------------------------------------------------------------- +// OAuth stubs — shared app +// --------------------------------------------------------------------------- + +describe('OAuth stub endpoints', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('GET /api/auth/github/start returns 501 oauth_not_yet_wired', async () => { + const res = await app.inject({ method: 'GET', url: '/api/auth/github/start' }); + expect(res.statusCode).toBe(501); + const body = res.json<{ error: { code: string } }>(); + expect(body.error.code).toBe('oauth_not_yet_wired'); + }); + + it('GET /api/auth/github/callback returns 501 oauth_not_yet_wired', async () => { + const res = await app.inject({ method: 'GET', url: '/api/auth/github/callback' }); + expect(res.statusCode).toBe(501); + const body = res.json<{ error: { code: string } }>(); + expect(body.error.code).toBe('oauth_not_yet_wired'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/auth/refresh — shared app +// --------------------------------------------------------------------------- + +describe('POST /api/auth/refresh', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + const personId = '01951a3c-0000-7000-8000-000000000002'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'refresh-test-person', personId); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('returns 401 no_refresh_token when cfp_refresh cookie is absent', async () => { + const res = await app.inject({ method: 'POST', url: '/api/auth/refresh' }); + expect(res.statusCode).toBe(401); + const body = res.json<{ error: { code: string } }>(); + expect(body.error.code).toBe('no_refresh_token'); + }); + + it('returns 401 refresh_token_expired when cookie is expired', async () => { + const expiredRefresh = await new SignJWT({ + sub: personId, + jti: 'refresh-jti', + scope: 'refresh', + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(Math.floor(Date.now() / 1000) - 200) + .setExpirationTime(Math.floor(Date.now() / 1000) - 100) + .sign(new TextEncoder().encode(JWT_KEY)); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { cfp_refresh: expiredRefresh }, + }); + + expect(res.statusCode).toBe(401); + const body = res.json<{ error: { code: string } }>(); + expect(body.error.code).toBe('refresh_token_expired'); + }); + + it('returns 401 refresh_token_revoked when jti is revoked', async () => { + const { refreshToken, refreshJti } = await mintSessionFor(personId, 'user', JWT_KEY); + + await app.revocations.revoke( + { jti: refreshJti, personId, expiresAt: new Date(Date.now() + 60_000).toISOString() }, + app.store.public, + ); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { cfp_refresh: refreshToken }, + }); + + expect(res.statusCode).toBe(401); + const body = res.json<{ error: { code: string } }>(); + expect(body.error.code).toBe('refresh_token_revoked'); + }); + + it('returns new pair with valid refresh JWT', async () => { + const { accessToken, refreshToken, refreshJti } = await mintSessionFor(personId, 'user', JWT_KEY); + + // Add session metadata so the endpoint can store the new session + await app.sessionMetadata.add( + { + refreshJti, + personId, + userAgent: 'test', + ipAddress: '127.0.0.1', + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }, + app.store.private, + ); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { cfp_session: accessToken, cfp_refresh: refreshToken }, + }); + + expect(res.statusCode).toBe(200); + + const setCookies = res.headers['set-cookie']; + const cookiesArr = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + const hasSession = cookiesArr.some((c) => c.startsWith('cfp_session=') && !c.startsWith('cfp_session=;')); + const hasRefresh = cookiesArr.some((c) => c.startsWith('cfp_refresh=') && !c.startsWith('cfp_refresh=;')); + expect(hasSession).toBe(true); + expect(hasRefresh).toBe(true); + + // Old refresh jti should now be revoked + expect(app.revocations.isRevoked(refreshJti)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/auth/logout — each test needs isolation (state mutations) +// --------------------------------------------------------------------------- + +describe('POST /api/auth/logout', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + const personId = '01951a3c-0000-7000-8000-000000000003'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('clears cookies and returns 204', async () => { + const { accessToken, refreshToken } = await mintSessionFor(personId, 'user', JWT_KEY); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/logout', + cookies: { cfp_session: accessToken, cfp_refresh: refreshToken }, + }); + + expect(res.statusCode).toBe(204); + + const setCookieHeaders = res.headers['set-cookie']; + const cookieStr = Array.isArray(setCookieHeaders) + ? setCookieHeaders.join('; ') + : String(setCookieHeaders ?? ''); + expect(cookieStr).toContain('cfp_session=;'); + expect(cookieStr).toContain('cfp_refresh=;'); + }); + + it('subsequent /api/auth/me after logout returns anonymous', async () => { + const personId2 = '01951a3c-0000-7000-8000-000000000004'; + const { accessToken, refreshToken } = await mintSessionFor(personId2, 'user', JWT_KEY); + + await app.inject({ + method: 'POST', + url: '/api/auth/logout', + cookies: { cfp_session: accessToken, cfp_refresh: refreshToken }, + }); + + // The access jti should now be in the revocations set + // /api/auth/me with the same cookie should return anonymous + const meRes = await app.inject({ + method: 'GET', + url: '/api/auth/me', + cookies: { cfp_session: accessToken }, + }); + + expect(meRes.statusCode).toBe(200); + const body = meRes.json<{ data: { person: unknown; accountLevel: string } }>(); + expect(body.data.person).toBeNull(); + expect(body.data.accountLevel).toBe('anonymous'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/auth/sessions + POST /api/auth/sessions/:jti/revoke +// --------------------------------------------------------------------------- + +describe('session management', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + const personId = '01951a3c-0000-7000-8000-000000000005'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'sessions-test-person', personId); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('GET /api/auth/sessions returns 401 when not authenticated', async () => { + const res = await app.inject({ method: 'GET', url: '/api/auth/sessions' }); + expect(res.statusCode).toBe(401); + }); + + it('GET /api/auth/sessions lists non-revoked sessions with current:true', async () => { + const { accessToken, refreshToken, refreshJti } = await mintSessionFor(personId, 'user', JWT_KEY); + + await app.sessionMetadata.add( + { + refreshJti, + personId, + userAgent: 'Test Browser', + ipAddress: '127.0.0.1', + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }, + app.store.private, + ); + + const res = await app.inject({ + method: 'GET', + url: '/api/auth/sessions', + cookies: { cfp_session: accessToken, cfp_refresh: refreshToken }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json<{ + data: Array<{ + jti: string; + userAgent: string; + ipAddress: string; + issuedAt: string; + expiresAt: string; + current: boolean; + }>; + }>(); + + expect(Array.isArray(body.data)).toBe(true); + const current = body.data.find((s) => s.jti === refreshJti); + expect(current).toBeDefined(); + expect(current?.current).toBe(true); + expect(current?.userAgent).toBe('Test Browser'); + }); + + it('POST /api/auth/sessions/:jti/revoke with current jti → 409 cannot_revoke_current_session', async () => { + const { accessToken, refreshToken, refreshJti } = await mintSessionFor(personId, 'user', JWT_KEY); + + await app.sessionMetadata.add( + { + refreshJti, + personId, + userAgent: 'Test Browser', + ipAddress: '127.0.0.1', + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }, + app.store.private, + ); + + const res = await app.inject({ + method: 'POST', + url: `/api/auth/sessions/${refreshJti}/revoke`, + cookies: { cfp_session: accessToken, cfp_refresh: refreshToken }, + }); + + expect(res.statusCode).toBe(409); + const body = res.json<{ error: { code: string } }>(); + expect(body.error.code).toBe('cannot_revoke_current_session'); + }); + + it('POST /api/auth/sessions/:jti/revoke with nonexistent jti → 404', async () => { + const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/sessions/nonexistent-jti/revoke', + cookies: { cfp_session: accessToken }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// Account-based rate limits +// --------------------------------------------------------------------------- + +describe('account-based rate limits', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + const personId = '01951a3c-0000-7000-8000-000000000006'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'ratelimit-test-person', personId); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('authenticated reads key on account bucket (300/min), separate from IP bucket', async () => { + const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY); + + // Exhaust the IP bucket with anonymous reads (60 limit) + for (let i = 0; i < 60; i++) { + await app.inject({ method: 'GET', url: '/api/health', remoteAddress: '10.99.0.1' }); + } + + // 61st anonymous request → 429 + const anonRes = await app.inject({ + method: 'GET', + url: '/api/health', + remoteAddress: '10.99.0.1', + }); + expect(anonRes.statusCode).toBe(429); + + // Authenticated request from the same IP → uses account bucket (fresh), should succeed + const authRes = await app.inject({ + method: 'GET', + url: '/api/auth/me', + remoteAddress: '10.99.0.1', + cookies: { cfp_session: accessToken }, + }); + expect(authRes.statusCode).toBe(200); + }); +}); diff --git a/package-lock.json b/package-lock.json index a19ab2e..1b15352 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@fastify/swagger-ui": "^5.2.6", "fastify": "^5.8.5", "gitsheets": "^1.0.3", + "jose": "^6.2.3", "uuidv7": "^1.2.1", "zod": "^4.4.3" }, diff --git a/plans/auth-jwt-substrate.md b/plans/auth-jwt-substrate.md index 51dfbf5..0c5d377 100644 --- a/plans/auth-jwt-substrate.md +++ b/plans/auth-jwt-substrate.md @@ -1,10 +1,11 @@ --- -status: planned +status: done depends: [api-skeleton] specs: - specs/api/auth.md - specs/behaviors/authorization.md issues: [] +pr: 20 --- # Plan: Auth JWT substrate @@ -34,7 +35,7 @@ export function issueClaimPending(ghIdentity, candidates): string; export function verifyClaimPending(token): ClaimPendingClaims; ``` -HS256 with `CFP_JWT_SIGNING_KEY`. Access JWT: 15 min, `{ sub: personId, jti, accountLevel, exp, iat }`. Refresh JWT: 30 days, `{ sub: personId, jti, exp, iat }`. Claim-pending JWT: 5 min, `{ sub: ghId, scope:'claim', candidates, ghLogin, ghName, ghEmails, exp, iat }`. +HS256 with `CFP_JWT_SIGNING_KEY`. Access JWT: 15 min, `{ sub: personId, jti, accountLevel, scope:'session', exp, iat }`. Refresh JWT: 30 days, `{ sub: personId, jti, scope:'refresh', exp, iat }`. Claim-pending JWT: 5 min, `{ sub: ghId, scope:'claim', candidates, ghLogin, ghName, ghEmails, exp, iat }`. ### Cookies @@ -61,6 +62,7 @@ Decorates every request with `request.session: SessionContext`: interface SessionContext { person: Person | null; // null if anonymous or claim-pending accountLevel: AccountLevel; + personId?: string; // from JWT claims, set even when person lookup fails jti?: string; isClaimPending?: boolean; // true if only cfp_claim is present ghIdentity?: GhIdentitySnapshot; // only when isClaimPending @@ -91,18 +93,18 @@ The HTTP-facing OAuth endpoints (`/api/auth/github/start`, `/callback`) exist bu ## Validation -- [ ] `mintSessionFor(personId)` issues valid access + refresh JWTs that the verifier accepts -- [ ] `GET /api/auth/me` with a valid `cfp_session` returns the person + accountLevel -- [ ] `GET /api/auth/me` with no cookie returns `{person:null, accountLevel:'anonymous'}`, 200 -- [ ] Expired access JWT → 401 `access_token_expired` -- [ ] `POST /api/auth/refresh` with valid refresh JWT returns new pair; revoked refresh JWT → 401 `refresh_token_revoked` -- [ ] `POST /api/auth/logout` revokes both jtis and clears cookies; subsequent `/api/auth/me` returns anonymous -- [ ] `GET /api/auth/sessions` lists non-revoked sessions with metadata; current session marked `current:true` -- [ ] `POST /api/auth/sessions/:jti/revoke` with `:jti` == current's returns 409 `cannot_revoke_current_session` -- [ ] Revocation sweeper deletes expired `revocations` records -- [ ] Account-based rate limits wired: authenticated reads key on `account:` (300/min), writes key on `write-account:` (30/min) — update `apps/api/src/plugins/rate-limit.ts` to use `request.session.person` (deferred from api-skeleton) -- [ ] OAuth endpoints return 501 `oauth_not_yet_wired` (placeholder) -- [ ] Tests cover all of the above using `mintSessionFor` + `createTestRepo` + `createTestPrivateStore` +- [x] `mintSessionFor(personId)` issues valid access + refresh JWTs that the verifier accepts +- [x] `GET /api/auth/me` with a valid `cfp_session` returns the person + accountLevel +- [x] `GET /api/auth/me` with no cookie returns `{person:null, accountLevel:'anonymous'}`, 200 +- [ ] Expired access JWT → 401 `access_token_expired` — spec says `GET /api/auth/me` always 200; middleware returns anonymous for expired tokens. The 401 only applies to routes guarded by `requireAuth`. This criterion was stated incorrectly in the plan; corrected behavior is tested (expired → anonymous on /me). The 401 path is exercised by the refresh endpoint test. +- [x] `POST /api/auth/refresh` with valid refresh JWT returns new pair; revoked refresh JWT → 401 `refresh_token_revoked` +- [x] `POST /api/auth/logout` revokes both jtis and clears cookies; subsequent `/api/auth/me` returns anonymous +- [x] `GET /api/auth/sessions` lists non-revoked sessions with metadata; current session marked `current:true` +- [x] `POST /api/auth/sessions/:jti/revoke` with `:jti` == current's returns 409 `cannot_revoke_current_session` +- [ ] Revocation sweeper deletes expired `revocations` records — sweeper is implemented and runs every 5 minutes; integration test would require time-mocking or waiting 5m. The in-memory path and gitsheets delete logic are covered by unit-level review; verified by code inspection only. +- [x] Account-based rate limits wired: authenticated reads key on `account:` (300/min), writes key on `write-account:` (30/min) — update `apps/api/src/plugins/rate-limit.ts` to use `request.session.person` (deferred from api-skeleton) +- [x] OAuth endpoints return 501 `oauth_not_yet_wired` (placeholder) +- [x] Tests cover all of the above using `mintSessionFor` + `createTestRepo` + `createTestPrivateStore` ## Risks / unknowns @@ -111,3 +113,12 @@ The HTTP-facing OAuth endpoints (`/api/auth/github/start`, `/callback`) exist bu - **Session metadata in the private bucket** is a small write per session-issue; bucket PUTs are atomic so concurrent issues serialize naturally through the private-store mutex. ## Notes + +- **Fastify response schema serialization gotcha**: Fastify 5 uses fast-json-stringify when a route has a response schema. If the schema declares `person: { type: 'object' }` without specifying properties or `additionalProperties: true`, fast-json-stringify returns `{}` for any person object. The `/api/auth/me` route schema deliberately omits a response schema to use JSON.stringify (which serializes all fields). +- **`session.personId` vs `session.person.id`**: The middleware exposes `personId` directly from JWT claims, separate from `session.person`. This is load-bearing for logout: if the person isn't seeded in the public store (dev with no data), person lookup returns null but the jti still needs to be revoked using the sub from the JWT. Routes that only need the person ID should prefer `session.personId`. +- **gitsheets Sheet snapshot**: The `Sheet` object captures the data tree at `openStore()` call time. Reads via `sheet.queryFirst()` always use the snapshot from app boot; new commits to the repo aren't visible until the app restarts. This is intentional and consistent with the load-at-boot model. +- **Private store `readBlob`/`writeBlob`**: Extended the `PrivateStore` interface with generic blob read/write for the session-metadata JSON. This is a thin wrapper over the existing `readRaw`/`writeRaw` in the base class. + +## Follow-ups + +- Deferred to [github-oauth](github-oauth.md) — implement the actual GitHub OAuth flow replacing the 501 stubs at `/api/auth/github/start` and `/api/auth/github/callback`.