Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions apps/web/app/api/auth/oauth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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<string, unknown> }>;

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<PrivacyAuthEventInput['outcomeReason'], 'callback_started' | 'failed'>,
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep OAuth callbacks on the initiating origin

When a user starts Google auth on a production alias whose origin differs from NEXT_PUBLIC_SITE_URL or the default—for example tzudong.app while the canonical origin is www.tzudong.app—this constructs the callback on the other host. The signed transaction and onboarding challenge cookies are host-only, so the callback receives neither and is rejected before code exchange; the release configuration recognizes both production aliases and there is no hostname redirect in next.config.mjs, making Google login unusable on one alias. Use the initiating origin for the callback or canonicalize the browser before creating the cookies.

Useful? React with 👍 / 👎.

callback.searchParams.set('next', next);
callback.searchParams.set('flow', flow);
emitOAuthCallbackEvent('callback_started', correlationId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Count callback starts at the callback boundary

Fresh evidence after the earlier terminal-outcome report is that callback_started is now emitted by OAuth initiation, while the callback route emits failed even for requests with no valid transaction. A user who abandons Google produces a start with no terminal outcome, whereas any unsolicited malformed callback produces a failure with no start, so the callback failure-rate monitor required by privacy-auth-recovery-runbook.md lines 57-58 can be arbitrarily diluted or inflated rather than measuring callback executions. Emit the start when a callback request is accepted into the callback state machine and track initiation separately.

AGENTS.md reference: AGENTS.md:L80-L80

Useful? React with 👍 / 👎.

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);
Comment on lines +157 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a visible OAuth initiation failure

When Supabase rejects OAuth initiation or returns no provider URL—for example during a provider outage or configuration error—this redirects to / without any fixed error marker or UI state. Because the modal has already navigated away, the user sees an unexplained page reload and, during signup, loses the consent selections they just entered; the previous client-side flow displayed a bounded Google-login/signup failure message. Redirect to a loop-safe error state that renders a generic failure, or otherwise preserve a fixed outcome the UI can consume.

Useful? React with 👍 / 👎.

}

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);
}

}
104 changes: 104 additions & 0 deletions apps/web/app/api/auth/password-login/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }>;
type PasswordLoginRequest = Readonly<{ email: string; password: string }>;

function hasExactKeys(value: Record<string, unknown>, 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<string, unknown>;
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow password login from every production alias

When NEXT_PUBLIC_SITE_URL is https://www.tzudong.app, a login submitted from the supported https://tzudong.app alias carries that alias in its Origin header, so isTrustedSameOriginMutation rejects this new endpoint with 403 before checking credentials. The release configuration explicitly recognizes both production aliases and next.config.mjs does not canonicalize one to the other; because the modal now routes all password logins through this POST instead of the browser Supabase client, password login is unusable on the nonconfigured alias. Accept the exact trusted aliases or canonicalize navigation before submitting.

Useful? React with 👍 / 👎.


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);
}
}
Loading
Loading