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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .audit/auth-email-and-demo-code-sign-in.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ts phase decision why evidence result
2026-09-02T00:00:00Z preflight reused the dedicated clean T3 worktree and feature branch it already exactly matched the requested isolation and avoided redundant worktree state git worktree list; git status --short --branch clean t3code/email-otp-demo-code-auth at origin/main
2026-09-02T00:00:01Z design preserved the established auth and admin design system the feature extends existing product surfaces and does not justify a redesign src/pages/LoginPage.tsx; src/pages/AdminPage.tsx implementation split into disjoint existing modules
2026-09-02T00:00:02Z security kept demo login on the existing hashed-code and OTP-handoff backend the plan requires normal Supabase sessions without exposing raw codes or service credentials supabase/functions/demo-code-login/index.ts; e2e/staging/auth.setup.ts shared contract locked before parallel edits
2026-09-07T12:52:55Z security replaced the whole-file role assertion with shared executable policy tests the old check confused the general set-role endpoint with demo creation supabase/functions/admin/demo-code-contract.test.ts; focused Deno test 10 focused Deno tests pass
2026-09-07T12:52:56Z security blocked demo codes from reusing unrelated Auth users and restricted new codes to user role independent review found an account-takeover path and unusable banned-code path supabase/functions/_shared/demo-code.test.ts; supabase/functions/demo-code-login/index.ts focused Deno, typecheck, lint, and component tests pass
2026-09-07T12:52:57Z database replayed all migrations under the shared lock and ran pgTAP this feature changes grants and a privileged atomic reservation function supabase/tests/demo_access_codes_rls_test.sql 27 database tests pass; local stack stopped
2026-09-07T12:52:58Z verification ran the credential-free release ladder after auth and UI fixes release requires current working-tree proof across frontend and Edge code npm run verify 975 Vitest, 130 Deno, build, and 134 Chromium tests pass
2026-09-07T12:52:59Z staging kept protected staging blocked instead of substituting production all three required STAGING values are absent locally and in the GitHub staging environment docs/testing.md; environment and GitHub configuration name checks BLOCKED pending staging URL, anon key, and dedicated demo code
2026-09-07T13:29:52Z security moved demo-account ownership from editable user_metadata to server-controlled app_metadata user_metadata cannot authorize reuse of an existing Auth account supabase/functions/demo-code-login/index_test.ts red-green proof covers spoofing, correct binding, unrelated-account non-mutation, and new-user binding
2026-09-07T13:29:53Z review preserved admin and banned roles and made attempt-store failures fail closed independent standards and security reviews found two additional privilege and rate-limit failures supabase/functions/demo-code-login/index.ts; index_test.ts 6 handler regressions and 137 total Edge tests pass
2026-09-07T13:29:54Z verification reran database and complete credential-free release gates after the final auth fixes old results do not prove the current tree ./.superset/db-gate.sh; npm run verify 27 pgTAP, 976 Vitest, 137 Deno, build, and 134 Chromium tests pass; local Supabase stopped
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ Do not infer current schema from old README examples or store query names. Repla

Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive.

- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
- RLS owns row visibility and mutation rights.
- Shared backend helpers validate bearer tokens and admin roles.
- Admin endpoints verify server-side roles.
- Stripe webhook requests use Stripe signatures rather than user sessions.
- Demo-code login is a pre-session flow with hashing, rate limits, bounded use, and a rule that demo access cannot grant administrator status.
- Demo-code login is a pre-session flow with hashing, fail-closed rate limits, bounded use, and a rule that demo access cannot grant administrator status. Existing demo accounts are reusable only when server-controlled Auth `app_metadata` binds the user to that code.

## Billing and usage

Expand Down
95 changes: 95 additions & 0 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { expect, test } from '@playwright/test';
import { setLocale } from './helpers/auth';

test.describe('login passwordless flows', () => {
test.beforeEach(async ({ page }) => {
await setLocale(page, 'en');
});

test('keeps Google primary and exposes email and demo-code tabs', async ({ page }) => {
await page.route('**/auth/v1/authorize**', (route) => route.fulfill({ status: 200, body: 'ok' }));
await page.goto('/login', { waitUntil: 'domcontentloaded' });

const googleButton = page.getByRole('button', { name: 'Continue with Google' });
const expectedRedirect = `${new URL(page.url()).origin}/app`;
await expect(googleButton).toBeVisible();
await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Demo code' })).toBeVisible();

const requestPromise = page.waitForRequest('**/auth/v1/authorize**');
await googleButton.click();
const requestUrl = new URL((await requestPromise).url());
expect(requestUrl.searchParams.get('provider')).toBe('google');
expect(requestUrl.searchParams.get('redirect_to')).toBe(expectedRedirect);
});

test('exchanges a demo-code magic-link hash for a Supabase session', async ({ page }) => {
let verifyBody: Record<string, unknown> | undefined;
await page.route('**/functions/v1/demo-code-login', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ email: 'demo@example.com', tokenHash: 'demo-token-hash', type: 'magiclink' }),
}));
await page.route('**/auth/v1/verify', async (route) => {
verifyBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'stub-access',
refresh_token: 'stub-refresh',
expires_in: 3600,
token_type: 'bearer',
user: { id: '00000000-0000-4000-8000-000000000001', email: 'demo@example.com', app_metadata: {}, user_metadata: {} },
}),
});
});

await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.getByRole('tab', { name: 'Demo code' }).click();
await page.getByRole('textbox', { name: 'Demo code' }).fill('demo-ab12-cd34');
await page.getByRole('button', { name: 'Sign in with demo code' }).click();

await expect.poll(() => verifyBody).toMatchObject({ token_hash: 'demo-token-hash', type: 'magiclink' });
});

test('sends an email code and focuses the verification field', async ({ page }) => {
let requestEmail: string | undefined;
await page.route('**/auth/v1/otp', async (route) => {
const body = route.request().postDataJSON() as { email?: unknown };
requestEmail = typeof body.email === 'string' ? body.email : undefined;
await new Promise((resolve) => setTimeout(resolve, 250));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});

await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByLabel('Email address').fill(' student@example.com ');
await page.getByRole('button', { name: 'Send code' }).click();

await expect(page.getByRole('button', { name: 'Sending code...' })).toBeVisible();
await expect(page.getByText('Code sent', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Verification code' })).toBeFocused();
expect(requestEmail).toBe('student@example.com');
});

test('shows a demo-code backend error while keeping the form editable', async ({ page }) => {
await page.route('**/functions/v1/demo-code-login', (route) => route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({ error: 'This demo code has expired.' }),
}));

await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.getByRole('tab', { name: 'Demo code' }).click();
await page.getByRole('textbox', { name: 'Demo code' }).fill('DEMO-AB12-CD34');
await page.getByRole('button', { name: 'Sign in with demo code' }).click();

await expect(page.getByRole('alert')).toHaveText('This demo code has expired.');
await expect(page.getByRole('textbox', { name: 'Demo code' })).toBeEnabled();
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:edge": "deno test --allow-read --allow-env=EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENAI_API_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENAI_API_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:webkit": "playwright test --project=webkit",
Expand Down
101 changes: 101 additions & 0 deletions src/lib/supabase/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,71 @@ interface AuthContextValue {
user: User | null;
loading: boolean;
signInWithGoogle: (options?: { driveAccess?: boolean }) => Promise<void>;
signInWithEmailOtp: (email: string) => Promise<void>;
verifyEmailOtp: (email: string, token: string) => Promise<void>;
signInWithDemoCode: (code: string) => Promise<void>;
signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const AUTH_OPERATION_TIMEOUT_MS = 4000;
const OAUTH_CODE_EXCHANGE_TIMEOUT_MS = 15_000;
const DEMO_FINGERPRINT_STORAGE_KEY = 'debate-demo-browser-fingerprint';

type DemoCodeHandoff = {
email: string;
tokenHash: string;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function parseDemoCodeHandoff(value: unknown): DemoCodeHandoff {
if (!isRecord(value) || typeof value.email !== 'string' || value.type !== 'magiclink') {
throw new Error('Demo sign-in returned an invalid session handoff.');
}

const tokenHash = typeof value.tokenHash === 'string'
? value.tokenHash
: typeof value.token_hash === 'string'
? value.token_hash
: '';
const email = value.email.trim();
if (!email || !tokenHash) throw new Error('Demo sign-in returned an invalid session handoff.');

return {
email,
tokenHash,
};
}

async function getFunctionError(error: unknown) {
if (isRecord(error) && error.context instanceof Response) {
try {
const body: unknown = await error.context.json();
if (isRecord(body) && typeof body.error === 'string' && body.error) return new Error(body.error);
} catch {
// Fall back to the SDK error below when the response is not JSON.
}
}
return error instanceof Error ? error : new Error('Demo sign-in failed.');
}

function getDemoBrowserFingerprint() {
try {
const existing = sessionStorage.getItem(DEMO_FINGERPRINT_STORAGE_KEY);
if (existing) return existing;

const fingerprint = typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem(DEMO_FINGERPRINT_STORAGE_KEY, fingerprint);
return fingerprint;
} catch {
return 'unavailable';
}
}

class AuthTimeoutError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -215,6 +274,48 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {

await openExternalUrl(data.url);
},
signInWithEmailOtp: async (email) => {
const normalizedEmail = email.trim();
if (!normalizedEmail) throw new Error('Email is required.');

const { error } = await supabase.auth.signInWithOtp({
email: normalizedEmail,
options: { shouldCreateUser: false },
});
if (error) throw error;
},
verifyEmailOtp: async (email, token) => {
const normalizedEmail = email.trim();
const normalizedToken = token.trim();
if (!normalizedEmail) throw new Error('Email is required.');
if (!normalizedToken) throw new Error('Verification code is required.');

const { error } = await supabase.auth.verifyOtp({
email: normalizedEmail,
token: normalizedToken,
type: 'email',
});
if (error) throw error;
},
signInWithDemoCode: async (code) => {
const normalizedCode = code.trim().toUpperCase().replace(/\s+/g, '');
if (!normalizedCode) throw new Error('Demo code is required.');

const { data, error } = await supabase.functions.invoke('demo-code-login', {
body: {
code: normalizedCode,
browserFingerprint: getDemoBrowserFingerprint(),
},
});
if (error) throw await getFunctionError(error);

const handoff = parseDemoCodeHandoff(data);
const { error: verificationError } = await supabase.auth.verifyOtp({
token_hash: handoff.tokenHash,
type: 'magiclink',
});
if (verificationError) throw verificationError;
},
signOut: async () => {
await supabase.auth.signOut();
clearCrossfireDraftOnAuthChange(session?.user?.id, undefined);
Expand Down
105 changes: 101 additions & 4 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,105 @@
},
"login": {
"title": "Sign in to DebateLab",
"subtitle": "Google sign-in is required to use the app.",
"google": "Continue with Google"
"subtitle": "Sign in with Google, email, or an admin-issued demo code.",
"google": "Continue with Google",
"googleTab": "Google",
"email": "Email",
"demoCode": "Demo code",
"emailTab": "Email",
"demoTab": "Demo code",
"orContinueWith": "Or continue with",
"emailLabel": "Email address",
"emailPlaceholder": "you@example.com",
"emailHint": "We'll send a six-digit code.",
"sendCode": "Send code",
"sendingCode": "Sending code...",
"resendCode": "Resend code",
"resendingCode": "Resending code...",
"codeSent": "Code sent",
"checkEmail": "Check your email for a six-digit code.",
"codeSentDesc": "Check your email for the six-digit code.",
"useDifferentEmail": "Use a different email",
"otpLabel": "Verification code",
"verificationCode": "Verification code",
"verificationCodePlaceholder": "123456",
"verifyCode": "Verify code",
"verifying": "Verifying...",
"signedIn": "Signed in",
"demoCodeLabel": "Demo code",
"demoCodePlaceholder": "DEMO-AB12-CD34",
"signInWithDemoCode": "Sign in with demo code",
"signingIn": "Signing in...",
"demoDescription": "Use an admin-issued code for temporary demo access.",
"invalidEmail": "Enter a valid email address.",
"invalidCode": "Enter a valid six-digit code.",
"invalidDemoCode": "Enter a valid demo code.",
"requiredEmail": "Enter your email address.",
"emailOtpError": "Could not send code.",
"requiredOtp": "Enter the verification code.",
"verifyOtpError": "Could not verify code.",
"requiredDemoCode": "Enter your demo code.",
"demoError": "Could not sign in with demo code.",
"demoCodeHint": "Enter the code provided by your event host.",
"redirecting": "Redirecting...",
"failed": "Could not start sign in",
"tryAgain": "Please try again.",
"termsNotice": "By continuing, you agree to our Terms and Privacy Policy."
},
"admin": {
"demoAccess": "Demo access",
"createDemoCode": "Create demo code",
"createDemoCodeDescription": "Issue a one-time code for a demo account. The raw code appears only after creation.",
"label": "Label",
"labelPlaceholder": "e.g. September school demo",
"emailOptional": "Assigned email (optional)",
"expiryOptional": "Expiry (optional)",
"maxUsesOptional": "Max uses (optional)",
"unlimited": "Unlimited",
"role": "Role",
"creating": "Creating…",
"created": "Demo code created",
"copyNow": "Copy this code now. It will not be shown again.",
"copied": "Copied",
"copyCode": "Copy code",
"codesIssued_one": "{{count}} code issued",
"codesIssued_other": "{{count}} codes issued",
"loading": "Loading demo codes…",
"loadFailed": "Unable to load demo codes: {{error}}",
"noneTitle": "No demo codes yet",
"noneDescription": "Create a code for your next demo or event.",
"tableCaption": "Issued demo access codes",
"assignedEmail": "Assigned email",
"expiry": "Expiry",
"uses": "Uses",
"lastUsed": "Last used",
"status": "Status",
"actions": "Actions",
"active": "active",
"disabled": "disabled",
"expired": "expired",
"exhausted": "exhausted",
"disable": "Disable",
"resetUses": "Reset uses",
"disableAriaLabel": "Disable demo code {{label}}",
"resetAriaLabel": "Reset use count for {{label}}",
"disableTitle": "Disable demo code?",
"resetTitle": "Reset use count?",
"disableDescription": "People will no longer be able to sign in with {{label}}.",
"resetDescription": "Set {{label}} use count back to zero.",
"cancel": "Cancel",
"disableCode": "Disable code",
"resetUseCount": "Reset use count",
"labelRequired": "Label is required.",
"maxUsesInvalid": "Max uses must be a whole number greater than zero.",
"expiryInvalid": "Expiry must be a valid date and time.",
"serverCodeMissing": "The server did not return the generated demo code.",
"createFailed": "Failed to create demo code.",
"copyFailed": "Could not copy the demo code. Select it and copy it manually.",
"actionFailed": "Demo code action failed.",
"never": "Never",
"invalidDate": "Invalid date",
"thisCode": "this code"
},
"notFound": {
"title": "404",
Expand Down Expand Up @@ -1062,7 +1159,7 @@
"lastUpdated": "Last updated",
"date": "August 31, 2026",
"s1": "Information We Collect",
"p1": "We collect information you provide when creating an account (name, email via Google OAuth) and usage data including generated content, saved items, and interaction patterns.",
"p1": "We collect information you provide when creating an account, including your name and email from Google sign-in or email sign-in, plus usage data such as generated content, saved items, and interaction patterns. If you use an admin-issued demo code, we process that code to authenticate your demo account.",
"s2": "How We Use Your Information",
"p2": "Your information is used to provide and improve our services, generate AI responses, maintain your saved content, and communicate service updates.",
"s3": "AI Processing",
Expand Down Expand Up @@ -1091,7 +1188,7 @@
"s2": "Service Description",
"p2": "DebateLab is an AI-powered debate preparation tool that provides argument generation, analysis, and coaching features.",
"s3": "Account Requirements",
"p3": "You must sign in with Google OAuth. You are responsible for all activity under your account.",
"p3": "You may sign in with Google, email-based sign-in, or an admin-issued demo code. Demo codes provide temporary access to a designated account. You are responsible for all activity under your account.",
"s4": "Subscription and Billing",
"p4": "Free trials last 3 days with limited requests. Pro subscriptions are $5/month plus tax, billed via Stripe. Cancel anytime from Settings.",
"s5": "Acceptable Use",
Expand Down
Loading
Loading