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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -97,6 +99,9 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
// ----- 8. Idempotency -----
await fastify.register(idempotencyPlugin);

// ----- 8a. Session middleware (JWT auth) -----
await fastify.register(sessionMiddlewarePlugin);

// ----- 9-10. OpenAPI / Swagger UI -----
await fastify.register(fastifySwagger, {
openapi: {
Expand All @@ -122,6 +127,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta

// ----- 11. Routes -----
await fastify.register(healthRoutes);
await fastify.register(authRoutes);

// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)
Expand Down
61 changes: 61 additions & 0 deletions apps/api/src/auth/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Cookie helpers for session JWT management.
*
* Consistent set/clear for cfp_session, cfp_refresh, cfp_claim across all
* auth endpoints. The Secure flag is omitted in non-production environments
* per specs/behaviors/authorization.md.
*/
import type { FastifyReply } from 'fastify';

const COOKIE_OPTS_BASE = {
httpOnly: true,
sameSite: 'lax' as const,
};

const ACCESS_TTL_MS = 15 * 60 * 1000;
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const CLAIM_TTL_MS = 5 * 60 * 1000;

function isSecure(nodeEnv: string): boolean {
return nodeEnv === 'production';
}

export function setSessionCookies(
reply: FastifyReply,
tokens: { access: string; refresh: string },
nodeEnv: string,
): void {
const secure = isSecure(nodeEnv);

reply.setCookie('cfp_session', tokens.access, {
...COOKIE_OPTS_BASE,
secure,
path: '/',
maxAge: ACCESS_TTL_MS / 1000,
});

reply.setCookie('cfp_refresh', tokens.refresh, {
...COOKIE_OPTS_BASE,
secure,
path: '/api/auth/refresh',
maxAge: REFRESH_TTL_MS / 1000,
});
}

export function setClaimCookie(reply: FastifyReply, token: string, nodeEnv: string): void {
reply.setCookie('cfp_claim', token, {
...COOKIE_OPTS_BASE,
secure: isSecure(nodeEnv),
path: '/api/account-claim',
maxAge: CLAIM_TTL_MS / 1000,
});
}

export function clearSessionCookies(reply: FastifyReply): void {
reply.clearCookie('cfp_session', { path: '/' });
reply.clearCookie('cfp_refresh', { path: '/api/auth/refresh' });
}

export function clearClaimCookie(reply: FastifyReply): void {
reply.clearCookie('cfp_claim', { path: '/api/account-claim' });
}
41 changes: 41 additions & 0 deletions apps/api/src/auth/guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Route auth guard helpers.
*
* Routes that require authentication call requireAuth() with the needed markers.
* The function throws UnauthenticatedError or ForbiddenError as appropriate.
*
* Markers follow specs/behaviors/authorization.md.
*/
import type { FastifyRequest } from 'fastify';
import { UnauthenticatedError, ForbiddenError } from '../lib/errors.js';
import type { SessionContext } from './middleware.js';

export type AuthMarker = 'public' | 'user' | 'staff' | 'administrator' | 'self';

/**
* Assert the request has a valid session meeting at least one of the given markers.
* Returns the session context for convenience.
*/
export function requireAuth(request: FastifyRequest, markers: AuthMarker[]): SessionContext {
const session = request.session;

if (markers.includes('public')) return session;

if (session.accountLevel === 'anonymous' || (!session.personId && !session.person)) {
throw new UnauthenticatedError('Authentication required');
}

if (markers.includes('user')) return session;

if (markers.includes('staff')) {
if (session.accountLevel === 'staff' || session.accountLevel === 'administrator') {
return session;
}
}

if (markers.includes('administrator')) {
if (session.accountLevel === 'administrator') return session;
}

throw new ForbiddenError('Insufficient permissions');
}
23 changes: 23 additions & 0 deletions apps/api/src/auth/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* mintSessionFor — issuance stub for tests and internal callers.
*
* The github-oauth plan will replace this with the real OAuth-backed flow.
* Tests call this directly to exercise session mechanics without OAuth.
*/
import { type AccountLevel, issueSession } from './jwt.js';

export interface MintedSession {
readonly accessToken: string;
readonly refreshToken: string;
readonly accessJti: string;
readonly refreshJti: string;
}

export async function mintSessionFor(
personId: string,
accountLevel: AccountLevel,
signingKey: string,
): Promise<MintedSession> {
const { access, refresh, accessJti, refreshJti } = await issueSession(personId, accountLevel, signingKey);
return { accessToken: access, refreshToken: refresh, accessJti, refreshJti };
}
179 changes: 179 additions & 0 deletions apps/api/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<JWTPayload> & { 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<JWTPayload> & { 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<AccessClaims> {
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<RefreshClaims> {
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<string> {
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<JWTPayload> & {
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<ClaimPendingClaims> {
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!,
};
}
Loading