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.tsxBlame779 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">
58 <h2>Create account</h2>
59 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude60 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude61 <FormGroup label="Username" htmlFor="username">
62 <Input
63 id="username"
06d5ffeClaude64 type="text"
65 name="username"
66 required
67 pattern="^[a-zA-Z0-9_-]+$"
68 minLength={2}
69 maxLength={39}
70 placeholder="your-username"
71 autocomplete="username"
72 />
bb0f894Claude73 </FormGroup>
74 <FormGroup label="Email" htmlFor="email">
75 <Input
06d5ffeClaude76 type="email"
77 name="email"
78 required
79 placeholder="you@example.com"
80 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]81 aria-label="Email"
06d5ffeClaude82 />
bb0f894Claude83 </FormGroup>
84 <FormGroup label="Password" htmlFor="password">
85 <Input
06d5ffeClaude86 type="password"
87 name="password"
88 required
89 minLength={8}
90 placeholder="Min 8 characters"
91 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]92 aria-label="Password"
06d5ffeClaude93 />
bb0f894Claude94 </FormGroup>
c63b860Claude95 {/* P3 — Terms / Privacy acceptance. Required client-side via the
96 `required` attribute; server-side re-checked in POST handler. */}
97 <div class="form-group" style="margin: 12px 0">
98 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
99 <input
100 type="checkbox"
101 name="accept_terms"
102 value="1"
103 required
104 style="margin-top: 3px"
105 aria-label="Accept Terms of Service and Privacy Policy"
106 />
107 <span>
108 I agree to the{" "}
2e8a4d5Claude109 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude110 Terms of Service
111 </a>{" "}
112 and{" "}
2e8a4d5Claude113 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude114 Privacy Policy
115 </a>
116 .
117 </span>
118 </label>
119 </div>
bb0f894Claude120 <Button type="submit" variant="primary">
06d5ffeClaude121 Create account
bb0f894Claude122 </Button>
123 </Form>
06d5ffeClaude124 <p class="auth-switch">
bb0f894Claude125 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude126 </p>
127 </div>
128 </Layout>
129 );
130});
131
132auth.post("/register", async (c) => {
133 const body = await c.req.parseBody();
134 const username = String(body.username || "").trim();
135 const email = String(body.email || "").trim();
136 const password = String(body.password || "");
137
138 if (!username || !email || !password) {
139 return c.redirect("/register?error=All+fields+are+required");
140 }
141
c63b860Claude142 // Block P3 — Terms acceptance is required. The form's checkbox has
143 // `required` so browsers normally enforce client-side; the server
144 // re-checks for defensive depth (curl, scripted POST, etc.).
145 if (!body.accept_terms) {
146 return c.redirect(
147 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
148 );
149 }
150
06d5ffeClaude151 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
152 return c.redirect(
153 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
154 );
155 }
156
157 if (password.length < 8) {
158 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
159 }
160
161 // Check existing
162 const [existingUser] = await db
163 .select()
164 .from(users)
165 .where(eq(users.username, username))
166 .limit(1);
167 if (existingUser) {
168 return c.redirect("/register?error=Username+already+taken");
169 }
170
7437605Claude171 // B2: usernames share the URL namespace with org slugs; refuse collisions.
172 const [existingOrg] = await db
173 .select({ id: organizations.id })
174 .from(organizations)
175 .where(eq(organizations.slug, username.toLowerCase()))
176 .limit(1);
177 if (existingOrg) {
178 return c.redirect("/register?error=Username+already+taken");
179 }
180
06d5ffeClaude181 const [existingEmail] = await db
182 .select()
183 .from(users)
184 .where(eq(users.email, email))
185 .limit(1);
186 if (existingEmail) {
187 return c.redirect("/register?error=Email+already+registered");
188 }
189
190 const passwordHash = await hashPassword(password);
191
a4f3e24Claude192 // First user ever registered becomes admin automatically
193 const [userCount] = await db
194 .select({ count: sql`count(*)::int` })
195 .from(users);
196 const isFirstUser = (userCount?.count as number) === 0;
197
06d5ffeClaude198 const [user] = await db
199 .insert(users)
c63b860Claude200 .values({
201 username,
202 email,
203 passwordHash,
204 isAdmin: isFirstUser,
205 // P3 — record terms acceptance now. Version bumps when Terms change.
206 termsAcceptedAt: new Date(),
207 termsVersion: "1.0",
208 })
06d5ffeClaude209 .returning();
210
2d985e5Claude211 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
212 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude213 await import("../lib/admin-bootstrap")
214 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
215 .catch((err) => {
216 console.warn(
217 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
218 err instanceof Error ? err.message : err
219 );
220 });
2d985e5Claude221
06d5ffeClaude222 // Create session
223 const token = generateSessionToken();
224 await db.insert(sessions).values({
225 userId: user.id,
226 token,
227 expiresAt: sessionExpiry(),
228 });
229
230 setCookie(c, "session", token, sessionCookieOptions());
231
976d7f7Claude232 // Block P2 — email verification. If RESEND_API_KEY is configured the
233 // verification email goes out and the user clicks the link to verify.
234 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
235 // etc.), the email would silently never arrive and the user would be
236 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
237 // account on registration so the user can actually use the site.
238 // Operators who want real verification should set EMAIL_PROVIDER=resend
239 // + RESEND_API_KEY in their environment.
240 const { config: _emailConfig } = await import("../lib/config");
241 const emailConfigured =
242 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
243 if (emailConfigured) {
244 import("../lib/email-verification")
245 .then((m) => m.startEmailVerification(user.id, email))
246 .catch((err) => {
247 console.error(
248 `[auth] startEmailVerification failed for ${user.id}:`,
249 err instanceof Error ? err.message : err
250 );
251 });
252 } else {
253 // Auto-verify immediately so the user isn't trapped in an unverified
254 // state. Log once so operators notice the misconfiguration.
255 if (!_autoVerifyWarned) {
256 _autoVerifyWarned = true;
257 console.warn(
258 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
259 );
260 }
261 await db
262 .update(users)
263 .set({ emailVerifiedAt: new Date() })
264 .where(eq(users.id, user.id))
265 .catch((err) => {
266 console.error(
267 `[auth] auto-verify failed for ${user.id}:`,
268 err instanceof Error ? err.message : err
269 );
270 });
271 }
c63b860Claude272
273 // P3 — default landing is /onboarding (the guided first-five-minutes
274 // flow). The `redirect=` query is still honoured for OAuth-style flows.
275 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude276 return c.redirect(redirect);
277});
278
36cc17aClaude279auth.get("/login", softAuth, async (c) => {
280 // Already-authed users hitting the sign-in page get bounced to their
281 // dashboard (or the `redirect=` target if one was supplied).
282 const existing = c.get("user");
06d5ffeClaude283 const error = c.req.query("error");
c63b860Claude284 const success = c.req.query("success");
06d5ffeClaude285 const redirect = c.req.query("redirect") || "";
36cc17aClaude286 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude287 const ssoCfg = await getSsoConfig();
288 const ssoEnabled =
289 !!ssoCfg?.enabled &&
290 !!ssoCfg.authorizationEndpoint &&
291 !!ssoCfg.tokenEndpoint &&
292 !!ssoCfg.userinfoEndpoint &&
293 !!ssoCfg.clientId &&
294 !!ssoCfg.clientSecret;
fc0ca99Claude295 const ssoLabel =
296 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude297 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
298 const githubCfg = await getGithubOauthConfig();
299 const githubEnabled =
300 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
134750bClaude301 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude302 return c.html(
36cc17aClaude303 <Layout title="Sign in" user={null}>
06d5ffeClaude304 <div class="auth-container">
305 <h2>Sign in</h2>
306 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude307 {success && (
308 <div class="auth-success">{decodeURIComponent(success)}</div>
309 )}
0316dbbClaude310 <Form
b9968e3Claude311 method="post"
06d5ffeClaude312 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude313 csrfToken={csrf}
06d5ffeClaude314 >
bb0f894Claude315 <FormGroup label="Username or email" htmlFor="username">
316 <Input
06d5ffeClaude317 type="text"
318 name="username"
319 required
320 placeholder="username or email"
321 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]322 aria-label="Username or email"
06d5ffeClaude323 />
bb0f894Claude324 </FormGroup>
325 <FormGroup label="Password" htmlFor="password">
326 <Input
06d5ffeClaude327 type="password"
328 name="password"
329 required
330 placeholder="Password"
331 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]332 aria-label="Password"
06d5ffeClaude333 />
bb0f894Claude334 </FormGroup>
c63b860Claude335 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
336 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User337 {/* BLOCK Q2 — magic-link sign-in. */}
338 <span style="margin:0 6px;color:var(--text-muted)">·</span>
339 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude340 </div>
bb0f894Claude341 <Button type="submit" variant="primary">
06d5ffeClaude342 Sign in
bb0f894Claude343 </Button>
344 </Form>
46d6165Claude345 {githubEnabled && (
346 <div class="auth-sso">
347 <div class="auth-divider">or</div>
348 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
349 </div>
350 )}
09be7bfClaude351 {ssoEnabled && (
352 <div class="auth-sso">
353 <div class="auth-divider">or</div>
354 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
355 </div>
356 )}
fa06ad2Claude357 <div class="auth-passkey">
358 <div class="auth-divider">or</div>
359 <button type="button" id="pk-signin-btn" class="btn">
360 Sign in with passkey
361 </button>
362 <div
363 id="pk-signin-status"
364 class="auth-status"
365 aria-live="polite"
366 ></div>
367 </div>
06d5ffeClaude368 <p class="auth-switch">
bb0f894Claude369 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude370 </p>
2df1f8cClaude371 <script
372 dangerouslySetInnerHTML={{
373 __html: /* js */ `
374 (function () {
375 const btn = document.getElementById('pk-signin-btn');
376 const status = document.getElementById('pk-signin-status');
377 const userInput = document.getElementById('username');
378 const redirect = ${JSON.stringify(redirect || "/")};
379 if (!btn) return;
380 function b64uToBuf(s) {
381 s = s.replace(/-/g,'+').replace(/_/g,'/');
382 while (s.length % 4) s += '=';
383 const bin = atob(s);
384 const buf = new Uint8Array(bin.length);
385 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
386 return buf.buffer;
387 }
388 function bufToB64u(buf) {
389 const bytes = new Uint8Array(buf);
390 let bin = '';
391 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
392 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
393 }
394 btn.addEventListener('click', async function () {
395 if (!window.PublicKeyCredential) {
396 status.textContent = 'Passkeys not supported in this browser.';
397 return;
398 }
399 status.textContent = 'Preparing…';
400 try {
401 const username = (userInput && userInput.value || '').trim();
402 const optsRes = await fetch('/api/passkeys/auth/options', {
403 method: 'POST',
404 headers: { 'content-type': 'application/json' },
405 body: JSON.stringify(username ? { username: username } : {})
406 });
407 if (!optsRes.ok) throw new Error('options failed');
408 const { options, sessionKey } = await optsRes.json();
409 options.challenge = b64uToBuf(options.challenge);
410 if (options.allowCredentials) {
411 options.allowCredentials = options.allowCredentials.map(function (c) {
412 return Object.assign({}, c, { id: b64uToBuf(c.id) });
413 });
414 }
415 status.textContent = 'Touch your authenticator…';
416 const cred = await navigator.credentials.get({ publicKey: options });
417 const resp = {
418 id: cred.id,
419 rawId: bufToB64u(cred.rawId),
420 type: cred.type,
421 response: {
422 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
423 authenticatorData: bufToB64u(cred.response.authenticatorData),
424 signature: bufToB64u(cred.response.signature),
425 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
426 },
427 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
428 };
429 const verifyRes = await fetch('/api/passkeys/auth/verify', {
430 method: 'POST',
431 headers: { 'content-type': 'application/json' },
432 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
433 });
434 if (!verifyRes.ok) {
435 const j = await verifyRes.json().catch(function () { return {}; });
436 throw new Error(j.error || 'verify failed');
437 }
438 status.textContent = 'Signed in. Redirecting…';
439 window.location.href = redirect;
440 } catch (e) {
441 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
442 }
443 });
444 })();
445 `,
446 }}
447 />
06d5ffeClaude448 </div>
449 </Layout>
450 );
451});
452
453auth.post("/login", async (c) => {
454 const body = await c.req.parseBody();
455 const identifier = String(body.username || "").trim();
456 const password = String(body.password || "");
457 const redirect = c.req.query("redirect") || "/";
458
459 if (!identifier || !password) {
460 return c.redirect("/login?error=All+fields+are+required");
461 }
462
463 // Find user by username or email
464 const isEmail = identifier.includes("@");
465 const [user] = await db
466 .select()
467 .from(users)
468 .where(
469 isEmail
470 ? eq(users.email, identifier)
471 : eq(users.username, identifier)
472 )
473 .limit(1);
474
475 if (!user) {
476 return c.redirect("/login?error=Invalid+credentials");
477 }
478
479 const valid = await verifyPassword(password, user.passwordHash);
480 if (!valid) {
481 return c.redirect("/login?error=Invalid+credentials");
482 }
483
7298a17Claude484 // B4: if the user has TOTP enabled, issue a pending-2fa session and
485 // redirect to the code prompt.
486 const [totp] = await db
487 .select({ enabledAt: userTotp.enabledAt })
488 .from(userTotp)
489 .where(eq(userTotp.userId, user.id))
490 .limit(1);
491 const needs2fa = !!(totp && totp.enabledAt);
492
06d5ffeClaude493 const token = generateSessionToken();
494 await db.insert(sessions).values({
495 userId: user.id,
496 token,
497 expiresAt: sessionExpiry(),
7298a17Claude498 requires2fa: needs2fa,
06d5ffeClaude499 });
500
501 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude502
503 // Block P5 — If account was scheduled for deletion but user signed back
504 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
505 if (user.deletedAt) {
506 await cancelAccountDeletion(user.id);
507 }
508
7298a17Claude509 if (needs2fa) {
510 return c.redirect(
511 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
512 );
513 }
06d5ffeClaude514 return c.redirect(redirect);
515});
516
7298a17Claude517// --- 2FA verify (B4) ---
518auth.get("/login/2fa", async (c) => {
519 const token = getCookie(c, "session");
520 if (!token) return c.redirect("/login");
521 const error = c.req.query("error");
522 const redirect = c.req.query("redirect") || "/";
523 return c.html(
36cc17aClaude524 <Layout title="Two-factor authentication" user={null}>
7298a17Claude525 <div class="auth-container">
526 <h2>Enter your code</h2>
527 <p
528 class="auth-switch"
529 style="margin-bottom: 16px; margin-top: 0"
530 >
531 Open your authenticator app and enter the 6-digit code. Lost your
532 device? Paste a recovery code instead.
533 </p>
534 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
535 <form
b9968e3Claude536 method="post"
7298a17Claude537 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
538 >
134750bClaude539 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude540 <div class="form-group">
541 <label for="code">Code</label>
542 <input
543 type="text"
544 id="code"
545 name="code"
546 required
547 autocomplete="one-time-code"
548 inputmode="numeric"
549 maxLength={24}
550 placeholder="123456 or xxxx-xxxx-xxxx"
551 />
552 </div>
553 <button type="submit" class="btn btn-primary">
554 Verify
555 </button>
556 </form>
557 <p class="auth-switch">
558 <a href="/logout">Cancel</a>
559 </p>
560 </div>
561 </Layout>
562 );
563});
564
565auth.post("/login/2fa", async (c) => {
566 const token = getCookie(c, "session");
567 if (!token) return c.redirect("/login");
568 const body = await c.req.parseBody();
569 const code = String(body.code || "").trim();
570 const redirect = c.req.query("redirect") || "/";
571
572 if (!code) {
573 return c.redirect(
574 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
575 );
576 }
577
578 try {
579 const [session] = await db
580 .select()
581 .from(sessions)
582 .where(eq(sessions.token, token))
583 .limit(1);
584 if (
585 !session ||
586 new Date(session.expiresAt) < new Date() ||
587 !session.requires2fa
588 ) {
589 return c.redirect("/login");
590 }
591
592 const [totp] = await db
593 .select()
594 .from(userTotp)
595 .where(eq(userTotp.userId, session.userId))
596 .limit(1);
597 if (!totp || !totp.enabledAt) {
598 // User doesn't have 2FA actually enabled — clear the flag and let
599 // them in. This can only happen if 2FA was disabled in another
600 // session between password check and code prompt.
601 await db
602 .update(sessions)
603 .set({ requires2fa: false })
604 .where(eq(sessions.token, token));
605 return c.redirect(redirect);
606 }
607
608 // Try TOTP code first.
609 const isSix = /^\d{6}$/.test(code);
610 let ok = false;
611 if (isSix) {
612 ok = await verifyTotpCode(totp.secret, code);
613 }
614 // Fall through to recovery code.
615 if (!ok) {
616 const hash = await hashRecoveryCode(code);
617 const [rec] = await db
618 .select()
619 .from(userRecoveryCodes)
620 .where(
621 and(
622 eq(userRecoveryCodes.userId, session.userId),
623 eq(userRecoveryCodes.codeHash, hash),
624 isNull(userRecoveryCodes.usedAt)
625 )
626 )
627 .limit(1);
628 if (rec) {
629 await db
630 .update(userRecoveryCodes)
631 .set({ usedAt: new Date() })
632 .where(eq(userRecoveryCodes.id, rec.id));
633 ok = true;
634 }
635 }
636
637 if (!ok) {
638 return c.redirect(
639 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
640 );
641 }
642
643 await db
644 .update(sessions)
645 .set({ requires2fa: false })
646 .where(eq(sessions.token, token));
647 await db
648 .update(userTotp)
649 .set({ lastUsedAt: new Date() })
650 .where(eq(userTotp.userId, session.userId));
651
652 return c.redirect(redirect);
653 } catch (err) {
654 console.error("[auth] 2fa verify:", err);
655 return c.redirect(
656 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
657 );
658 }
659});
660
06d5ffeClaude661auth.get("/logout", async (c) => {
662 deleteCookie(c, "session", { path: "/" });
663 return c.redirect("/");
664});
665
666// --- API ---
667
668auth.post("/api/auth/register", async (c) => {
669 const body = await c.req.json<{
670 username: string;
671 email: string;
672 password: string;
673 }>();
674
675 if (!body.username || !body.email || !body.password) {
676 return c.json({ error: "username, email, and password are required" }, 400);
677 }
678
679 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
680 return c.json({ error: "Invalid username" }, 400);
681 }
682
683 if (body.password.length < 8) {
684 return c.json({ error: "Password must be at least 8 characters" }, 400);
685 }
686
687 const [existing] = await db
688 .select()
689 .from(users)
690 .where(eq(users.username, body.username))
691 .limit(1);
692 if (existing) {
693 return c.json({ error: "Username already taken" }, 409);
694 }
695
696 const passwordHash = await hashPassword(body.password);
697 const [user] = await db
698 .insert(users)
699 .values({
700 username: body.username,
701 email: body.email,
702 passwordHash,
703 })
704 .returning();
705
706 const token = generateSessionToken();
707 await db.insert(sessions).values({
708 userId: user.id,
709 token,
710 expiresAt: sessionExpiry(),
711 });
712
713 return c.json(
714 {
715 user: { id: user.id, username: user.username, email: user.email },
716 token,
717 },
718 201
719 );
720});
721
722auth.post("/api/auth/login", async (c) => {
723 const body = await c.req.json<{ username: string; password: string }>();
724
725 if (!body.username || !body.password) {
726 return c.json({ error: "username and password are required" }, 400);
727 }
728
729 const isEmail = body.username.includes("@");
730 const [user] = await db
731 .select()
732 .from(users)
733 .where(
734 isEmail
735 ? eq(users.email, body.username)
736 : eq(users.username, body.username)
737 )
738 .limit(1);
739
740 if (!user) return c.json({ error: "Invalid credentials" }, 401);
741
742 const valid = await verifyPassword(body.password, user.passwordHash);
743 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
744
745 const token = generateSessionToken();
746 await db.insert(sessions).values({
747 userId: user.id,
748 token,
749 expiresAt: sessionExpiry(),
750 });
751
752 return c.json({
753 user: { id: user.id, username: user.username, email: user.email },
754 token,
755 });
756});
757
fc0ca99Claude758/**
759 * Pick a friendly provider name for the "Sign in with X" button when the
760 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
761 * Falls back to undefined so the caller can default to a literal "SSO".
762 */
763function inferSsoProviderName(
764 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
765): string | undefined {
766 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
767 .filter((s): s is string => !!s)
768 .join(" ")
769 .toLowerCase();
770 if (!urls) return undefined;
771 if (urls.includes("google")) return "Google";
772 if (urls.includes("okta")) return "Okta";
773 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
774 if (urls.includes("auth0.com")) return "Auth0";
775 if (urls.includes("authentik")) return "Authentik";
776 return undefined;
777}
778
06d5ffeClaude779export default auth;