diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 3081850..f02e200 100755 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -4,11 +4,26 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useLocation } from "wouter"; +const ERROR_MESSAGES: Record = { + invalid_credentials: "Invalid email or password", + no_account: "No ChittyFinance account linked to this ChittyID", + account_disabled: "Account is disabled", + token_exchange: "Authentication failed — try again", + auth_unavailable: "ChittyID service unavailable", + invalid_state: "Session expired — try again", + expired_session: "Session expired — try again", + no_identity: "Could not retrieve identity", +}; + export default function Login() { const [, setLocation] = useLocation(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(""); + const [error, setError] = useState(() => { + const params = new URLSearchParams(window.location.search); + const err = params.get("error"); + return err ? (ERROR_MESSAGES[err] || err) : ""; + }); const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { @@ -26,11 +41,10 @@ export default function Login() { if (!res.ok) { const data = await res.json().catch(() => null) as { error?: string } | null; - setError(data?.error === "invalid_credentials" ? "Invalid email or password" : "Login failed"); + setError(ERROR_MESSAGES[data?.error || ""] || "Login failed"); return; } - // Session cookie is set — reload to pick up auth state window.location.href = "/"; } catch { setError("Network error"); @@ -50,45 +64,63 @@ export default function Login() {

Sign in to continue

-
+
{error && (
{error}
)} -
- - setEmail(e.target.value)} - required - autoFocus - className="h-9 text-sm" - placeholder="you@example.com" - /> -
+ + + + ID + + Sign in with ChittyID + -
- - setPassword(e.target.value)} - required - className="h-9 text-sm" - /> +
+
+ or +
- - +
+
+ + setEmail(e.target.value)} + required + className="h-9 text-sm" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="h-9 text-sm" + /> +
+ + +
+
); diff --git a/server/app.ts b/server/app.ts index e71c7ae..fa67be6 100644 --- a/server/app.ts +++ b/server/app.ts @@ -34,6 +34,7 @@ import { googleRoutes, googleCallbackRoute } from './routes/google'; import { commsRoutes } from './routes/comms'; import { workflowRoutes } from './routes/workflows'; import { leaseRoutes } from './routes/leases'; +import { chittyIdAuthRoutes } from './routes/chittyid-auth'; import { createDb } from './db/connection'; import { SystemStorage } from './storage/system'; @@ -68,6 +69,7 @@ export function createApp() { app.route('/', healthRoutes); app.route('/', docRoutes); app.route('/', sessionRoutes); + app.route('/', chittyIdAuthRoutes); // Redirects (public) app.get('/connect', (c) => { diff --git a/server/env.ts b/server/env.ts index de94dfb..76f0ae6 100644 --- a/server/env.ts +++ b/server/env.ts @@ -32,6 +32,9 @@ export interface Env { // SendGrid email SENDGRID_API_KEY?: string; SENDGRID_FROM_EMAIL?: string; + // ChittyID OAuth + CHITTYAUTH_CLIENT_ID?: string; + CHITTYAUTH_CLIENT_SECRET?: string; MODE?: string; NODE_ENV?: string; APP_VERSION?: string; diff --git a/server/routes/chittyid-auth.ts b/server/routes/chittyid-auth.ts new file mode 100644 index 0000000..adcc70e --- /dev/null +++ b/server/routes/chittyid-auth.ts @@ -0,0 +1,178 @@ +import { Hono } from 'hono'; +import { setCookie } from 'hono/cookie'; +import type { HonoEnv } from '../env'; +import { createDb } from '../db/connection'; +import { SystemStorage } from '../storage/system'; +import { SESSION_COOKIE_NAME, SESSION_TTL } from '../lib/session'; +import { generateOAuthState, validateOAuthState } from '../lib/oauth-state-edge'; + +const CHITTYAUTH_BASE = 'https://auth.chitty.cc'; + +function generateSessionId(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** Generate PKCE code_verifier + code_challenge (S256) */ +async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const verifier = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return { verifier, challenge }; +} + +export const chittyIdAuthRoutes = new Hono(); + +// GET /api/auth/chittyid/authorize — start OAuth flow +chittyIdAuthRoutes.get('/api/auth/chittyid/authorize', async (c) => { + const clientId = c.env.CHITTYAUTH_CLIENT_ID; + const stateSecret = c.env.OAUTH_STATE_SECRET; + + if (!clientId || !stateSecret) { + return c.json({ error: 'ChittyID SSO not configured' }, 503); + } + + const baseUrl = c.env.PUBLIC_APP_BASE_URL || new URL(c.req.url).origin; + const redirectUri = `${baseUrl}/api/auth/chittyid/callback`; + + // Generate PKCE pair and state token + const { verifier, challenge } = await generatePKCE(); + const state = await generateOAuthState('chittyid-login', stateSecret); + + // Store verifier in KV (keyed by state) for callback retrieval + const kv = c.env.FINANCE_KV; + await kv.put(`pkce:${state}`, verifier, { expirationTtl: 600 }); // 10 min TTL + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + scope: 'chittyid:read', + }); + + return c.redirect(`${CHITTYAUTH_BASE}/v1/oauth/authorize?${params}`); +}); + +// GET /api/auth/chittyid/callback — handle OAuth callback +chittyIdAuthRoutes.get('/api/auth/chittyid/callback', async (c) => { + const code = c.req.query('code'); + const state = c.req.query('state'); + const error = c.req.query('error'); + + if (error) { + return c.redirect(`/login?error=${encodeURIComponent(error)}`); + } + + if (!code || !state) { + return c.redirect('/login?error=missing_params'); + } + + const stateSecret = c.env.OAUTH_STATE_SECRET; + if (!stateSecret) { + return c.redirect('/login?error=server_config'); + } + + // Validate CSRF state token + const stateData = await validateOAuthState(state, stateSecret); + if (!stateData) { + return c.redirect('/login?error=invalid_state'); + } + + // Retrieve PKCE verifier + const kv = c.env.FINANCE_KV; + const verifier = await kv.get(`pkce:${state}`); + await kv.delete(`pkce:${state}`); + if (!verifier) { + return c.redirect('/login?error=expired_session'); + } + + const clientId = c.env.CHITTYAUTH_CLIENT_ID!; + const clientSecret = c.env.CHITTYAUTH_CLIENT_SECRET; + const baseUrl = c.env.PUBLIC_APP_BASE_URL || new URL(c.req.url).origin; + const redirectUri = `${baseUrl}/api/auth/chittyid/callback`; + + // Exchange code for tokens + const tokenBody: Record = { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier, + }; + if (clientSecret) { + tokenBody.client_secret = clientSecret; + } + + let tokenData: { access_token?: string; token_type?: string; error?: string; sub?: string; chitty_id?: string; email?: string }; + try { + const tokenRes = await fetch(`${CHITTYAUTH_BASE}/v1/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(tokenBody), + }); + tokenData = await tokenRes.json() as typeof tokenData; + if (!tokenRes.ok || tokenData.error) { + console.error('[chittyid-auth] Token exchange failed:', tokenData); + return c.redirect(`/login?error=token_exchange`); + } + } catch (err) { + console.error('[chittyid-auth] Token exchange error:', err); + return c.redirect('/login?error=auth_unavailable'); + } + + // Extract identity from token response + const chittyId = tokenData.chitty_id || tokenData.sub; + const email = tokenData.email; + + if (!chittyId) { + console.error('[chittyid-auth] No chitty_id in token response'); + return c.redirect('/login?error=no_identity'); + } + + // Resolve or provision local user + const db = createDb(c.env.DATABASE_URL); + const storage = new SystemStorage(db); + + // 1. Look up by ChittyID + let user = await storage.getUserByChittyId(chittyId); + + // 2. Fall back to email match + link ChittyID + if (!user && email) { + user = await storage.getUserByEmail(email.toLowerCase()); + if (user && !user.chittyId) { + await storage.linkChittyId(user.id, chittyId); + } + } + + if (!user) { + return c.redirect('/login?error=no_account'); + } + + if (!user.isActive) { + return c.redirect('/login?error=account_disabled'); + } + + // Create session (same path as password login) + const sessionId = generateSessionId(); + await kv.put(`session:${sessionId}`, JSON.stringify({ userId: user.id }), { + expirationTtl: SESSION_TTL, + }); + + setCookie(c, SESSION_COOKIE_NAME, sessionId, { + path: '/', + httpOnly: true, + secure: new URL(c.req.url).protocol === 'https:', + sameSite: 'Lax', + maxAge: SESSION_TTL, + }); + + return c.redirect('/'); +}); diff --git a/server/routes/session.ts b/server/routes/session.ts index 7c38430..68e5b9f 100644 --- a/server/routes/session.ts +++ b/server/routes/session.ts @@ -51,6 +51,7 @@ sessionRoutes.get('/api/session', async (c) => { email: user.email, name: user.name, role: user.role, + chittyId: user.chittyId || null, }); }); @@ -98,6 +99,7 @@ sessionRoutes.post('/api/session', async (c) => { email: user.email, name: user.name, role: user.role, + chittyId: user.chittyId || null, }); }); diff --git a/server/storage/system.ts b/server/storage/system.ts index 80bde14..903e393 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -187,6 +187,19 @@ export class SystemStorage { return row; } + async getUserByChittyId(chittyId: string) { + const [row] = await this.db.select().from(schema.users).where(eq(schema.users.chittyId, chittyId)); + return row; + } + + async linkChittyId(userId: string, chittyId: string) { + const [row] = await this.db.update(schema.users) + .set({ chittyId }) + .where(eq(schema.users.id, userId)) + .returning(); + return row; + } + // ── INTEGRATIONS ── async getIntegrations(tenantId: string) {