Skip to content

Commit 8c568fe

Browse files
committed
feat(routes): auth surfaces — sign-in, registration, MFA, passkeys, SSO, SAML, LDAP, device, OIDC
1 parent d4c99ea commit 8c568fe

84 files changed

Lines changed: 9783 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/routes.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type RouteConfig, index, route } from '@react-router/dev/routes';
2+
3+
export default [
4+
index('routes/_index.tsx'),
5+
route('authorize', 'routes/authorize.tsx'),
6+
route('login', 'routes/login.tsx'),
7+
route('login/password', 'routes/login.password.tsx'),
8+
route('signup', 'routes/signup.tsx'),
9+
route('signup/password', 'routes/signup.password.tsx'),
10+
route('password/reset', 'routes/password.reset.tsx'),
11+
route('password/new', 'routes/password.new.tsx'),
12+
route('password/change', 'routes/password.change.tsx'),
13+
route('verify', 'routes/verify.tsx'),
14+
route('verify/success', 'routes/verify.success.tsx'),
15+
route('signed-in', 'routes/signed-in.tsx'),
16+
route('sso', 'routes/sso.tsx'),
17+
route('sso/link', 'routes/sso.link.tsx'),
18+
route('logout', 'routes/logout.tsx'),
19+
route('logout/success', 'routes/logout.success.tsx'),
20+
route('error', 'routes/error.tsx'),
21+
route('sso/:provider/callback', 'routes/sso.$provider.callback.tsx'),
22+
route('sso/:provider/error', 'routes/sso.$provider.error.tsx'),
23+
route('login/mfa', 'routes/login.mfa.tsx'),
24+
route('login/passkey', 'routes/login.passkey.tsx'),
25+
route('login/security-key', 'routes/login.security-key.tsx'),
26+
route('login/verify/authenticator', 'routes/login.verify.authenticator.tsx'),
27+
route('login/verify/email', 'routes/login.verify.email.tsx'),
28+
route('login/verify/sms', 'routes/login.verify.sms.tsx'),
29+
route('setup/passkey', 'routes/setup.passkey.tsx'),
30+
route('setup/security-key', 'routes/setup.security-key.tsx'),
31+
route('setup/authenticator', 'routes/setup.authenticator.tsx'),
32+
route('setup/email', 'routes/setup.email.tsx'),
33+
route('setup/sms', 'routes/setup.sms.tsx'),
34+
route('setup/mfa', 'routes/setup.mfa.tsx'),
35+
route('accounts', 'routes/accounts.tsx'),
36+
// P6 Task 6: device authorization grant — user-code entry screen.
37+
route('device', 'routes/device.tsx'),
38+
// P6 Task 7: device/authorize — consent (authorize / deny).
39+
route('device/authorize', 'routes/device.authorize.tsx'),
40+
// P6 Task 9: LDAP credential-entry route.
41+
route('sso/ldap', 'routes/sso.ldap.tsx'),
42+
// Catch-all uses a separate module — RR7 derives route IDs from file paths,
43+
// so two entries pointing at the same file produce a duplicate-ID error.
44+
route('*', 'routes/catchall.tsx'),
45+
] satisfies RouteConfig;

app/routes/_index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { redirect } from 'react-router';
2+
3+
export function loader() {
4+
return redirect('/login'); // Phase 1 implements /login; until then this falls through to error.tsx
5+
}
6+
7+
export default function Index() {
8+
return null;
9+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { data } from 'react-router';
2+
3+
/**
4+
* Enumeration-safe "check your email" response helper.
5+
*
6+
* SECURITY: Both the new-user path and the duplicate-email (ALREADY_EXISTS) path
7+
* MUST return the same response when email verification is required. Sharing a single
8+
* function in this module makes that structural guarantee explicit — callers cannot
9+
* accidentally diverge by copy-pasting or writing their own version.
10+
*
11+
* Timing hardening (constant-delay no-op) is a Phase 6 item (ENH-01).
12+
*/
13+
export function genericCheckYourEmail(email: string) {
14+
return data({ sent: true as const, email }, { status: 200 });
15+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { resetRequestSchema, newPasswordSchema } from './password';
2+
import { describe, it, expect } from 'vitest';
3+
4+
describe('resetRequestSchema', () => {
5+
it('accepts a valid loginName', () => {
6+
const result = resetRequestSchema.safeParse({ loginName: 'user@example.com' });
7+
expect(result.success).toBe(true);
8+
});
9+
10+
it('rejects an empty loginName', () => {
11+
const result = resetRequestSchema.safeParse({ loginName: '' });
12+
expect(result.success).toBe(false);
13+
});
14+
15+
it('accepts optional organization and requestId', () => {
16+
const result = resetRequestSchema.safeParse({
17+
loginName: 'user@example.com',
18+
organization: 'org-123',
19+
requestId: 'oidc_abc',
20+
});
21+
expect(result.success).toBe(true);
22+
});
23+
});
24+
25+
describe('newPasswordSchema', () => {
26+
it('accepts valid code, userId, and matching passwords', () => {
27+
const result = newPasswordSchema.safeParse({
28+
code: 'RESETCODE',
29+
userId: 'user-1',
30+
password: 'SuperSecret1!',
31+
confirm: 'SuperSecret1!',
32+
});
33+
expect(result.success).toBe(true);
34+
});
35+
36+
it('rejects when code is missing', () => {
37+
const result = newPasswordSchema.safeParse({
38+
code: '',
39+
userId: 'user-1',
40+
password: 'SuperSecret1!',
41+
confirm: 'SuperSecret1!',
42+
});
43+
expect(result.success).toBe(false);
44+
});
45+
46+
it('rejects when userId is missing', () => {
47+
const result = newPasswordSchema.safeParse({
48+
code: 'RESETCODE',
49+
userId: '',
50+
password: 'SuperSecret1!',
51+
confirm: 'SuperSecret1!',
52+
});
53+
expect(result.success).toBe(false);
54+
});
55+
56+
it('rejects when passwords do not match', () => {
57+
const result = newPasswordSchema.safeParse({
58+
code: 'RESETCODE',
59+
userId: 'user-1',
60+
password: 'SuperSecret1!',
61+
confirm: 'DifferentPass2!',
62+
});
63+
expect(result.success).toBe(false);
64+
if (!result.success) {
65+
const paths = result.error.issues.map((i) => i.path.join('.'));
66+
expect(paths).toContain('confirm');
67+
}
68+
});
69+
70+
it('rejects password shorter than 8 characters', () => {
71+
const result = newPasswordSchema.safeParse({
72+
code: 'RESETCODE',
73+
userId: 'user-1',
74+
password: 'short',
75+
confirm: 'short',
76+
});
77+
expect(result.success).toBe(false);
78+
});
79+
});

app/routes/_schemas/password.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { REQUEST_ID_PATTERN } from '../_shared/request-id';
2+
import { z } from 'zod';
3+
4+
export const resetRequestSchema = z.object({
5+
loginName: z.string().min(1),
6+
organization: z.string().optional(),
7+
requestId: z.string().optional(),
8+
});
9+
10+
export const newPasswordSchema = z
11+
.object({
12+
code: z.string().min(1),
13+
userId: z.string().min(1),
14+
password: z.string().min(8),
15+
confirm: z.string().min(8),
16+
organization: z.string().optional(),
17+
// CODE-MIN-24: reject a tampered mid-ceremony requestId at the boundary so it cannot
18+
// forward into /authorize. Allowlist matches the Zitadel-issued prefixes.
19+
requestId: z.string().regex(REQUEST_ID_PATTERN).optional(),
20+
})
21+
.refine((v) => v.password === v.confirm, {
22+
path: ['confirm'],
23+
message: 'Passwords must match',
24+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { registerSchema, signupPasswordSchema } from './register';
2+
import { describe, it, expect } from 'vitest';
3+
4+
describe('registerSchema', () => {
5+
it('accepts a valid registration', () => {
6+
const result = registerSchema.safeParse({
7+
email: 'alice@acme.test',
8+
firstName: 'Alice',
9+
lastName: 'Acme',
10+
});
11+
expect(result.success).toBe(true);
12+
});
13+
14+
it('rejects an invalid email', () => {
15+
const result = registerSchema.safeParse({
16+
email: 'not-an-email',
17+
firstName: 'Alice',
18+
lastName: 'Acme',
19+
});
20+
expect(result.success).toBe(false);
21+
});
22+
23+
it('accepts optional fields when provided', () => {
24+
const result = registerSchema.safeParse({
25+
email: 'alice@acme.test',
26+
firstName: 'Alice',
27+
lastName: 'Acme',
28+
organization: 'org1',
29+
requestId: 'oidc_1',
30+
deviceTrackingToken: 'tok123',
31+
});
32+
expect(result.success).toBe(true);
33+
});
34+
});
35+
36+
describe('signupPasswordSchema', () => {
37+
it('rejects when password and confirm do not match', () => {
38+
const result = signupPasswordSchema.safeParse({
39+
password: 'correct-horse',
40+
confirm: 'wrong-horse',
41+
});
42+
expect(result.success).toBe(false);
43+
if (!result.success) {
44+
const confirmError = result.error.issues.find((i) => i.path.includes('confirm'));
45+
expect(confirmError?.message).toBe('Passwords must match');
46+
}
47+
});
48+
49+
it('accepts when password and confirm match', () => {
50+
const result = signupPasswordSchema.safeParse({
51+
password: 'correct-horse',
52+
confirm: 'correct-horse',
53+
});
54+
expect(result.success).toBe(true);
55+
});
56+
});

app/routes/_schemas/register.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod';
2+
3+
// NOTE: zod 4 ships z.email() as a top-level validator; z.string().email() still works
4+
// (it delegates to z.email() internally) — we keep the string().email() form here
5+
// because it keeps the schema consistent with the other string fields and satisfies
6+
// the locked spec. If z.string().email() is deprecated in a future minor, swap to
7+
// z.email() and drop the z.string() wrapper.
8+
export const registerSchema = z.object({
9+
email: z.string().email(),
10+
firstName: z.string().min(1),
11+
lastName: z.string().min(1),
12+
deviceTrackingToken: z.string().optional(),
13+
requestId: z.string().optional(),
14+
organization: z.string().optional(),
15+
});
16+
17+
export const signupPasswordSchema = z
18+
.object({ password: z.string().min(8), confirm: z.string().min(8) })
19+
.refine((v) => v.password === v.confirm, { path: ['confirm'], message: 'Passwords must match' });

app/routes/_schemas/verify.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { verifyCodeSchema, changePasswordSchema } from './verify';
2+
import { describe, it, expect } from 'vitest';
3+
4+
describe('verifyCodeSchema', () => {
5+
it('accepts userId and code at minimum', () => {
6+
const result = verifyCodeSchema.safeParse({ userId: 'u1', code: '123456' });
7+
expect(result.success).toBe(true);
8+
});
9+
10+
it('accepts optional invite flag', () => {
11+
const result = verifyCodeSchema.safeParse({ userId: 'u1', code: '123456', invite: 'true' });
12+
expect(result.success).toBe(true);
13+
});
14+
15+
it('rejects missing userId', () => {
16+
const result = verifyCodeSchema.safeParse({ code: '123456' });
17+
expect(result.success).toBe(false);
18+
});
19+
20+
it('rejects missing code', () => {
21+
const result = verifyCodeSchema.safeParse({ userId: 'u1' });
22+
expect(result.success).toBe(false);
23+
});
24+
});
25+
26+
describe('changePasswordSchema', () => {
27+
it('accepts matching passwords with a sessionId', () => {
28+
const result = changePasswordSchema.safeParse({
29+
sessionId: 'sess1',
30+
password: 'Secret123',
31+
confirm: 'Secret123',
32+
});
33+
expect(result.success).toBe(true);
34+
});
35+
36+
it('rejects mismatched confirm', () => {
37+
const result = changePasswordSchema.safeParse({
38+
sessionId: 'sess1',
39+
password: 'Secret123',
40+
confirm: 'Different1',
41+
});
42+
expect(result.success).toBe(false);
43+
if (!result.success) {
44+
expect(result.error.issues[0].path).toContain('confirm');
45+
}
46+
});
47+
48+
it('rejects password shorter than 8 characters', () => {
49+
const result = changePasswordSchema.safeParse({
50+
sessionId: 'sess1',
51+
password: 'short',
52+
confirm: 'short',
53+
});
54+
expect(result.success).toBe(false);
55+
});
56+
});

app/routes/_schemas/verify.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod';
2+
3+
export const verifyCodeSchema = z.object({
4+
userId: z.string().min(1),
5+
code: z.string().min(1),
6+
invite: z.enum(['true', 'false']).optional(),
7+
loginName: z.string().optional(),
8+
organization: z.string().optional(),
9+
requestId: z.string().optional(),
10+
});
11+
12+
export const changePasswordSchema = z
13+
.object({
14+
sessionId: z.string().min(1),
15+
password: z.string().min(8),
16+
confirm: z.string().min(8),
17+
requestId: z.string().optional(),
18+
})
19+
.refine((v) => v.password === v.confirm, { path: ['confirm'], message: 'Passwords must match' });
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { trustedAppOrigin } from './app-origin.server';
2+
import { describe, it, expect } from 'vitest';
3+
4+
describe('trustedAppOrigin', () => {
5+
it('returns the trusted publicOrigin and IGNORES the request Host (anti-injection)', () => {
6+
// SECURITY: even when the attacker spoofs the request Host (and thus the URL host),
7+
// the returned origin is the trusted config value — never the request host. This is
8+
// the property that closes the Host-header verification-link injection.
9+
const request = new Request('https://attacker.example/id/signup');
10+
const origin = trustedAppOrigin(request, 'https://auth.datum.net');
11+
expect(origin).toBe('https://auth.datum.net');
12+
expect(origin).not.toContain('attacker.example');
13+
});
14+
15+
it('honors a non-https trusted origin verbatim (e.g. local dev)', () => {
16+
const request = new Request('http://evil.test/id/signup');
17+
expect(trustedAppOrigin(request, 'http://localhost:3000')).toBe('http://localhost:3000');
18+
});
19+
20+
it('falls back to the request origin when publicOrigin is undefined (dev/test/fake)', () => {
21+
// In dev/test/fake PUBLIC_ORIGIN is optional, so the request origin is the fallback.
22+
const request = new Request('http://localhost:3000/id/signup?x=1');
23+
expect(trustedAppOrigin(request, undefined)).toBe('http://localhost:3000');
24+
});
25+
});

0 commit comments

Comments
 (0)