diff --git a/apps/api/src/auth/github-oauth.ts b/apps/api/src/auth/github-oauth.ts index 84eef68..f8c2ef3 100644 --- a/apps/api/src/auth/github-oauth.ts +++ b/apps/api/src/auth/github-oauth.ts @@ -144,6 +144,7 @@ export async function completeCallback( match.personId, result.value.person.accountLevel, cfg.CFP_JWT_SIGNING_KEY, + { loginMethod: 'github' }, ); return { kind: 'session', @@ -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', diff --git a/apps/api/src/auth/issue.ts b/apps/api/src/auth/issue.ts index a0ff9e0..ff1a7c9 100644 --- a/apps/api/src/auth/issue.ts +++ b/apps/api/src/auth/issue.ts @@ -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; @@ -17,7 +17,13 @@ export async function mintSessionFor( personId: string, accountLevel: AccountLevel, signingKey: string, + options?: { loginMethod?: LoginMethod }, ): Promise { - 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 }; } diff --git a/apps/api/src/auth/jwt.ts b/apps/api/src/auth/jwt.ts index 065efd0..5ca6250 100644 --- a/apps/api/src/auth/jwt.ts +++ b/apps/api/src/auth/jwt.ts @@ -13,10 +13,25 @@ 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; } @@ -24,6 +39,7 @@ export interface AccessClaims { export interface RefreshClaims { readonly sub: string; // personId readonly jti: string; + readonly loginMethod?: LoginMethod; readonly exp: number; readonly iat: number; } @@ -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 & { + accountLevel: AccountLevel; + scope: string; + loginMethod?: LoginMethod; + } = { sub: personId, jti: accessJti, accountLevel, scope: 'session', - } satisfies Partial & { 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 & { + scope: string; + loginMethod?: LoginMethod; + } = { sub: personId, jti: refreshJti, scope: 'refresh', - } satisfies Partial & { scope: string }) + }; + if (loginMethod) refreshPayload.loginMethod = loginMethod; + + const refresh = await new SignJWT(refreshPayload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt(now) .setExpirationTime(now + REFRESH_TTL_SECONDS) @@ -100,13 +131,18 @@ export async function verifyAccess(token: string, signingKey: string): Promise { @@ -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( diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 16a02cb..2ffa6b0 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -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'; @@ -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 = { @@ -116,6 +122,7 @@ async function sessionMiddlewarePlugin(fastify: FastifyInstance): Promise accountLevel: claims.accountLevel, personId: claims.sub, jti: claims.jti, + ...(claims.loginMethod !== undefined ? { loginMethod: claims.loginMethod } : {}), }; } catch (err) { if ( diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 108d6e1..9865f84 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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, @@ -251,13 +257,126 @@ export async function authRoutes(fastify: FastifyInstance): Promise { }, 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 // --------------------------------------------------------------------------- @@ -297,6 +416,11 @@ export async function authRoutes(fastify: FastifyInstance): Promise { 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(); diff --git a/apps/api/src/store/private/base.ts b/apps/api/src/store/private/base.ts index 36e5bb9..1c42df3 100644 --- a/apps/api/src/store/private/base.ts +++ b/apps/api/src/store/private/base.ts @@ -104,6 +104,12 @@ export abstract class BasePrivateStore implements PrivateStore { return this.legacyPasswords.get(personId) ?? null; } + async putLegacyPassword(cred: LegacyPasswordCredential): Promise { + const parsed = LegacyPasswordCredentialSchema.parse(cred); + this.legacyPasswords.set(parsed.personId, parsed); + await this.flushLegacyPasswords(); + } + async deleteLegacyPassword(personId: string): Promise { this.legacyPasswords.delete(personId); await this.flushLegacyPasswords(); @@ -148,6 +154,7 @@ export abstract class BasePrivateStore implements PrivateStore { // Staged mutations applied only in-memory during the handler const stagedProfilePuts: Map = new Map(); const stagedProfileDeletes: Set = new Set(); + const stagedLegacyPuts: Map = new Map(); const stagedLegacyDeletes: Set = new Set(); const stagedClaimRequestPuts: Map = new Map(); @@ -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); @@ -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); } @@ -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) { diff --git a/apps/api/src/store/private/interface.ts b/apps/api/src/store/private/interface.ts index d0e4dac..5b2f41d 100644 --- a/apps/api/src/store/private/interface.ts +++ b/apps/api/src/store/private/interface.ts @@ -22,6 +22,7 @@ export interface PrivateIndices { export interface PrivateStoreTx { putProfile(profile: PrivateProfile): void; deleteProfile(personId: string): void; + putLegacyPassword(cred: LegacyPasswordCredential): void; deleteLegacyPassword(personId: string): void; putClaimRequest(req: AccountClaimRequest): void; } @@ -49,6 +50,7 @@ export interface PrivateStore { // --- Legacy passwords --- getLegacyPassword(personId: string): Promise; + putLegacyPassword(cred: LegacyPasswordCredential): Promise; deleteLegacyPassword(personId: string): Promise; countLegacyPasswords(): Promise; diff --git a/apps/api/src/store/store.ts b/apps/api/src/store/store.ts index 0726d91..d9d88c8 100644 --- a/apps/api/src/store/store.ts +++ b/apps/api/src/store/store.ts @@ -1,5 +1,5 @@ import type { TransactionOptions, TransactionResult } from 'gitsheets'; -import type { AccountClaimRequest, PrivateProfile } from '@cfp/shared/schemas'; +import type { AccountClaimRequest, LegacyPasswordCredential, PrivateProfile } from '@cfp/shared/schemas'; import type { PrivateStore, PrivateStoreTx } from './private/index.js'; import type { PublicStore, PublicStoreTx } from './public.js'; @@ -97,12 +97,14 @@ export class Store { // Staged private mutations collected during the handler const stagedPrivatePuts: PrivateProfile[] = []; const stagedPrivateProfileDeletes: string[] = []; + const stagedLegacyPasswordPuts: LegacyPasswordCredential[] = []; const stagedLegacyPasswordDeletes: string[] = []; const stagedClaimRequestPuts: AccountClaimRequest[] = []; const privateTx: PrivateStoreTx = { putProfile: (profile) => { stagedPrivatePuts.push(profile); }, deleteProfile: (personId) => { stagedPrivateProfileDeletes.push(personId); }, + putLegacyPassword: (cred) => { stagedLegacyPasswordPuts.push(cred); }, deleteLegacyPassword: (personId) => { stagedLegacyPasswordDeletes.push(personId); }, putClaimRequest: (req) => { stagedClaimRequestPuts.push(req); }, }; @@ -110,6 +112,7 @@ export class Store { const hasPrivateMutations = () => stagedPrivatePuts.length > 0 || stagedPrivateProfileDeletes.length > 0 || + stagedLegacyPasswordPuts.length > 0 || stagedLegacyPasswordDeletes.length > 0 || stagedClaimRequestPuts.length > 0; @@ -118,6 +121,7 @@ export class Store { await this.#private.transact(async (tx) => { for (const profile of stagedPrivatePuts) tx.putProfile(profile); for (const id of stagedPrivateProfileDeletes) tx.deleteProfile(id); + for (const cred of stagedLegacyPasswordPuts) tx.putLegacyPassword(cred); for (const id of stagedLegacyPasswordDeletes) tx.deleteLegacyPassword(id); for (const req of stagedClaimRequestPuts) tx.putClaimRequest(req); }); diff --git a/apps/api/tests/auth-login.test.ts b/apps/api/tests/auth-login.test.ts new file mode 100644 index 0000000..81a3cc3 --- /dev/null +++ b/apps/api/tests/auth-login.test.ts @@ -0,0 +1,403 @@ +/** + * Tests for POST /api/auth/login per specs/api/auth.md + + * specs/behaviors/account-migration.md + password-hash-rotation.md. + * + * Each test uses a unique remoteAddress so the 10/min/IP rate cap on + * /api/auth/* doesn't cross-contaminate. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type FastifyInstance } from 'fastify'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { writeFile, readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { buildApp } from '../src/app.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const exec = promisify(execFile); +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +let testIpCounter = 0; +function nextTestIp(): string { + testIpCounter += 1; + return `10.5.${Math.floor(testIpCounter / 250)}.${testIpCounter % 250}`; +} + +async function seedPerson( + repoPath: string, + slug: string, + id: string, + extra: Record = {}, +): Promise { + const fields = { + id, + slug, + fullName: slug + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '), + accountLevel: 'user', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ...extra, + }; + const toml = Object.entries(fields) + .map(([k, v]) => + typeof v === 'number' + ? `${k} = ${v}` + : `${k} = ${JSON.stringify(v)}`, + ) + .join('\n'); + await seedRawToml( + repoPath, + `people/${slug}.toml`, + toml + '\n', + `seed people/${slug}`, + ); +} + +async function seedPrivateProfile( + privatePath: string, + personId: string, + email: string, +): Promise { + const filePath = join(privatePath, 'profiles.jsonl'); + const profile = { + personId, + email, + emailRefreshedAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + let content = ''; + try { + content = await readFile(filePath, 'utf8'); + } catch { + // first write + } + await writeFile(filePath, content + JSON.stringify(profile) + '\n'); +} + +async function seedLegacyPassword( + privatePath: string, + personId: string, + passwordHash: string, +): Promise { + const filePath = join(privatePath, 'legacy-passwords.jsonl'); + const cred = { + personId, + passwordHash, + importedAt: '2026-05-01T00:00:00Z', + }; + let content = ''; + try { + content = await readFile(filePath, 'utf8'); + } catch { + // first write + } + await writeFile(filePath, content + JSON.stringify(cred) + '\n'); +} + +async function readLegacyPasswords( + privatePath: string, +): Promise> { + const filePath = join(privatePath, 'legacy-passwords.jsonl'); + try { + const content = await readFile(filePath, 'utf8'); + return content + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch { + return []; + } +} + +async function buildTestApp( + dataPath: string, + privatePath: string, +): 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', + }, + }); +} + +describe('POST /api/auth/login', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const sha1PersonId = '01951a3c-0000-7000-8000-0000aaaaaaa1'; + const argon2PersonId = '01951a3c-0000-7000-8000-0000aaaaaaa2'; + const linkedPersonId = '01951a3c-0000-7000-8000-0000aaaaaaa3'; + const correctPassword = 'hunter2-correct'; + const wrongPassword = 'definitely-wrong'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + + // SHA-1 user — matches laddr's production hashing + await seedPerson(dataRepo.path, 'sha1-login', sha1PersonId); + await seedPrivateProfile(privateStore.path, sha1PersonId, 'sha1@example.com'); + const sha1 = createHash('sha1').update(correctPassword).digest('hex'); + await seedLegacyPassword(privateStore.path, sha1PersonId, sha1); + + // Already-argon2id user (e.g., previously rehashed) — login should + // succeed and not need a rehash. + await seedPerson(dataRepo.path, 'argon2-login', argon2PersonId); + await seedPrivateProfile(privateStore.path, argon2PersonId, 'argon2@example.com'); + const argon2Hash = await (async () => { + const { rehashPassword } = await import('../src/auth/legacy-password.js'); + return rehashPassword(correctPassword); + })(); + await seedLegacyPassword(privateStore.path, argon2PersonId, argon2Hash); + + // GitHub-linked user with a password credential — login should work + // (kept for migrated users who have linked GitHub but haven't sunset + // their password) and /api/auth/me should report hasGitHubLink: true. + await seedPerson(dataRepo.path, 'linked-login', linkedPersonId, { + githubUserId: 7777, + githubLogin: 'linked-gh-login', + githubLinkedAt: '2026-04-01T00:00:00Z', + }); + await seedPrivateProfile(privateStore.path, linkedPersonId, 'linked@example.com'); + await seedLegacyPassword(privateStore.path, linkedPersonId, sha1); + + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + describe('happy path', () => { + it('signs in with correct password against a SHA-1 hash', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'sha1-login', password: correctPassword }, + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ success: true; data: { person: { slug: string } } }>(); + expect(body.data.person.slug).toBe('sha1-login'); + + // Session cookies set + const cookies = res.headers['set-cookie']; + const cookieStr = Array.isArray(cookies) ? cookies.join('\n') : (cookies ?? ''); + expect(cookieStr).toContain('cfp_session='); + expect(cookieStr).toContain('cfp_refresh='); + }); + + it('resolves usernameOrEmail by email', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'argon2@example.com', password: correctPassword }, + }); + expect(res.statusCode).toBe(200); + }); + + it('signs in a GitHub-linked Person who still has a password credential', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'linked-login', password: correctPassword }, + }); + expect(res.statusCode).toBe(200); + }); + + it('rehashes a SHA-1 credential to argon2id on successful login', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'sha1-login', password: correctPassword }, + }); + expect(res.statusCode).toBe(200); + + const creds = await readLegacyPasswords(privateStore.path); + const cred = creds.find((c) => c.personId === sha1PersonId); + expect(cred).toBeDefined(); + expect(cred!.passwordHash.startsWith('$argon2id$')).toBe(true); + expect(cred!.lastUsedAt).toBeDefined(); + }); + + it('does not rotate an already-argon2id credential', async () => { + const credsBefore = await readLegacyPasswords(privateStore.path); + const before = credsBefore.find((c) => c.personId === argon2PersonId); + expect(before).toBeDefined(); + const hashBefore = before!.passwordHash; + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'argon2-login', password: correctPassword }, + }); + expect(res.statusCode).toBe(200); + + const credsAfter = await readLegacyPasswords(privateStore.path); + const after = credsAfter.find((c) => c.personId === argon2PersonId); + expect(after).toBeDefined(); + // Hash bytes unchanged when needsRehash is false + expect(after!.passwordHash).toBe(hashBefore); + // But lastUsedAt is refreshed + expect(after!.lastUsedAt).toBeDefined(); + }); + }); + + describe('failure paths (uniform 401)', () => { + it('wrong password returns 401 invalid_credentials', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'sha1-login', password: wrongPassword }, + }); + expect(res.statusCode).toBe(401); + const body = res.json<{ success: false; error: { code: string } }>(); + expect(body.error.code).toBe('invalid_credentials'); + }); + + it('unknown user returns 401 invalid_credentials', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'no-such-user', password: correctPassword }, + }); + expect(res.statusCode).toBe(401); + const body = res.json<{ success: false; error: { code: string } }>(); + expect(body.error.code).toBe('invalid_credentials'); + }); + + it('unknown email returns 401 invalid_credentials', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'nobody@example.com', password: correctPassword }, + }); + expect(res.statusCode).toBe(401); + }); + + it('rejects missing body fields with 400 (schema validation)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'sha1-login' }, + }); + // Fastify/Ajv validation surfaces as 422 via our error mapper + // (ApiValidationError); 400 if a different middleware caught it. + // Either way, not 200. + expect([400, 422]).toContain(res.statusCode); + }); + }); + + describe('GET /api/auth/me after login', () => { + it('returns hasGitHubLink: false + lastLoginMethod: legacy_password for SHA-1 user', async () => { + const loginRes = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'sha1-login', password: correctPassword }, + }); + expect(loginRes.statusCode).toBe(200); + const sessionCookie = parseSessionCookie(loginRes.headers['set-cookie']); + expect(sessionCookie).toBeDefined(); + + const meRes = await app.inject({ + method: 'GET', + url: '/api/auth/me', + remoteAddress: nextTestIp(), + cookies: { cfp_session: sessionCookie! }, + }); + expect(meRes.statusCode).toBe(200); + const body = meRes.json<{ + data: { + person: { slug: string } | null; + hasGitHubLink: boolean; + lastLoginMethod: string | null; + }; + }>(); + expect(body.data.person?.slug).toBe('sha1-login'); + expect(body.data.hasGitHubLink).toBe(false); + expect(body.data.lastLoginMethod).toBe('legacy_password'); + }); + + it('returns hasGitHubLink: true for a github-linked user', async () => { + const loginRes = await app.inject({ + method: 'POST', + url: '/api/auth/login', + remoteAddress: nextTestIp(), + payload: { usernameOrEmail: 'linked-login', password: correctPassword }, + }); + expect(loginRes.statusCode).toBe(200); + const sessionCookie = parseSessionCookie(loginRes.headers['set-cookie']); + + const meRes = await app.inject({ + method: 'GET', + url: '/api/auth/me', + remoteAddress: nextTestIp(), + cookies: { cfp_session: sessionCookie! }, + }); + const body = meRes.json<{ + data: { hasGitHubLink: boolean; lastLoginMethod: string | null }; + }>(); + expect(body.data.hasGitHubLink).toBe(true); + expect(body.data.lastLoginMethod).toBe('legacy_password'); + }); + + it('returns anonymous fields when no session', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/auth/me', + remoteAddress: nextTestIp(), + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ + data: { + person: unknown; + accountLevel: string; + hasGitHubLink: boolean; + lastLoginMethod: null; + }; + }>(); + expect(body.data.person).toBeNull(); + expect(body.data.accountLevel).toBe('anonymous'); + expect(body.data.hasGitHubLink).toBe(false); + expect(body.data.lastLoginMethod).toBeNull(); + }); + }); +}); + +function parseSessionCookie(setCookie: string | string[] | undefined): string | undefined { + const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + for (const c of cookies) { + const match = /^cfp_session=([^;]+)/.exec(c); + if (match) return match[1]; + } + return undefined; +} + +// Silence unused-helper warnings since the suite is the only consumer. +void exec; +void mkdtemp; +void tmpdir; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c108f54..1b8f7c8 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -716,6 +716,19 @@ export const api = { request(`/api/blog-posts/${encodeURIComponent(slug)}`), }, auth: { + /** + * Legacy password sign-in. Returns 200 on success; throws ApiError + * with `code: "invalid_credentials"` on any failure. Per + * specs/api/auth.md. + */ + login: ( + usernameOrEmail: string, + password: string, + ): Promise> => + request(`/api/auth/login`, { + method: 'POST', + body: JSON.stringify({ usernameOrEmail, password }), + }), sessions: (): Promise> => request(`/api/auth/sessions`), revokeSession: (jti: string): Promise => request(`/api/auth/sessions/${encodeURIComponent(jti)}/revoke`, { method: 'POST' }), diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx index 5511062..932a1ae 100644 --- a/apps/web/src/pages/LoginPlaceholder.tsx +++ b/apps/web/src/pages/LoginPlaceholder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type FormEvent } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { Card, @@ -8,8 +8,11 @@ import { CardTitle, } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { useAuth } from '@/hooks/useAuth'; +import { api, ApiError } from '@/lib/api'; type ErrorCode = | 'access_denied' @@ -153,11 +156,115 @@ export function LoginPlaceholder() {

- Returning Code for Philly member? You will be - prompted to connect your old account after you sign in with GitHub. + Returning Code for Philly member? If you had an + account before our 2026 switch to GitHub sign-in, you can sign in + with your old password below — or use GitHub if your old email + matches.

+ + { + const target = + returnPath && returnPath.startsWith('/') ? returnPath : '/'; + void navigate(target, { replace: true }); + }} + /> ); } + +interface LegacyPasswordLoginProps { + onSuccess: () => void; +} + +function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { + const [open, setOpen] = useState(false); + const [usernameOrEmail, setUsernameOrEmail] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + async function handleSubmit(e: FormEvent): Promise { + e.preventDefault(); + if (submitting) return; + setSubmitting(true); + setErrorMessage(null); + try { + await api.auth.login(usernameOrEmail.trim(), password); + onSuccess(); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 429) { + setErrorMessage( + 'Too many sign-in attempts. Please wait a minute and try again.', + ); + } else { + // Uniform 401 for any failure — don't reveal whether + // username or password was wrong. + setErrorMessage('The username or password you entered is incorrect.'); + } + } else { + setErrorMessage('Sign-in failed. Please try again.'); + } + } finally { + setSubmitting(false); + } + } + + if (!open) { + return ( + + ); + } + + return ( +
+
+ + setUsernameOrEmail(e.target.value)} + required + autoComplete="username" + aria-invalid={errorMessage ? 'true' : 'false'} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + aria-invalid={errorMessage ? 'true' : 'false'} + /> +
+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + +
+ ); +} diff --git a/apps/web/tests/LoginPlaceholder.test.tsx b/apps/web/tests/LoginPlaceholder.test.tsx new file mode 100644 index 0000000..6b82d54 --- /dev/null +++ b/apps/web/tests/LoginPlaceholder.test.tsx @@ -0,0 +1,124 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { renderScreen } from './test-utils.js'; +import { LoginPlaceholder } from '../src/pages/LoginPlaceholder.js'; +import { AuthProvider } from '../src/hooks/useAuth.js'; + +describe('LoginPlaceholder', () => { + beforeEach(() => { + // Default: anonymous /api/auth/me. Tests that need a logged-in + // state override this. + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve(new Response(null, { status: 404 })); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function render() { + return renderScreen( + + + , + { initialEntries: ['/login'] }, + ); + } + + it('renders the primary GitHub button + a collapsed password disclosure', async () => { + render(); + await waitFor(() => { + expect(screen.getByRole('link', { name: /sign in with github/i })).toBeInTheDocument(); + }); + // Disclosure exists but is closed — fields are not yet in the DOM + expect( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ).toBeInTheDocument(); + expect(screen.queryByLabelText(/username or email/i)).not.toBeInTheDocument(); + }); + + it('expanding the disclosure reveals the password form', async () => { + render(); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ).toBeInTheDocument(); + }); + fireEvent.click( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + }); + + it('keeps submit disabled until both fields are filled', async () => { + render(); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ).toBeInTheDocument(); + }); + fireEvent.click( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ); + const submitBtn = screen.getByRole('button', { name: /^sign in$/i }); + expect(submitBtn).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/username or email/i), { + target: { value: 'jane' }, + }); + expect(submitBtn).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/^password$/i), { + target: { value: 'secret' }, + }); + expect(submitBtn).not.toBeDisabled(); + }); + + it('renders inline error on 401 invalid_credentials', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve(new Response(null, { status: 404 })); + } + if (input === '/api/auth/login' && init?.method === 'POST') { + return Promise.resolve( + new Response( + JSON.stringify({ + success: false, + error: { code: 'invalid_credentials', message: 'Invalid credentials' }, + }), + { status: 401, headers: { 'content-type': 'application/json' } }, + ), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + + render(); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ).toBeInTheDocument(); + }); + fireEvent.click( + screen.getByRole('button', { name: /sign in with your code for philly password/i }), + ); + fireEvent.change(screen.getByLabelText(/username or email/i), { + target: { value: 'jane' }, + }); + fireEvent.change(screen.getByLabelText(/^password$/i), { + target: { value: 'wrong' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^sign in$/i })); + + await waitFor(() => { + expect( + screen.getByText(/username or password you entered is incorrect/i), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plans/login-migration-impl-phase-b.md b/plans/login-migration-impl-phase-b.md new file mode 100644 index 0000000..76c994e --- /dev/null +++ b/plans/login-migration-impl-phase-b.md @@ -0,0 +1,111 @@ +--- +status: done +depends: [login-migration-impl-phase-a] +specs: + - specs/api/auth.md + - specs/screens/login.md +issues: [] +pr: 119 +--- + +# Plan: login-migration impl — phase B (POST /api/auth/login + /api/auth/me updates + SPA secondary form) + +## Scope + +Second phase of the [login-migration-strategy](./login-migration-strategy.md) implementation track. Wires the phase-A verifier into a real login endpoint, adds the new `/api/auth/me` fields, and surfaces the SPA's secondary password form. + +What ships: + +- **`POST /api/auth/login`** — verifies via `verifyLegacyPassword`, rotates the credential on success (rehash + `lastUsedAt`), mints a session with `loginMethod: 'legacy_password'`. +- **`PrivateStore.putLegacyPassword`** — added so the login route can write back rotated credentials. Wired through the cross-store transaction's staged-mutation set. +- **JWT `loginMethod` claim** — optional claim on both access and refresh tokens. Preserved across refresh. +- **`/api/auth/me`** returns `hasGitHubLink` + `lastLoginMethod`. +- **GitHub OAuth callback** passes `loginMethod: 'github'` when minting. +- **SPA `/login`** — secondary collapsed "Or sign in with your Code for Philly password" disclosure that POSTs to `/api/auth/login`. "Returning member" copy updated to match the new spec. + +## Implements + +- [api/auth.md](../specs/api/auth.md) — `POST /api/auth/login` endpoint + `/api/auth/me` field additions. +- [screens/login.md](../specs/screens/login.md) — secondary password form (the "Forgot your password?" affordance is deferred to phase C). +- [behaviors/account-migration.md](../specs/behaviors/account-migration.md) — the three-paths sign-in story: GitHub-new, GitHub-matched, legacy-password. +- [behaviors/password-hash-rotation.md](../specs/behaviors/password-hash-rotation.md) — wired into the route, not just the verifier. + +## Approach + +### 1. `POST /api/auth/login` route + +In `apps/api/src/routes/auth.ts`, inserted before `/api/auth/refresh`. Body validated as `{ usernameOrEmail, password }`. Pipeline: + +1. Resolve `usernameOrEmail.toLowerCase()` against `personIdBySlug`, then `findPersonIdByEmail` if the value contains `@`. +2. Anti-enumeration: on miss (no resolved person, no person record, no credential), call `dummyVerify()` and 401. +3. Verify via `verifyLegacyPassword` from phase A. +4. On `valid: true`: rotate the credential (rehash if `needsRehash`; always refresh `lastUsedAt`). Write back via `putLegacyPassword`. +5. Mint session with `loginMethod: 'legacy_password'`, persist session metadata, set cookies, return `{ person }`. + +Rate-limit cap already covers this path via the existing `/api/auth/*` 10/min/IP bucket. + +### 2. `PrivateStore.putLegacyPassword` + +Existing private store had `deleteLegacyPassword` (used by the claim flow) but no put. Added to the interface, the base impl, and the cross-store transaction's staged-mutation set. Conflicting puts/deletes within a single transaction follow the existing pattern (later op wins on the same key). + +### 3. JWT `loginMethod` claim + +`issueSession` takes an optional `loginMethod`. The value is encoded on both access and refresh tokens. `verifyAccess` and `verifyRefresh` surface it via `AccessClaims.loginMethod` / `RefreshClaims.loginMethod`. The refresh route preserves the claim through token rotation. Existing sessions (issued before this PR) have no claim — verifier returns the claim object without the field, the SPA reads `null`. + +### 4. `/api/auth/me` + +Adds two fields: + +- `hasGitHubLink: boolean` — derived from `Person.githubUserId !== null`. False for anonymous. +- `lastLoginMethod: 'github' | 'legacy_password' | 'password_reset' | null` — pulled from the current access token's `loginMethod` claim. + +Existing callers parsing only `{ person, accountLevel }` are unaffected. + +### 5. SPA `/login` + +The existing `LoginPlaceholder.tsx`: + +- Updates the "Returning member" copy to match the new spec ("you can sign in with your old password below — or use GitHub if your old email matches") +- Adds a collapsed `LegacyPasswordLogin` component below the GitHub CTA. Click the disclosure → form expands with `usernameOrEmail` + `password` + submit. On success: `navigate(returnPath ?? '/')`. +- Inline error rendering for 401 (uniform "username or password is incorrect") and 429. + +"Forgot your password?" is deliberately deferred to phase C — needs the email-token plumbing which lives in `PasswordToken` / `password-reset/{request,confirm}` routes. + +### 6. Tests + +- **`apps/api/tests/auth-login.test.ts` (new)** — 12 cases: SHA-1/argon2 happy paths, email resolution, GitHub-linked user, rehash-on-login, no-rotate-when-current, all four 401 paths (wrong password, unknown user, unknown email, missing body), `/api/auth/me` post-login fields (legacy_password + hasGitHubLink false), GitHub-linked /api/auth/me (legacy_password + hasGitHubLink true), anonymous /api/auth/me. +- **`apps/web/tests/LoginPlaceholder.test.tsx` (new)** — 4 cases: GitHub button + collapsed disclosure render, disclosure expands to reveal fields, submit gated until both fields filled, inline 401 error. + +## Validation + +- [x] `POST /api/auth/login` returns 200 with the Person on correct credentials and 401 `invalid_credentials` on any failure. +- [x] Rehash-on-login rotates SHA-1 → argon2id; leaves already-argon2id-with-current-params unchanged but refreshes `lastUsedAt`. +- [x] `/api/auth/me` returns `hasGitHubLink` + `lastLoginMethod` (`null` for anonymous, `'legacy_password'` after password login, `'github'` after OAuth — verified via the github-oauth path). +- [x] JWT `loginMethod` claim persists across refresh. +- [x] SPA login form is collapsed by default, expands on click, submits + handles 401/429 inline. +- [x] `npm run type-check && npm run lint` clean. +- [x] 12 new API tests pass; 4 new web tests pass; full sweep validated separately. + +## Risks / unknowns + +- **Cross-store transaction surface widened.** `PrivateStoreTx` gains `putLegacyPassword`. The login route uses the direct `putLegacyPassword` method (not the transaction wrapper) since it doesn't need cross-store atomicity. The transaction path is exercised by other future callers (password-reset's confirm route will use it). +- **Older sessions without `loginMethod` claim.** Pre-PR sessions report `lastLoginMethod: null`. The SPA banner state for these is benign — they're GitHub-linked sessions (the only kind issued before this PR), so `hasGitHubLink: true` and the banner stays hidden. +- **Concurrent login + refresh race.** A user logs in, then the SPA fires a refresh at the same moment. The refresh route's `await verifyRefresh` doesn't know about the new session yet. Both end up with valid sessions — fine, the old one expires in 15m. No corruption. +- **Lockout from too-many-fails.** The 10/min/IP cap protects against brute-force from a single source. NAT'd users sharing an IP could hit it; the spec accepts this trade-off (alternative is per-account locking which is its own can of worms). + +## Notes + +Two commits: plan-open (this file at status: in-progress) + the impl. Actually one — given the spec is already locked, decided to write the plan with the work already done and ship as a single feat commit with the closeout in place. + +Surprises: + +- **`PrivateStoreTx` only had a put for profiles, not credentials.** Easy to add but required threading through the cross-store `StoreTx` wrapper too. Worth noting because future tx surface additions (e.g., the `PasswordToken` private record in phase C) will follow the same pattern: interface → base.transact stage set → store.transact stage set. +- **`setSessionCookies` takes nodeEnv as a third arg.** Not obvious from the function name; the parameter switches between Secure-flag-on vs. off based on `NODE_ENV`. Easy to miss for the first caller (caught by TS at type-check). Worth not refactoring — the explicit threading is clearer than implicit fastify-config access. +- **JWT claim is optional throughout.** `loginMethod` not in the type union (existing sessions don't have it). The verifier explicitly type-narrows when the value is one of the known strings; everything else returns the claim object without the field. Forward-compatible. +- **Web `parseSessionCookie` helper.** The test infra returns `Set-Cookie` headers as a string or string-array. The integration test for `/api/auth/me` post-login parses out the `cfp_session=...` portion manually since the test agent doesn't auto-attach cookies across inject calls. + +## Follow-ups + +- **Phase C — password reset.** `POST /api/auth/password-reset/{request,confirm}` + `PasswordToken` private record + email-notifier integration + SPA "Forgot your password?" flow. *Deferred to plan* — `plans/login-migration-impl-phase-c.md`. +- **Phase D — link-github.** `POST /api/auth/link-github` + link-mode OAuth callback variant + `/account` banner + SPA flow. *Deferred to plan* — `plans/login-migration-impl-phase-d.md`. +- **Coverage report.** `lastUsedAt` is now populated; a future small script can report "X% of active password users have linked GitHub" to inform sunset timing. *None* — wait for the data to mean something.