Skip to content

Commit f8a331c

Browse files
chitcommitclaude
andauthored
feat: ChittyID SSO via OAuth 2.0 PKCE (#72)
Adds ChittyID single sign-on alongside existing email/password auth. Backend: - New /api/auth/chittyid/authorize + /callback routes (OAuth 2.0 + PKCE S256) - getUserByChittyId + linkChittyId storage methods for user resolution - PKCE verifier stored in KV with 10-min TTL - Auto-links ChittyID to existing users matched by email - Session response now includes chittyId field - CHITTYAUTH_CLIENT_ID + CHITTYAUTH_CLIENT_SECRET env bindings Frontend: - "Sign in with ChittyID" button on Login page (above email/password form) - OAuth error codes mapped to user-friendly messages - URL param error display for callback failures Activation requires: 1. Register OAuth client with ChittyAuth (POST /v1/oauth/register) 2. Set CHITTYAUTH_CLIENT_ID + CHITTYAUTH_CLIENT_SECRET as Worker secrets Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7070253 commit f8a331c

6 files changed

Lines changed: 265 additions & 35 deletions

File tree

client/src/pages/Login.tsx

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,26 @@ import { Input } from "@/components/ui/input";
44
import { Label } from "@/components/ui/label";
55
import { useLocation } from "wouter";
66

7+
const ERROR_MESSAGES: Record<string, string> = {
8+
invalid_credentials: "Invalid email or password",
9+
no_account: "No ChittyFinance account linked to this ChittyID",
10+
account_disabled: "Account is disabled",
11+
token_exchange: "Authentication failed — try again",
12+
auth_unavailable: "ChittyID service unavailable",
13+
invalid_state: "Session expired — try again",
14+
expired_session: "Session expired — try again",
15+
no_identity: "Could not retrieve identity",
16+
};
17+
718
export default function Login() {
819
const [, setLocation] = useLocation();
920
const [email, setEmail] = useState("");
1021
const [password, setPassword] = useState("");
11-
const [error, setError] = useState("");
22+
const [error, setError] = useState(() => {
23+
const params = new URLSearchParams(window.location.search);
24+
const err = params.get("error");
25+
return err ? (ERROR_MESSAGES[err] || err) : "";
26+
});
1227
const [loading, setLoading] = useState(false);
1328

1429
const handleSubmit = async (e: React.FormEvent) => {
@@ -26,11 +41,10 @@ export default function Login() {
2641

2742
if (!res.ok) {
2843
const data = await res.json().catch(() => null) as { error?: string } | null;
29-
setError(data?.error === "invalid_credentials" ? "Invalid email or password" : "Login failed");
44+
setError(ERROR_MESSAGES[data?.error || ""] || "Login failed");
3045
return;
3146
}
3247

33-
// Session cookie is set — reload to pick up auth state
3448
window.location.href = "/";
3549
} catch {
3650
setError("Network error");
@@ -50,45 +64,63 @@ export default function Login() {
5064
<p className="text-xs text-[hsl(var(--cf-text-muted))] mt-1">Sign in to continue</p>
5165
</div>
5266

53-
<form onSubmit={handleSubmit} className="cf-card p-5 space-y-4">
67+
<div className="cf-card p-5 space-y-4">
5468
{error && (
5569
<div className="text-xs text-rose-400 bg-rose-400/10 rounded px-3 py-2">{error}</div>
5670
)}
5771

58-
<div className="space-y-1.5">
59-
<Label htmlFor="email" className="text-xs text-[hsl(var(--cf-text-secondary))]">Email</Label>
60-
<Input
61-
id="email"
62-
type="email"
63-
value={email}
64-
onChange={(e) => setEmail(e.target.value)}
65-
required
66-
autoFocus
67-
className="h-9 text-sm"
68-
placeholder="you@example.com"
69-
/>
70-
</div>
72+
<a
73+
href="/api/auth/chittyid/authorize"
74+
className="flex items-center justify-center gap-2 w-full h-9 rounded-md bg-[hsl(var(--cf-surface))] border border-[hsl(var(--cf-border-subtle))] text-sm font-medium text-[hsl(var(--cf-text))] hover:bg-[hsl(var(--cf-surface-hover))] transition-colors"
75+
>
76+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
77+
<rect width="16" height="16" rx="3" fill="#667eea"/>
78+
<text x="8" y="12" textAnchor="middle" fontSize="10" fontWeight="700" fill="white">ID</text>
79+
</svg>
80+
Sign in with ChittyID
81+
</a>
7182

72-
<div className="space-y-1.5">
73-
<Label htmlFor="password" className="text-xs text-[hsl(var(--cf-text-secondary))]">Password</Label>
74-
<Input
75-
id="password"
76-
type="password"
77-
value={password}
78-
onChange={(e) => setPassword(e.target.value)}
79-
required
80-
className="h-9 text-sm"
81-
/>
83+
<div className="flex items-center gap-3">
84+
<div className="flex-1 h-px bg-[hsl(var(--cf-border-subtle))]" />
85+
<span className="text-[10px] text-[hsl(var(--cf-text-muted))] uppercase tracking-wider">or</span>
86+
<div className="flex-1 h-px bg-[hsl(var(--cf-border-subtle))]" />
8287
</div>
8388

84-
<Button
85-
type="submit"
86-
disabled={loading}
87-
className="w-full bg-lime-500 hover:bg-lime-600 text-black font-medium h-9 text-sm"
88-
>
89-
{loading ? "Signing in..." : "Sign In"}
90-
</Button>
91-
</form>
89+
<form onSubmit={handleSubmit} className="space-y-4">
90+
<div className="space-y-1.5">
91+
<Label htmlFor="email" className="text-xs text-[hsl(var(--cf-text-secondary))]">Email</Label>
92+
<Input
93+
id="email"
94+
type="email"
95+
value={email}
96+
onChange={(e) => setEmail(e.target.value)}
97+
required
98+
className="h-9 text-sm"
99+
placeholder="you@example.com"
100+
/>
101+
</div>
102+
103+
<div className="space-y-1.5">
104+
<Label htmlFor="password" className="text-xs text-[hsl(var(--cf-text-secondary))]">Password</Label>
105+
<Input
106+
id="password"
107+
type="password"
108+
value={password}
109+
onChange={(e) => setPassword(e.target.value)}
110+
required
111+
className="h-9 text-sm"
112+
/>
113+
</div>
114+
115+
<Button
116+
type="submit"
117+
disabled={loading}
118+
className="w-full bg-lime-500 hover:bg-lime-600 text-black font-medium h-9 text-sm"
119+
>
120+
{loading ? "Signing in..." : "Sign In"}
121+
</Button>
122+
</form>
123+
</div>
92124
</div>
93125
</div>
94126
);

server/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { googleRoutes, googleCallbackRoute } from './routes/google';
3434
import { commsRoutes } from './routes/comms';
3535
import { workflowRoutes } from './routes/workflows';
3636
import { leaseRoutes } from './routes/leases';
37+
import { chittyIdAuthRoutes } from './routes/chittyid-auth';
3738
import { createDb } from './db/connection';
3839
import { SystemStorage } from './storage/system';
3940

@@ -68,6 +69,7 @@ export function createApp() {
6869
app.route('/', healthRoutes);
6970
app.route('/', docRoutes);
7071
app.route('/', sessionRoutes);
72+
app.route('/', chittyIdAuthRoutes);
7173

7274
// Redirects (public)
7375
app.get('/connect', (c) => {

server/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface Env {
3232
// SendGrid email
3333
SENDGRID_API_KEY?: string;
3434
SENDGRID_FROM_EMAIL?: string;
35+
// ChittyID OAuth
36+
CHITTYAUTH_CLIENT_ID?: string;
37+
CHITTYAUTH_CLIENT_SECRET?: string;
3538
MODE?: string;
3639
NODE_ENV?: string;
3740
APP_VERSION?: string;

server/routes/chittyid-auth.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { Hono } from 'hono';
2+
import { setCookie } from 'hono/cookie';
3+
import type { HonoEnv } from '../env';
4+
import { createDb } from '../db/connection';
5+
import { SystemStorage } from '../storage/system';
6+
import { SESSION_COOKIE_NAME, SESSION_TTL } from '../lib/session';
7+
import { generateOAuthState, validateOAuthState } from '../lib/oauth-state-edge';
8+
9+
const CHITTYAUTH_BASE = 'https://auth.chitty.cc';
10+
11+
function generateSessionId(): string {
12+
const bytes = new Uint8Array(32);
13+
crypto.getRandomValues(bytes);
14+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
15+
}
16+
17+
/** Generate PKCE code_verifier + code_challenge (S256) */
18+
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
19+
const bytes = new Uint8Array(32);
20+
crypto.getRandomValues(bytes);
21+
const verifier = btoa(String.fromCharCode(...bytes))
22+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
23+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
24+
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
25+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
26+
return { verifier, challenge };
27+
}
28+
29+
export const chittyIdAuthRoutes = new Hono<HonoEnv>();
30+
31+
// GET /api/auth/chittyid/authorize — start OAuth flow
32+
chittyIdAuthRoutes.get('/api/auth/chittyid/authorize', async (c) => {
33+
const clientId = c.env.CHITTYAUTH_CLIENT_ID;
34+
const stateSecret = c.env.OAUTH_STATE_SECRET;
35+
36+
if (!clientId || !stateSecret) {
37+
return c.json({ error: 'ChittyID SSO not configured' }, 503);
38+
}
39+
40+
const baseUrl = c.env.PUBLIC_APP_BASE_URL || new URL(c.req.url).origin;
41+
const redirectUri = `${baseUrl}/api/auth/chittyid/callback`;
42+
43+
// Generate PKCE pair and state token
44+
const { verifier, challenge } = await generatePKCE();
45+
const state = await generateOAuthState('chittyid-login', stateSecret);
46+
47+
// Store verifier in KV (keyed by state) for callback retrieval
48+
const kv = c.env.FINANCE_KV;
49+
await kv.put(`pkce:${state}`, verifier, { expirationTtl: 600 }); // 10 min TTL
50+
51+
const params = new URLSearchParams({
52+
response_type: 'code',
53+
client_id: clientId,
54+
redirect_uri: redirectUri,
55+
state,
56+
code_challenge: challenge,
57+
code_challenge_method: 'S256',
58+
scope: 'chittyid:read',
59+
});
60+
61+
return c.redirect(`${CHITTYAUTH_BASE}/v1/oauth/authorize?${params}`);
62+
});
63+
64+
// GET /api/auth/chittyid/callback — handle OAuth callback
65+
chittyIdAuthRoutes.get('/api/auth/chittyid/callback', async (c) => {
66+
const code = c.req.query('code');
67+
const state = c.req.query('state');
68+
const error = c.req.query('error');
69+
70+
if (error) {
71+
return c.redirect(`/login?error=${encodeURIComponent(error)}`);
72+
}
73+
74+
if (!code || !state) {
75+
return c.redirect('/login?error=missing_params');
76+
}
77+
78+
const stateSecret = c.env.OAUTH_STATE_SECRET;
79+
if (!stateSecret) {
80+
return c.redirect('/login?error=server_config');
81+
}
82+
83+
// Validate CSRF state token
84+
const stateData = await validateOAuthState(state, stateSecret);
85+
if (!stateData) {
86+
return c.redirect('/login?error=invalid_state');
87+
}
88+
89+
// Retrieve PKCE verifier
90+
const kv = c.env.FINANCE_KV;
91+
const verifier = await kv.get(`pkce:${state}`);
92+
await kv.delete(`pkce:${state}`);
93+
if (!verifier) {
94+
return c.redirect('/login?error=expired_session');
95+
}
96+
97+
const clientId = c.env.CHITTYAUTH_CLIENT_ID!;
98+
const clientSecret = c.env.CHITTYAUTH_CLIENT_SECRET;
99+
const baseUrl = c.env.PUBLIC_APP_BASE_URL || new URL(c.req.url).origin;
100+
const redirectUri = `${baseUrl}/api/auth/chittyid/callback`;
101+
102+
// Exchange code for tokens
103+
const tokenBody: Record<string, string> = {
104+
grant_type: 'authorization_code',
105+
code,
106+
redirect_uri: redirectUri,
107+
client_id: clientId,
108+
code_verifier: verifier,
109+
};
110+
if (clientSecret) {
111+
tokenBody.client_secret = clientSecret;
112+
}
113+
114+
let tokenData: { access_token?: string; token_type?: string; error?: string; sub?: string; chitty_id?: string; email?: string };
115+
try {
116+
const tokenRes = await fetch(`${CHITTYAUTH_BASE}/v1/oauth/token`, {
117+
method: 'POST',
118+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
119+
body: new URLSearchParams(tokenBody),
120+
});
121+
tokenData = await tokenRes.json() as typeof tokenData;
122+
if (!tokenRes.ok || tokenData.error) {
123+
console.error('[chittyid-auth] Token exchange failed:', tokenData);
124+
return c.redirect(`/login?error=token_exchange`);
125+
}
126+
} catch (err) {
127+
console.error('[chittyid-auth] Token exchange error:', err);
128+
return c.redirect('/login?error=auth_unavailable');
129+
}
130+
131+
// Extract identity from token response
132+
const chittyId = tokenData.chitty_id || tokenData.sub;
133+
const email = tokenData.email;
134+
135+
if (!chittyId) {
136+
console.error('[chittyid-auth] No chitty_id in token response');
137+
return c.redirect('/login?error=no_identity');
138+
}
139+
140+
// Resolve or provision local user
141+
const db = createDb(c.env.DATABASE_URL);
142+
const storage = new SystemStorage(db);
143+
144+
// 1. Look up by ChittyID
145+
let user = await storage.getUserByChittyId(chittyId);
146+
147+
// 2. Fall back to email match + link ChittyID
148+
if (!user && email) {
149+
user = await storage.getUserByEmail(email.toLowerCase());
150+
if (user && !user.chittyId) {
151+
await storage.linkChittyId(user.id, chittyId);
152+
}
153+
}
154+
155+
if (!user) {
156+
return c.redirect('/login?error=no_account');
157+
}
158+
159+
if (!user.isActive) {
160+
return c.redirect('/login?error=account_disabled');
161+
}
162+
163+
// Create session (same path as password login)
164+
const sessionId = generateSessionId();
165+
await kv.put(`session:${sessionId}`, JSON.stringify({ userId: user.id }), {
166+
expirationTtl: SESSION_TTL,
167+
});
168+
169+
setCookie(c, SESSION_COOKIE_NAME, sessionId, {
170+
path: '/',
171+
httpOnly: true,
172+
secure: new URL(c.req.url).protocol === 'https:',
173+
sameSite: 'Lax',
174+
maxAge: SESSION_TTL,
175+
});
176+
177+
return c.redirect('/');
178+
});

server/routes/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ sessionRoutes.get('/api/session', async (c) => {
5151
email: user.email,
5252
name: user.name,
5353
role: user.role,
54+
chittyId: user.chittyId || null,
5455
});
5556
});
5657

@@ -98,6 +99,7 @@ sessionRoutes.post('/api/session', async (c) => {
9899
email: user.email,
99100
name: user.name,
100101
role: user.role,
102+
chittyId: user.chittyId || null,
101103
});
102104
});
103105

server/storage/system.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,19 @@ export class SystemStorage {
187187
return row;
188188
}
189189

190+
async getUserByChittyId(chittyId: string) {
191+
const [row] = await this.db.select().from(schema.users).where(eq(schema.users.chittyId, chittyId));
192+
return row;
193+
}
194+
195+
async linkChittyId(userId: string, chittyId: string) {
196+
const [row] = await this.db.update(schema.users)
197+
.set({ chittyId })
198+
.where(eq(schema.users.id, userId))
199+
.returning();
200+
return row;
201+
}
202+
190203
// ── INTEGRATIONS ──
191204

192205
async getIntegrations(tenantId: string) {

0 commit comments

Comments
 (0)