Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

auth.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

auth.tsxBlame786 lines · 3 contributors
06d5ffeClaude1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
7298a17Claude6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
a4f3e24Claude7import { and, eq, isNull, sql } from "drizzle-orm";
06d5ffeClaude8import { db } from "../db";
7298a17Claude9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
06d5ffeClaude16import {
17 hashPassword,
18 verifyPassword,
19 generateSessionToken,
20 sessionCookieOptions,
21 sessionExpiry,
22} from "../lib/auth";
7298a17Claude23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
c63b860Claude24import { cancelAccountDeletion } from "../lib/account-deletion";
46d6165Claude25import { getSsoConfig, getGithubOauthConfig } from "../lib/sso";
06d5ffeClaude26import { Layout } from "../views/layout";
bb0f894Claude27import {
28 Form,
29 FormGroup,
30 Input,
31 Button,
09be7bfClaude32 LinkButton,
bb0f894Claude33 Alert,
34 Text,
35} from "../views/ui";
36cc17aClaude36import { softAuth } from "../middleware/auth";
06d5ffeClaude37import type { AuthEnv } from "../middleware/auth";
38
39const auth = new Hono<AuthEnv>();
40
976d7f7Claude41// One-shot latch — log the auto-verify warning at most once per process,
42// since the misconfiguration is operator-level (env var) and won't change
43// between requests.
44let _autoVerifyWarned = false;
45
06d5ffeClaude46// --- Web UI ---
47
36cc17aClaude48auth.get("/register", softAuth, (c) => {
49 // If the user is already signed in, drop them on their dashboard rather
50 // than rendering the logged-out sign-up shell over an authed session.
51 const existing = c.get("user");
52 if (existing) return c.redirect("/dashboard");
06d5ffeClaude53 const error = c.req.query("error");
134750bClaude54 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude55 return c.html(
36cc17aClaude56 <Layout title="Register" user={null}>
06d5ffeClaude57 <div class="auth-container">
98f45b4Claude58 <h2>Create your account</h2>
59 <p class="auth-subtitle">
60 Get the full AI suite — code review, auto-merge, spec-to-PR — on
61 unlimited public repos. No credit card.
62 </p>
06d5ffeClaude63 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude64 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude65 <FormGroup label="Username" htmlFor="username">
66 <Input
67 id="username"
06d5ffeClaude68 type="text"
69 name="username"
70 required
71 pattern="^[a-zA-Z0-9_-]+$"
72 minLength={2}
73 maxLength={39}
74 placeholder="your-username"
75 autocomplete="username"
76 />
bb0f894Claude77 </FormGroup>
78 <FormGroup label="Email" htmlFor="email">
79 <Input
06d5ffeClaude80 type="email"
81 name="email"
82 required
83 placeholder="you@example.com"
84 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]85 aria-label="Email"
06d5ffeClaude86 />
bb0f894Claude87 </FormGroup>
88 <FormGroup label="Password" htmlFor="password">
89 <Input
06d5ffeClaude90 type="password"
91 name="password"
92 required
93 minLength={8}
94 placeholder="Min 8 characters"
95 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]96 aria-label="Password"
06d5ffeClaude97 />
bb0f894Claude98 </FormGroup>
c63b860Claude99 {/* P3 — Terms / Privacy acceptance. Required client-side via the
100 `required` attribute; server-side re-checked in POST handler. */}
101 <div class="form-group" style="margin: 12px 0">
102 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
103 <input
104 type="checkbox"
105 name="accept_terms"
106 value="1"
107 required
108 style="margin-top: 3px"
109 aria-label="Accept Terms of Service and Privacy Policy"
110 />
111 <span>
112 I agree to the{" "}
2e8a4d5Claude113 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude114 Terms of Service
115 </a>{" "}
116 and{" "}
2e8a4d5Claude117 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude118 Privacy Policy
119 </a>
120 .
121 </span>
122 </label>
123 </div>
bb0f894Claude124 <Button type="submit" variant="primary">
06d5ffeClaude125 Create account
bb0f894Claude126 </Button>
127 </Form>
06d5ffeClaude128 <p class="auth-switch">
bb0f894Claude129 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude130 </p>
131 </div>
132 </Layout>
133 );
134});
135
136auth.post("/register", async (c) => {
137 const body = await c.req.parseBody();
138 const username = String(body.username || "").trim();
139 const email = String(body.email || "").trim();
140 const password = String(body.password || "");
141
142 if (!username || !email || !password) {
143 return c.redirect("/register?error=All+fields+are+required");
144 }
145
c63b860Claude146 // Block P3 — Terms acceptance is required. The form's checkbox has
147 // `required` so browsers normally enforce client-side; the server
148 // re-checks for defensive depth (curl, scripted POST, etc.).
149 if (!body.accept_terms) {
150 return c.redirect(
151 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
152 );
153 }
154
06d5ffeClaude155 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
156 return c.redirect(
157 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
158 );
159 }
160
161 if (password.length < 8) {
162 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
163 }
164
165 // Check existing
166 const [existingUser] = await db
167 .select()
168 .from(users)
169 .where(eq(users.username, username))
170 .limit(1);
171 if (existingUser) {
172 return c.redirect("/register?error=Username+already+taken");
173 }
174
7437605Claude175 // B2: usernames share the URL namespace with org slugs; refuse collisions.
176 const [existingOrg] = await db
177 .select({ id: organizations.id })
178 .from(organizations)
179 .where(eq(organizations.slug, username.toLowerCase()))
180 .limit(1);
181 if (existingOrg) {
182 return c.redirect("/register?error=Username+already+taken");
183 }
184
06d5ffeClaude185 const [existingEmail] = await db
186 .select()
187 .from(users)
188 .where(eq(users.email, email))
189 .limit(1);
190 if (existingEmail) {
191 return c.redirect("/register?error=Email+already+registered");
192 }
193
194 const passwordHash = await hashPassword(password);
195
a4f3e24Claude196 // First user ever registered becomes admin automatically
197 const [userCount] = await db
198 .select({ count: sql`count(*)::int` })
199 .from(users);
200 const isFirstUser = (userCount?.count as number) === 0;
201
06d5ffeClaude202 const [user] = await db
203 .insert(users)
c63b860Claude204 .values({
205 username,
206 email,
207 passwordHash,
208 isAdmin: isFirstUser,
209 // P3 — record terms acceptance now. Version bumps when Terms change.
210 termsAcceptedAt: new Date(),
211 termsVersion: "1.0",
212 })
06d5ffeClaude213 .returning();
214
2d985e5Claude215 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
216 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude217 await import("../lib/admin-bootstrap")
218 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
219 .catch((err) => {
220 console.warn(
221 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
222 err instanceof Error ? err.message : err
223 );
224 });
2d985e5Claude225
06d5ffeClaude226 // Create session
227 const token = generateSessionToken();
228 await db.insert(sessions).values({
229 userId: user.id,
230 token,
231 expiresAt: sessionExpiry(),
232 });
233
234 setCookie(c, "session", token, sessionCookieOptions());
235
976d7f7Claude236 // Block P2 — email verification. If RESEND_API_KEY is configured the
237 // verification email goes out and the user clicks the link to verify.
238 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
239 // etc.), the email would silently never arrive and the user would be
240 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
241 // account on registration so the user can actually use the site.
242 // Operators who want real verification should set EMAIL_PROVIDER=resend
243 // + RESEND_API_KEY in their environment.
244 const { config: _emailConfig } = await import("../lib/config");
245 const emailConfigured =
246 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
247 if (emailConfigured) {
248 import("../lib/email-verification")
249 .then((m) => m.startEmailVerification(user.id, email))
250 .catch((err) => {
251 console.error(
252 `[auth] startEmailVerification failed for ${user.id}:`,
253 err instanceof Error ? err.message : err
254 );
255 });
256 } else {
257 // Auto-verify immediately so the user isn't trapped in an unverified
258 // state. Log once so operators notice the misconfiguration.
259 if (!_autoVerifyWarned) {
260 _autoVerifyWarned = true;
261 console.warn(
262 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
263 );
264 }
265 await db
266 .update(users)
267 .set({ emailVerifiedAt: new Date() })
268 .where(eq(users.id, user.id))
269 .catch((err) => {
270 console.error(
271 `[auth] auto-verify failed for ${user.id}:`,
272 err instanceof Error ? err.message : err
273 );
274 });
275 }
c63b860Claude276
277 // P3 — default landing is /onboarding (the guided first-five-minutes
278 // flow). The `redirect=` query is still honoured for OAuth-style flows.
279 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude280 return c.redirect(redirect);
281});
282
36cc17aClaude283auth.get("/login", softAuth, async (c) => {
284 // Already-authed users hitting the sign-in page get bounced to their
285 // dashboard (or the `redirect=` target if one was supplied).
286 const existing = c.get("user");
06d5ffeClaude287 const error = c.req.query("error");
c63b860Claude288 const success = c.req.query("success");
06d5ffeClaude289 const redirect = c.req.query("redirect") || "";
36cc17aClaude290 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude291 const ssoCfg = await getSsoConfig();
292 const ssoEnabled =
293 !!ssoCfg?.enabled &&
294 !!ssoCfg.authorizationEndpoint &&
295 !!ssoCfg.tokenEndpoint &&
296 !!ssoCfg.userinfoEndpoint &&
297 !!ssoCfg.clientId &&
298 !!ssoCfg.clientSecret;
fc0ca99Claude299 const ssoLabel =
300 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude301 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
302 const githubCfg = await getGithubOauthConfig();
303 const githubEnabled =
304 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
134750bClaude305 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude306 return c.html(
36cc17aClaude307 <Layout title="Sign in" user={null}>
06d5ffeClaude308 <div class="auth-container">
98f45b4Claude309 <h2>Welcome back</h2>
310 <p class="auth-subtitle">
311 Sign in to your gluecron account.
312 </p>
06d5ffeClaude313 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude314 {success && (
315 <div class="auth-success">{decodeURIComponent(success)}</div>
316 )}
0316dbbClaude317 <Form
b9968e3Claude318 method="post"
06d5ffeClaude319 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude320 csrfToken={csrf}
06d5ffeClaude321 >
bb0f894Claude322 <FormGroup label="Username or email" htmlFor="username">
323 <Input
06d5ffeClaude324 type="text"
325 name="username"
326 required
327 placeholder="username or email"
328 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]329 aria-label="Username or email"
06d5ffeClaude330 />
bb0f894Claude331 </FormGroup>
332 <FormGroup label="Password" htmlFor="password">
333 <Input
06d5ffeClaude334 type="password"
335 name="password"
336 required
337 placeholder="Password"
338 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]339 aria-label="Password"
06d5ffeClaude340 />
bb0f894Claude341 </FormGroup>
c63b860Claude342 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
343 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User344 {/* BLOCK Q2 — magic-link sign-in. */}
345 <span style="margin:0 6px;color:var(--text-muted)">·</span>
346 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude347 </div>
bb0f894Claude348 <Button type="submit" variant="primary">
06d5ffeClaude349 Sign in
bb0f894Claude350 </Button>
351 </Form>
46d6165Claude352 {githubEnabled && (
353 <div class="auth-sso">
354 <div class="auth-divider">or</div>
355 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
356 </div>
357 )}
09be7bfClaude358 {ssoEnabled && (
359 <div class="auth-sso">
360 <div class="auth-divider">or</div>
361 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
362 </div>
363 )}
fa06ad2Claude364 <div class="auth-passkey">
365 <div class="auth-divider">or</div>
366 <button type="button" id="pk-signin-btn" class="btn">
367 Sign in with passkey
368 </button>
369 <div
370 id="pk-signin-status"
371 class="auth-status"
372 aria-live="polite"
373 ></div>
374 </div>
06d5ffeClaude375 <p class="auth-switch">
bb0f894Claude376 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude377 </p>
2df1f8cClaude378 <script
379 dangerouslySetInnerHTML={{
380 __html: /* js */ `
381 (function () {
382 const btn = document.getElementById('pk-signin-btn');
383 const status = document.getElementById('pk-signin-status');
384 const userInput = document.getElementById('username');
385 const redirect = ${JSON.stringify(redirect || "/")};
386 if (!btn) return;
387 function b64uToBuf(s) {
388 s = s.replace(/-/g,'+').replace(/_/g,'/');
389 while (s.length % 4) s += '=';
390 const bin = atob(s);
391 const buf = new Uint8Array(bin.length);
392 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
393 return buf.buffer;
394 }
395 function bufToB64u(buf) {
396 const bytes = new Uint8Array(buf);
397 let bin = '';
398 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
399 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
400 }
401 btn.addEventListener('click', async function () {
402 if (!window.PublicKeyCredential) {
403 status.textContent = 'Passkeys not supported in this browser.';
404 return;
405 }
406 status.textContent = 'Preparing…';
407 try {
408 const username = (userInput && userInput.value || '').trim();
409 const optsRes = await fetch('/api/passkeys/auth/options', {
410 method: 'POST',
411 headers: { 'content-type': 'application/json' },
412 body: JSON.stringify(username ? { username: username } : {})
413 });
414 if (!optsRes.ok) throw new Error('options failed');
415 const { options, sessionKey } = await optsRes.json();
416 options.challenge = b64uToBuf(options.challenge);
417 if (options.allowCredentials) {
418 options.allowCredentials = options.allowCredentials.map(function (c) {
419 return Object.assign({}, c, { id: b64uToBuf(c.id) });
420 });
421 }
422 status.textContent = 'Touch your authenticator…';
423 const cred = await navigator.credentials.get({ publicKey: options });
424 const resp = {
425 id: cred.id,
426 rawId: bufToB64u(cred.rawId),
427 type: cred.type,
428 response: {
429 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
430 authenticatorData: bufToB64u(cred.response.authenticatorData),
431 signature: bufToB64u(cred.response.signature),
432 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
433 },
434 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
435 };
436 const verifyRes = await fetch('/api/passkeys/auth/verify', {
437 method: 'POST',
438 headers: { 'content-type': 'application/json' },
439 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
440 });
441 if (!verifyRes.ok) {
442 const j = await verifyRes.json().catch(function () { return {}; });
443 throw new Error(j.error || 'verify failed');
444 }
445 status.textContent = 'Signed in. Redirecting…';
446 window.location.href = redirect;
447 } catch (e) {
448 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
449 }
450 });
451 })();
452 `,
453 }}
454 />
06d5ffeClaude455 </div>
456 </Layout>
457 );
458});
459
460auth.post("/login", async (c) => {
461 const body = await c.req.parseBody();
462 const identifier = String(body.username || "").trim();
463 const password = String(body.password || "");
464 const redirect = c.req.query("redirect") || "/";
465
466 if (!identifier || !password) {
467 return c.redirect("/login?error=All+fields+are+required");
468 }
469
470 // Find user by username or email
471 const isEmail = identifier.includes("@");
472 const [user] = await db
473 .select()
474 .from(users)
475 .where(
476 isEmail
477 ? eq(users.email, identifier)
478 : eq(users.username, identifier)
479 )
480 .limit(1);
481
482 if (!user) {
483 return c.redirect("/login?error=Invalid+credentials");
484 }
485
486 const valid = await verifyPassword(password, user.passwordHash);
487 if (!valid) {
488 return c.redirect("/login?error=Invalid+credentials");
489 }
490
7298a17Claude491 // B4: if the user has TOTP enabled, issue a pending-2fa session and
492 // redirect to the code prompt.
493 const [totp] = await db
494 .select({ enabledAt: userTotp.enabledAt })
495 .from(userTotp)
496 .where(eq(userTotp.userId, user.id))
497 .limit(1);
498 const needs2fa = !!(totp && totp.enabledAt);
499
06d5ffeClaude500 const token = generateSessionToken();
501 await db.insert(sessions).values({
502 userId: user.id,
503 token,
504 expiresAt: sessionExpiry(),
7298a17Claude505 requires2fa: needs2fa,
06d5ffeClaude506 });
507
508 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude509
510 // Block P5 — If account was scheduled for deletion but user signed back
511 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
512 if (user.deletedAt) {
513 await cancelAccountDeletion(user.id);
514 }
515
7298a17Claude516 if (needs2fa) {
517 return c.redirect(
518 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
519 );
520 }
06d5ffeClaude521 return c.redirect(redirect);
522});
523
7298a17Claude524// --- 2FA verify (B4) ---
525auth.get("/login/2fa", async (c) => {
526 const token = getCookie(c, "session");
527 if (!token) return c.redirect("/login");
528 const error = c.req.query("error");
529 const redirect = c.req.query("redirect") || "/";
530 return c.html(
36cc17aClaude531 <Layout title="Two-factor authentication" user={null}>
7298a17Claude532 <div class="auth-container">
533 <h2>Enter your code</h2>
534 <p
535 class="auth-switch"
536 style="margin-bottom: 16px; margin-top: 0"
537 >
538 Open your authenticator app and enter the 6-digit code. Lost your
539 device? Paste a recovery code instead.
540 </p>
541 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
542 <form
b9968e3Claude543 method="post"
7298a17Claude544 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
545 >
134750bClaude546 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude547 <div class="form-group">
548 <label for="code">Code</label>
549 <input
550 type="text"
551 id="code"
552 name="code"
553 required
554 autocomplete="one-time-code"
555 inputmode="numeric"
556 maxLength={24}
557 placeholder="123456 or xxxx-xxxx-xxxx"
558 />
559 </div>
560 <button type="submit" class="btn btn-primary">
561 Verify
562 </button>
563 </form>
564 <p class="auth-switch">
565 <a href="/logout">Cancel</a>
566 </p>
567 </div>
568 </Layout>
569 );
570});
571
572auth.post("/login/2fa", async (c) => {
573 const token = getCookie(c, "session");
574 if (!token) return c.redirect("/login");
575 const body = await c.req.parseBody();
576 const code = String(body.code || "").trim();
577 const redirect = c.req.query("redirect") || "/";
578
579 if (!code) {
580 return c.redirect(
581 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
582 );
583 }
584
585 try {
586 const [session] = await db
587 .select()
588 .from(sessions)
589 .where(eq(sessions.token, token))
590 .limit(1);
591 if (
592 !session ||
593 new Date(session.expiresAt) < new Date() ||
594 !session.requires2fa
595 ) {
596 return c.redirect("/login");
597 }
598
599 const [totp] = await db
600 .select()
601 .from(userTotp)
602 .where(eq(userTotp.userId, session.userId))
603 .limit(1);
604 if (!totp || !totp.enabledAt) {
605 // User doesn't have 2FA actually enabled — clear the flag and let
606 // them in. This can only happen if 2FA was disabled in another
607 // session between password check and code prompt.
608 await db
609 .update(sessions)
610 .set({ requires2fa: false })
611 .where(eq(sessions.token, token));
612 return c.redirect(redirect);
613 }
614
615 // Try TOTP code first.
616 const isSix = /^\d{6}$/.test(code);
617 let ok = false;
618 if (isSix) {
619 ok = await verifyTotpCode(totp.secret, code);
620 }
621 // Fall through to recovery code.
622 if (!ok) {
623 const hash = await hashRecoveryCode(code);
624 const [rec] = await db
625 .select()
626 .from(userRecoveryCodes)
627 .where(
628 and(
629 eq(userRecoveryCodes.userId, session.userId),
630 eq(userRecoveryCodes.codeHash, hash),
631 isNull(userRecoveryCodes.usedAt)
632 )
633 )
634 .limit(1);
635 if (rec) {
636 await db
637 .update(userRecoveryCodes)
638 .set({ usedAt: new Date() })
639 .where(eq(userRecoveryCodes.id, rec.id));
640 ok = true;
641 }
642 }
643
644 if (!ok) {
645 return c.redirect(
646 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
647 );
648 }
649
650 await db
651 .update(sessions)
652 .set({ requires2fa: false })
653 .where(eq(sessions.token, token));
654 await db
655 .update(userTotp)
656 .set({ lastUsedAt: new Date() })
657 .where(eq(userTotp.userId, session.userId));
658
659 return c.redirect(redirect);
660 } catch (err) {
661 console.error("[auth] 2fa verify:", err);
662 return c.redirect(
663 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
664 );
665 }
666});
667
06d5ffeClaude668auth.get("/logout", async (c) => {
669 deleteCookie(c, "session", { path: "/" });
670 return c.redirect("/");
671});
672
673// --- API ---
674
675auth.post("/api/auth/register", async (c) => {
676 const body = await c.req.json<{
677 username: string;
678 email: string;
679 password: string;
680 }>();
681
682 if (!body.username || !body.email || !body.password) {
683 return c.json({ error: "username, email, and password are required" }, 400);
684 }
685
686 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
687 return c.json({ error: "Invalid username" }, 400);
688 }
689
690 if (body.password.length < 8) {
691 return c.json({ error: "Password must be at least 8 characters" }, 400);
692 }
693
694 const [existing] = await db
695 .select()
696 .from(users)
697 .where(eq(users.username, body.username))
698 .limit(1);
699 if (existing) {
700 return c.json({ error: "Username already taken" }, 409);
701 }
702
703 const passwordHash = await hashPassword(body.password);
704 const [user] = await db
705 .insert(users)
706 .values({
707 username: body.username,
708 email: body.email,
709 passwordHash,
710 })
711 .returning();
712
713 const token = generateSessionToken();
714 await db.insert(sessions).values({
715 userId: user.id,
716 token,
717 expiresAt: sessionExpiry(),
718 });
719
720 return c.json(
721 {
722 user: { id: user.id, username: user.username, email: user.email },
723 token,
724 },
725 201
726 );
727});
728
729auth.post("/api/auth/login", async (c) => {
730 const body = await c.req.json<{ username: string; password: string }>();
731
732 if (!body.username || !body.password) {
733 return c.json({ error: "username and password are required" }, 400);
734 }
735
736 const isEmail = body.username.includes("@");
737 const [user] = await db
738 .select()
739 .from(users)
740 .where(
741 isEmail
742 ? eq(users.email, body.username)
743 : eq(users.username, body.username)
744 )
745 .limit(1);
746
747 if (!user) return c.json({ error: "Invalid credentials" }, 401);
748
749 const valid = await verifyPassword(body.password, user.passwordHash);
750 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
751
752 const token = generateSessionToken();
753 await db.insert(sessions).values({
754 userId: user.id,
755 token,
756 expiresAt: sessionExpiry(),
757 });
758
759 return c.json({
760 user: { id: user.id, username: user.username, email: user.email },
761 token,
762 });
763});
764
fc0ca99Claude765/**
766 * Pick a friendly provider name for the "Sign in with X" button when the
767 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
768 * Falls back to undefined so the caller can default to a literal "SSO".
769 */
770function inferSsoProviderName(
771 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
772): string | undefined {
773 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
774 .filter((s): s is string => !!s)
775 .join(" ")
776 .toLowerCase();
777 if (!urls) return undefined;
778 if (urls.includes("google")) return "Google";
779 if (urls.includes("okta")) return "Okta";
780 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
781 if (urls.includes("auth0.com")) return "Auth0";
782 if (urls.includes("authentik")) return "Authentik";
783 return undefined;
784}
785
06d5ffeClaude786export default auth;