From d124010163b755b36f91d55b75c049a787650f44 Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 3 Aug 2026 04:18:20 +0900 Subject: [PATCH 1/9] feat(auth): add privacy recovery controls --- .../lib/observability/privacy-auth-events.ts | 141 +++++++++++++ apps/web/lib/privacy/roster-classification.ts | 192 ++++++++++++++++++ .../privacy-auth-observability.test.ts | 96 +++++++++ .../privacy-roster-classification.test.ts | 130 ++++++++++++ .../privacy-auth-fallback-receipt.json | 47 +++++ .../privacy-auth-recovery-runbook.md | 103 ++++++++++ 6 files changed, 709 insertions(+) create mode 100644 apps/web/lib/observability/privacy-auth-events.ts create mode 100644 apps/web/lib/privacy/roster-classification.ts create mode 100644 apps/web/tests-unit/privacy-auth-observability.test.ts create mode 100644 apps/web/tests-unit/privacy-roster-classification.test.ts create mode 100644 docs/operations/privacy-auth-fallback-receipt.json create mode 100644 docs/operations/privacy-auth-recovery-runbook.md diff --git a/apps/web/lib/observability/privacy-auth-events.ts b/apps/web/lib/observability/privacy-auth-events.ts new file mode 100644 index 0000000000..99b5866c02 --- /dev/null +++ b/apps/web/lib/observability/privacy-auth-events.ts @@ -0,0 +1,141 @@ +const EVENTS = [ + 'onboarding', + 'auth_callback', + 'middleware', + 'logout', + 'roster_classification', + 'release', +] as const; + +const ROUTE_CLASSES = [ + 'public_page', + 'public_api', + 'loop_safe_page', + 'loop_safe_api', + 'protected', +] as const; + +const PROVIDERS = ['password', 'oauth', 'session', 'none'] as const; + +const OUTCOME_REASONS = [ + 'started', + 'pending_email_confirmation', + 'onboarding_required', + 'admitted', + 'held', + 'failed', + 'denied', + 'completed', + 'already_current_eligible', + 'needs_user_onboarding', + 'audit_write_failed', + 'catalog_drift', + 'roster_conservation_mismatch', + 'release_verified', +] as const; + +export type PrivacyAuthEvent = { + event: (typeof EVENTS)[number]; + buildCommit: string; + deploymentId: string; + migrationManifestSha: string; + policyVersion: string; + policySha: string; + routeClass: (typeof ROUTE_CLASSES)[number]; + provider: (typeof PROVIDERS)[number]; + outcomeReason: (typeof OUTCOME_REASONS)[number]; + correlationId: string; + subjectDigest: string | null; +}; + +export type EmittedPrivacyAuthEvent = PrivacyAuthEvent & { + utcMinute: string; +}; + +const EVENT_KEYS = [ + 'event', + 'buildCommit', + 'deploymentId', + 'migrationManifestSha', + 'policyVersion', + 'policySha', + 'routeClass', + 'provider', + 'outcomeReason', + 'correlationId', + 'subjectDigest', +] as const; + +const COMMIT_PATTERN = /^[a-f0-9]{7,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const DEPLOYMENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +const POLICY_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype; +} + +function isAllowed(value: unknown, allowed: T): value is T[number] { + return typeof value === 'string' && (allowed as readonly string[]).includes(value); +} + +function requireMatch(value: unknown, pattern: RegExp, field: string): asserts value is string { + if (typeof value !== 'string' || !pattern.test(value)) { + throw new TypeError(`Invalid privacy auth event ${field}.`); + } +} + +function validatePrivacyAuthEvent(input: unknown): PrivacyAuthEvent { + if (!isRecord(input)) { + throw new TypeError('Privacy auth event must be a plain object.'); + } + + const keys = Reflect.ownKeys(input); + if ( + keys.length !== EVENT_KEYS.length || + keys.some((key) => typeof key !== 'string' || !EVENT_KEYS.includes(key as (typeof EVENT_KEYS)[number])) + ) { + throw new TypeError('Privacy auth event contains forbidden fields.'); + } + + if (!isAllowed(input.event, EVENTS)) throw new TypeError('Invalid privacy auth event event.'); + if (!isAllowed(input.routeClass, ROUTE_CLASSES)) throw new TypeError('Invalid privacy auth event routeClass.'); + if (!isAllowed(input.provider, PROVIDERS)) throw new TypeError('Invalid privacy auth event provider.'); + if (!isAllowed(input.outcomeReason, OUTCOME_REASONS)) throw new TypeError('Invalid privacy auth event outcomeReason.'); + + requireMatch(input.buildCommit, COMMIT_PATTERN, 'buildCommit'); + requireMatch(input.deploymentId, DEPLOYMENT_ID_PATTERN, 'deploymentId'); + requireMatch(input.migrationManifestSha, SHA256_PATTERN, 'migrationManifestSha'); + requireMatch(input.policyVersion, POLICY_VERSION_PATTERN, 'policyVersion'); + requireMatch(input.policySha, SHA256_PATTERN, 'policySha'); + requireMatch(input.correlationId, UUID_PATTERN, 'correlationId'); + + if (input.subjectDigest !== null) requireMatch(input.subjectDigest, SHA256_PATTERN, 'subjectDigest'); + + return input as PrivacyAuthEvent; +} + +export function formatPrivacyAuthUtcMinute(now: Date): string { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) { + throw new TypeError('Privacy auth event timestamp must be a valid Date.'); + } + + return `${now.toISOString().slice(0, 16)}:00.000Z`; +} + +/** + * Emits the sole privacy-auth recovery event shape to server runtime logs. + * The input is runtime-validated before serialization so malformed or sensitive + * payloads are never logged. + */ +export function emitPrivacyAuthEvent(input: unknown, now: Date = new Date()): EmittedPrivacyAuthEvent { + if (typeof window !== 'undefined') { + throw new Error('Privacy auth events can only be emitted on the server.'); + } + + const event = validatePrivacyAuthEvent(input); + const emitted = { utcMinute: formatPrivacyAuthUtcMinute(now), ...event }; + console.info(JSON.stringify(emitted)); + return emitted; +} diff --git a/apps/web/lib/privacy/roster-classification.ts b/apps/web/lib/privacy/roster-classification.ts new file mode 100644 index 0000000000..3d4f1e2032 --- /dev/null +++ b/apps/web/lib/privacy/roster-classification.ts @@ -0,0 +1,192 @@ +import { createHash } from 'node:crypto'; +import type { CurrentPrivacyEligibility } from '@/lib/privacy/eligibility'; + +if (typeof window !== 'undefined') { + throw new Error('Privacy roster classification is server-only.'); +} + +export const ROSTER_CLASSIFICATION_SIZE = 16; + +export type RosterClassification = + | 'already_current_eligible' + | 'needs_user_onboarding' + | 'held' + | 'failed'; + +export type StoredRosterClassification = Readonly<{ + batchId: string; + userId: string; + classification: RosterClassification; + subjectDigest: string; + receiptDigest: string; + resultDigest: string; +}>; + +export type RosterClassificationSink = Readonly<{ + get: (batchId: string, userId: string) => Promise; + putIfAbsent: (result: StoredRosterClassification) => Promise>; +}>; + +export type RosterClassificationDependencies = Readonly<{ + getCurrentPrivacyEligibilityForUser: (userId: string) => Promise; + sink: RosterClassificationSink; +}>; + +export type RosterClassificationResult = Readonly<{ + batchDigest: string; + counts: Readonly>; + subjects: readonly Readonly<{ + classification: RosterClassification; + subjectDigest: string; + receiptDigest: string; + resultDigest: string; + }>[]; +}>; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const CLASSIFICATIONS = new Set([ + 'already_current_eligible', + 'needs_user_onboarding', + 'held', + 'failed', +]); + +function digest(value: string) { + return createHash('sha256').update(value).digest('hex'); +} + +function receiptDigest(eligibility: CurrentPrivacyEligibility | null) { + if (eligibility === null) return digest('eligibility-error'); + + return digest(JSON.stringify({ + eligible: eligibility.eligible, + reasonCode: eligibility.reasonCode, + receipt: eligibility.receipt, + })); +} + +function classifyEligibility(eligibility: CurrentPrivacyEligibility): RosterClassification { + if (eligibility.eligible === true && eligibility.reasonCode === 'PRIVACY_ELIGIBLE') { + return 'already_current_eligible'; + } + + switch (eligibility.reasonCode) { + case 'PRIVACY_AGE_ATTESTATION_REQUIRED': + case 'PRIVACY_POLICY_REATTESTATION_REQUIRED': + return 'needs_user_onboarding'; + case 'PRIVACY_AGE_BLOCKED': + case 'PRIVACY_GUARDIAN_REQUIRED': + case 'PRIVACY_GUARDIAN_CONSENT_REQUIRED': + return 'held'; + default: + return 'failed'; + } +} + +function validateManifest(batchId: string, userIds: readonly string[]) { + if (typeof batchId !== 'string' || batchId.trim().length === 0) { + throw new Error('A non-empty batchId is required.'); + } + if (userIds.length !== ROSTER_CLASSIFICATION_SIZE) { + throw new Error(`Roster must contain exactly ${ROSTER_CLASSIFICATION_SIZE} subjects.`); + } + + const normalizedUserIds = userIds.map((userId) => { + if (typeof userId !== 'string' || !UUID_PATTERN.test(userId)) { + throw new Error('Roster subjects must be UUIDs.'); + } + return userId.toLowerCase(); + }); + + if (new Set(normalizedUserIds).size !== ROSTER_CLASSIFICATION_SIZE) { + throw new Error('Roster subjects must be unique.'); + } + + return normalizedUserIds; +} + +function isStoredResult(value: StoredRosterClassification, batchId: string, userId: string) { + return value.batchId === batchId + && value.userId === userId + && CLASSIFICATIONS.has(value.classification) + && SHA256_PATTERN.test(value.subjectDigest) + && SHA256_PATTERN.test(value.receiptDigest) + && SHA256_PATTERN.test(value.resultDigest); +} + +function publicSubject(result: StoredRosterClassification) { + return { + classification: result.classification, + subjectDigest: result.subjectDigest, + receiptDigest: result.receiptDigest, + resultDigest: result.resultDigest, + }; +} + +export async function classifyPrivacyRoster( + batchId: string, + userIds: readonly string[], + dependencies: RosterClassificationDependencies, +): Promise { + const normalizedUserIds = validateManifest(batchId, userIds); + const counts: Record = { + already_current_eligible: 0, + needs_user_onboarding: 0, + held: 0, + failed: 0, + }; + const subjects: Array> = []; + + for (const userId of normalizedUserIds) { + const stored = await dependencies.sink.get(batchId, userId); + if (stored !== null) { + if (!isStoredResult(stored, batchId, userId)) { + throw new Error('Durable roster classification is invalid.'); + } + counts[stored.classification] += 1; + subjects.push(publicSubject(stored)); + continue; + } + + let eligibility: CurrentPrivacyEligibility | null = null; + try { + eligibility = await dependencies.getCurrentPrivacyEligibilityForUser(userId); + } catch { + eligibility = null; + } + + const classification = eligibility === null ? 'failed' : classifyEligibility(eligibility); + const evidenceDigest = receiptDigest(eligibility); + const subjectDigest = digest(userId); + const result: StoredRosterClassification = { + batchId, + userId, + classification, + subjectDigest, + receiptDigest: evidenceDigest, + resultDigest: digest(`${batchId}:${userId}:${classification}:${evidenceDigest}`), + }; + const persisted = await dependencies.sink.putIfAbsent(result); + + if (!isStoredResult(persisted.classification, batchId, userId)) { + throw new Error('Durable roster classification is invalid.'); + } + counts[persisted.classification.classification] += 1; + subjects.push(publicSubject(persisted.classification)); + } + + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + if (total !== ROSTER_CLASSIFICATION_SIZE || subjects.length !== ROSTER_CLASSIFICATION_SIZE) { + throw new Error('Roster classification count conservation failed.'); + } + + return { + batchDigest: digest(batchId), + counts, + subjects, + }; +} diff --git a/apps/web/tests-unit/privacy-auth-observability.test.ts b/apps/web/tests-unit/privacy-auth-observability.test.ts new file mode 100644 index 0000000000..d4ac4df0d2 --- /dev/null +++ b/apps/web/tests-unit/privacy-auth-observability.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'bun:test'; +import { + emitPrivacyAuthEvent, + formatPrivacyAuthUtcMinute, + type PrivacyAuthEvent, +} from '../lib/observability/privacy-auth-events'; + +const SHA = 'a'.repeat(64); +const SUBJECT_DIGEST = 'b'.repeat(64); + +function validEvent(overrides: Partial = {}): PrivacyAuthEvent { + return { + event: 'onboarding', + buildCommit: '58b758470', + deploymentId: 'dpl_privacy_auth_recovery', + migrationManifestSha: SHA, + policyVersion: '2026.08.01', + policySha: SHA, + routeClass: 'loop_safe_api', + provider: 'password', + outcomeReason: 'started', + correlationId: '550e8400-e29b-41d4-a716-446655440000', + subjectDigest: SUBJECT_DIGEST, + ...overrides, + }; +} + +describe('privacy auth observability', () => { + test('emits only the allowlisted JSON fields', () => { + const messages: unknown[] = []; + const originalInfo = console.info; + console.info = (message: unknown) => messages.push(message); + + try { + const emitted = emitPrivacyAuthEvent(validEvent(), new Date('2026-08-02T14:35:59.999Z')); + expect(messages).toEqual([JSON.stringify(emitted)]); + expect(JSON.parse(messages[0] as string)).toEqual({ + utcMinute: '2026-08-02T14:35:00.000Z', + ...validEvent(), + }); + } finally { + console.info = originalInfo; + } + }); + + test('rejects raw identity, credential, SQL, audit, and arbitrary fields before logging', () => { + const prohibitedFields = { + email: 'person@example.com', + userId: '550e8400-e29b-41d4-a716-446655440000', + cookie: 'session=value', + token: 'secret-token', + sql: 'select * from privacy_audit_events', + auditPayload: { consent: true }, + unexpected: 'value', + }; + const originalInfo = console.info; + const messages: unknown[] = []; + console.info = (message: unknown) => messages.push(message); + + try { + for (const [field, value] of Object.entries(prohibitedFields)) { + expect(() => emitPrivacyAuthEvent({ ...validEvent(), [field]: value })).toThrow( + 'Privacy auth event contains forbidden fields.', + ); + } + expect(() => emitPrivacyAuthEvent(validEvent({ subjectDigest: 'person@example.com' }))).toThrow( + 'Invalid privacy auth event subjectDigest.', + ); + expect(() => emitPrivacyAuthEvent({ ...validEvent(), [Symbol('audit')]: 'payload' })).toThrow( + 'Privacy auth event contains forbidden fields.', + ); + expect(messages).toEqual([]); + } finally { + console.info = originalInfo; + } + }); + + test('rejects values outside the closed enums', () => { + expect(() => emitPrivacyAuthEvent(validEvent({ event: 'custom' as PrivacyAuthEvent['event'] }))).toThrow( + 'Invalid privacy auth event event.', + ); + expect(() => emitPrivacyAuthEvent(validEvent({ provider: 'google' as PrivacyAuthEvent['provider'] }))).toThrow( + 'Invalid privacy auth event provider.', + ); + expect(() => emitPrivacyAuthEvent(validEvent({ outcomeReason: 'success' as PrivacyAuthEvent['outcomeReason'] }))).toThrow( + 'Invalid privacy auth event outcomeReason.', + ); + }); + + test('formats timestamps deterministically at the UTC minute', () => { + expect(formatPrivacyAuthUtcMinute(new Date('2026-08-02T23:59:59.999-07:00'))).toBe('2026-08-03T06:59:00.000Z'); + expect(() => formatPrivacyAuthUtcMinute(new Date('invalid'))).toThrow( + 'Privacy auth event timestamp must be a valid Date.', + ); + }); +}); diff --git a/apps/web/tests-unit/privacy-roster-classification.test.ts b/apps/web/tests-unit/privacy-roster-classification.test.ts new file mode 100644 index 0000000000..75340dbb06 --- /dev/null +++ b/apps/web/tests-unit/privacy-roster-classification.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + classifyPrivacyRoster, + type RosterClassificationDependencies, + type StoredRosterClassification, +} from '@/lib/privacy/roster-classification'; +import type { CurrentPrivacyEligibility } from '@/lib/privacy/eligibility'; + +const roster = Array.from( + { length: 16 }, + (_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, +); + +function eligibility( + reasonCode: CurrentPrivacyEligibility['reasonCode'], +): CurrentPrivacyEligibility { + return { + eligible: reasonCode === 'PRIVACY_ELIGIBLE', + reasonCode, + receipt: null, + }; +} + +function createDependencies(receipts: readonly (CurrentPrivacyEligibility | Error)[]) { + const durable = new Map(); + let lookups = 0; + const key = (batchId: string, userId: string) => `${batchId}:${userId}`; + const dependencies: RosterClassificationDependencies = { + getCurrentPrivacyEligibilityForUser: async () => { + const receipt = receipts[lookups++]; + if (receipt instanceof Error) throw receipt; + return receipt ?? eligibility(null); + }, + sink: { + get: async (batchId, userId) => durable.get(key(batchId, userId)) ?? null, + putIfAbsent: async (result) => { + const existing = durable.get(key(result.batchId, result.userId)); + if (existing) return { inserted: false, classification: existing }; + durable.set(key(result.batchId, result.userId), result); + return { inserted: true, classification: result }; + }, + }, + }; + + return { dependencies, getLookups: () => lookups }; +} + +describe('classifyPrivacyRoster', () => { + test('rejects malformed, duplicate, and non-16 manifests', async () => { + const { dependencies } = createDependencies([]); + + await expect(classifyPrivacyRoster('batch-1', roster.slice(0, 15), dependencies)).rejects.toThrow('exactly 16'); + await expect(classifyPrivacyRoster('batch-1', [...roster.slice(0, 15), roster[0]], dependencies)).rejects.toThrow('unique'); + await expect(classifyPrivacyRoster('batch-1', [...roster.slice(0, 15), 'not-a-uuid'], dependencies)).rejects.toThrow('UUIDs'); + }); + + test('conserves exactly sixteen opaque outcomes', async () => { + const receipts = [ + ...Array.from({ length: 5 }, () => eligibility('PRIVACY_ELIGIBLE')), + ...Array.from({ length: 4 }, () => eligibility('PRIVACY_POLICY_REATTESTATION_REQUIRED')), + ...Array.from({ length: 3 }, () => eligibility('PRIVACY_GUARDIAN_REQUIRED')), + ...Array.from({ length: 3 }, () => eligibility(null)), + new Error('RPC unavailable'), + ]; + const { dependencies } = createDependencies(receipts); + + const result = await classifyPrivacyRoster('batch-conservation', roster, dependencies); + + expect(result.counts).toEqual({ + already_current_eligible: 5, + needs_user_onboarding: 4, + held: 3, + failed: 4, + }); + expect(Object.values(result.counts).reduce((sum, count) => sum + count, 0)).toBe(16); + expect(result.subjects).toHaveLength(16); + expect(JSON.stringify(result)).not.toContain(roster[0]); + for (const subject of result.subjects) { + expect(subject.subjectDigest).toMatch(/^[a-f0-9]{64}$/); + expect(subject.receiptDigest).toMatch(/^[a-f0-9]{64}$/); + expect(subject.resultDigest).toMatch(/^[a-f0-9]{64}$/); + } + }); + + test('replays durable results without re-reading eligibility or overwriting them', async () => { + const { dependencies, getLookups } = createDependencies( + Array.from({ length: 16 }, () => eligibility('PRIVACY_ELIGIBLE')), + ); + + const first = await classifyPrivacyRoster('batch-replay', roster, dependencies); + const replay = await classifyPrivacyRoster('batch-replay', roster, dependencies); + + expect(replay).toEqual(first); + expect(getLookups()).toBe(16); + }); + + test('maps held receipts and reader errors fail closed', async () => { + const { dependencies } = createDependencies([ + eligibility('PRIVACY_AGE_BLOCKED'), + new Error('RPC unavailable'), + ...Array.from({ length: 14 }, () => eligibility('PRIVACY_AGE_ATTESTATION_REQUIRED')), + ]); + + const result = await classifyPrivacyRoster('batch-held', roster, dependencies); + + expect(result.subjects[0]?.classification).toBe('held'); + expect(result.subjects[1]?.classification).toBe('failed'); + expect(result.counts).toEqual({ + already_current_eligible: 0, + needs_user_onboarding: 14, + held: 1, + failed: 1, + }); + }); + + test('has no privacy mutation surface', () => { + const source = fs.readFileSync( + path.resolve(import.meta.dir, '../lib/privacy/roster-classification.ts'), + 'utf8', + ); + + expect(source).toContain("typeof window !== 'undefined'"); + expect(source).toContain('getCurrentPrivacyEligibilityForUser'); + expect(source).toContain('putIfAbsent'); + expect(source).not.toMatch(/\b(?:insert|upsert|delete)\w*\s*\(/i); + expect(source).not.toMatch(/\b(?:consent|guardian|marketing|age|profile)\w*\s*[:=]/i); + }); +}); diff --git a/docs/operations/privacy-auth-fallback-receipt.json b/docs/operations/privacy-auth-fallback-receipt.json new file mode 100644 index 0000000000..943fec9cf0 --- /dev/null +++ b/docs/operations/privacy-auth-fallback-receipt.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "recordedAt": "2026-08-02T15:04:00Z", + "purpose": "Immutable receipt-only web fallback candidate for privacy-auth recovery", + "source": { + "repository": "twoimo/tzudong", + "commit": "0b6e99309f686d01d84110b013352c4ec8266dd0", + "pullRequest": 2463 + }, + "deployment": { + "provider": "vercel", + "id": "dpl_9Er9ZvU54Rq7C7Swnq6C57k6t1fm", + "url": "tzudong-lu5y1uul7-twoimos-projects.vercel.app", + "target": "production", + "state": "READY", + "createdAtUnixMs": 1785694153488 + }, + "compatibility": { + "admissionModel": "current-policy schema-v1 live eligibility receipt", + "migrationVersion": "20260801000100", + "migrationManifestSha256": "bba79f264f26158d2fd93f62a0632f44ff8a0575619b50928e23ecefccf8ab95", + "policyVersion": "g010-recovery-2026-07-12.1", + "productionReadback": { + "migrationApplied": true, + "currentPolicyPublished": true, + "policyContentSha256": "55cc4bd20d66c1c34e43d2d9c5427926040fbc0b8b17802d373ea36ca98f8ab3", + "authRouteStatus": 401, + "accountDeleteRouteStatus": 401, + "privacyConsentsRouteStatus": 401, + "onboardingUnauthenticatedStatus": 401, + "homepageStatus": 200 + }, + "focusedVerification": { + "backend": "263 passed, 15 skipped", + "webAuthPrivacy": "79 passed, 1 skipped", + "webLint": "passed", + "webTypecheck": "passed", + "webProductionBuild": "passed" + } + }, + "rollbackBoundary": "Web fallback only. Never replay G016, alter migration history, disable RLS, or manufacture consent, age, guardian, marketing, policy approval, or operator evidence.", + "limitations": [ + "This receipt does not prove Datadog drain or monitor configuration.", + "This receipt does not authorize migration replay or policy publication.", + "This receipt does not replace controlled password and Google canary evidence." + ] +} diff --git a/docs/operations/privacy-auth-recovery-runbook.md b/docs/operations/privacy-auth-recovery-runbook.md new file mode 100644 index 0000000000..987c180a7e --- /dev/null +++ b/docs/operations/privacy-auth-recovery-runbook.md @@ -0,0 +1,103 @@ +# Privacy-auth recovery runbook + +## Purpose and release boundary + +This runbook governs the observability and operational-recovery portion of the privacy-auth recovery release. It is a deployment gate, not evidence that a deployment, log drain, Datadog configuration, consent, age verification, guardian authorization, legal approval, or provider migration has occurred. + +Only receipt-only eligibility may admit a user. Malformed, stale, mismatched, withdrawn, expired-guardian, or unavailable eligibility evidence fails closed. Roster work is classification-only and must not create or overwrite eligibility, profile, consent, age, guardian, or marketing facts. + +## Configuration contract + +Complete the following in the approved production organization; do not substitute similarly named resources. + +| Item | Required value | Local configuration status | Required external proof | +| --- | --- | --- | --- | +| Vercel project and log-drain name | `privacy-auth-recovery-v1` | Not established by this runbook | Vercel project and drain configuration showing the exact name, destination, and successful delivery | +| Datadog log index | `vercel_privacy_auth_recovery` | Not established by this runbook | Datadog index configuration and a redacted event visible in that index | +| Datadog retention | 30 days | Not established by this runbook | Index retention setting or approved retention record | +| Credential reference | `OPERATOR-SUPPLIED: ` | Placeholder only; no credential or receipt is present | Credential-manager reference, authorized scope review, and rotation owner; never paste a token into this document, shell history, Vercel, or Datadog evidence | +| Dashboard and saved query | `privacy-auth-recovery-v1` | Not established by this runbook | Dashboard URL/export and saved query result for the drain index | + +The operator must configure the Vercel Log Drain named `privacy-auth-recovery-v1` to the approved Datadog HTTPS intake routed to `vercel_privacy_auth_recovery`. Do not record the intake URL with credentials. Configure 30-day retention on that index. Missing drain delivery, index, retention, dashboard/query, monitor, or notification-routing proof blocks deployment. + +## Preflight gates + +Before provisioning, injection, canary, or promotion, collect and attach references (not secrets or raw telemetry) for all of the following: + +1. Privacy/legal approval for the current policy and the applicable DPA and retention terms for Datadog processing. +2. Platform on-call and Security on-call acknowledgement of monitor ownership, notification routes, escalation destination, and the 30-day retention setting. +3. Provider-owned G016/ledger/catalog terminal proof, current policy tuple, and a pinned compatible receipt-only fallback deployment. This runbook cannot replace those proofs. +4. An allowlisted server-event implementation only. Events may contain event name, UTC minute, build/commit, deployment ID, migration/manifest SHA, policy version/SHA, route class, provider, outcome/reason enum, correlation UUID, and approved opaque subject digest. Do not send raw email, user UUID, credentials, cookies, tokens, SQL, audit payloads, or other PII. + +Hard stop immediately on source, ledger, catalog, policy, or retention/DPA mismatch; missing approval; unavailable provider; nonzero role membership; duplicate or unbound G016; unpinned fallback; absent drain proof; unsafe version pair; prohibited telemetry; or any proposal to weaken RLS, bypass authorization, or fabricate user facts. + +## Dashboard and query + +Create the dashboard and saved query named `privacy-auth-recovery-v1` using only `vercel_privacy_auth_recovery`. Scope panels to the recovery event allowlist and show, by deployment/build and route class: + +- count of `42501` and privacy-audit-write-failure outcomes; +- catalog-drift and roster-conservation outcomes; +- authentication starts, failures, and failure rate, excluding `onboarding_required`; +- callback starts, failures, and failure rate, excluding `onboarding_required`; +- eligibility error and policy-drift rate, with an alerting review threshold of more than 0.5% over at least 20 checks; +- drain delivery and event-field redaction inspection results. + +The saved query must preserve the denominator used for rates, exclude `onboarding_required` from auth and callback failure numerators, and group by opaque correlation only. Do not make a dashboard panel or query a substitute for a monitor receipt. + +## Required monitors + +Create exactly these six monitors with the listed names and thresholds. Attach Platform and Security on-call routing and retain a redacted monitor configuration/export as external proof. “Block” means no promotion; “hold” means pause promotion and investigate; “page” means page the assigned on-call route. + +| Monitor | Condition | Required action | +| --- | --- | --- | +| `privacy-workflow-42501-v1` | Any privacy-workflow `42501` in a rolling 5-minute window | Page; block promotion and preserve redacted evidence. | +| `privacy-audit-write-failure-v1` | Any privacy-audit write failure in a rolling 5-minute window | Page; block promotion and preserve redacted evidence. | +| `privacy-catalog-drift-v1` | Any catalog-drift event | Block promotion; escalate for catalog/RLS/definer review. | +| `privacy-roster-conservation-v1` | Any roster count mismatch (classifications must sum to exactly 16) | Block promotion; do not repair by mutating eligibility or consent data. | +| `privacy-auth-failure-rate-v1` | Auth failure rate greater than 2% over 15 minutes with at least 20 auth starts; exclude `onboarding_required` | Hold promotion and investigate. | +| `privacy-callback-failure-rate-v1` | Callback failure rate greater than 2% over 15 minutes with at least 20 callback starts; exclude `onboarding_required` | Hold promotion and investigate. | + +The dashboard query also requires review of eligibility-error or policy-drift rate greater than 0.5% with at least 20 eligibility checks. Treat a breach as a release hold and fail-closed eligibility condition; do not invent a seventh named monitor without separately approved scope. + +## Redacted non-production injection procedure + +Use a non-production project and non-production Datadog routing only. Never inject into production to validate alerting. + +1. Confirm the non-production drain/index are isolated from production, the 30-day retention/DPA checks are recorded for the target, and the event emitter allowlist is deployed. +2. Generate one synthetic `42501` event and one synthetic catalog-drift event through the approved server-side event path. Use a new opaque test correlation value and enum-only fields; do not use a real user identifier, email, cookie, token, credential, SQL, or audit payload. +3. Verify in Vercel Runtime Logs that each event is structured and redacted, then verify delivery to the target Datadog index. +4. Verify the `privacy-auth-recovery-v1` dashboard/query renders both events, the matching monitors transition as configured, and Platform/Security on-call receive the expected test notification. +5. Capture redacted Vercel delivery, Datadog index/query, dashboard, monitor-state, and on-call-routing receipts. Mark them as non-production synthetic evidence. +6. Delete only the synthetic test correlation reference from working notes after evidence capture. Do not delete Datadog or Vercel records outside the approved retention process. + +Injection failure, unredacted payload, missing delivery, missing alert, or misrouted notification blocks promotion. Correct configuration and repeat only the synthetic non-production procedure. + +## Production promotion and stabilization + +Promotion requires all external proofs in the checklist below; completed local configuration alone is insufficient. During controlled production canaries, validate password and Google outcomes, receipt/protected access, incomplete-user denial/onboarding, drain delivery, alert routing, and redaction. Do not treat an OAuth callback as proof of fresh identity and never delete, ban, or hold an OAuth identity based on callback timing or shape. + +Maintain a 60-minute stabilization window after canary evidence begins. Any page, block, hold condition, missing receipt, or fail-closed eligibility error stops promotion. + +## Evidence checklist + +Record references, owners, timestamps, and redacted exports for: + +- Vercel project and exact `privacy-auth-recovery-v1` drain configuration and delivery; +- Datadog organization, `vercel_privacy_auth_recovery` index, and 30-day retention proof; +- operator-supplied least-privilege credential-manager reference, scope review, and rotation owner (never the credential); +- DPA/retention approval and Platform/Security on-call acknowledgement and routing test; +- `privacy-auth-recovery-v1` dashboard and saved-query proof; +- all six monitor configurations, thresholds, state transitions, and notification receipts; +- redacted non-production `42501` and drift injection evidence; +- production canary evidence for password, Google, protected access, incomplete denial/onboarding, drain delivery, redaction, and 60-minute stabilization; +- immutable release, provider/ledger/catalog, policy, roster, and pinned fallback receipts required by the release gate. + +Absence of any item is an unresolved external-proof gap, not a completed local configuration. + +## Rollback and hard-stop boundaries + +Do not bypass a failed gate to restore login. For canary, telemetry, dashboard, monitor, routing, DPA/retention, or redaction failure: halt promotion, retain redacted evidence, notify Platform and Security on-call, and use only the pinned compatible receipt-only fallback deployment. + +Before or during failed G016, the provider rolls back its transaction and supplies terminal zero-membership readback; do not retry ambiguously. After G016, do not recreate roles, delete history, disable RLS/FORCE RLS, add an automatic successor, or use a direct/app/psql replay. Only a newly reviewed provider-owned forward remedy may proceed. + +For policy or roster failure, stop or retry only idempotent classification; unsupported users remain gated. Never create eligibility, consent, age, guardian, or marketing state to clear an alert. Escalate source/ledger/catalog, RLS/definer, policy/legal, and telemetry issues to their designated owners before resuming promotion. From f1018f294bfefd7f2d1cd31f6f56b97f5f754a25 Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 3 Aug 2026 04:31:43 +0900 Subject: [PATCH 2/9] test(auth): cover privacy recovery contracts --- .../tests-unit/privacy-auth-receipts.test.ts | 77 +++++++++ .../privacy-auth-route-classifier.test.ts | 104 ++++++++++++ .../privacy-auth-state-machine.test.ts | 120 ++++++++++++++ apps/web/tests/privacy-auth-recovery.spec.ts | 151 ++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 apps/web/tests-unit/privacy-auth-receipts.test.ts create mode 100644 apps/web/tests-unit/privacy-auth-route-classifier.test.ts create mode 100644 apps/web/tests-unit/privacy-auth-state-machine.test.ts create mode 100644 apps/web/tests/privacy-auth-recovery.spec.ts diff --git a/apps/web/tests-unit/privacy-auth-receipts.test.ts b/apps/web/tests-unit/privacy-auth-receipts.test.ts new file mode 100644 index 0000000000..e92512cc9b --- /dev/null +++ b/apps/web/tests-unit/privacy-auth-receipts.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test'; + +import { + getCurrentPrivacyEligibility, + hasLivePrivacyEligibilityReceipt, + parsePrivacyEligibilityReceipt, +} from '@/lib/privacy/eligibility'; +import { + PRIVACY_POLICY_CONTENT_SHA256, + PRIVACY_POLICY_VERSION, +} from '@/lib/privacy/policy'; + +const POLICY_ID = '11111111-1111-4111-8111-111111111111'; + +function receipt(overrides: Record = {}) { + return { + schemaVersion: 1, + eligible: true, + reasonCode: 'PRIVACY_ELIGIBLE', + policyVersionId: POLICY_ID, + policyVersion: PRIVACY_POLICY_VERSION, + contentSha256: PRIVACY_POLICY_CONTENT_SHA256, + ...overrides, + }; +} + +function rpcClient(data: unknown, error: unknown = null) { + return { rpc: async () => ({ data, error }) }; +} + +describe('privacy eligibility receipts', () => { + test('admits only a schema-v1 receipt bound to the current policy', () => { + const parsed = parsePrivacyEligibilityReceipt(receipt()); + expect(parsed).not.toBeNull(); + expect(hasLivePrivacyEligibilityReceipt({ + eligible: true, + reasonCode: 'PRIVACY_ELIGIBLE', + receipt: parsed, + })).toBe(true); + + for (const malformed of [ + receipt({ schemaVersion: 2 }), + receipt({ policyVersion: 'retired-policy' }), + receipt({ policyVersionId: 'not-a-uuid' }), + receipt({ extra: true }), + receipt({ reasonCode: 'PRIVACY_GUARDIAN_CONSENT_REQUIRED', eligible: true }), + ]) { + expect(parsePrivacyEligibilityReceipt(malformed)).toBeNull(); + } + + const stale = parsePrivacyEligibilityReceipt(receipt({ contentSha256: 'b'.repeat(64) })); + expect(stale).not.toBeNull(); + expect(hasLivePrivacyEligibilityReceipt({ + eligible: true, + reasonCode: 'PRIVACY_ELIGIBLE', + receipt: stale, + })).toBe(false); + }); + + test('fails closed for stale, guardian-expired, withdrawn, malformed, and RPC-error receipts', async () => { + const deniedReceipts = [ + receipt({ eligible: false, reasonCode: 'PRIVACY_POLICY_REATTESTATION_REQUIRED' }), + receipt({ eligible: false, reasonCode: 'PRIVACY_GUARDIAN_CONSENT_REQUIRED' }), + receipt({ eligible: false, reasonCode: 'PRIVACY_AGE_BLOCKED' }), + { schemaVersion: 1, eligible: true }, + ]; + + for (const value of deniedReceipts) { + const eligibility = await getCurrentPrivacyEligibility(rpcClient(value) as never); + expect(eligibility.eligible).toBe(false); + expect(hasLivePrivacyEligibilityReceipt(eligibility)).toBe(false); + } + + const unavailable = await getCurrentPrivacyEligibility(rpcClient(receipt(), { message: 'RPC unavailable' }) as never); + expect(unavailable).toEqual({ eligible: false, reasonCode: null, receipt: null }); + }); +}); diff --git a/apps/web/tests-unit/privacy-auth-route-classifier.test.ts b/apps/web/tests-unit/privacy-auth-route-classifier.test.ts new file mode 100644 index 0000000000..d32ecb29f3 --- /dev/null +++ b/apps/web/tests-unit/privacy-auth-route-classifier.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from 'bun:test'; +import { + classifyPublicEligibilitySessionRoute, + type PublicEligibilitySessionRouteClass, +} from '@/lib/auth/public-eligibility-session'; + +function classify(path: string, method = 'GET'): PublicEligibilitySessionRouteClass { + const url = new URL(path, 'http://localhost:3000'); + return classifyPublicEligibilitySessionRoute({ + pathname: url.pathname, + method, + }); +} + +const readMethods = ['GET', 'HEAD'] as const; +const writeMethods = ['POST', 'PUT'] as const; + +test('admits only literal credentialless public routes for GET and HEAD', () => { + for (const path of [ + '/', + '/home-frame', + '/stamp', + '/privacy', + '/data-deletion', + '/api/health', + '/api/shorten', + ]) { + for (const method of readMethods) { + expect(classify(path, method)).toBe('credentialless-public'); + } + for (const method of writeMethods) { + expect(classify(path, method)).toBe('protected'); + } + } +}); + +test('admits only declared loop-safe recovery and onboarding routes', () => { + for (const path of [ + '/privacy/onboarding', + '/auth/required?reason=eligibility&next=%2Fmypage', + '/auth/reset-password?code=once-only-recovery-token&type=recovery', + ]) { + for (const method of readMethods) { + expect(classify(path, method)).toBe('loop-safe'); + } + for (const method of writeMethods) { + expect(classify(path, method)).toBe('protected'); + } + } + + expect(classify('/auth/callback', 'GET')).toBe('loop-safe'); + expect(classify('/auth/callback', 'HEAD')).toBe('protected'); + expect(classify('/auth/callback', 'POST')).toBe('protected'); + expect(classify('/auth/callback', 'PUT')).toBe('protected'); + + expect(classify('/api/privacy/onboarding', 'GET')).toBe('loop-safe'); + expect(classify('/api/privacy/onboarding', 'POST')).toBe('loop-safe'); + expect(classify('/api/privacy/onboarding', 'HEAD')).toBe('protected'); + expect(classify('/api/privacy/onboarding', 'PUT')).toBe('protected'); + expect(classify('/api/auth/logout', 'POST')).toBe('loop-safe'); + expect(classify('/api/auth/logout', 'GET')).toBe('protected'); +}); + +test('fails closed for encoded separators, repeated separators, and near-miss paths', () => { + for (const path of [ + '/api%2fhealth', + '/api%2Fhealth', + '/api%5chealth', + '/api%5Chealth', + '/api//health', + '/api/health/', + '/api/healthz', + '/api/shorten/extra', + '/auth%2freset-password', + '/auth%5crequired', + '/auth//required', + '/auth/required/', + '/auth/reset-password-confirm', + '/privacy/onboarding-extra', + ]) { + for (const method of [...readMethods, ...writeMethods]) { + expect(classify(path, method)).toBe('protected'); + } + } +}); + +test('queries, malformed query strings, and hostile next values never broaden access', () => { + for (const path of [ + '/admin?next=/auth/required', + '/mypage?next=https://attacker.example/auth/required', + '/api/healthz?next=//attacker.example', + '/auth/requiredly?next=%2Fauth%2Frequired', + '/api/health/extra?%', + ]) { + expect(classify(path)).toBe('protected'); + } + + expect( + classify('/auth/required?next=https://attacker.example/%2Fapi%2Fhealth'), + ).toBe('loop-safe'); + expect( + classify('/auth/reset-password?code=once-only-token&type=recovery&next=//attacker.example', 'HEAD'), + ).toBe('loop-safe'); +}); diff --git a/apps/web/tests-unit/privacy-auth-state-machine.test.ts b/apps/web/tests-unit/privacy-auth-state-machine.test.ts new file mode 100644 index 0000000000..7cd9ea0a0e --- /dev/null +++ b/apps/web/tests-unit/privacy-auth-state-machine.test.ts @@ -0,0 +1,120 @@ +import { afterAll, describe, expect, mock, test } from 'bun:test'; + +import { + classifyPublicEligibilitySessionRoute, + shouldSkipPublicEligibilitySession, +} from '@/lib/auth/public-eligibility-session'; +import { + consumePasswordRecoveryProof, + recordPasswordRecoveryProof, +} from '@/lib/auth/password-recovery-proof'; +import { + ONBOARDING_CHALLENGE_COOKIE, + sealOnboardingChallenge, +} from '@/lib/privacy/onboarding'; + +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const POLICY_ID = '11111111-1111-4111-8111-111111111111'; +const HASH = 'a'.repeat(64); + +let exchangeCalls = 0; +let signOutCalls = 0; +let confirmationCalls = 0; +let callbackUser: { id: string } | null = { id: USER_ID }; + +mock.module('@/lib/supabase/server', () => ({ + createClient: async () => ({ + auth: { + exchangeCodeForSession: async () => { + exchangeCalls += 1; + return { error: null }; + }, + getUser: async () => ({ data: { user: callbackUser }, error: null }), + signOut: async () => { + signOutCalls += 1; + return { error: null }; + }, + }, + }), +})); + +mock.module('@/lib/supabase/service-role', () => ({ + createSupabaseServiceRoleClient: () => ({ + rpc: async () => { + confirmationCalls += 1; + return { data: null, error: null }; + }, + }), +})); + +const previousSecret = process.env.PRIVACY_ONBOARDING_COOKIE_SECRET; +process.env.PRIVACY_ONBOARDING_COOKIE_SECRET = 'x'.repeat(32); +afterAll(() => { + if (previousSecret === undefined) delete process.env.PRIVACY_ONBOARDING_COOKIE_SECRET; + else process.env.PRIVACY_ONBOARDING_COOKIE_SECRET = previousSecret; +}); + +describe('privacy auth state machine', () => { + test('preserves incomplete sessions only on literal loop-safe routes and denies every near match', () => { + for (const { pathname, method } of [ + { pathname: '/auth/callback', method: 'GET' }, + { pathname: '/privacy/onboarding', method: 'HEAD' }, + { pathname: '/api/privacy/onboarding', method: 'POST' }, + { pathname: '/auth/reset-password', method: 'GET' }, + ]) { + expect(classifyPublicEligibilitySessionRoute({ pathname, method })).toBe('loop-safe'); + expect(shouldSkipPublicEligibilitySession({ pathname, method, hasSessionHint: true })).toBe(true); + } + + for (const { pathname, method } of [ + { pathname: '/auth/callback/', method: 'GET' }, + { pathname: '/privacy/onboarding', method: 'POST' }, + { pathname: '/api/privacy/onboarding/', method: 'POST' }, + { pathname: '/auth/reset-password', method: 'POST' }, + { pathname: '/privacy%2fonboarding', method: 'GET' }, + { pathname: '/mypage', method: 'GET' }, + ]) { + expect(classifyPublicEligibilitySessionRoute({ pathname, method })).toBe('protected'); + expect(shouldSkipPublicEligibilitySession({ pathname, method, hasSessionHint: true })).toBe(false); + } + }); + + test('makes a password-recovery proof user-bound and once-only', () => { + recordPasswordRecoveryProof(USER_ID); + expect(consumePasswordRecoveryProof('other-user')).toBe(false); + + recordPasswordRecoveryProof(USER_ID); + expect(consumePasswordRecoveryProof(USER_ID)).toBe(true); + expect(consumePasswordRecoveryProof(USER_ID)).toBe(false); + }); + + test('rejects an ambiguous OAuth identity before confirmation can mutate onboarding state', async () => { + exchangeCalls = 0; + signOutCalls = 0; + confirmationCalls = 0; + callbackUser = { id: 'not-a-uuid' }; + const challenge = sealOnboardingChallenge({ + version: 1, + challengeId: POLICY_ID, + challengeToken: HASH, + oauthNonce: HASH, + policyVersionId: POLICY_ID, + contentSha256: HASH, + ageBand: 'age_14_plus', + intent: 'oauth', + origin: 'http://localhost:3000', + expiresAt: Date.now() + 60_000, + }); + expect(challenge).not.toBeNull(); + + const { GET } = await import(`@/app/auth/callback/route?state-machine=${Date.now()}`); + const response = await GET(new Request('http://localhost:3000/auth/callback?code=provider-code', { + headers: { cookie: `${ONBOARDING_CHALLENGE_COOKIE}=${challenge}` }, + })); + + expect(response.headers.get('location')).toBe('http://localhost:3000/'); + expect(exchangeCalls).toBe(1); + expect(signOutCalls).toBe(2); + expect(confirmationCalls).toBe(0); + }); +}); diff --git a/apps/web/tests/privacy-auth-recovery.spec.ts b/apps/web/tests/privacy-auth-recovery.spec.ts new file mode 100644 index 0000000000..19141a2721 --- /dev/null +++ b/apps/web/tests/privacy-auth-recovery.spec.ts @@ -0,0 +1,151 @@ +import { expect, test, type Page } from '@playwright/test'; +import { hidePopupOverlay } from './helpers'; + +const POLICY_ID = '11111111-1111-4111-8111-111111111111'; +const POLICY_SHA = 'a'.repeat(64); + +async function mockCurrentPolicy(page: Page, onPost?: (body: Record) => void) { + await page.route('**/api/privacy/onboarding', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ id: POLICY_ID, contentSha256: POLICY_SHA }), + }); + return; + } + + const body = route.request().postDataJSON() as Record; + onPost?.(body); + if (body.action === 'password_signup') { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ emailConfirmationRequired: true }), + }); + return; + } + + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ oauthNonce: 'b'.repeat(64) }), + }); + }); +} + +async function openSignup(page: Page) { + await page.goto('/privacy/onboarding'); + await expect(page.getByRole('tab', { name: '회원가입' })).toHaveAttribute('data-state', 'active'); + await page.getByLabel('이메일', { exact: true }).fill('new@example.test'); + await page.getByLabel('비밀번호', { exact: true }).fill('password1'); + await page.getByLabel('비밀번호 확인').fill('password1'); + await page.locator('#signup-username').fill('new-user'); + await page.getByRole('radio', { name: '만 14세 이상입니다' }).check(); + await page.locator('#privacy-agree').click(); +} + +test.describe('privacy auth recovery browser contracts', () => { + test('new password signup binds a current-policy challenge before confirmation can proceed', async ({ page }) => { + const posts: Array> = []; + await mockCurrentPolicy(page, (body) => posts.push(body)); + await openSignup(page); + + await page.getByRole('button', { name: '회원가입' }).click(); + + await expect.poll(() => posts.length).toBe(1); + expect(posts[0]).toMatchObject({ + intent: 'password', + policyVersion: POLICY_ID, + ageBand: 'age_14_plus', + policyAcknowledged: true, + }); + }); + + test('new OAuth signup cannot start until its current-policy nonce challenge succeeds', async ({ page }) => { + const posts: Array> = []; + await mockCurrentPolicy(page, (body) => posts.push(body)); + await page.route('**/auth/v1/authorize**', async (route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + }); + await openSignup(page); + + await page.getByRole('button', { name: 'Google 개인정보 확인 계속하기' }).click(); + + await expect.poll(() => posts.length).toBe(1); + expect(posts[0]).toMatchObject({ + intent: 'oauth', + policyVersion: POLICY_ID, + ageBand: 'age_14_plus', + }); + }); + + test('existing password authentication failure does not fabricate a privacy recovery session', async ({ page }) => { + + await page.goto('/'); + await hidePopupOverlay(page); + await page.getByRole('button', { name: /로그인/i }).first().click(); + await page.locator('#login-email').fill('existing@example.test'); + await page.locator('#login-password').fill('password1'); + await page.getByRole('button', { name: '로그인', exact: true }).click(); + + await expect(page.getByRole('status')).toContainText('로그인에 실패했습니다'); + }); + + test('existing OAuth login does not mint a privacy onboarding challenge without a freshness signal', async ({ page }) => { + let onboardingPosts = 0; + await mockCurrentPolicy(page, () => { onboardingPosts += 1; }); + await page.route('**/auth/v1/authorize**', async (route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + }); + + await page.goto('/'); + await hidePopupOverlay(page); + await page.getByRole('button', { name: /로그인/i }).first().click(); + await page.getByRole('button', { name: 'Google로 계속하기' }).click(); + + await page.waitForTimeout(100); + expect(onboardingPosts).toBe(0); + }); + + test('ambiguous OAuth callbacks are rejected before provider exchange', async ({ page }) => { + let providerRequests = 0; + await page.route('**/auth/v1/**', async (route) => { + providerRequests += 1; + await route.abort(); + }); + + const response = await page.goto('/auth/callback?code=first&code=second'); + expect(response?.status()).toBe(200); + await expect(page).toHaveURL(/\/$/); + expect(providerRequests).toBe(0); + }); + + test('OAuth callback provider errors and nonce replay-shaped duplicate values are rejected', async ({ page }) => { + let providerRequests = 0; + await page.route('**/auth/v1/**', async (route) => { + providerRequests += 1; + await route.abort(); + }); + + await page.goto('/auth/callback?error=access_denied&code=replayed'); + await expect(page).toHaveURL(/\/$/); + await page.goto('/auth/callback?code=replayed&next=%2Fsafe&next=%2Fadmin'); + await expect(page).toHaveURL(/\/$/); + expect(providerRequests).toBe(0); + }); + + test('incomplete sessions keep the literal loop-safe onboarding route, but protected admin and API surfaces deny access', async ({ page, request }) => { + await page.goto('/privacy/onboarding'); + await expect(page).toHaveURL(/\/privacy\/onboarding$/); + + const apiResponse = await request.get('/api/privacy/consents'); + expect(apiResponse.status()).toBeGreaterThanOrEqual(400); + + const adminResponse = await request.get('/admin', { maxRedirects: 0 }); + expect([302, 307, 308, 401, 403, 503]).toContain(adminResponse.status()); + }); + + test('password recovery rejects ambiguous query authority before a password can be updated', async ({ page }) => { + await page.goto('/auth/reset-password?code=one&token=two&type=recovery'); + await expect(page.getByText(/이 페이지는 이메일로 받은 비밀번호 재설정 링크를 통해 접속해야/)).toBeVisible(); + await expect(page.getByRole('button', { name: '비밀번호 변경', exact: true })).toHaveCount(0); + }); +}); From 7363b9847144dd8a34f4c3ec95a063928f422aec Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 3 Aug 2026 05:00:23 +0900 Subject: [PATCH 3/9] fix(auth): close privacy recovery review gaps --- apps/web/app/api/privacy/onboarding/route.ts | 28 +++++ apps/web/app/auth/callback/route.ts | 50 +++++++- apps/web/components/auth/AuthModal.tsx | 5 + .../lib/observability/privacy-auth-events.ts | 58 +++++++++ apps/web/lib/privacy/roster-classification.ts | 102 +++++++++++++--- apps/web/lib/supabase/middleware.ts | 43 +++++++ .../oauth-onboarding-flow-binding.test.ts | 14 +++ .../privacy-auth-observability.test.ts | 56 +++++++++ .../privacy-roster-classification.test.ts | 114 +++++++++++++++++- .../privacy-auth-fallback-receipt.json | 47 -------- 10 files changed, 447 insertions(+), 70 deletions(-) create mode 100644 apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts delete mode 100644 docs/operations/privacy-auth-fallback-receipt.json diff --git a/apps/web/app/api/privacy/onboarding/route.ts b/apps/web/app/api/privacy/onboarding/route.ts index 5bb2a186c4..e5e743e582 100644 --- a/apps/web/app/api/privacy/onboarding/route.ts +++ b/apps/web/app/api/privacy/onboarding/route.ts @@ -35,6 +35,10 @@ import { import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; import { createClient } from '@/lib/supabase/server'; +import { + emitPrivacyAuthEventFromServerEnvironment, + type PrivacyAuthEventInput, +} from '@/lib/observability/privacy-auth-events'; export const runtime = 'nodejs'; @@ -53,6 +57,26 @@ const PASSWORD_RECOVERY_KEYS = [ 'origin', 'expiresAt', ] as const; +function emitOnboardingPrivacyAuthEvent( + outcomeReason: PrivacyAuthEventInput['outcomeReason'], + provider: PrivacyAuthEventInput['provider'], +) { + try { + emitPrivacyAuthEventFromServerEnvironment({ + event: 'onboarding', + policyVersion: PRIVACY_POLICY_VERSION, + policySha: PRIVACY_POLICY_CONTENT_SHA256, + routeClass: 'loop_safe_api', + provider, + outcomeReason, + correlationId: crypto.randomUUID(), + subjectDigest: null, + }); + } catch { + // Telemetry must not affect privacy onboarding. + } +} + type PasswordSignupRequest = { action: 'password_signup'; @@ -784,6 +808,10 @@ export async function POST(request: NextRequest) { const input = parseOnboardingStart(body); if (!input) return errorResponse('INVALID_ONBOARDING_REQUEST', 400, request); + emitOnboardingPrivacyAuthEvent( + 'onboarding_started', + input.intent === 'oauth' ? 'oauth' : 'password', + ); if (input.ageBand === 'under_14') { return under14SignupRejectedResponse(request); } diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index 8bf6edf316..80791866e7 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -14,6 +14,14 @@ import { import { getSafeAuthNextPath } from '@/lib/auth/auth-redirect'; import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; import { createClient } from '@/lib/supabase/server'; +import { + emitPrivacyAuthEventFromServerEnvironment, + type PrivacyAuthEventInput, +} from '@/lib/observability/privacy-auth-events'; +import { + PRIVACY_POLICY_CONTENT_SHA256, + PRIVACY_POLICY_VERSION, +} from '@/lib/privacy/policy'; export const runtime = 'nodejs'; @@ -27,12 +35,14 @@ const CALLBACK_QUERY_KEYS = new Set([ 'error', 'error_code', 'error_description', + 'flow', ]); type CallbackQuery = Readonly<{ code: string | null; next: string; providerError: boolean; + flow: string | null; }>; function parseCallbackQuery(searchParams: URLSearchParams): CallbackQuery | null { @@ -48,17 +58,24 @@ function parseCallbackQuery(searchParams: URLSearchParams): CallbackQuery | null } const code = searchParams.get('code'); + const flow = searchParams.get('flow'); const providerError = ['error', 'error_code', 'error_description'] .some((key) => searchParams.has(key)); if (providerError) { return code === null - ? { code: null, next: getSafeAuthNextPath(searchParams.get('next')), providerError: true } + ? { + code: null, + next: getSafeAuthNextPath(searchParams.get('next')), + providerError: true, + flow, + } : null; } if ( !code || code.length > MAX_OAUTH_CODE_LENGTH || /[\u0000-\u0020]/.test(code) + || (flow !== null && !/^[0-9a-f]{64}$/.test(flow)) ) { return null; } @@ -67,8 +84,26 @@ function parseCallbackQuery(searchParams: URLSearchParams): CallbackQuery | null code, next: getSafeAuthNextPath(searchParams.get('next')), providerError: false, + flow, }; } +function emitCallbackPrivacyAuthEvent(outcomeReason: PrivacyAuthEventInput['outcomeReason']) { + try { + emitPrivacyAuthEventFromServerEnvironment({ + event: 'auth_callback', + policyVersion: PRIVACY_POLICY_VERSION, + policySha: PRIVACY_POLICY_CONTENT_SHA256, + routeClass: 'loop_safe_api', + provider: 'oauth', + outcomeReason, + correlationId: crypto.randomUUID(), + subjectDigest: null, + }); + } catch { + // Telemetry must not affect OAuth callback handling. + } +} + type CallbackSupabaseClient = Awaited>; type OnboardingChallenge = NonNullable>; @@ -156,6 +191,7 @@ async function rejectOAuthCallbackSession(supabase: CallbackSupabaseClient) { export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); + emitCallbackPrivacyAuthEvent('callback_started'); const callback = parseCallbackQuery(searchParams); if (!callback || callback.providerError) return rejectedCallbackRedirect(request, origin); const challengeCookie = request.headers.get('cookie') @@ -165,7 +201,17 @@ export async function GET(request: Request) { ?.slice(ONBOARDING_CHALLENGE_COOKIE.length + 1); const challenge = readOnboardingChallenge(challengeCookie); if (challengeCookie && !challenge) return rejectedCallbackRedirect(request, origin); - const onboardingRequested = challenge?.intent === 'oauth'; + const onboardingRequested = callback.flow !== null; + if ( + onboardingRequested + && ( + challenge?.intent !== 'oauth' + || !challenge.oauthNonce + || sha256(challenge.oauthNonce) !== callback.flow + ) + ) { + return rejectedCallbackRedirect(request, origin); + } const { code, next } = callback; if (!code) return rejectedCallbackRedirect(request, origin); diff --git a/apps/web/components/auth/AuthModal.tsx b/apps/web/components/auth/AuthModal.tsx index 3382c24d36..2c5daae816 100644 --- a/apps/web/components/auth/AuthModal.tsx +++ b/apps/web/components/auth/AuthModal.tsx @@ -48,6 +48,10 @@ const generateRandomNickname = (): string => { const randomSuffix = String(Math.floor(Math.random() * 10000)).padStart(4, '0'); return `${randomPrefix}_${randomSuffix}`; }; +const sha256Hex = async (value: string): Promise => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +}; interface AuthModalProps { isOpen: boolean; @@ -385,6 +389,7 @@ const AuthModal = memo(({ isOpen, onClose, onAuthSuccess, redirectTo, reason, in if (isAdminRedirect) { callbackUrl.searchParams.set("next", safeRedirectTo); } + callbackUrl.searchParams.set("flow", await sha256Hex(challenge.oauthNonce)); const { error } = await supabase.auth.signInWithOAuth({ provider: "google", diff --git a/apps/web/lib/observability/privacy-auth-events.ts b/apps/web/lib/observability/privacy-auth-events.ts index 99b5866c02..5755281183 100644 --- a/apps/web/lib/observability/privacy-auth-events.ts +++ b/apps/web/lib/observability/privacy-auth-events.ts @@ -19,6 +19,9 @@ const PROVIDERS = ['password', 'oauth', 'session', 'none'] as const; const OUTCOME_REASONS = [ 'started', + 'auth_started', + 'callback_started', + 'onboarding_started', 'pending_email_confirmation', 'onboarding_required', 'admitted', @@ -28,7 +31,10 @@ const OUTCOME_REASONS = [ 'completed', 'already_current_eligible', 'needs_user_onboarding', + 'workflow_42501', 'audit_write_failed', + 'eligibility_error', + 'policy_drift', 'catalog_drift', 'roster_conservation_mismatch', 'release_verified', @@ -51,6 +57,58 @@ export type PrivacyAuthEvent = { export type EmittedPrivacyAuthEvent = PrivacyAuthEvent & { utcMinute: string; }; +export type PrivacyAuthEventInput = Omit< + PrivacyAuthEvent, + 'buildCommit' | 'deploymentId' | 'migrationManifestSha' +>; + +const SERVER_METADATA_ENVIRONMENT_KEYS = [ + 'VERCEL_GIT_COMMIT_SHA', + 'VERCEL_DEPLOYMENT_ID', + 'RELEASE_MIGRATION_MANIFEST_SHA256', +] as const; + +function serverMetadataFromEnvironment(environment: Record): Pick< + PrivacyAuthEvent, + 'buildCommit' | 'deploymentId' | 'migrationManifestSha' +> | null { + const [buildCommit, deploymentId, migrationManifestSha] = SERVER_METADATA_ENVIRONMENT_KEYS + .map((key) => environment[key]?.trim()); + + if ( + !buildCommit || !COMMIT_PATTERN.test(buildCommit) + || !deploymentId || !DEPLOYMENT_ID_PATTERN.test(deploymentId) + || !migrationManifestSha || !SHA256_PATTERN.test(migrationManifestSha) + ) { + return null; + } + + return { buildCommit, deploymentId, migrationManifestSha }; +} + +/** + * Best-effort server telemetry which never alters an auth/privacy decision. + * Invalid or unavailable deployment provenance is deliberately suppressed rather + * than represented by a synthetic value. + */ +export function emitPrivacyAuthEventFromServerEnvironment( + input: PrivacyAuthEventInput, + environment: Record = process.env, + now: Date = new Date(), +): EmittedPrivacyAuthEvent | null { + const metadata = serverMetadataFromEnvironment(environment); + if (!metadata) { + console.warn('privacy_auth_event_suppressed: invalid_server_metadata'); + return null; + } + + try { + return emitPrivacyAuthEvent({ ...metadata, ...input }, now); + } catch { + console.warn('privacy_auth_event_suppressed: invalid_event'); + return null; + } +} const EVENT_KEYS = [ 'event', diff --git a/apps/web/lib/privacy/roster-classification.ts b/apps/web/lib/privacy/roster-classification.ts index 3d4f1e2032..b6f4c3909c 100644 --- a/apps/web/lib/privacy/roster-classification.ts +++ b/apps/web/lib/privacy/roster-classification.ts @@ -1,5 +1,8 @@ -import { createHash } from 'node:crypto'; -import type { CurrentPrivacyEligibility } from '@/lib/privacy/eligibility'; +import { createHash, createHmac } from 'node:crypto'; +import { + hasLivePrivacyEligibilityReceipt, + type CurrentPrivacyEligibility, +} from '@/lib/privacy/eligibility'; if (typeof window !== 'undefined') { throw new Error('Privacy roster classification is server-only.'); @@ -23,6 +26,10 @@ export type StoredRosterClassification = Readonly<{ }>; export type RosterClassificationSink = Readonly<{ + bindManifestIfAbsent: (batchId: string, manifestDigest: string) => Promise>; get: (batchId: string, userId: string) => Promise; putIfAbsent: (result: StoredRosterClassification) => Promise Promise; sink: RosterClassificationSink; + subjectPseudonymKey: Uint8Array; }>; export type RosterClassificationResult = Readonly<{ @@ -48,6 +56,7 @@ export type RosterClassificationResult = Readonly<{ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SUBJECT_PSEUDONYM_PURPOSE = 'privacy-roster-classification:subject:v1'; const CLASSIFICATIONS = new Set([ 'already_current_eligible', 'needs_user_onboarding', @@ -59,22 +68,52 @@ function digest(value: string) { return createHash('sha256').update(value).digest('hex'); } +function subjectDigest(userId: string, key: Uint8Array) { + return createHmac('sha256', key) + .update(`${SUBJECT_PSEUDONYM_PURPOSE}:${userId}`) + .digest('hex'); +} + function receiptDigest(eligibility: CurrentPrivacyEligibility | null) { if (eligibility === null) return digest('eligibility-error'); + try { + return digest(JSON.stringify({ + eligible: eligibility.eligible, + reasonCode: eligibility.reasonCode, + receipt: eligibility.receipt, + })); + } catch { + return digest('eligibility-error'); + } +} + +function resultDigest(result: Pick< + StoredRosterClassification, + 'batchId' | 'userId' | 'classification' | 'subjectDigest' | 'receiptDigest' +>) { + const { + batchId, + userId, + classification, + subjectDigest: storedSubjectDigest, + receiptDigest: storedReceiptDigest, + } = result; return digest(JSON.stringify({ - eligible: eligibility.eligible, - reasonCode: eligibility.reasonCode, - receipt: eligibility.receipt, + batchId, + userId, + classification, + subjectDigest: storedSubjectDigest, + receiptDigest: storedReceiptDigest, })); } -function classifyEligibility(eligibility: CurrentPrivacyEligibility): RosterClassification { - if (eligibility.eligible === true && eligibility.reasonCode === 'PRIVACY_ELIGIBLE') { +function classifyEligibility(eligibility: CurrentPrivacyEligibility | null): RosterClassification { + if (hasLivePrivacyEligibilityReceipt(eligibility)) { return 'already_current_eligible'; } - switch (eligibility.reasonCode) { + switch (eligibility?.reasonCode) { case 'PRIVACY_AGE_ATTESTATION_REQUIRED': case 'PRIVACY_POLICY_REATTESTATION_REQUIRED': return 'needs_user_onboarding'; @@ -109,13 +148,24 @@ function validateManifest(batchId: string, userIds: readonly string[]) { return normalizedUserIds; } -function isStoredResult(value: StoredRosterClassification, batchId: string, userId: string) { +function manifestDigest(userIds: readonly string[]) { + return digest(JSON.stringify([...userIds].sort())); +} + +function isStoredResult( + value: StoredRosterClassification, + batchId: string, + userId: string, + pseudonymKey: Uint8Array, +) { return value.batchId === batchId && value.userId === userId && CLASSIFICATIONS.has(value.classification) && SHA256_PATTERN.test(value.subjectDigest) && SHA256_PATTERN.test(value.receiptDigest) - && SHA256_PATTERN.test(value.resultDigest); + && SHA256_PATTERN.test(value.resultDigest) + && value.subjectDigest === subjectDigest(userId, pseudonymKey) + && value.resultDigest === resultDigest(value); } function publicSubject(result: StoredRosterClassification) { @@ -133,6 +183,20 @@ export async function classifyPrivacyRoster( dependencies: RosterClassificationDependencies, ): Promise { const normalizedUserIds = validateManifest(batchId, userIds); + if (!(dependencies.subjectPseudonymKey instanceof Uint8Array) || dependencies.subjectPseudonymKey.byteLength === 0) { + throw new Error('A non-empty server-held subject pseudonym key is required.'); + } + + const canonicalManifestDigest = manifestDigest(normalizedUserIds); + const manifestBinding = await dependencies.sink.bindManifestIfAbsent(batchId, canonicalManifestDigest); + if ( + typeof manifestBinding.manifestDigest !== 'string' + || !SHA256_PATTERN.test(manifestBinding.manifestDigest) + || manifestBinding.manifestDigest !== canonicalManifestDigest + ) { + throw new Error('Durable roster batch manifest is invalid.'); + } + const counts: Record = { already_current_eligible: 0, needs_user_onboarding: 0, @@ -144,7 +208,7 @@ export async function classifyPrivacyRoster( for (const userId of normalizedUserIds) { const stored = await dependencies.sink.get(batchId, userId); if (stored !== null) { - if (!isStoredResult(stored, batchId, userId)) { + if (!isStoredResult(stored, batchId, userId, dependencies.subjectPseudonymKey)) { throw new Error('Durable roster classification is invalid.'); } counts[stored.classification] += 1; @@ -159,20 +223,26 @@ export async function classifyPrivacyRoster( eligibility = null; } - const classification = eligibility === null ? 'failed' : classifyEligibility(eligibility); + const classification = classifyEligibility(eligibility); const evidenceDigest = receiptDigest(eligibility); - const subjectDigest = digest(userId); + const pseudonym = subjectDigest(userId, dependencies.subjectPseudonymKey); const result: StoredRosterClassification = { batchId, userId, classification, - subjectDigest, + subjectDigest: pseudonym, receiptDigest: evidenceDigest, - resultDigest: digest(`${batchId}:${userId}:${classification}:${evidenceDigest}`), + resultDigest: resultDigest({ + batchId, + userId, + classification, + subjectDigest: pseudonym, + receiptDigest: evidenceDigest, + }), }; const persisted = await dependencies.sink.putIfAbsent(result); - if (!isStoredResult(persisted.classification, batchId, userId)) { + if (!isStoredResult(persisted.classification, batchId, userId, dependencies.subjectPseudonymKey)) { throw new Error('Durable roster classification is invalid.'); } counts[persisted.classification.classification] += 1; diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts index b08691ae10..181fe0be7f 100644 --- a/apps/web/lib/supabase/middleware.ts +++ b/apps/web/lib/supabase/middleware.ts @@ -17,6 +17,14 @@ import { import { classifyPublicEligibilitySessionRoute } from '@/lib/auth/public-eligibility-session' import { hasSupabaseAuthCookieSessionHint } from '@/lib/supabase-auth-session-hints' +import { + emitPrivacyAuthEventFromServerEnvironment, + type PrivacyAuthEventInput, +} from '@/lib/observability/privacy-auth-events' +import { + PRIVACY_POLICY_CONTENT_SHA256, + PRIVACY_POLICY_VERSION, +} from '@/lib/privacy/policy' const isSupabaseAuthCookie = (name: string) => @@ -38,6 +46,33 @@ const isEligibilityExemptRequest = (request: NextRequest) => method: request.method, }) === 'loop-safe'; const isApiRequest = (request: NextRequest) => request.nextUrl.pathname.startsWith('/api/'); +const telemetryRouteClass = (request: NextRequest): PrivacyAuthEventInput['routeClass'] => + isEligibilityExemptRequest(request) + ? (isApiRequest(request) ? 'loop_safe_api' : 'loop_safe_page') + : isProtectedAdminRequest(request) || isMyPageRequest(request) + ? 'protected' + : isApiRequest(request) + ? 'public_api' + : 'public_page'; +const emitMiddlewarePrivacyAuthEvent = ( + request: NextRequest, + outcomeReason: PrivacyAuthEventInput['outcomeReason'], +) => { + try { + emitPrivacyAuthEventFromServerEnvironment({ + event: 'middleware', + policyVersion: PRIVACY_POLICY_VERSION, + policySha: PRIVACY_POLICY_CONTENT_SHA256, + routeClass: telemetryRouteClass(request), + provider: 'session', + outcomeReason, + correlationId: crypto.randomUUID(), + subjectDigest: null, + }); + } catch { + // Telemetry must not affect privacy enforcement. + } +}; const isProtectedAdminRequest = (request: NextRequest) => { const { pathname } = request.nextUrl; @@ -176,6 +211,9 @@ export async function updateSession( } const hasAuthCookie = hasSupabaseAuthCookieSessionHint(request.headers.get('cookie') ?? undefined); + if (hasAuthCookie) { + emitMiddlewarePrivacyAuthEvent(request, 'auth_started'); + } const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(); @@ -228,6 +266,7 @@ export async function updateSession( } if (hasAuthCookie && (authFailed || !authUserId)) { + emitMiddlewarePrivacyAuthEvent(request, 'eligibility_error'); await signOutRejectedPrivacySession(supabase); return eligibilityFailureResponse(request, supabaseResponse); } @@ -235,6 +274,10 @@ export async function updateSession( if (authUserId) { const eligibility = await getCurrentPrivacyEligibility(supabase); if (!hasLivePrivacyEligibilityReceipt(eligibility)) { + emitMiddlewarePrivacyAuthEvent( + request, + eligibility.reasonCode === 'PRIVACY_POLICY_UNAVAILABLE' ? 'policy_drift' : 'denied', + ); await signOutRejectedPrivacySession(supabase); return eligibilityFailureResponse(request, supabaseResponse); } diff --git a/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts b/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts new file mode 100644 index 0000000000..9365b1c99d --- /dev/null +++ b/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'bun:test'; + +test('OAuth signup callbacks are transaction-bound without direct profile authority', async () => { + const modalSource = await Bun.file('components/auth/AuthModal.tsx').text(); + const callbackSource = await Bun.file('app/auth/callback/route.ts').text(); + + expect(modalSource).toContain('callbackUrl.searchParams.set("flow", await sha256Hex(challenge.oauthNonce))'); + expect(callbackSource).toContain("'flow',"); + expect(callbackSource).toContain("!/^[0-9a-f]{64}$/.test(flow)"); + expect(callbackSource).toContain("const onboardingRequested = callback.flow !== null"); + expect(callbackSource).toContain('sha256(challenge.oauthNonce) !== callback.flow'); + expect(callbackSource).not.toContain(".from('privacy_age_profiles'"); + expect(callbackSource).not.toContain('isPrivacyProfileStatusAllowed'); +}); diff --git a/apps/web/tests-unit/privacy-auth-observability.test.ts b/apps/web/tests-unit/privacy-auth-observability.test.ts index d4ac4df0d2..fac5d00afa 100644 --- a/apps/web/tests-unit/privacy-auth-observability.test.ts +++ b/apps/web/tests-unit/privacy-auth-observability.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { emitPrivacyAuthEvent, + emitPrivacyAuthEventFromServerEnvironment, formatPrivacyAuthUtcMinute, type PrivacyAuthEvent, } from '../lib/observability/privacy-auth-events'; @@ -86,6 +89,59 @@ describe('privacy auth observability', () => { 'Invalid privacy auth event outcomeReason.', ); }); + test('allows the closed recovery monitor outcomes', () => { + for (const outcomeReason of [ + 'auth_started', + 'callback_started', + 'onboarding_started', + 'workflow_42501', + 'audit_write_failed', + 'eligibility_error', + 'policy_drift', + 'catalog_drift', + 'roster_conservation_mismatch', + ] as const) { + expect(() => emitPrivacyAuthEvent(validEvent({ outcomeReason }))).not.toThrow(); + } + }); + + test('uses validated server metadata and suppresses missing provenance without event data', () => { + const infos: unknown[] = []; + const warnings: unknown[] = []; + const originalInfo = console.info; + const originalWarn = console.warn; + console.info = (message: unknown) => infos.push(message); + console.warn = (message: unknown) => warnings.push(message); + + try { + const { buildCommit, deploymentId, migrationManifestSha, ...input } = validEvent(); + expect(emitPrivacyAuthEventFromServerEnvironment(input, { + VERCEL_GIT_COMMIT_SHA: buildCommit, + VERCEL_DEPLOYMENT_ID: deploymentId, + RELEASE_MIGRATION_MANIFEST_SHA256: migrationManifestSha, + })).toMatchObject({ buildCommit, migrationManifestSha }); + expect(emitPrivacyAuthEventFromServerEnvironment(input, {})).toBeNull(); + expect(infos).toHaveLength(1); + expect(warnings).toEqual(['privacy_auth_event_suppressed: invalid_server_metadata']); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + }); + test('wires fail-safe no-PII telemetry at callback, onboarding, and middleware transitions', () => { + const root = resolve(import.meta.dir, '..'); + const callback = readFileSync(resolve(root, 'app/auth/callback/route.ts'), 'utf8'); + const onboarding = readFileSync(resolve(root, 'app/api/privacy/onboarding/route.ts'), 'utf8'); + const middleware = readFileSync(resolve(root, 'lib/supabase/middleware.ts'), 'utf8'); + + expect(callback).toContain("emitCallbackPrivacyAuthEvent('callback_started')"); + expect(callback).toContain("subjectDigest: null"); + expect(onboarding).toContain("emitOnboardingPrivacyAuthEvent("); + expect(onboarding).toContain("'onboarding_started'"); + expect(middleware).toContain("emitMiddlewarePrivacyAuthEvent(request, 'auth_started')"); + expect(middleware).toContain("'eligibility_error'"); + expect(middleware).toContain('Telemetry must not affect privacy enforcement.'); + }); test('formats timestamps deterministically at the UTC minute', () => { expect(formatPrivacyAuthUtcMinute(new Date('2026-08-02T23:59:59.999-07:00'))).toBe('2026-08-03T06:59:00.000Z'); diff --git a/apps/web/tests-unit/privacy-roster-classification.test.ts b/apps/web/tests-unit/privacy-roster-classification.test.ts index 75340dbb06..1f74cceb1e 100644 --- a/apps/web/tests-unit/privacy-roster-classification.test.ts +++ b/apps/web/tests-unit/privacy-roster-classification.test.ts @@ -7,11 +7,16 @@ import { type StoredRosterClassification, } from '@/lib/privacy/roster-classification'; import type { CurrentPrivacyEligibility } from '@/lib/privacy/eligibility'; +import { + PRIVACY_POLICY_CONTENT_SHA256, + PRIVACY_POLICY_VERSION, +} from '@/lib/privacy/policy'; const roster = Array.from( { length: 16 }, (_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, ); +const pseudonymKey = new TextEncoder().encode('server-held-roster-pseudonym-key'); function eligibility( reasonCode: CurrentPrivacyEligibility['reasonCode'], @@ -23,8 +28,27 @@ function eligibility( }; } -function createDependencies(receipts: readonly (CurrentPrivacyEligibility | Error)[]) { +function liveEligibility(): CurrentPrivacyEligibility { + return { + eligible: true, + reasonCode: 'PRIVACY_ELIGIBLE', + receipt: { + schemaVersion: 1, + eligible: true, + reasonCode: 'PRIVACY_ELIGIBLE', + policyVersionId: '11111111-1111-4111-8111-111111111111', + policyVersion: PRIVACY_POLICY_VERSION, + contentSha256: PRIVACY_POLICY_CONTENT_SHA256, + }, + }; +} + +function createDependencies( + receipts: readonly (CurrentPrivacyEligibility | Error)[], + subjectPseudonymKey = pseudonymKey, +) { const durable = new Map(); + const manifests = new Map(); let lookups = 0; const key = (batchId: string, userId: string) => `${batchId}:${userId}`; const dependencies: RosterClassificationDependencies = { @@ -33,7 +57,14 @@ function createDependencies(receipts: readonly (CurrentPrivacyEligibility | Erro if (receipt instanceof Error) throw receipt; return receipt ?? eligibility(null); }, + subjectPseudonymKey, sink: { + bindManifestIfAbsent: async (batchId, manifestDigest) => { + const existing = manifests.get(batchId); + if (existing) return { inserted: false, manifestDigest: existing }; + manifests.set(batchId, manifestDigest); + return { inserted: true, manifestDigest }; + }, get: async (batchId, userId) => durable.get(key(batchId, userId)) ?? null, putIfAbsent: async (result) => { const existing = durable.get(key(result.batchId, result.userId)); @@ -44,7 +75,7 @@ function createDependencies(receipts: readonly (CurrentPrivacyEligibility | Erro }, }; - return { dependencies, getLookups: () => lookups }; + return { dependencies, durable, getLookups: () => lookups }; } describe('classifyPrivacyRoster', () => { @@ -56,9 +87,28 @@ describe('classifyPrivacyRoster', () => { await expect(classifyPrivacyRoster('batch-1', [...roster.slice(0, 15), 'not-a-uuid'], dependencies)).rejects.toThrow('UUIDs'); }); + test('requires a live current-policy schema-v1 receipt for eligible classification', async () => { +const live = liveEligibility(); + const stale: CurrentPrivacyEligibility = { + ...live, + receipt: { ...live.receipt!, policyVersion: 'stale-policy' }, + }; + const { dependencies } = createDependencies([ + eligibility('PRIVACY_ELIGIBLE'), + stale, + ...Array.from({ length: 14 }, liveEligibility), + ]); + + const result = await classifyPrivacyRoster('batch-live-receipt', roster, dependencies); + + expect(result.subjects[0]?.classification).toBe('failed'); + expect(result.subjects[1]?.classification).toBe('failed'); + expect(result.counts.already_current_eligible).toBe(14); + }); + test('conserves exactly sixteen opaque outcomes', async () => { const receipts = [ - ...Array.from({ length: 5 }, () => eligibility('PRIVACY_ELIGIBLE')), + ...Array.from({ length: 5 }, liveEligibility), ...Array.from({ length: 4 }, () => eligibility('PRIVACY_POLICY_REATTESTATION_REQUIRED')), ...Array.from({ length: 3 }, () => eligibility('PRIVACY_GUARDIAN_REQUIRED')), ...Array.from({ length: 3 }, () => eligibility(null)), @@ -84,9 +134,61 @@ describe('classifyPrivacyRoster', () => { } }); + test('rejects corrupt manifest bindings and same-batch different-manifest replay', async () => { +const { dependencies } = createDependencies(Array.from({ length: 32 }, liveEligibility)); + const corruptDependencies: RosterClassificationDependencies = { + ...dependencies, + sink: { + ...dependencies.sink, + bindManifestIfAbsent: async () => ({ + inserted: false, + manifestDigest: 'not-a-digest', + }), + }, + }; + await expect( + classifyPrivacyRoster('batch-corrupt-manifest', roster, corruptDependencies), + ).rejects.toThrow('batch manifest'); + + const replay = createDependencies(Array.from({ length: 32 }, liveEligibility)); + await classifyPrivacyRoster('batch-manifest-replay', roster, replay.dependencies); + const differentRoster = [...roster]; + differentRoster[15] = '00000000-0000-4000-8000-000000000099'; + await expect( + classifyPrivacyRoster('batch-manifest-replay', differentRoster, replay.dependencies), + ).rejects.toThrow('batch manifest'); + }); + + test('uses purpose-scoped HMAC pseudonyms and verifies raced durable bindings', async () => { + const first = createDependencies(Array.from({ length: 16 }, liveEligibility)); + const second = createDependencies( + Array.from({ length: 16 }, liveEligibility), + new TextEncoder().encode('different-server-held-roster-pseudonym-key'), + ); + const firstResult = await classifyPrivacyRoster('batch-pseudonym-a', roster, first.dependencies); + const secondResult = await classifyPrivacyRoster('batch-pseudonym-b', roster, second.dependencies); + expect(firstResult.subjects[0]?.subjectDigest).not.toBe(secondResult.subjects[0]?.subjectDigest); + + const stored = first.durable.get('batch-pseudonym-a:00000000-0000-4000-8000-000000000001')!; + first.durable.set(stored.batchId + ':' + stored.userId, { + ...stored, + subjectDigest: '0'.repeat(64), + }); + await expect( + classifyPrivacyRoster('batch-pseudonym-a', roster, first.dependencies), + ).rejects.toThrow('classification is invalid'); + first.durable.set(stored.batchId + ':' + stored.userId, { + ...stored, + resultDigest: '0'.repeat(64), + }); + await expect( + classifyPrivacyRoster('batch-pseudonym-a', roster, first.dependencies), + ).rejects.toThrow('classification is invalid'); + }); + test('replays durable results without re-reading eligibility or overwriting them', async () => { const { dependencies, getLookups } = createDependencies( - Array.from({ length: 16 }, () => eligibility('PRIVACY_ELIGIBLE')), + Array.from({ length: 16 }, liveEligibility), ); const first = await classifyPrivacyRoster('batch-replay', roster, dependencies); @@ -122,7 +224,9 @@ describe('classifyPrivacyRoster', () => { ); expect(source).toContain("typeof window !== 'undefined'"); - expect(source).toContain('getCurrentPrivacyEligibilityForUser'); + expect(source).toContain('hasLivePrivacyEligibilityReceipt'); + expect(source).toContain('bindManifestIfAbsent'); + expect(source).toContain('createHmac'); expect(source).toContain('putIfAbsent'); expect(source).not.toMatch(/\b(?:insert|upsert|delete)\w*\s*\(/i); expect(source).not.toMatch(/\b(?:consent|guardian|marketing|age|profile)\w*\s*[:=]/i); diff --git a/docs/operations/privacy-auth-fallback-receipt.json b/docs/operations/privacy-auth-fallback-receipt.json deleted file mode 100644 index 943fec9cf0..0000000000 --- a/docs/operations/privacy-auth-fallback-receipt.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "schemaVersion": 1, - "recordedAt": "2026-08-02T15:04:00Z", - "purpose": "Immutable receipt-only web fallback candidate for privacy-auth recovery", - "source": { - "repository": "twoimo/tzudong", - "commit": "0b6e99309f686d01d84110b013352c4ec8266dd0", - "pullRequest": 2463 - }, - "deployment": { - "provider": "vercel", - "id": "dpl_9Er9ZvU54Rq7C7Swnq6C57k6t1fm", - "url": "tzudong-lu5y1uul7-twoimos-projects.vercel.app", - "target": "production", - "state": "READY", - "createdAtUnixMs": 1785694153488 - }, - "compatibility": { - "admissionModel": "current-policy schema-v1 live eligibility receipt", - "migrationVersion": "20260801000100", - "migrationManifestSha256": "bba79f264f26158d2fd93f62a0632f44ff8a0575619b50928e23ecefccf8ab95", - "policyVersion": "g010-recovery-2026-07-12.1", - "productionReadback": { - "migrationApplied": true, - "currentPolicyPublished": true, - "policyContentSha256": "55cc4bd20d66c1c34e43d2d9c5427926040fbc0b8b17802d373ea36ca98f8ab3", - "authRouteStatus": 401, - "accountDeleteRouteStatus": 401, - "privacyConsentsRouteStatus": 401, - "onboardingUnauthenticatedStatus": 401, - "homepageStatus": 200 - }, - "focusedVerification": { - "backend": "263 passed, 15 skipped", - "webAuthPrivacy": "79 passed, 1 skipped", - "webLint": "passed", - "webTypecheck": "passed", - "webProductionBuild": "passed" - } - }, - "rollbackBoundary": "Web fallback only. Never replay G016, alter migration history, disable RLS, or manufacture consent, age, guardian, marketing, policy approval, or operator evidence.", - "limitations": [ - "This receipt does not prove Datadog drain or monitor configuration.", - "This receipt does not authorize migration replay or policy publication.", - "This receipt does not replace controlled password and Google canary evidence." - ] -} From 10cc5c8e08cbb53355140ce2b2432a1582059a31 Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 3 Aug 2026 05:27:09 +0900 Subject: [PATCH 4/9] fix(auth): bind recovery authority and telemetry --- apps/web/app/api/auth/oauth/route.ts | 164 +++++++++++++++++ apps/web/app/api/auth/password-login/route.ts | 104 +++++++++++ apps/web/app/api/privacy/onboarding/route.ts | 74 ++++++-- apps/web/app/auth/callback/route.ts | 170 +++++++++++++++--- apps/web/components/auth/AuthModal.tsx | 66 +++---- apps/web/lib/privacy/roster-classification.ts | 93 +++++++--- apps/web/lib/supabase/middleware.ts | 11 +- .../oauth-onboarding-flow-binding.test.ts | 40 ++++- .../privacy-auth-observability.test.ts | 39 +++- .../privacy-auth-state-machine.test.ts | 6 +- .../web/tests-unit/privacy-onboarding.test.ts | 64 ++++--- .../privacy-roster-classification.test.ts | 72 ++++++-- 12 files changed, 727 insertions(+), 176 deletions(-) create mode 100644 apps/web/app/api/auth/oauth/route.ts create mode 100644 apps/web/app/api/auth/password-login/route.ts diff --git a/apps/web/app/api/auth/oauth/route.ts b/apps/web/app/api/auth/oauth/route.ts new file mode 100644 index 0000000000..e7223c0ca6 --- /dev/null +++ b/apps/web/app/api/auth/oauth/route.ts @@ -0,0 +1,164 @@ +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; +import { NextResponse } from 'next/server'; +import { + clearOnboardingCookies, + ONBOARDING_CHALLENGE_COOKIE, + readOnboardingChallenge, + sha256, +} from '@/lib/privacy/onboarding'; +import { getSafeAuthNextPath } from '@/lib/auth/auth-redirect'; +import { createClientForCookieStore } from '@/lib/supabase/server'; +import { + emitPrivacyAuthEventFromServerEnvironment, + type PrivacyAuthEventInput, +} from '@/lib/observability/privacy-auth-events'; +import { + PRIVACY_POLICY_CONTENT_SHA256, + PRIVACY_POLICY_VERSION, +} from '@/lib/privacy/policy'; + +export const runtime = 'nodejs'; + +const OAUTH_TRANSACTION_COOKIE = 'tzudong_oauth_transaction'; +const OAUTH_TRANSACTION_TTL_SECONDS = 10 * 60; +const DEFAULT_PRODUCTION_REDIRECT_ORIGIN = 'https://www.tzudong.app'; + +type OAuthTransaction = Readonly<{ + version: 1; + flow: string; + correlationId: string; + intent: 'login' | 'signup'; + challengeId: string | null; + challengeTokenDigest: string | null; + next: string; + expiresAt: number; +}>; + +type CookieWrite = Readonly<{ name: string; value: string; options: Record }>; + +function trustedOrigin(requestOrigin: string) { + const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (configured) { + try { + return new URL(configured).origin; + } catch { + return DEFAULT_PRODUCTION_REDIRECT_ORIGIN; + } + } + if (process.env.NODE_ENV !== 'production') { + try { + return new URL(requestOrigin).origin; + } catch { + return DEFAULT_PRODUCTION_REDIRECT_ORIGIN; + } + } + return DEFAULT_PRODUCTION_REDIRECT_ORIGIN; +} + +function requestCookie(request: Request, name: string) { + return request.headers.get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +function transactionSignature(encoded: string) { + const secret = process.env.PRIVACY_ONBOARDING_COOKIE_SECRET; + if (!secret || Buffer.byteLength(secret, 'utf8') < 32) return null; + return createHmac('sha256', secret).update(encoded, 'utf8').digest('base64url'); +} + +function sealOAuthTransaction(transaction: OAuthTransaction) { + const encoded = Buffer.from(JSON.stringify(transaction), 'utf8').toString('base64url'); + const signature = transactionSignature(encoded); + return signature ? `${encoded}.${signature}` : null; +} + +function clearOAuthTransaction(response: NextResponse) { + response.cookies.set({ name: OAUTH_TRANSACTION_COOKIE, value: '', httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 0 }); +} + +function rejectedResponse(origin: string) { + const response = NextResponse.redirect(`${trustedOrigin(origin)}/`); + response.headers.set('Cache-Control', 'no-store'); + clearOnboardingCookies(response); + clearOAuthTransaction(response); + return response; +} +function emitOAuthCallbackEvent( + outcomeReason: Extract, + correlationId: string, +) { + try { + emitPrivacyAuthEventFromServerEnvironment({ + event: 'auth_callback', + policyVersion: PRIVACY_POLICY_VERSION, + policySha: PRIVACY_POLICY_CONTENT_SHA256, + routeClass: 'loop_safe_api', + provider: 'oauth', + outcomeReason, + correlationId, + subjectDigest: null, + }); + } catch { + // Telemetry must not affect OAuth initiation. + } +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const intent = url.searchParams.get('intent'); + const next = getSafeAuthNextPath(url.searchParams.get('next')); + if ((intent !== 'login' && intent !== 'signup') || [...url.searchParams.keys()].some((key) => key !== 'intent' && key !== 'next')) { + return rejectedResponse(url.origin); + } + + const writes: CookieWrite[] = []; + const requestCookies = (request.headers.get('cookie')?.split(';').flatMap((part) => { + const index = part.indexOf('='); + return index < 1 ? [] : [{ name: part.slice(0, index).trim(), value: part.slice(index + 1).trim() }]; + }) ?? []).filter(({ name }) => intent === 'signup' + || (name !== ONBOARDING_CHALLENGE_COOKIE && name !== OAUTH_TRANSACTION_COOKIE)); + const supabase = createClientForCookieStore({ + getAll: () => requestCookies, + set: (name, value, options) => writes.push({ name, value, options }), + }); + + const flow = randomBytes(32).toString('hex'); + const correlationId = randomUUID(); + const challenge = intent === 'signup' + ? readOnboardingChallenge(requestCookie(request, ONBOARDING_CHALLENGE_COOKIE)) + : null; + if (intent === 'signup' && (!challenge || challenge.intent !== 'oauth' || !challenge.oauthNonce || challenge.origin !== url.origin)) { + return rejectedResponse(url.origin); + } + const transaction = sealOAuthTransaction({ + version: 1, + flow, + correlationId, + intent, + challengeId: challenge?.challengeId ?? null, + challengeTokenDigest: challenge ? sha256(challenge.challengeToken) : null, + next, + expiresAt: Date.now() + OAUTH_TRANSACTION_TTL_SECONDS * 1000, + }); + if (!transaction) return rejectedResponse(url.origin); + + const callback = new URL('/auth/callback', trustedOrigin(url.origin)); + callback.searchParams.set('next', next); + callback.searchParams.set('flow', flow); + emitOAuthCallbackEvent('callback_started', correlationId); + const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: callback.toString() } }); + if (error || !data.url) { + emitOAuthCallbackEvent('failed', correlationId); + return rejectedResponse(url.origin); + } + + const response = NextResponse.redirect(data.url); + response.headers.set('Cache-Control', 'no-store'); + for (const write of writes) response.cookies.set(write.name, write.value, write.options); + if (intent === 'login') clearOnboardingCookies(response); + response.cookies.set({ name: OAUTH_TRANSACTION_COOKIE, value: transaction, httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: OAUTH_TRANSACTION_TTL_SECONDS }); + return response; +} diff --git a/apps/web/app/api/auth/password-login/route.ts b/apps/web/app/api/auth/password-login/route.ts new file mode 100644 index 0000000000..73d41ba23f --- /dev/null +++ b/apps/web/app/api/auth/password-login/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { getCurrentPrivacyEligibility, hasLivePrivacyEligibilityReceipt, signOutRejectedPrivacySession } from '@/lib/privacy/eligibility'; +import { PRIVACY_POLICY_CONTENT_SHA256, PRIVACY_POLICY_VERSION } from '@/lib/privacy/policy'; +import { readBoundedJsonRequest } from '@/lib/security/bounded-json-request'; +import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { emitPrivacyAuthEventFromServerEnvironment, type PrivacyAuthEventInput } from '@/lib/observability/privacy-auth-events'; +import { createClientForCookieStore } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; + +const MAX_REQUEST_BYTES = 4 * 1024; +const PASSWORD_LOGIN_KEYS = ['email', 'password'] as const; + +type CookieWrite = Readonly<{ name: string; value: string; options: Record }>; +type PasswordLoginRequest = Readonly<{ email: string; password: string }>; + +function hasExactKeys(value: Record, keys: readonly string[]) { + const actualKeys = Object.keys(value); + return actualKeys.length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)); +} + +function parsePasswordLoginRequest(value: unknown): PasswordLoginRequest | null { + if (typeof value !== 'object' || value === null || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) return null; + const record = value as Record; + if (!hasExactKeys(record, PASSWORD_LOGIN_KEYS)) return null; + if ( + typeof record.email !== 'string' || record.email.length < 1 || record.email.length > 320 + || typeof record.password !== 'string' || record.password.length < 1 || record.password.length > 1_024 + ) return null; + return { email: record.email, password: record.password }; +} + +function withNoStore(response: NextResponse, writes: CookieWrite[] = []) { + response.headers.set('Cache-Control', 'no-store'); + for (const write of writes) response.cookies.set(write.name, write.value, write.options); + return response; +} + +function loginResponse(outcome: 'admitted' | 'onboarding_required' | 'auth_failed', status: number, writes: CookieWrite[] = []) { + return withNoStore(NextResponse.json({ outcome }, { status }), writes); +} + +function emitPasswordLoginEvent(correlationId: string, outcomeReason: PrivacyAuthEventInput['outcomeReason']) { + try { + emitPrivacyAuthEventFromServerEnvironment({ + event: 'middleware', + policyVersion: PRIVACY_POLICY_VERSION, + policySha: PRIVACY_POLICY_CONTENT_SHA256, + routeClass: 'public_api', + provider: 'password', + outcomeReason, + correlationId, + subjectDigest: null, + }); + } catch { + // Telemetry must not affect password authentication. + } +} + +export async function POST(request: NextRequest) { + if (!isTrustedSameOriginMutation(request)) return loginResponse('auth_failed', 403); + + const parsed = await readBoundedJsonRequest(request, MAX_REQUEST_BYTES); + if (!parsed.ok) return loginResponse('auth_failed', 400); + const credentials = parsePasswordLoginRequest(parsed.value); + if (!credentials) return loginResponse('auth_failed', 400); + + const correlationId = crypto.randomUUID(); + let terminalEmitted = false; + const emitTerminal = (outcomeReason: 'admitted' | 'onboarding_required' | 'failed') => { + if (terminalEmitted) return; + terminalEmitted = true; + emitPasswordLoginEvent(correlationId, outcomeReason); + }; + emitPasswordLoginEvent(correlationId, 'auth_started'); + + const writes: CookieWrite[] = []; + try { + const cookieStore = await cookies(); + const supabase = createClientForCookieStore({ + getAll: () => cookieStore.getAll(), + set: (name, value, options) => writes.push({ name, value, options }), + }); + const { data, error } = await supabase.auth.signInWithPassword(credentials); + if (error || !data.session?.user) { + emitTerminal('failed'); + return loginResponse('auth_failed', 401, writes); + } + + const eligibility = await getCurrentPrivacyEligibility(supabase); + if (!hasLivePrivacyEligibilityReceipt(eligibility)) { + await signOutRejectedPrivacySession(supabase); + emitTerminal('onboarding_required'); + return loginResponse('onboarding_required', 409, writes); + } + + emitTerminal('admitted'); + return loginResponse('admitted', 200, writes); + } catch { + emitTerminal('failed'); + return loginResponse('auth_failed', 401, writes); + } +} diff --git a/apps/web/app/api/privacy/onboarding/route.ts b/apps/web/app/api/privacy/onboarding/route.ts index e5e743e582..ee844b149d 100644 --- a/apps/web/app/api/privacy/onboarding/route.ts +++ b/apps/web/app/api/privacy/onboarding/route.ts @@ -60,6 +60,7 @@ const PASSWORD_RECOVERY_KEYS = [ function emitOnboardingPrivacyAuthEvent( outcomeReason: PrivacyAuthEventInput['outcomeReason'], provider: PrivacyAuthEventInput['provider'], + correlationId: string, ) { try { emitPrivacyAuthEventFromServerEnvironment({ @@ -69,7 +70,7 @@ function emitOnboardingPrivacyAuthEvent( routeClass: 'loop_safe_api', provider, outcomeReason, - correlationId: crypto.randomUUID(), + correlationId, subjectDigest: null, }); } catch { @@ -279,6 +280,9 @@ async function getCurrentPolicyVersion(): Promise { publishedAt, }; } +function isWorkflowAuthorizationError(error: unknown) { + return isRecord(error) && error.code === '42501'; +} type ChallengeReceipt = Readonly<{ challengeId: string; @@ -312,28 +316,59 @@ function isExactChallengeReceipt( async function createChallenge( input: NonNullable>, origin: string, + correlationId: string, ) { - const currentPolicy = await getCurrentPolicyVersion(); - if (!currentPolicy || currentPolicy.id !== input.policyVersion) return null; + let currentPolicy: CurrentPolicyVersion | null = null; + try { + currentPolicy = await getCurrentPolicyVersion(); + } catch { + emitOnboardingPrivacyAuthEvent( + 'policy_drift', + input.intent === 'oauth' ? 'oauth' : 'password', + correlationId, + ); + return null; + } + if (!currentPolicy || currentPolicy.id !== input.policyVersion) { + emitOnboardingPrivacyAuthEvent( + 'policy_drift', + input.intent === 'oauth' ? 'oauth' : 'password', + correlationId, + ); + return null; + } const oauthNonce = input.intent === 'oauth' ? randomBytes(32).toString('hex') : undefined; const challengeToken = oauthNonce ?? randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + ONBOARDING_CHALLENGE_TTL_MS); const admin = createSupabaseServiceRoleClient(); - const { data, error } = await admin.rpc('create_privacy_onboarding_challenge', { - p_token_hash: sha256(challengeToken), - p_policy_version_id: input.policyVersion, - p_age_band: input.ageBand, - p_requested_consents: input.marketing, - p_oauth_nonce_hash: oauthNonce ? sha256(oauthNonce) : null, - p_expires_at: expiresAt.toISOString(), - }); + let data: unknown = null; + let error: unknown = null; + try { + const response = await admin.rpc('create_privacy_onboarding_challenge', { + p_token_hash: sha256(challengeToken), + p_policy_version_id: input.policyVersion, + p_age_band: input.ageBand, + p_requested_consents: input.marketing, + p_oauth_nonce_hash: oauthNonce ? sha256(oauthNonce) : null, + p_expires_at: expiresAt.toISOString(), + }); + data = response.data; + error = response.error; + } catch { + error = { code: 'unknown' }; + } const result = getResultRecord(data); if (error || !isExactChallengeReceipt(result, input, expiresAt)) { + emitOnboardingPrivacyAuthEvent( + error && isWorkflowAuthorizationError(error) ? 'workflow_42501' : 'audit_write_failed', + input.intent === 'oauth' ? 'oauth' : 'password', + correlationId, + ); return null; } - return { + const challenge = { challengeId: result.challengeId, challengeToken, policyVersionId: currentPolicy.id, @@ -345,6 +380,12 @@ async function createChallenge( expiresAt: expiresAt.getTime(), publicExpiresAt: result.expiresAt, }; + emitOnboardingPrivacyAuthEvent( + 'completed', + input.intent === 'oauth' ? 'oauth' : 'password', + correlationId, + ); + return challenge; } async function confirmChallenge( @@ -808,15 +849,22 @@ export async function POST(request: NextRequest) { const input = parseOnboardingStart(body); if (!input) return errorResponse('INVALID_ONBOARDING_REQUEST', 400, request); + const correlationId = crypto.randomUUID(); emitOnboardingPrivacyAuthEvent( 'onboarding_started', input.intent === 'oauth' ? 'oauth' : 'password', + correlationId, ); if (input.ageBand === 'under_14') { + emitOnboardingPrivacyAuthEvent( + 'denied', + input.intent === 'oauth' ? 'oauth' : 'password', + correlationId, + ); return under14SignupRejectedResponse(request); } - const challenge = await createChallenge(input, requestOrigin(request)); + const challenge = await createChallenge(input, requestOrigin(request), correlationId); if (!challenge) return errorResponse('ONBOARDING_CHALLENGE_UNAVAILABLE', 409, request); const signedChallenge = sealOnboardingChallenge({ diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index 80791866e7..6b96ebef70 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -1,3 +1,4 @@ +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { NextResponse } from 'next/server'; import { clearOnboardingCookies, @@ -7,11 +8,11 @@ import { readOnboardingChallenge, sha256, } from '@/lib/privacy/onboarding'; +import { getSafeAuthNextPath } from '@/lib/auth/auth-redirect'; import { getCurrentPrivacyEligibility, hasLivePrivacyEligibilityReceipt, } from '@/lib/privacy/eligibility'; -import { getSafeAuthNextPath } from '@/lib/auth/auth-redirect'; import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; import { createClient } from '@/lib/supabase/server'; import { @@ -87,7 +88,10 @@ function parseCallbackQuery(searchParams: URLSearchParams): CallbackQuery | null flow, }; } -function emitCallbackPrivacyAuthEvent(outcomeReason: PrivacyAuthEventInput['outcomeReason']) { +function emitCallbackPrivacyAuthEvent( + outcomeReason: Extract, + correlationId: string, +) { try { emitPrivacyAuthEventFromServerEnvironment({ event: 'auth_callback', @@ -96,7 +100,7 @@ function emitCallbackPrivacyAuthEvent(outcomeReason: PrivacyAuthEventInput['outc routeClass: 'loop_safe_api', provider: 'oauth', outcomeReason, - correlationId: crypto.randomUUID(), + correlationId, subjectDigest: null, }); } catch { @@ -146,10 +150,23 @@ async function revokeRejectedCallbackSession(supabase: CallbackSupabaseClient) { } } +function clearOAuthTransaction(response: NextResponse) { + response.cookies.set({ + name: OAUTH_TRANSACTION_COOKIE, + value: '', + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }); +} + function redirectWithOnboardingCookiesCleared(origin: string, path = '/') { const response = NextResponse.redirect(`${getTrustedRedirectOrigin(origin)}${path}`); response.headers.set('Cache-Control', 'no-store'); clearOnboardingCookies(response); + clearOAuthTransaction(response); return response; } @@ -157,10 +174,84 @@ function rejectedCallbackRedirect(request: Request, origin: string) { const response = NextResponse.redirect(`${getTrustedRedirectOrigin(origin)}/`); response.headers.set('Cache-Control', 'no-store'); clearRejectedOnboardingCookies(response, request); + clearOAuthTransaction(response); return response; } +const OAUTH_TRANSACTION_COOKIE = 'tzudong_oauth_transaction'; + +type OAuthTransaction = Readonly<{ + version: 1; + flow: string; + correlationId: string; + intent: 'login' | 'signup'; + challengeId: string | null; + challengeTokenDigest: string | null; + next: string; + expiresAt: number; +}>; + +function readOAuthTransaction(value: string | undefined): OAuthTransaction | null { + const secret = process.env.PRIVACY_ONBOARDING_COOKIE_SECRET; + if (!secret || Buffer.byteLength(secret, 'utf8') < 32 || !value) return null; + const [encoded, signature, ...extra] = value.split('.'); + if (!encoded || !signature || extra.length !== 0) return null; + const expected = createHmac('sha256', secret).update(encoded, 'utf8').digest('base64url'); + const actualBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expected); + if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) return null; + try { + const payload: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + if ( + !payload + || typeof payload !== 'object' + || Array.isArray(payload) + || Object.keys(payload).length !== 8 + ) return null; + const transaction = payload as OAuthTransaction; + return transaction.version === 1 + && /^[0-9a-f]{64}$/.test(transaction.flow) + && UUID_PATTERN.test(transaction.correlationId) + && (transaction.intent === 'login' || transaction.intent === 'signup') + && (transaction.challengeId === null || UUID_PATTERN.test(transaction.challengeId)) + && (transaction.challengeTokenDigest === null || /^[0-9a-f]{64}$/.test(transaction.challengeTokenDigest)) + && (transaction.intent === 'signup' + ? transaction.challengeId !== null && transaction.challengeTokenDigest !== null + : transaction.challengeId === null && transaction.challengeTokenDigest === null) + && typeof transaction.next === 'string' + && transaction.next === getSafeAuthNextPath(transaction.next) + && typeof transaction.expiresAt === 'number' + && Number.isSafeInteger(transaction.expiresAt) + && transaction.expiresAt > Date.now() + ? transaction + : null; + } catch { + return null; + } +} + +function requestCookie(request: Request, name: string) { + return request.headers.get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +function matchingOAuthTransaction( + transaction: OAuthTransaction | null, + challenge: OnboardingChallenge | null, + callback: CallbackQuery, +) { + return transaction !== null + && challenge !== null + && callback.flow !== null + && transaction.flow === callback.flow + && transaction.next === callback.next + && transaction.challengeId === challenge.challengeId + && transaction.challengeTokenDigest === sha256(challenge.challengeToken); +} async function confirmOAuthOnboarding( challenge: OnboardingChallenge, userId: string, @@ -191,61 +282,81 @@ async function rejectOAuthCallbackSession(supabase: CallbackSupabaseClient) { export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); - emitCallbackPrivacyAuthEvent('callback_started'); + const freshCorrelationId = randomUUID(); const callback = parseCallbackQuery(searchParams); - if (!callback || callback.providerError) return rejectedCallbackRedirect(request, origin); - const challengeCookie = request.headers.get('cookie') - ?.split(';') - .map((part) => part.trim()) - .find((part) => part.startsWith(`${ONBOARDING_CHALLENGE_COOKIE}=`)) - ?.slice(ONBOARDING_CHALLENGE_COOKIE.length + 1); - const challenge = readOnboardingChallenge(challengeCookie); - if (challengeCookie && !challenge) return rejectedCallbackRedirect(request, origin); - const onboardingRequested = callback.flow !== null; + if (!callback) { + emitCallbackPrivacyAuthEvent('failed', freshCorrelationId); + return rejectedCallbackRedirect(request, origin); + } + + const transaction = readOAuthTransaction(requestCookie(request, OAUTH_TRANSACTION_COOKIE)); if ( - onboardingRequested - && ( - challenge?.intent !== 'oauth' - || !challenge.oauthNonce - || sha256(challenge.oauthNonce) !== callback.flow - ) + !transaction + || callback.flow === null + || transaction.flow !== callback.flow + || transaction.next !== callback.next ) { + emitCallbackPrivacyAuthEvent('failed', freshCorrelationId); + return rejectedCallbackRedirect(request, origin); + } + const correlationId = transaction.correlationId; + const challengeCookie = requestCookie(request, ONBOARDING_CHALLENGE_COOKIE); + const challenge = readOnboardingChallenge(challengeCookie); + if (challengeCookie && !challenge) { + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } - const { code, next } = callback; - if (!code) return rejectedCallbackRedirect(request, origin); + const onboardingRequested = transaction.intent === 'signup'; + if (onboardingRequested && ( + challenge?.intent !== 'oauth' + || !challenge.oauthNonce + || challenge.origin !== origin + || !matchingOAuthTransaction(transaction, challenge, callback) + )) { + emitCallbackPrivacyAuthEvent('failed', freshCorrelationId); + return rejectedCallbackRedirect(request, origin); + } + if (callback.providerError || !callback.code) { + emitCallbackPrivacyAuthEvent('failed', correlationId); + return rejectedCallbackRedirect(request, origin); + } + const { code, next } = callback; if (!onboardingRequested) { - let supabase: CallbackSupabaseClient | null = null; try { supabase = await createClient(); const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code); if (exchangeError) { await revokeRejectedCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } const { data: { user }, error: userError } = await supabase.auth.getUser(); if (userError || !user?.id || !UUID_PATTERN.test(user.id)) { await revokeRejectedCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } const eligibility = await getCurrentPrivacyEligibility(supabase); if (!hasLivePrivacyEligibilityReceipt(eligibility)) { + emitCallbackPrivacyAuthEvent('onboarding_required', correlationId); return redirectWithOnboardingCookiesCleared(origin, '/privacy/onboarding'); } + emitCallbackPrivacyAuthEvent('admitted', correlationId); return redirectWithOnboardingCookiesCleared(origin, next); } catch { - if (!supabase) return rejectedCallbackRedirect(request, origin); - await revokeRejectedCallbackSession(supabase); + if (supabase) await revokeRejectedCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } } if (!challenge || !challenge.oauthNonce || challenge.origin !== origin) { + emitCallbackPrivacyAuthEvent('failed', freshCorrelationId); return rejectedCallbackRedirect(request, origin); } @@ -255,6 +366,7 @@ export async function GET(request: Request) { const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code); if (exchangeError) { await rejectOAuthCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } @@ -264,13 +376,13 @@ export async function GET(request: Request) { : null; if (userError || !candidateUserId) { await rejectOAuthCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } - const userId = candidateUserId; let confirmed = false; try { - confirmed = await confirmOAuthOnboarding(challenge, userId); + confirmed = await confirmOAuthOnboarding(challenge, candidateUserId); } catch { confirmed = false; } @@ -281,13 +393,15 @@ export async function GET(request: Request) { || eligibility.receipt.contentSha256 !== challenge.contentSha256 ) { await rejectOAuthCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } + emitCallbackPrivacyAuthEvent('admitted', correlationId); return redirectWithOnboardingCookiesCleared(origin, next); } catch { - if (!supabase) return rejectedCallbackRedirect(request, origin); - await rejectOAuthCallbackSession(supabase); + if (supabase) await rejectOAuthCallbackSession(supabase); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } } diff --git a/apps/web/components/auth/AuthModal.tsx b/apps/web/components/auth/AuthModal.tsx index 2c5daae816..e3cdc51f2b 100644 --- a/apps/web/components/auth/AuthModal.tsx +++ b/apps/web/components/auth/AuthModal.tsx @@ -48,10 +48,6 @@ const generateRandomNickname = (): string => { const randomSuffix = String(Math.floor(Math.random() * 10000)).padStart(4, '0'); return `${randomPrefix}_${randomSuffix}`; }; -const sha256Hex = async (value: string): Promise => { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); -}; interface AuthModalProps { isOpen: boolean; @@ -357,23 +353,11 @@ const AuthModal = memo(({ isOpen, onClose, onAuthSuccess, redirectTo, reason, in } }, [ageBand, marketingConsent, policyContentSha256, policyVersion, privacyAgreed]); - const handleGoogleLogin = useCallback(async () => { + const handleGoogleLogin = useCallback(() => { setIsGoogleLoading(true); - try { - const callbackUrl = new URL("/auth/callback", window.location.origin); - if (isAdminRedirect) { - callbackUrl.searchParams.set("next", safeRedirectTo); - } - - const { error } = await supabase.auth.signInWithOAuth({ - provider: "google", - options: { redirectTo: callbackUrl.toString() }, - }); - if (error) throw new Error("oauth_start_failed"); - } catch { - toast.error("Google 로그인에 실패했습니다"); - setIsGoogleLoading(false); - } + const params = new URLSearchParams({ intent: "login" }); + if (isAdminRedirect) params.set("next", safeRedirectTo); + window.location.assign(`/api/auth/oauth?${params.toString()}`); }, [isAdminRedirect, safeRedirectTo]); const handleGoogleSignup = useCallback(async () => { @@ -384,22 +368,9 @@ const AuthModal = memo(({ isOpen, onClose, onAuthSuccess, redirectTo, reason, in return; } - try { - const callbackUrl = new URL("/auth/callback", window.location.origin); - if (isAdminRedirect) { - callbackUrl.searchParams.set("next", safeRedirectTo); - } - callbackUrl.searchParams.set("flow", await sha256Hex(challenge.oauthNonce)); - - const { error } = await supabase.auth.signInWithOAuth({ - provider: "google", - options: { redirectTo: callbackUrl.toString() }, - }); - if (error) throw new Error("oauth_start_failed"); - } catch { - toast.error("Google 가입을 시작할 수 없습니다"); - setIsGoogleLoading(false); - } + const params = new URLSearchParams({ intent: "signup" }); + if (isAdminRedirect) params.set("next", safeRedirectTo); + window.location.assign(`/api/auth/oauth?${params.toString()}`); }, [isAdminRedirect, safeRedirectTo, startOnboardingChallenge]); const redirectAfterAdminLogin = useCallback(() => { @@ -433,20 +404,23 @@ const AuthModal = memo(({ isOpen, onClose, onAuthSuccess, redirectTo, reason, in setIsLoading(true); try { - const { data, error } = await supabase.auth.signInWithPassword({ email, password }); - const signedInUserId = data.session?.user?.id; - if (error || !signedInUserId) { - if (signedInUserId) await rejectPrivacyIneligibleSession(signedInUserId); - throw new Error("password_login_failed"); - } - - const eligibility = await getCurrentPrivacyEligibility(supabase); - if (!hasLivePrivacyEligibilityReceipt(eligibility)) { + const response = await fetch("/api/auth/password-login", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const payload: unknown = await response.json().catch(() => null); + const outcome = typeof payload === "object" && payload !== null && !Array.isArray(payload) + ? (payload as { outcome?: unknown }).outcome + : null; + if (outcome === "onboarding_required") { setAuthTab("signup"); setIsExistingAccountRecovery(true); toast.error("현재 개인정보 처리방침과 연령 확인을 완료해주세요."); return; } + if (!response.ok || outcome !== "admitted") throw new Error("password_login_failed"); toast.success("로그인 성공!"); dispatchHomeAuthSessionUpdated({ @@ -463,7 +437,7 @@ const AuthModal = memo(({ isOpen, onClose, onAuthSuccess, redirectTo, reason, in } finally { setIsLoading(false); } - }, [email, password, redirectAfterAdminLogin, resetForm, closeAfterAuthSuccess, rejectPrivacyIneligibleSession]); + }, [email, password, redirectAfterAdminLogin, resetForm, closeAfterAuthSuccess]); const handleSignup = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/apps/web/lib/privacy/roster-classification.ts b/apps/web/lib/privacy/roster-classification.ts index b6f4c3909c..ea4227701c 100644 --- a/apps/web/lib/privacy/roster-classification.ts +++ b/apps/web/lib/privacy/roster-classification.ts @@ -1,8 +1,16 @@ -import { createHash, createHmac } from 'node:crypto'; +import { createHash, createHmac, randomUUID } from 'node:crypto'; import { hasLivePrivacyEligibilityReceipt, type CurrentPrivacyEligibility, } from '@/lib/privacy/eligibility'; +import { + emitPrivacyAuthEventFromServerEnvironment, + type PrivacyAuthEventInput, +} from '@/lib/observability/privacy-auth-events'; +import { + PRIVACY_POLICY_CONTENT_SHA256, + PRIVACY_POLICY_VERSION, +} from '@/lib/privacy/policy'; if (typeof window !== 'undefined') { throw new Error('Privacy roster classification is server-only.'); @@ -57,6 +65,8 @@ export type RosterClassificationResult = Readonly<{ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const SUBJECT_PSEUDONYM_PURPOSE = 'privacy-roster-classification:subject:v1'; +const RESULT_SIGNATURE_KEY_PURPOSE = 'privacy-roster-classification:result-signing-key:v1'; +const RESULT_SIGNATURE_PURPOSE = 'privacy-roster-classification:result:v1'; const CLASSIFICATIONS = new Set([ 'already_current_eligible', 'needs_user_onboarding', @@ -88,24 +98,27 @@ function receiptDigest(eligibility: CurrentPrivacyEligibility | null) { } } -function resultDigest(result: Pick< - StoredRosterClassification, - 'batchId' | 'userId' | 'classification' | 'subjectDigest' | 'receiptDigest' ->) { - const { - batchId, - userId, - classification, - subjectDigest: storedSubjectDigest, - receiptDigest: storedReceiptDigest, - } = result; - return digest(JSON.stringify({ - batchId, - userId, - classification, - subjectDigest: storedSubjectDigest, - receiptDigest: storedReceiptDigest, - })); +function resultDigest( + result: Pick< + StoredRosterClassification, + 'batchId' | 'classification' | 'subjectDigest' | 'receiptDigest' + >, + canonicalManifestDigest: string, + pseudonymKey: Uint8Array, +) { + const resultSigningKey = createHmac('sha256', pseudonymKey) + .update(RESULT_SIGNATURE_KEY_PURPOSE) + .digest(); + return createHmac('sha256', resultSigningKey) + .update(RESULT_SIGNATURE_PURPOSE) + .update(JSON.stringify({ + manifestDigest: canonicalManifestDigest, + batchId: result.batchId, + classification: result.classification, + subjectDigest: result.subjectDigest, + receiptDigest: result.receiptDigest, + })) + .digest('hex'); } function classifyEligibility(eligibility: CurrentPrivacyEligibility | null): RosterClassification { @@ -156,6 +169,7 @@ function isStoredResult( value: StoredRosterClassification, batchId: string, userId: string, + canonicalManifestDigest: string, pseudonymKey: Uint8Array, ) { return value.batchId === batchId @@ -165,7 +179,7 @@ function isStoredResult( && SHA256_PATTERN.test(value.receiptDigest) && SHA256_PATTERN.test(value.resultDigest) && value.subjectDigest === subjectDigest(userId, pseudonymKey) - && value.resultDigest === resultDigest(value); + && value.resultDigest === resultDigest(value, canonicalManifestDigest, pseudonymKey); } function publicSubject(result: StoredRosterClassification) { @@ -176,12 +190,32 @@ function publicSubject(result: StoredRosterClassification) { resultDigest: result.resultDigest, }; } +function emitRosterClassificationEvent( + outcomeReason: PrivacyAuthEventInput['outcomeReason'], + correlationId: string, +) { + try { + emitPrivacyAuthEventFromServerEnvironment({ + event: 'roster_classification', + policyVersion: PRIVACY_POLICY_VERSION, + policySha: PRIVACY_POLICY_CONTENT_SHA256, + routeClass: 'protected', + provider: 'none', + outcomeReason, + correlationId, + subjectDigest: null, + }); + } catch { + // Telemetry must not affect roster classification. + } +} export async function classifyPrivacyRoster( batchId: string, userIds: readonly string[], dependencies: RosterClassificationDependencies, ): Promise { + const correlationId = randomUUID(); const normalizedUserIds = validateManifest(batchId, userIds); if (!(dependencies.subjectPseudonymKey instanceof Uint8Array) || dependencies.subjectPseudonymKey.byteLength === 0) { throw new Error('A non-empty server-held subject pseudonym key is required.'); @@ -208,7 +242,7 @@ export async function classifyPrivacyRoster( for (const userId of normalizedUserIds) { const stored = await dependencies.sink.get(batchId, userId); if (stored !== null) { - if (!isStoredResult(stored, batchId, userId, dependencies.subjectPseudonymKey)) { + if (!isStoredResult(stored, batchId, userId, canonicalManifestDigest, dependencies.subjectPseudonymKey)) { throw new Error('Durable roster classification is invalid.'); } counts[stored.classification] += 1; @@ -234,15 +268,20 @@ export async function classifyPrivacyRoster( receiptDigest: evidenceDigest, resultDigest: resultDigest({ batchId, - userId, classification, subjectDigest: pseudonym, receiptDigest: evidenceDigest, - }), + }, canonicalManifestDigest, dependencies.subjectPseudonymKey), }; const persisted = await dependencies.sink.putIfAbsent(result); - if (!isStoredResult(persisted.classification, batchId, userId, dependencies.subjectPseudonymKey)) { + if (!isStoredResult( + persisted.classification, + batchId, + userId, + canonicalManifestDigest, + dependencies.subjectPseudonymKey, + )) { throw new Error('Durable roster classification is invalid.'); } counts[persisted.classification.classification] += 1; @@ -251,8 +290,14 @@ export async function classifyPrivacyRoster( const total = Object.values(counts).reduce((sum, count) => sum + count, 0); if (total !== ROSTER_CLASSIFICATION_SIZE || subjects.length !== ROSTER_CLASSIFICATION_SIZE) { + emitRosterClassificationEvent('roster_conservation_mismatch', correlationId); throw new Error('Roster classification count conservation failed.'); } + for (const classification of CLASSIFICATIONS) { + if (counts[classification] > 0) { + emitRosterClassificationEvent(classification, correlationId); + } + } return { batchDigest: digest(batchId), diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts index 181fe0be7f..780e93e862 100644 --- a/apps/web/lib/supabase/middleware.ts +++ b/apps/web/lib/supabase/middleware.ts @@ -57,6 +57,7 @@ const telemetryRouteClass = (request: NextRequest): PrivacyAuthEventInput['route const emitMiddlewarePrivacyAuthEvent = ( request: NextRequest, outcomeReason: PrivacyAuthEventInput['outcomeReason'], + correlationId: string, ) => { try { emitPrivacyAuthEventFromServerEnvironment({ @@ -66,7 +67,7 @@ const emitMiddlewarePrivacyAuthEvent = ( routeClass: telemetryRouteClass(request), provider: 'session', outcomeReason, - correlationId: crypto.randomUUID(), + correlationId, subjectDigest: null, }); } catch { @@ -211,9 +212,7 @@ export async function updateSession( } const hasAuthCookie = hasSupabaseAuthCookieSessionHint(request.headers.get('cookie') ?? undefined); - if (hasAuthCookie) { - emitMiddlewarePrivacyAuthEvent(request, 'auth_started'); - } + const eligibilityCorrelationId = hasAuthCookie ? crypto.randomUUID() : null; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(); @@ -266,7 +265,7 @@ export async function updateSession( } if (hasAuthCookie && (authFailed || !authUserId)) { - emitMiddlewarePrivacyAuthEvent(request, 'eligibility_error'); + emitMiddlewarePrivacyAuthEvent(request, 'eligibility_error', eligibilityCorrelationId!); await signOutRejectedPrivacySession(supabase); return eligibilityFailureResponse(request, supabaseResponse); } @@ -277,10 +276,12 @@ export async function updateSession( emitMiddlewarePrivacyAuthEvent( request, eligibility.reasonCode === 'PRIVACY_POLICY_UNAVAILABLE' ? 'policy_drift' : 'denied', + eligibilityCorrelationId ?? crypto.randomUUID(), ); await signOutRejectedPrivacySession(supabase); return eligibilityFailureResponse(request, supabaseResponse); } + emitMiddlewarePrivacyAuthEvent(request, 'admitted', eligibilityCorrelationId ?? crypto.randomUUID()); } if (isProtectedAdminRequest(request)) { diff --git a/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts b/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts index 9365b1c99d..e9e47119d7 100644 --- a/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts +++ b/apps/web/tests-unit/oauth-onboarding-flow-binding.test.ts @@ -1,14 +1,40 @@ import { expect, test } from 'bun:test'; -test('OAuth signup callbacks are transaction-bound without direct profile authority', async () => { +test('OAuth initiation binds login and signup callbacks to one opaque correlation ID', async () => { const modalSource = await Bun.file('components/auth/AuthModal.tsx').text(); + const initiationSource = await Bun.file('app/api/auth/oauth/route.ts').text(); const callbackSource = await Bun.file('app/auth/callback/route.ts').text(); - expect(modalSource).toContain('callbackUrl.searchParams.set("flow", await sha256Hex(challenge.oauthNonce))'); - expect(callbackSource).toContain("'flow',"); - expect(callbackSource).toContain("!/^[0-9a-f]{64}$/.test(flow)"); - expect(callbackSource).toContain("const onboardingRequested = callback.flow !== null"); - expect(callbackSource).toContain('sha256(challenge.oauthNonce) !== callback.flow'); + expect(modalSource).toContain('window.location.assign(`/api/auth/oauth?${params.toString()}`)'); + expect(modalSource).not.toContain('supabase.auth.signInWithOAuth'); + expect(initiationSource).toContain("const OAUTH_TRANSACTION_COOKIE = 'tzudong_oauth_transaction'"); + expect(initiationSource).toContain("intent: 'login' | 'signup'"); + expect(initiationSource).toContain('const correlationId = randomUUID();'); + expect(initiationSource).toContain('correlationId,'); + expect(initiationSource).toContain("callback.searchParams.set('flow', flow)"); + expect(initiationSource).toContain("emitOAuthCallbackEvent('callback_started', correlationId)"); + expect(initiationSource).toContain("emitOAuthCallbackEvent('failed', correlationId)"); + expect(initiationSource).toContain('supabase.auth.signInWithOAuth'); + expect(initiationSource).toContain('response.cookies.set({ name: OAUTH_TRANSACTION_COOKIE, value: transaction'); + + // The signed transaction is mandatory for both login and signup, and its + // correlation is reused only after the flow and redirect binding validate. + expect(callbackSource).toContain('const transaction = readOAuthTransaction(requestCookie(request, OAUTH_TRANSACTION_COOKIE));'); + expect(callbackSource).toContain('const correlationId = transaction.correlationId;'); + expect(callbackSource).toContain('transaction.flow !== callback.flow'); + expect(callbackSource).toContain('transaction.next !== callback.next'); + expect(callbackSource).toContain("emitCallbackPrivacyAuthEvent('failed', freshCorrelationId)"); + expect(callbackSource.indexOf('transaction.flow !== callback.flow')) + .toBeLessThan(callbackSource.indexOf('exchangeCodeForSession(code)')); + + // Incomplete ordinary logins have their own terminal denominator; both + // successful login and signup admissions are terminal and identity-free. + expect(callbackSource).toContain("emitCallbackPrivacyAuthEvent('onboarding_required', correlationId)"); + expect(callbackSource).toContain("emitCallbackPrivacyAuthEvent('admitted', correlationId)"); + expect(callbackSource).toContain("outcomeReason: Extract"); + expect(callbackSource).toContain('subjectDigest: null'); + expect(callbackSource).not.toContain('email'); + expect(callbackSource).not.toContain('user.id,'); + expect(callbackSource).toContain('parseFreshPrivacyOnboardingConfirmationReceipt'); expect(callbackSource).not.toContain(".from('privacy_age_profiles'"); - expect(callbackSource).not.toContain('isPrivacyProfileStatusAllowed'); }); diff --git a/apps/web/tests-unit/privacy-auth-observability.test.ts b/apps/web/tests-unit/privacy-auth-observability.test.ts index fac5d00afa..980fad2eb5 100644 --- a/apps/web/tests-unit/privacy-auth-observability.test.ts +++ b/apps/web/tests-unit/privacy-auth-observability.test.ts @@ -128,21 +128,52 @@ describe('privacy auth observability', () => { console.warn = originalWarn; } }); - test('wires fail-safe no-PII telemetry at callback, onboarding, and middleware transitions', () => { + test('wires fail-safe no-PII telemetry at callback, onboarding, middleware, and roster transitions', () => { const root = resolve(import.meta.dir, '..'); const callback = readFileSync(resolve(root, 'app/auth/callback/route.ts'), 'utf8'); const onboarding = readFileSync(resolve(root, 'app/api/privacy/onboarding/route.ts'), 'utf8'); const middleware = readFileSync(resolve(root, 'lib/supabase/middleware.ts'), 'utf8'); + const roster = readFileSync(resolve(root, 'lib/privacy/roster-classification.ts'), 'utf8'); - expect(callback).toContain("emitCallbackPrivacyAuthEvent('callback_started')"); + expect(callback).toContain("emitCallbackPrivacyAuthEvent('admitted', correlationId)"); expect(callback).toContain("subjectDigest: null"); - expect(onboarding).toContain("emitOnboardingPrivacyAuthEvent("); expect(onboarding).toContain("'onboarding_started'"); - expect(middleware).toContain("emitMiddlewarePrivacyAuthEvent(request, 'auth_started')"); + expect(onboarding).toContain("'workflow_42501'"); + expect(onboarding).toContain("'audit_write_failed'"); + expect(onboarding).toContain("'policy_drift'"); + expect(onboarding).toContain('correlationId'); + expect(middleware).not.toContain("emitMiddlewarePrivacyAuthEvent(request, 'auth_started')"); expect(middleware).toContain("'eligibility_error'"); + expect(middleware).toContain("'admitted'"); + expect(roster).toContain("'roster_conservation_mismatch'"); + expect(roster).toContain("event: 'roster_classification'"); + expect(roster).toContain('randomUUID'); expect(middleware).toContain('Telemetry must not affect privacy enforcement.'); }); + test('routes password login through the server cookie client with one fail-safe telemetry terminal', () => { + const root = resolve(import.meta.dir, '..'); + const route = readFileSync(resolve(root, 'app/api/auth/password-login/route.ts'), 'utf8'); + const modal = readFileSync(resolve(root, 'components/auth/AuthModal.tsx'), 'utf8'); + const passwordLoginHandler = modal.slice(modal.indexOf('const handleLogin'), modal.indexOf('const handleSignup')); + + expect(route).toContain("const correlationId = crypto.randomUUID();"); + expect(route).toContain("emitPasswordLoginEvent(correlationId, 'auth_started');"); + expect(route).toContain("let terminalEmitted = false;"); + expect(route).toContain("emitTerminal('admitted');"); + expect(route).toContain("emitTerminal('onboarding_required');"); + expect(route).toContain("emitTerminal('failed');"); + expect(route).toContain("await signOutRejectedPrivacySession(supabase);"); + expect(route).toContain("createClientForCookieStore({"); + expect(route).toContain("return loginResponse('admitted', 200, writes);"); + expect(route).toContain("return loginResponse('onboarding_required', 409, writes);"); + expect(route).toContain("return loginResponse('auth_failed', 401, writes);"); + expect(route).toContain('Telemetry must not affect password authentication.'); + expect(route).not.toContain('console.'); + expect(route).not.toContain('JSON.stringify(credentials)'); + expect(passwordLoginHandler).toContain('fetch("/api/auth/password-login"'); + expect(passwordLoginHandler).not.toContain('supabase.auth.signInWithPassword'); + }); test('formats timestamps deterministically at the UTC minute', () => { expect(formatPrivacyAuthUtcMinute(new Date('2026-08-02T23:59:59.999-07:00'))).toBe('2026-08-03T06:59:00.000Z'); expect(() => formatPrivacyAuthUtcMinute(new Date('invalid'))).toThrow( diff --git a/apps/web/tests-unit/privacy-auth-state-machine.test.ts b/apps/web/tests-unit/privacy-auth-state-machine.test.ts index 7cd9ea0a0e..e468a286cc 100644 --- a/apps/web/tests-unit/privacy-auth-state-machine.test.ts +++ b/apps/web/tests-unit/privacy-auth-state-machine.test.ts @@ -88,7 +88,7 @@ describe('privacy auth state machine', () => { expect(consumePasswordRecoveryProof(USER_ID)).toBe(false); }); - test('rejects an ambiguous OAuth identity before confirmation can mutate onboarding state', async () => { + test('rejects a signed OAuth challenge without a matching transaction before exchange or onboarding mutation', async () => { exchangeCalls = 0; signOutCalls = 0; confirmationCalls = 0; @@ -113,8 +113,8 @@ describe('privacy auth state machine', () => { })); expect(response.headers.get('location')).toBe('http://localhost:3000/'); - expect(exchangeCalls).toBe(1); - expect(signOutCalls).toBe(2); + expect(exchangeCalls).toBe(0); + expect(signOutCalls).toBe(0); expect(confirmationCalls).toBe(0); }); }); diff --git a/apps/web/tests-unit/privacy-onboarding.test.ts b/apps/web/tests-unit/privacy-onboarding.test.ts index d357731ef6..9d531a3056 100644 --- a/apps/web/tests-unit/privacy-onboarding.test.ts +++ b/apps/web/tests-unit/privacy-onboarding.test.ts @@ -98,16 +98,8 @@ function resetOAuthRejectionRouteMock() { } function oauthRejectionRequest() { - const challenge = signedChallenge(Date.now() + 60_000); - if (!challenge) throw new Error('Expected signed OAuth onboarding challenge'); - return new Request( 'https://www.tzudong.app/auth/callback?code=oauth-code&next=%2Fsafe', - { - headers: { - cookie: `${ONBOARDING_CHALLENGE_COOKIE}=${challenge}`, - }, - }, ); } @@ -199,7 +191,7 @@ describe('privacy onboarding challenge', () => { expect(callbackRoute).toContain('p_challenge_token: challenge.challengeToken'); expect(callbackRoute).toContain('p_oauth_nonce_hash: sha256(challenge.oauthNonce)'); expect(callbackRoute).toContain('challenge.origin !== origin'); - expect(callbackRoute).toContain('if (challengeCookie && !challenge) return rejectedCallbackRedirect(request, origin);'); + expect(callbackRoute).toContain('if (challengeCookie && !challenge) {'); expect(readOnboardingChallenge(rawSignedChallenge({ ...rawPayload, origin: 'https://www.tzudong.app/onboarding', @@ -250,14 +242,19 @@ describe('privacy onboarding challenge', () => { expect(onboardingRoute).not.toContain('.from(\'privacy_consent_events\')'); }); - test('OAuth callback uses only the signed HttpOnly challenge as the onboarding discriminator', () => { + test('OAuth onboarding requires a server-bound transaction in addition to the signed challenge', () => { + const initiationRoute = source('app/api/auth/oauth/route.ts'); const callbackRoute = source('app/auth/callback/route.ts'); expect(callbackRoute).not.toContain("'onboarding_nonce'"); - expect(callbackRoute).not.toContain("'onboarding'"); - expect(callbackRoute).toContain("const onboardingRequested = challenge?.intent === 'oauth'"); - expect(callbackRoute).toContain("if (!challenge || !challenge.oauthNonce || challenge.origin !== origin)"); + expect(callbackRoute).toContain("const onboardingRequested = transaction.intent === 'signup';"); + expect(callbackRoute).toContain('callback.flow === null'); + expect(callbackRoute).toContain('transaction.flow !== callback.flow'); + expect(callbackRoute).toContain('transaction.challengeId === challenge.challengeId'); + expect(callbackRoute).toContain('transaction.challengeTokenDigest === sha256(challenge.challengeToken)'); expect(callbackRoute).toContain("await supabase.auth.signOut({ scope: 'local' })"); + expect(initiationRoute).toContain('(name !== ONBOARDING_CHALLENGE_COOKIE && name !== OAUTH_TRANSACTION_COOKIE)'); + expect(initiationRoute).toContain('if (intent === \'login\') clearOnboardingCookies(response);'); }); test('no-challenge OAuth sessions without a live receipt enter only exact onboarding without revocation', () => { @@ -300,16 +297,13 @@ describe('privacy onboarding challenge', () => { expect(onboardingRoute).toContain('signupClient.auth.signUp'); expect(authModal).toContain('startOnboardingChallenge("oauth")'); expect(authModal).not.toContain('onboarding_nonce'); - expect(callbackRoute).toContain('confirmOAuthOnboarding(challenge, userId)'); + expect(callbackRoute).toContain('confirmOAuthOnboarding(challenge, candidateUserId)'); }); test('live eligibility receipt gates browser sessions without process-global policy state', () => { const authContext = source('contexts/AuthContext.tsx'); const authModal = source('components/auth/AuthModal.tsx'); - const passwordLogin = authModal.slice( - authModal.indexOf('const handleLogin = useCallback'), - authModal.indexOf('const handleSignup = useCallback'), - ); + const passwordLogin = source('app/api/auth/password-login/route.ts'); const passwordSignup = authModal.slice( authModal.indexOf('const handleSignup = useCallback'), authModal.indexOf('const handleForgotPassword = useCallback'), @@ -317,6 +311,7 @@ describe('privacy onboarding challenge', () => { const eligibilityLookupIndex = authContext.indexOf('const eligibility = await getCurrentPrivacyEligibility(supabase);'); const roleLookupIndex = authContext.indexOf('.from("user_roles")'); const profileLookupIndex = authContext.indexOf('.from("profiles")'); + const passwordEligibilityIndex = passwordLogin.indexOf('const eligibility = await getCurrentPrivacyEligibility(supabase);'); expect(authContext).not.toContain('privacy_age_profiles'); expect(authContext).not.toContain('as never'); @@ -329,14 +324,17 @@ describe('privacy onboarding challenge', () => { expect(authContext).toContain('await signOutRejectedPrivacySession(supabase)'); expect(authContext).toContain("dispatchHomeAuthSessionUpdated({ hasSession: true, source: 'auth-eligible-session' })"); - const loginEligibilityIndex = passwordLogin.indexOf('const eligibility = await getCurrentPrivacyEligibility(supabase);'); - expect(loginEligibilityIndex).toBeGreaterThan(-1); - expect(passwordLogin.indexOf('toast.success("로그인 성공!")')).toBeGreaterThan(loginEligibilityIndex); - expect(passwordLogin.indexOf('dispatchHomeAuthSessionUpdated({')).toBeGreaterThan(loginEligibilityIndex); - expect(passwordLogin.indexOf('redirectAfterAdminLogin()')).toBeGreaterThan(loginEligibilityIndex); - expect(passwordLogin).toContain('setAuthTab("signup")'); - expect(passwordLogin).toContain('setIsExistingAccountRecovery(true)'); - expect(passwordLogin).toContain('현재 개인정보 처리방침과 연령 확인을 완료해주세요.'); + expect(authModal).toContain('fetch("/api/auth/password-login"'); + expect(passwordLogin).toContain('supabase.auth.signInWithPassword(credentials)'); + expect(passwordEligibilityIndex).toBeGreaterThan(passwordLogin.indexOf('supabase.auth.signInWithPassword(credentials)')); + expect(passwordLogin.indexOf('await signOutRejectedPrivacySession(supabase)')).toBeGreaterThan(passwordEligibilityIndex); + expect(passwordLogin).toContain("return loginResponse('onboarding_required', 409, writes)"); + expect(authModal.indexOf('toast.success("로그인 성공!")')).toBeGreaterThan( + authModal.indexOf('outcome !== "admitted"'), + ); + expect(authModal).toContain('setAuthTab("signup")'); + expect(authModal).toContain('setIsExistingAccountRecovery(true)'); + expect(authModal).toContain('현재 개인정보 처리방침과 연령 확인을 완료해주세요.'); const signupEligibilityIndex = passwordSignup.indexOf('const eligibility = await getCurrentPrivacyEligibility(supabase);'); expect(signupEligibilityIndex).toBeGreaterThan(-1); @@ -433,7 +431,7 @@ describe('minimum-data age and consent choices', () => { const guardianRoute = source('app/api/privacy/guardian/route.ts'); const onboardingRoute = source('app/api/privacy/onboarding/route.ts'); const under14StartIndex = onboardingRoute.indexOf("if (input.ageBand === 'under_14')"); - const challengeCreateIndex = onboardingRoute.indexOf('const challenge = await createChallenge(input, requestOrigin(request));'); + const challengeCreateIndex = onboardingRoute.indexOf('const challenge = await createChallenge(input, requestOrigin(request), correlationId);'); const under14PasswordIndex = onboardingRoute.indexOf("if (challenge.ageBand !== 'age_14_plus')"); const accountCreateIndex = onboardingRoute.indexOf('signupClient.auth.signUp'); @@ -599,9 +597,9 @@ describe('G014 server session release boundaries', () => { callback.indexOf('return redirectWithOnboardingCookiesCleared(origin, next);', oauthFlowStart), ); - expect(oauthFlow.indexOf('confirmOAuthOnboarding(challenge, userId)')).toBeGreaterThan(-1); + expect(oauthFlow.indexOf('confirmOAuthOnboarding(challenge, candidateUserId)')).toBeGreaterThan(-1); expect(oauthFlow.indexOf('getCurrentPrivacyEligibility(supabase)')).toBeGreaterThan( - oauthFlow.indexOf('confirmOAuthOnboarding(challenge, userId)'), + oauthFlow.indexOf('confirmOAuthOnboarding(challenge, candidateUserId)'), ); expect(callback).toContain('eligibility.receipt.policyVersionId !== challenge.policyVersionId'); expect(callback).toContain('eligibility.receipt.contentSha256 !== challenge.contentSha256'); @@ -624,7 +622,7 @@ describe('G014 server session release boundaries', () => { expect(authModal).toContain('await rejectPrivacyIneligibleSession(existingUserId)'); expect(authModal).toContain('Google 개인정보 확인 계속하기'); }); - test('rejected OAuth callbacks redirect no-store after attempting both sign-outs even when they fail', async () => { + test('ordinary OAuth callback without the server-issued transaction fails closed before a stale session can be used', async () => { oauthRejectionSignOutFails = true; const response = await oauthCallbackGet(oauthRejectionRequest()); @@ -632,15 +630,15 @@ describe('G014 server session release boundaries', () => { expect(response.status).toBe(307); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(response.headers.get('location')).toBe('https://www.tzudong.app/'); - expect(oauthRejectionSignOutScopes).toEqual(['global', 'local']); + expect(oauthRejectionSignOutScopes).toEqual([]); }); - test('rejected OAuth callbacks redirect no-store after both sign-outs resolve', async () => { + test('ordinary OAuth callback without a transaction cannot reach eligibility handling', async () => { const response = await oauthCallbackGet(oauthRejectionRequest()); expect(response.status).toBe(307); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect(response.headers.get('location')).toBe('https://www.tzudong.app/'); - expect(oauthRejectionSignOutScopes).toEqual(['global', 'local']); + expect(oauthRejectionSignOutScopes).toEqual([]); }); test('password onboarding keeps explicit Auth absence checks for identity cleanup', () => { diff --git a/apps/web/tests-unit/privacy-roster-classification.test.ts b/apps/web/tests-unit/privacy-roster-classification.test.ts index 1f74cceb1e..b29fa0c3fa 100644 --- a/apps/web/tests-unit/privacy-roster-classification.test.ts +++ b/apps/web/tests-unit/privacy-roster-classification.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { classifyPrivacyRoster, type RosterClassificationDependencies, @@ -42,7 +43,12 @@ function liveEligibility(): CurrentPrivacyEligibility { }, }; } - +function legacyPublicResultDigest(result: Pick< + StoredRosterClassification, + 'batchId' | 'userId' | 'classification' | 'subjectDigest' | 'receiptDigest' +>) { + return createHash('sha256').update(JSON.stringify(result)).digest('hex'); +} function createDependencies( receipts: readonly (CurrentPrivacyEligibility | Error)[], subjectPseudonymKey = pseudonymKey, @@ -159,7 +165,7 @@ const { dependencies } = createDependencies(Array.from({ length: 32 }, liveEligi ).rejects.toThrow('batch manifest'); }); - test('uses purpose-scoped HMAC pseudonyms and verifies raced durable bindings', async () => { + test('uses purpose-scoped HMAC pseudonyms', async () => { const first = createDependencies(Array.from({ length: 16 }, liveEligibility)); const second = createDependencies( Array.from({ length: 16 }, liveEligibility), @@ -167,22 +173,59 @@ const { dependencies } = createDependencies(Array.from({ length: 32 }, liveEligi ); const firstResult = await classifyPrivacyRoster('batch-pseudonym-a', roster, first.dependencies); const secondResult = await classifyPrivacyRoster('batch-pseudonym-b', roster, second.dependencies); + expect(firstResult.subjects[0]?.subjectDigest).not.toBe(secondResult.subjects[0]?.subjectDigest); + }); + + test('rejects forged coherent stored and raced sink rewrites', async () => { + const storedFixture = createDependencies(Array.from({ length: 16 }, liveEligibility)); + await classifyPrivacyRoster('batch-forged-stored', roster, storedFixture.dependencies); + + const storedKey = 'batch-forged-stored:00000000-0000-4000-8000-000000000001'; + const stored = storedFixture.durable.get(storedKey)!; + const forgedStoredFields = { + batchId: stored.batchId, + userId: stored.userId, + classification: 'held' as const, + subjectDigest: stored.subjectDigest, + receiptDigest: '0'.repeat(64), + }; + const forgedStored: StoredRosterClassification = { + ...forgedStoredFields, + resultDigest: legacyPublicResultDigest(forgedStoredFields), + }; + storedFixture.durable.set(storedKey, forgedStored); - const stored = first.durable.get('batch-pseudonym-a:00000000-0000-4000-8000-000000000001')!; - first.durable.set(stored.batchId + ':' + stored.userId, { - ...stored, - subjectDigest: '0'.repeat(64), - }); await expect( - classifyPrivacyRoster('batch-pseudonym-a', roster, first.dependencies), + classifyPrivacyRoster('batch-forged-stored', roster, storedFixture.dependencies), ).rejects.toThrow('classification is invalid'); - first.durable.set(stored.batchId + ':' + stored.userId, { - ...stored, - resultDigest: '0'.repeat(64), - }); + + const racedFixture = createDependencies(Array.from({ length: 16 }, liveEligibility)); + const racedDependencies: RosterClassificationDependencies = { + ...racedFixture.dependencies, + sink: { + ...racedFixture.dependencies.sink, + putIfAbsent: async (result) => { + const forgedResultFields = { + batchId: result.batchId, + userId: result.userId, + classification: 'held' as const, + subjectDigest: result.subjectDigest, + receiptDigest: 'f'.repeat(64), + }; + return { + inserted: false, + classification: { + ...forgedResultFields, + resultDigest: legacyPublicResultDigest(forgedResultFields), + }, + }; + }, + }, + }; + await expect( - classifyPrivacyRoster('batch-pseudonym-a', roster, first.dependencies), + classifyPrivacyRoster('batch-forged-race', roster, racedDependencies), ).rejects.toThrow('classification is invalid'); }); @@ -228,6 +271,9 @@ const { dependencies } = createDependencies(Array.from({ length: 32 }, liveEligi expect(source).toContain('bindManifestIfAbsent'); expect(source).toContain('createHmac'); expect(source).toContain('putIfAbsent'); + expect(source).toContain("event: 'roster_classification'"); + expect(source).toContain("'roster_conservation_mismatch'"); + expect(source).toContain("emitRosterClassificationEvent(classification, correlationId)"); expect(source).not.toMatch(/\b(?:insert|upsert|delete)\w*\s*\(/i); expect(source).not.toMatch(/\b(?:consent|guardian|marketing|age|profile)\w*\s*[:=]/i); }); From b011d51d4e9208827804a9438e8c80f307a36869 Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 3 Aug 2026 05:39:08 +0900 Subject: [PATCH 5/9] fix(auth): complete correlated failure signals --- apps/web/app/api/auth/oauth/route.ts | 25 +++++++++++++++++-------- apps/web/app/auth/callback/route.ts | 2 +- apps/web/lib/supabase/middleware.ts | 7 ++++++- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/web/app/api/auth/oauth/route.ts b/apps/web/app/api/auth/oauth/route.ts index e7223c0ca6..cb44e323be 100644 --- a/apps/web/app/api/auth/oauth/route.ts +++ b/apps/web/app/api/auth/oauth/route.ts @@ -149,16 +149,25 @@ export async function GET(request: Request) { callback.searchParams.set('next', next); callback.searchParams.set('flow', flow); emitOAuthCallbackEvent('callback_started', correlationId); - const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: callback.toString() } }); - if (error || !data.url) { + try { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: callback.toString() }, + }); + if (error || !data.url) { + emitOAuthCallbackEvent('failed', correlationId); + return rejectedResponse(url.origin); + } + + const response = NextResponse.redirect(data.url); + response.headers.set('Cache-Control', 'no-store'); + for (const write of writes) response.cookies.set(write.name, write.value, write.options); + if (intent === 'login') clearOnboardingCookies(response); + response.cookies.set({ name: OAUTH_TRANSACTION_COOKIE, value: transaction, httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: OAUTH_TRANSACTION_TTL_SECONDS }); + return response; + } catch { emitOAuthCallbackEvent('failed', correlationId); return rejectedResponse(url.origin); } - const response = NextResponse.redirect(data.url); - response.headers.set('Cache-Control', 'no-store'); - for (const write of writes) response.cookies.set(write.name, write.value, write.options); - if (intent === 'login') clearOnboardingCookies(response); - response.cookies.set({ name: OAUTH_TRANSACTION_COOKIE, value: transaction, httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: OAUTH_TRANSACTION_TTL_SECONDS }); - return response; } diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index 6b96ebef70..ded8937dfc 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -314,7 +314,7 @@ export async function GET(request: Request) { || challenge.origin !== origin || !matchingOAuthTransaction(transaction, challenge, callback) )) { - emitCallbackPrivacyAuthEvent('failed', freshCorrelationId); + emitCallbackPrivacyAuthEvent('failed', correlationId); return rejectedCallbackRedirect(request, origin); } if (callback.providerError || !callback.code) { diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts index 780e93e862..da79eec178 100644 --- a/apps/web/lib/supabase/middleware.ts +++ b/apps/web/lib/supabase/middleware.ts @@ -218,6 +218,7 @@ export async function updateSession( if (!supabaseUrl || !supabaseAnonKey) { if (hasAuthCookie) { + emitMiddlewarePrivacyAuthEvent(request, 'eligibility_error', eligibilityCorrelationId!); return eligibilityFailureResponse(request, createNextResponse()); } if (isProtectedAdminRequest(request) || isMyPageRequest(request)) { @@ -275,7 +276,11 @@ export async function updateSession( if (!hasLivePrivacyEligibilityReceipt(eligibility)) { emitMiddlewarePrivacyAuthEvent( request, - eligibility.reasonCode === 'PRIVACY_POLICY_UNAVAILABLE' ? 'policy_drift' : 'denied', + eligibility.reasonCode === null + ? 'eligibility_error' + : eligibility.reasonCode === 'PRIVACY_POLICY_UNAVAILABLE' + ? 'policy_drift' + : 'denied', eligibilityCorrelationId ?? crypto.randomUUID(), ); await signOutRejectedPrivacySession(supabase); From ee1741fddc369dd6e6f1e49966613b855b91d6a9 Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 3 Aug 2026 15:27:24 +0900 Subject: [PATCH 6/9] fix(auth): require strong roster pseudonym keys --- apps/web/lib/privacy/roster-classification.ts | 7 +++++-- .../web/tests-unit/privacy-roster-classification.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/privacy/roster-classification.ts b/apps/web/lib/privacy/roster-classification.ts index ea4227701c..613839f293 100644 --- a/apps/web/lib/privacy/roster-classification.ts +++ b/apps/web/lib/privacy/roster-classification.ts @@ -217,8 +217,11 @@ export async function classifyPrivacyRoster( ): Promise { const correlationId = randomUUID(); const normalizedUserIds = validateManifest(batchId, userIds); - if (!(dependencies.subjectPseudonymKey instanceof Uint8Array) || dependencies.subjectPseudonymKey.byteLength === 0) { - throw new Error('A non-empty server-held subject pseudonym key is required.'); + if ( + !(dependencies.subjectPseudonymKey instanceof Uint8Array) + || dependencies.subjectPseudonymKey.byteLength < 32 + ) { + throw new Error('A server-held subject pseudonym key of at least 32 bytes is required.'); } const canonicalManifestDigest = manifestDigest(normalizedUserIds); diff --git a/apps/web/tests-unit/privacy-roster-classification.test.ts b/apps/web/tests-unit/privacy-roster-classification.test.ts index b29fa0c3fa..b5bc36b693 100644 --- a/apps/web/tests-unit/privacy-roster-classification.test.ts +++ b/apps/web/tests-unit/privacy-roster-classification.test.ts @@ -93,6 +93,15 @@ describe('classifyPrivacyRoster', () => { await expect(classifyPrivacyRoster('batch-1', [...roster.slice(0, 15), 'not-a-uuid'], dependencies)).rejects.toThrow('UUIDs'); }); + test('rejects undersized subject pseudonym keys', async () => { + const { dependencies } = createDependencies([]); + + await expect(classifyPrivacyRoster('batch-short-key', roster, { + ...dependencies, + subjectPseudonymKey: new Uint8Array([1]), + })).rejects.toThrow('at least 32 bytes'); + }); + test('requires a live current-policy schema-v1 receipt for eligible classification', async () => { const live = liveEligibility(); const stale: CurrentPrivacyEligibility = { From 13d0a4939ec2f3d14e30b32f7f046d7025e3fc1d Mon Sep 17 00:00:00 2001 From: twoimo Date: Sat, 8 Aug 2026 20:35:09 +0900 Subject: [PATCH 7/9] fix(ci): restore typed nightly test fixtures --- apps/web/app/home-client-effects.tsx | 2 +- apps/web/app/home-client.tsx | 4 ++-- apps/web/tests/nightly/nightly-test.ts | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 apps/web/tests/nightly/nightly-test.ts diff --git a/apps/web/app/home-client-effects.tsx b/apps/web/app/home-client-effects.tsx index 71af299761..dc309606ce 100644 --- a/apps/web/app/home-client-effects.tsx +++ b/apps/web/app/home-client-effects.tsx @@ -339,7 +339,7 @@ export default function HomeClientEffects({ clearRegisteredRequestKeys(); timers.forEach((timer) => window.clearTimeout(timer)); }; - }, [mapMode, openDetailPanelRef, openPanelRef, router, searchParams, setMapMode, setSelectedAnnouncement]); + }, [closeAllPanels, mapMode, openDetailPanelRef, openPanelRef, router, searchParams, setMapMode, setSelectedAnnouncement]); useEffect(() => { const handleChangeMapMode = (event: Event) => { diff --git a/apps/web/app/home-client.tsx b/apps/web/app/home-client.tsx index f57904a5d0..1310751652 100644 --- a/apps/web/app/home-client.tsx +++ b/apps/web/app/home-client.tsx @@ -1215,12 +1215,12 @@ export default function HomeClient() { if (isPublicRestrictedMode) return; handlers.handleRequestEditRestaurant(restaurant); }, - [handlers.handleRequestEditRestaurant], + [handlers], ); const handleReviewModalOpen = useCallback(() => { if (isPublicRestrictedMode) return; state.setIsReviewModalOpen(true); - }, []); + }, [state]); return ( <> diff --git a/apps/web/tests/nightly/nightly-test.ts b/apps/web/tests/nightly/nightly-test.ts new file mode 100644 index 0000000000..01f929ea05 --- /dev/null +++ b/apps/web/tests/nightly/nightly-test.ts @@ -0,0 +1,17 @@ +import { expect, test as base } from '@playwright/test'; +import type { Page, TestInfo } from '@playwright/test'; + +type NightlyFixtures = { + isMobile: boolean; +}; + +export const test = base.extend({ + // eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright fixture callbacks use a non-React `use` function. + isMobile: async ({}, useIsMobile, testInfo) => { + // eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright fixture callbacks use a non-React `use` function. + await useIsMobile(testInfo.project.use.isMobile === true); + }, +}); + +export { expect }; +export type { Page, TestInfo }; From e79f328b089efd633d8d3bfd2643298e28dd51e9 Mon Sep 17 00:00:00 2001 From: twoimo Date: Sat, 8 Aug 2026 20:53:20 +0900 Subject: [PATCH 8/9] test(ci): align announcement source contract --- apps/web/tests-unit/admin-announcements-console-source.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests-unit/admin-announcements-console-source.test.ts b/apps/web/tests-unit/admin-announcements-console-source.test.ts index eadb49c6cf..ae80192e47 100644 --- a/apps/web/tests-unit/admin-announcements-console-source.test.ts +++ b/apps/web/tests-unit/admin-announcements-console-source.test.ts @@ -69,7 +69,7 @@ describe('admin announcements console integration source contract', () => { expect(headerSource).toContain('AnnouncementPanelLoadingFallback'); expect(headerSource).toContain('HeaderAnnouncementPanel ?'); expect(desktopControlPanelSource).toContain('AnnouncementPanelLoadingFallback'); - expect(desktopControlPanelSource).toContain('activeLeftPanelView === "announcement" ?'); + expect(desktopControlPanelSource).toContain('activeLeftPanelView === "announcement" && !isPublicRestrictedMode ?'); expect(homeSidePanelsSource).toContain('loading: () => Date: Sat, 8 Aug 2026 21:07:07 +0900 Subject: [PATCH 9/9] test(ci): align clean-next environment contract --- apps/web/tests-unit/admin-console-uiux-source.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests-unit/admin-console-uiux-source.test.ts b/apps/web/tests-unit/admin-console-uiux-source.test.ts index 0b01c879c3..caee382ac4 100644 --- a/apps/web/tests-unit/admin-console-uiux-source.test.ts +++ b/apps/web/tests-unit/admin-console-uiux-source.test.ts @@ -387,7 +387,7 @@ describe("admin console beginner-friendly UI/UX source contract", () => { ); expect(nextConfigSource).toContain("turbopackFileSystemCacheForDev: false"); expect(nextConfigSource).toContain("config.cache = false;"); - expect(cleanNextSource).toContain("childEnv.NODE_ENV = 'development';"); + expect(cleanNextSource).toContain("childEnv.NODE_ENV = nightlyLocalEnvOnly ? 'test' : 'development';"); expect(cleanNextSource).toContain("if (isNextDevCommand())"); expect(devPrewarmSource).toContain( "const shouldUseWebpackDev = !hasFlag('--turbopack') && !hasFlag('--turbo');",