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.tsxBlame774 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.
213 await import("../lib/admin-bootstrap").then((m) =>
214 m.ensureEnvAdminOnRegister({ userId: user.id, username })
215 ).catch(() => {});
216
06d5ffeClaude217 // Create session
218 const token = generateSessionToken();
219 await db.insert(sessions).values({
220 userId: user.id,
221 token,
222 expiresAt: sessionExpiry(),
223 });
224
225 setCookie(c, "session", token, sessionCookieOptions());
226
976d7f7Claude227 // Block P2 — email verification. If RESEND_API_KEY is configured the
228 // verification email goes out and the user clicks the link to verify.
229 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
230 // etc.), the email would silently never arrive and the user would be
231 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
232 // account on registration so the user can actually use the site.
233 // Operators who want real verification should set EMAIL_PROVIDER=resend
234 // + RESEND_API_KEY in their environment.
235 const { config: _emailConfig } = await import("../lib/config");
236 const emailConfigured =
237 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
238 if (emailConfigured) {
239 import("../lib/email-verification")
240 .then((m) => m.startEmailVerification(user.id, email))
241 .catch((err) => {
242 console.error(
243 `[auth] startEmailVerification failed for ${user.id}:`,
244 err instanceof Error ? err.message : err
245 );
246 });
247 } else {
248 // Auto-verify immediately so the user isn't trapped in an unverified
249 // state. Log once so operators notice the misconfiguration.
250 if (!_autoVerifyWarned) {
251 _autoVerifyWarned = true;
252 console.warn(
253 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
254 );
255 }
256 await db
257 .update(users)
258 .set({ emailVerifiedAt: new Date() })
259 .where(eq(users.id, user.id))
260 .catch((err) => {
261 console.error(
262 `[auth] auto-verify failed for ${user.id}:`,
263 err instanceof Error ? err.message : err
264 );
265 });
266 }
c63b860Claude267
268 // P3 — default landing is /onboarding (the guided first-five-minutes
269 // flow). The `redirect=` query is still honoured for OAuth-style flows.
270 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude271 return c.redirect(redirect);
272});
273
36cc17aClaude274auth.get("/login", softAuth, async (c) => {
275 // Already-authed users hitting the sign-in page get bounced to their
276 // dashboard (or the `redirect=` target if one was supplied).
277 const existing = c.get("user");
06d5ffeClaude278 const error = c.req.query("error");
c63b860Claude279 const success = c.req.query("success");
06d5ffeClaude280 const redirect = c.req.query("redirect") || "";
36cc17aClaude281 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude282 const ssoCfg = await getSsoConfig();
283 const ssoEnabled =
284 !!ssoCfg?.enabled &&
285 !!ssoCfg.authorizationEndpoint &&
286 !!ssoCfg.tokenEndpoint &&
287 !!ssoCfg.userinfoEndpoint &&
288 !!ssoCfg.clientId &&
289 !!ssoCfg.clientSecret;
fc0ca99Claude290 const ssoLabel =
291 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude292 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
293 const githubCfg = await getGithubOauthConfig();
294 const githubEnabled =
295 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
134750bClaude296 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude297 return c.html(
36cc17aClaude298 <Layout title="Sign in" user={null}>
06d5ffeClaude299 <div class="auth-container">
300 <h2>Sign in</h2>
301 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude302 {success && (
303 <div class="auth-success">{decodeURIComponent(success)}</div>
304 )}
0316dbbClaude305 <Form
b9968e3Claude306 method="post"
06d5ffeClaude307 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude308 csrfToken={csrf}
06d5ffeClaude309 >
bb0f894Claude310 <FormGroup label="Username or email" htmlFor="username">
311 <Input
06d5ffeClaude312 type="text"
313 name="username"
314 required
315 placeholder="username or email"
316 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]317 aria-label="Username or email"
06d5ffeClaude318 />
bb0f894Claude319 </FormGroup>
320 <FormGroup label="Password" htmlFor="password">
321 <Input
06d5ffeClaude322 type="password"
323 name="password"
324 required
325 placeholder="Password"
326 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]327 aria-label="Password"
06d5ffeClaude328 />
bb0f894Claude329 </FormGroup>
c63b860Claude330 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
331 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User332 {/* BLOCK Q2 — magic-link sign-in. */}
333 <span style="margin:0 6px;color:var(--text-muted)">·</span>
334 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude335 </div>
bb0f894Claude336 <Button type="submit" variant="primary">
06d5ffeClaude337 Sign in
bb0f894Claude338 </Button>
339 </Form>
46d6165Claude340 {githubEnabled && (
341 <div class="auth-sso">
342 <div class="auth-divider">or</div>
343 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
344 </div>
345 )}
09be7bfClaude346 {ssoEnabled && (
347 <div class="auth-sso">
348 <div class="auth-divider">or</div>
349 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
350 </div>
351 )}
fa06ad2Claude352 <div class="auth-passkey">
353 <div class="auth-divider">or</div>
354 <button type="button" id="pk-signin-btn" class="btn">
355 Sign in with passkey
356 </button>
357 <div
358 id="pk-signin-status"
359 class="auth-status"
360 aria-live="polite"
361 ></div>
362 </div>
06d5ffeClaude363 <p class="auth-switch">
bb0f894Claude364 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude365 </p>
2df1f8cClaude366 <script
367 dangerouslySetInnerHTML={{
368 __html: /* js */ `
369 (function () {
370 const btn = document.getElementById('pk-signin-btn');
371 const status = document.getElementById('pk-signin-status');
372 const userInput = document.getElementById('username');
373 const redirect = ${JSON.stringify(redirect || "/")};
374 if (!btn) return;
375 function b64uToBuf(s) {
376 s = s.replace(/-/g,'+').replace(/_/g,'/');
377 while (s.length % 4) s += '=';
378 const bin = atob(s);
379 const buf = new Uint8Array(bin.length);
380 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
381 return buf.buffer;
382 }
383 function bufToB64u(buf) {
384 const bytes = new Uint8Array(buf);
385 let bin = '';
386 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
387 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
388 }
389 btn.addEventListener('click', async function () {
390 if (!window.PublicKeyCredential) {
391 status.textContent = 'Passkeys not supported in this browser.';
392 return;
393 }
394 status.textContent = 'Preparing…';
395 try {
396 const username = (userInput && userInput.value || '').trim();
397 const optsRes = await fetch('/api/passkeys/auth/options', {
398 method: 'POST',
399 headers: { 'content-type': 'application/json' },
400 body: JSON.stringify(username ? { username: username } : {})
401 });
402 if (!optsRes.ok) throw new Error('options failed');
403 const { options, sessionKey } = await optsRes.json();
404 options.challenge = b64uToBuf(options.challenge);
405 if (options.allowCredentials) {
406 options.allowCredentials = options.allowCredentials.map(function (c) {
407 return Object.assign({}, c, { id: b64uToBuf(c.id) });
408 });
409 }
410 status.textContent = 'Touch your authenticator…';
411 const cred = await navigator.credentials.get({ publicKey: options });
412 const resp = {
413 id: cred.id,
414 rawId: bufToB64u(cred.rawId),
415 type: cred.type,
416 response: {
417 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
418 authenticatorData: bufToB64u(cred.response.authenticatorData),
419 signature: bufToB64u(cred.response.signature),
420 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
421 },
422 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
423 };
424 const verifyRes = await fetch('/api/passkeys/auth/verify', {
425 method: 'POST',
426 headers: { 'content-type': 'application/json' },
427 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
428 });
429 if (!verifyRes.ok) {
430 const j = await verifyRes.json().catch(function () { return {}; });
431 throw new Error(j.error || 'verify failed');
432 }
433 status.textContent = 'Signed in. Redirecting…';
434 window.location.href = redirect;
435 } catch (e) {
436 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
437 }
438 });
439 })();
440 `,
441 }}
442 />
06d5ffeClaude443 </div>
444 </Layout>
445 );
446});
447
448auth.post("/login", async (c) => {
449 const body = await c.req.parseBody();
450 const identifier = String(body.username || "").trim();
451 const password = String(body.password || "");
452 const redirect = c.req.query("redirect") || "/";
453
454 if (!identifier || !password) {
455 return c.redirect("/login?error=All+fields+are+required");
456 }
457
458 // Find user by username or email
459 const isEmail = identifier.includes("@");
460 const [user] = await db
461 .select()
462 .from(users)
463 .where(
464 isEmail
465 ? eq(users.email, identifier)
466 : eq(users.username, identifier)
467 )
468 .limit(1);
469
470 if (!user) {
471 return c.redirect("/login?error=Invalid+credentials");
472 }
473
474 const valid = await verifyPassword(password, user.passwordHash);
475 if (!valid) {
476 return c.redirect("/login?error=Invalid+credentials");
477 }
478
7298a17Claude479 // B4: if the user has TOTP enabled, issue a pending-2fa session and
480 // redirect to the code prompt.
481 const [totp] = await db
482 .select({ enabledAt: userTotp.enabledAt })
483 .from(userTotp)
484 .where(eq(userTotp.userId, user.id))
485 .limit(1);
486 const needs2fa = !!(totp && totp.enabledAt);
487
06d5ffeClaude488 const token = generateSessionToken();
489 await db.insert(sessions).values({
490 userId: user.id,
491 token,
492 expiresAt: sessionExpiry(),
7298a17Claude493 requires2fa: needs2fa,
06d5ffeClaude494 });
495
496 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude497
498 // Block P5 — If account was scheduled for deletion but user signed back
499 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
500 if (user.deletedAt) {
501 await cancelAccountDeletion(user.id);
502 }
503
7298a17Claude504 if (needs2fa) {
505 return c.redirect(
506 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
507 );
508 }
06d5ffeClaude509 return c.redirect(redirect);
510});
511
7298a17Claude512// --- 2FA verify (B4) ---
513auth.get("/login/2fa", async (c) => {
514 const token = getCookie(c, "session");
515 if (!token) return c.redirect("/login");
516 const error = c.req.query("error");
517 const redirect = c.req.query("redirect") || "/";
518 return c.html(
36cc17aClaude519 <Layout title="Two-factor authentication" user={null}>
7298a17Claude520 <div class="auth-container">
521 <h2>Enter your code</h2>
522 <p
523 class="auth-switch"
524 style="margin-bottom: 16px; margin-top: 0"
525 >
526 Open your authenticator app and enter the 6-digit code. Lost your
527 device? Paste a recovery code instead.
528 </p>
529 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
530 <form
b9968e3Claude531 method="post"
7298a17Claude532 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
533 >
134750bClaude534 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude535 <div class="form-group">
536 <label for="code">Code</label>
537 <input
538 type="text"
539 id="code"
540 name="code"
541 required
542 autocomplete="one-time-code"
543 inputmode="numeric"
544 maxLength={24}
545 placeholder="123456 or xxxx-xxxx-xxxx"
546 />
547 </div>
548 <button type="submit" class="btn btn-primary">
549 Verify
550 </button>
551 </form>
552 <p class="auth-switch">
553 <a href="/logout">Cancel</a>
554 </p>
555 </div>
556 </Layout>
557 );
558});
559
560auth.post("/login/2fa", async (c) => {
561 const token = getCookie(c, "session");
562 if (!token) return c.redirect("/login");
563 const body = await c.req.parseBody();
564 const code = String(body.code || "").trim();
565 const redirect = c.req.query("redirect") || "/";
566
567 if (!code) {
568 return c.redirect(
569 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
570 );
571 }
572
573 try {
574 const [session] = await db
575 .select()
576 .from(sessions)
577 .where(eq(sessions.token, token))
578 .limit(1);
579 if (
580 !session ||
581 new Date(session.expiresAt) < new Date() ||
582 !session.requires2fa
583 ) {
584 return c.redirect("/login");
585 }
586
587 const [totp] = await db
588 .select()
589 .from(userTotp)
590 .where(eq(userTotp.userId, session.userId))
591 .limit(1);
592 if (!totp || !totp.enabledAt) {
593 // User doesn't have 2FA actually enabled — clear the flag and let
594 // them in. This can only happen if 2FA was disabled in another
595 // session between password check and code prompt.
596 await db
597 .update(sessions)
598 .set({ requires2fa: false })
599 .where(eq(sessions.token, token));
600 return c.redirect(redirect);
601 }
602
603 // Try TOTP code first.
604 const isSix = /^\d{6}$/.test(code);
605 let ok = false;
606 if (isSix) {
607 ok = await verifyTotpCode(totp.secret, code);
608 }
609 // Fall through to recovery code.
610 if (!ok) {
611 const hash = await hashRecoveryCode(code);
612 const [rec] = await db
613 .select()
614 .from(userRecoveryCodes)
615 .where(
616 and(
617 eq(userRecoveryCodes.userId, session.userId),
618 eq(userRecoveryCodes.codeHash, hash),
619 isNull(userRecoveryCodes.usedAt)
620 )
621 )
622 .limit(1);
623 if (rec) {
624 await db
625 .update(userRecoveryCodes)
626 .set({ usedAt: new Date() })
627 .where(eq(userRecoveryCodes.id, rec.id));
628 ok = true;
629 }
630 }
631
632 if (!ok) {
633 return c.redirect(
634 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
635 );
636 }
637
638 await db
639 .update(sessions)
640 .set({ requires2fa: false })
641 .where(eq(sessions.token, token));
642 await db
643 .update(userTotp)
644 .set({ lastUsedAt: new Date() })
645 .where(eq(userTotp.userId, session.userId));
646
647 return c.redirect(redirect);
648 } catch (err) {
649 console.error("[auth] 2fa verify:", err);
650 return c.redirect(
651 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
652 );
653 }
654});
655
06d5ffeClaude656auth.get("/logout", async (c) => {
657 deleteCookie(c, "session", { path: "/" });
658 return c.redirect("/");
659});
660
661// --- API ---
662
663auth.post("/api/auth/register", async (c) => {
664 const body = await c.req.json<{
665 username: string;
666 email: string;
667 password: string;
668 }>();
669
670 if (!body.username || !body.email || !body.password) {
671 return c.json({ error: "username, email, and password are required" }, 400);
672 }
673
674 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
675 return c.json({ error: "Invalid username" }, 400);
676 }
677
678 if (body.password.length < 8) {
679 return c.json({ error: "Password must be at least 8 characters" }, 400);
680 }
681
682 const [existing] = await db
683 .select()
684 .from(users)
685 .where(eq(users.username, body.username))
686 .limit(1);
687 if (existing) {
688 return c.json({ error: "Username already taken" }, 409);
689 }
690
691 const passwordHash = await hashPassword(body.password);
692 const [user] = await db
693 .insert(users)
694 .values({
695 username: body.username,
696 email: body.email,
697 passwordHash,
698 })
699 .returning();
700
701 const token = generateSessionToken();
702 await db.insert(sessions).values({
703 userId: user.id,
704 token,
705 expiresAt: sessionExpiry(),
706 });
707
708 return c.json(
709 {
710 user: { id: user.id, username: user.username, email: user.email },
711 token,
712 },
713 201
714 );
715});
716
717auth.post("/api/auth/login", async (c) => {
718 const body = await c.req.json<{ username: string; password: string }>();
719
720 if (!body.username || !body.password) {
721 return c.json({ error: "username and password are required" }, 400);
722 }
723
724 const isEmail = body.username.includes("@");
725 const [user] = await db
726 .select()
727 .from(users)
728 .where(
729 isEmail
730 ? eq(users.email, body.username)
731 : eq(users.username, body.username)
732 )
733 .limit(1);
734
735 if (!user) return c.json({ error: "Invalid credentials" }, 401);
736
737 const valid = await verifyPassword(body.password, user.passwordHash);
738 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
739
740 const token = generateSessionToken();
741 await db.insert(sessions).values({
742 userId: user.id,
743 token,
744 expiresAt: sessionExpiry(),
745 });
746
747 return c.json({
748 user: { id: user.id, username: user.username, email: user.email },
749 token,
750 });
751});
752
fc0ca99Claude753/**
754 * Pick a friendly provider name for the "Sign in with X" button when the
755 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
756 * Falls back to undefined so the caller can default to a literal "SSO".
757 */
758function inferSsoProviderName(
759 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
760): string | undefined {
761 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
762 .filter((s): s is string => !!s)
763 .join(" ")
764 .toLowerCase();
765 if (!urls) return undefined;
766 if (urls.includes("google")) return "Google";
767 if (urls.includes("okta")) return "Okta";
768 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
769 if (urls.includes("auth0.com")) return "Auth0";
770 if (urls.includes("authentik")) return "Authentik";
771 return undefined;
772}
773
06d5ffeClaude774export default auth;