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
2 changes: 2 additions & 0 deletions apps/api/src/auth/github-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export async function completeCallback(
match.personId,
result.value.person.accountLevel,
cfg.CFP_JWT_SIGNING_KEY,
{ loginMethod: 'github' },
);
return {
kind: 'session',
Expand Down Expand Up @@ -188,6 +189,7 @@ export async function completeCallback(
result.value.person.id,
result.value.person.accountLevel,
cfg.CFP_JWT_SIGNING_KEY,
{ loginMethod: 'github' },
);
return {
kind: 'session',
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/auth/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 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';
import { type AccountLevel, type LoginMethod, issueSession } from './jwt.js';

export interface MintedSession {
readonly accessToken: string;
Expand All @@ -17,7 +17,13 @@ export async function mintSessionFor(
personId: string,
accountLevel: AccountLevel,
signingKey: string,
options?: { loginMethod?: LoginMethod },
): Promise<MintedSession> {
const { access, refresh, accessJti, refreshJti } = await issueSession(personId, accountLevel, signingKey);
const { access, refresh, accessJti, refreshJti } = await issueSession(
personId,
accountLevel,
signingKey,
options,
);
return { accessToken: access, refreshToken: refresh, accessJti, refreshJti };
}
53 changes: 47 additions & 6 deletions apps/api/src/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,33 @@ import { uuidv7 } from 'uuidv7';

export type AccountLevel = 'anonymous' | 'user' | 'staff' | 'administrator';

/**
* How the current session was minted. Surfaced via `/api/auth/me` so the
* SPA can render context-appropriate UI hints (e.g., "you signed in via
* password — link GitHub for faster sign-in next time").
*
* - `github` — minted from a GitHub OAuth callback
* - `legacy_password` — minted from POST /api/auth/login
* - `password_reset` — minted from POST /api/auth/password-reset/confirm
*
* Stored as an optional claim on both access and refresh JWTs so a
* refresh round-trip preserves the method.
*/
export type LoginMethod = 'github' | 'legacy_password' | 'password_reset';

export interface AccessClaims {
readonly sub: string; // personId
readonly jti: string;
readonly accountLevel: AccountLevel;
readonly loginMethod?: LoginMethod;
readonly exp: number;
readonly iat: number;
}

export interface RefreshClaims {
readonly sub: string; // personId
readonly jti: string;
readonly loginMethod?: LoginMethod;
readonly exp: number;
readonly iat: number;
}
Expand Down Expand Up @@ -60,28 +76,43 @@ export async function issueSession(
personId: string,
accountLevel: AccountLevel,
signingKey: string,
options?: { loginMethod?: LoginMethod },
): 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 loginMethod = options?.loginMethod;

const access = await new SignJWT({
const accessPayload: Partial<JWTPayload> & {
accountLevel: AccountLevel;
scope: string;
loginMethod?: LoginMethod;
} = {
sub: personId,
jti: accessJti,
accountLevel,
scope: 'session',
} satisfies Partial<JWTPayload> & { accountLevel: AccountLevel; scope: string })
};
if (loginMethod) accessPayload.loginMethod = loginMethod;

const access = await new SignJWT(accessPayload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now)
.setExpirationTime(now + ACCESS_TTL_SECONDS)
.sign(key);

const refresh = await new SignJWT({
const refreshPayload: Partial<JWTPayload> & {
scope: string;
loginMethod?: LoginMethod;
} = {
sub: personId,
jti: refreshJti,
scope: 'refresh',
} satisfies Partial<JWTPayload> & { scope: string })
};
if (loginMethod) refreshPayload.loginMethod = loginMethod;

const refresh = await new SignJWT(refreshPayload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now)
.setExpirationTime(now + REFRESH_TTL_SECONDS)
Expand All @@ -100,13 +131,18 @@ export async function verifyAccess(token: string, signingKey: string): Promise<A
throw new Error('Token scope mismatch: expected session');
}

return {
const claims: AccessClaims = {
sub: payload.sub!,
jti: payload.jti!,
accountLevel: payload['accountLevel'] as AccountLevel,
exp: payload.exp!,
iat: payload.iat!,
};
const lm = payload['loginMethod'];
if (lm === 'github' || lm === 'legacy_password' || lm === 'password_reset') {
return { ...claims, loginMethod: lm };
}
return claims;
}

export async function verifyRefresh(token: string, signingKey: string): Promise<RefreshClaims> {
Expand All @@ -119,12 +155,17 @@ export async function verifyRefresh(token: string, signingKey: string): Promise<
throw new Error('Token scope mismatch: expected refresh');
}

return {
const claims: RefreshClaims = {
sub: payload.sub!,
jti: payload.jti!,
exp: payload.exp!,
iat: payload.iat!,
};
const lm = payload['loginMethod'];
if (lm === 'github' || lm === 'legacy_password' || lm === 'password_reset') {
return { ...claims, loginMethod: lm };
}
return claims;
}

export async function issueClaimPending(
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { errors as JoseErrors } from 'jose';

import type { AccountLevel, GhIdentitySnapshot } from './jwt.js';
import type { AccountLevel, GhIdentitySnapshot, LoginMethod } from './jwt.js';
import { verifyAccess } from './jwt.js';
import { InMemoryRevocationStore } from './revocation.js';
import { SessionMetadataStore } from './session-metadata.js';
Expand All @@ -29,6 +29,12 @@ export interface SessionContext {
readonly jti?: string;
readonly isClaimPending?: boolean;
readonly ghIdentity?: GhIdentitySnapshot;
/**
* How this session was originally minted. Undefined for sessions that
* predate the loginMethod claim (i.e., issued before phase B); the
* frontend treats undefined as "unknown" and falls back to default UI.
*/
readonly loginMethod?: LoginMethod;
}

const ANONYMOUS_SESSION: SessionContext = {
Expand Down Expand Up @@ -116,6 +122,7 @@ async function sessionMiddlewarePlugin(fastify: FastifyInstance): Promise<void>
accountLevel: claims.accountLevel,
personId: claims.sub,
jti: claims.jti,
...(claims.loginMethod !== undefined ? { loginMethod: claims.loginMethod } : {}),
};
} catch (err) {
if (
Expand Down
126 changes: 125 additions & 1 deletion apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import {
clearOAuthCookies,
} from '../auth/cookies.js';
import { requireAuth } from '../auth/guards.js';
import {
dummyVerify,
rehashPassword,
verifyLegacyPassword,
} from '../auth/legacy-password.js';
import type { SessionMeta } from '../auth/session-metadata.js';
import type { LegacyPasswordCredential } from '@cfp/shared/schemas';
import {
generateCsrfState,
generatePkceVerifier,
Expand Down Expand Up @@ -251,13 +257,126 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
},
async (request) => {
const { session } = request;
const person = session.person ?? null;
return ok({
person: session.person ?? null,
person,
accountLevel: session.accountLevel,
// `hasGitHubLink` is derived from Person.githubUserId. For anonymous
// callers (no person), it's false. For password-only legacy users
// it's false until they link via the /account banner.
hasGitHubLink: person !== null && typeof person.githubUserId === 'number',
// `lastLoginMethod` reflects how the *current* session was minted.
// Undefined when the session predates the loginMethod claim or when
// anonymous. Returned as null for the SPA to treat uniformly.
lastLoginMethod: session.loginMethod ?? null,
});
},
);

// ---------------------------------------------------------------------------
// POST /api/auth/login — legacy password sign-in
//
// Per specs/api/auth.md + specs/behaviors/account-migration.md +
// specs/behaviors/password-hash-rotation.md. Accepts any Person with a
// LegacyPasswordCredential on file. Verifies via the three-algorithm
// dispatcher, rotates the credential to argon2id on success, mints a
// session with loginMethod: 'legacy_password'.
//
// All failure paths return a uniform 401 with `error.code =
// "invalid_credentials"` and run a dummy argon2 verify so wall-clock
// timing across "no such user," "no credential," "wrong password,"
// and "unknown format" is comparable.
// ---------------------------------------------------------------------------

fastify.post(
'/api/auth/login',
{
schema: {
tags: ['auth'],
summary: 'Sign in with legacy laddr credentials',
body: {
type: 'object',
properties: {
usernameOrEmail: { type: 'string', minLength: 1 },
password: { type: 'string', minLength: 1 },
},
required: ['usernameOrEmail', 'password'],
additionalProperties: false,
},
},
},
async (request, reply) => {
const { usernameOrEmail, password } = request.body as {
usernameOrEmail: string;
password: string;
};

// Resolve to a personId. Slug first (already normalized lowercase
// in personIdBySlug); else by email through the private store's
// email index. The latter respects the same lowercase convention.
const trimmed = usernameOrEmail.trim();
let personId = fastify.inMemoryState.personIdBySlug.get(trimmed.toLowerCase()) ?? null;
if (!personId && trimmed.includes('@')) {
personId = await fastify.store.private.findPersonIdByEmail(trimmed.toLowerCase());
}

if (!personId) {
// Anti-enumeration: keep timing comparable to the verify path.
await dummyVerify();
throw new UnauthenticatedError('Invalid credentials', 'invalid_credentials');
}

const person = fastify.inMemoryState.people.get(personId);
if (!person || person.deletedAt) {
await dummyVerify();
throw new UnauthenticatedError('Invalid credentials', 'invalid_credentials');
}

const cred = await fastify.store.private.getLegacyPassword(personId);
if (!cred) {
await dummyVerify();
throw new UnauthenticatedError('Invalid credentials', 'invalid_credentials');
}

const verifyResult = await verifyLegacyPassword(password, cred.passwordHash);
if (!verifyResult.valid) {
throw new UnauthenticatedError('Invalid credentials', 'invalid_credentials');
}

// Update the credential — rehash to current argon2id params if
// needed, always refresh lastUsedAt. The credential record stays
// on file (vs. the by-password claim flow which deletes it).
const newHash = verifyResult.needsRehash
? await rehashPassword(password)
: cred.passwordHash;
const updated: LegacyPasswordCredential = {
...cred,
passwordHash: newHash,
lastUsedAt: new Date().toISOString(),
};
await fastify.store.private.putLegacyPassword(updated);

// Mint session. loginMethod = 'legacy_password' surfaces on
// /api/auth/me so the SPA can render the "you signed in via
// password — connect GitHub for faster sign-in next time" hint.
const tokens = await issueSession(
personId,
person.accountLevel,
fastify.config.CFP_JWT_SIGNING_KEY,
{ loginMethod: 'legacy_password' },
);
await persistSessionMetadata(fastify, request, tokens.refreshJti, personId);

setSessionCookies(
reply,
{ access: tokens.access, refresh: tokens.refresh },
fastify.config.NODE_ENV,
);

return ok({ person });
},
);

// ---------------------------------------------------------------------------
// POST /api/auth/refresh — mint new access+refresh pair from refresh cookie
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -297,6 +416,11 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
claims.sub,
person.accountLevel,
fastify.config.CFP_JWT_SIGNING_KEY,
// Preserve the loginMethod across refresh so `/api/auth/me`
// continues reporting the original sign-in path. Older refresh
// tokens issued before this claim existed → loginMethod undefined,
// which `issueSession` correctly omits from the new tokens.
claims.loginMethod ? { loginMethod: claims.loginMethod } : undefined,
);

const oldExpiresAt = new Date(claims.exp * 1000).toISOString();
Expand Down
18 changes: 17 additions & 1 deletion apps/api/src/store/private/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export abstract class BasePrivateStore implements PrivateStore {
return this.legacyPasswords.get(personId) ?? null;
}

async putLegacyPassword(cred: LegacyPasswordCredential): Promise<void> {
const parsed = LegacyPasswordCredentialSchema.parse(cred);
this.legacyPasswords.set(parsed.personId, parsed);
await this.flushLegacyPasswords();
}

async deleteLegacyPassword(personId: string): Promise<void> {
this.legacyPasswords.delete(personId);
await this.flushLegacyPasswords();
Expand Down Expand Up @@ -148,6 +154,7 @@ export abstract class BasePrivateStore implements PrivateStore {
// Staged mutations applied only in-memory during the handler
const stagedProfilePuts: Map<string, PrivateProfile> = new Map();
const stagedProfileDeletes: Set<string> = new Set();
const stagedLegacyPuts: Map<string, LegacyPasswordCredential> = new Map();
const stagedLegacyDeletes: Set<string> = new Set();
const stagedClaimRequestPuts: Map<string, AccountClaimRequest> = new Map();

Expand All @@ -161,8 +168,14 @@ export abstract class BasePrivateStore implements PrivateStore {
stagedProfileDeletes.add(personId);
stagedProfilePuts.delete(personId);
},
putLegacyPassword: (cred) => {
const parsed = LegacyPasswordCredentialSchema.parse(cred);
stagedLegacyPuts.set(parsed.personId, parsed);
stagedLegacyDeletes.delete(parsed.personId);
},
deleteLegacyPassword: (personId) => {
stagedLegacyDeletes.add(personId);
stagedLegacyPuts.delete(personId);
},
putClaimRequest: (req) => {
const parsed = AccountClaimRequestSchema.parse(req);
Expand All @@ -189,6 +202,9 @@ export abstract class BasePrivateStore implements PrivateStore {
for (const id of stagedProfileDeletes) {
this.profiles.delete(id);
}
for (const [id, cred] of stagedLegacyPuts) {
this.legacyPasswords.set(id, cred);
}
for (const id of stagedLegacyDeletes) {
this.legacyPasswords.delete(id);
}
Expand All @@ -202,7 +218,7 @@ export abstract class BasePrivateStore implements PrivateStore {
if (stagedProfilePuts.size > 0 || stagedProfileDeletes.size > 0) {
flushOps.push(this.flushProfiles());
}
if (stagedLegacyDeletes.size > 0) {
if (stagedLegacyPuts.size > 0 || stagedLegacyDeletes.size > 0) {
flushOps.push(this.flushLegacyPasswords());
}
if (stagedClaimRequestPuts.size > 0) {
Expand Down
Loading