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
102 changes: 67 additions & 35 deletions client/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,26 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLocation } from "wouter";

const ERROR_MESSAGES: Record<string, string> = {
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) => {
Expand All @@ -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");
Expand All @@ -50,45 +64,63 @@ export default function Login() {
<p className="text-xs text-[hsl(var(--cf-text-muted))] mt-1">Sign in to continue</p>
</div>

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

<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs text-[hsl(var(--cf-text-secondary))]">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="h-9 text-sm"
placeholder="you@example.com"
/>
</div>
<a
href="/api/auth/chittyid/authorize"
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"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
<rect width="16" height="16" rx="3" fill="#667eea"/>
<text x="8" y="12" textAnchor="middle" fontSize="10" fontWeight="700" fill="white">ID</text>
</svg>
Sign in with ChittyID
</a>

<div className="space-y-1.5">
<Label htmlFor="password" className="text-xs text-[hsl(var(--cf-text-secondary))]">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-9 text-sm"
/>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-[hsl(var(--cf-border-subtle))]" />
<span className="text-[10px] text-[hsl(var(--cf-text-muted))] uppercase tracking-wider">or</span>
<div className="flex-1 h-px bg-[hsl(var(--cf-border-subtle))]" />
</div>

<Button
type="submit"
disabled={loading}
className="w-full bg-lime-500 hover:bg-lime-600 text-black font-medium h-9 text-sm"
>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs text-[hsl(var(--cf-text-secondary))]">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-9 text-sm"
placeholder="you@example.com"
/>
</div>

<div className="space-y-1.5">
<Label htmlFor="password" className="text-xs text-[hsl(var(--cf-text-secondary))]">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-9 text-sm"
/>
</div>

<Button
type="submit"
disabled={loading}
className="w-full bg-lime-500 hover:bg-lime-600 text-black font-medium h-9 text-sm"
>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</div>
</div>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
178 changes: 178 additions & 0 deletions server/routes/chittyid-auth.ts
Original file line number Diff line number Diff line change
@@ -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<HonoEnv>();

// 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<string, string> = {
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('/');
});
2 changes: 2 additions & 0 deletions server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ sessionRoutes.get('/api/session', async (c) => {
email: user.email,
name: user.name,
role: user.role,
chittyId: user.chittyId || null,
});
});

Expand Down Expand Up @@ -98,6 +99,7 @@ sessionRoutes.post('/api/session', async (c) => {
email: user.email,
name: user.name,
role: user.role,
chittyId: user.chittyId || null,
});
});

Expand Down
13 changes: 13 additions & 0 deletions server/storage/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading