Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame731 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
41// --- Web UI ---
42
36cc17aClaude43auth.get("/register", softAuth, (c) => {
44 // If the user is already signed in, drop them on their dashboard rather
45 // than rendering the logged-out sign-up shell over an authed session.
46 const existing = c.get("user");
47 if (existing) return c.redirect("/dashboard");
06d5ffeClaude48 const error = c.req.query("error");
134750bClaude49 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude50 return c.html(
36cc17aClaude51 <Layout title="Register" user={null}>
06d5ffeClaude52 <div class="auth-container">
53 <h2>Create account</h2>
54 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude55 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude56 <FormGroup label="Username" htmlFor="username">
57 <Input
58 id="username"
06d5ffeClaude59 type="text"
60 name="username"
61 required
62 pattern="^[a-zA-Z0-9_-]+$"
63 minLength={2}
64 maxLength={39}
65 placeholder="your-username"
66 autocomplete="username"
67 />
bb0f894Claude68 </FormGroup>
69 <FormGroup label="Email" htmlFor="email">
70 <Input
06d5ffeClaude71 type="email"
72 name="email"
73 required
74 placeholder="you@example.com"
75 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]76 aria-label="Email"
06d5ffeClaude77 />
bb0f894Claude78 </FormGroup>
79 <FormGroup label="Password" htmlFor="password">
80 <Input
06d5ffeClaude81 type="password"
82 name="password"
83 required
84 minLength={8}
85 placeholder="Min 8 characters"
86 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]87 aria-label="Password"
06d5ffeClaude88 />
bb0f894Claude89 </FormGroup>
c63b860Claude90 {/* P3 — Terms / Privacy acceptance. Required client-side via the
91 `required` attribute; server-side re-checked in POST handler. */}
92 <div class="form-group" style="margin: 12px 0">
93 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
94 <input
95 type="checkbox"
96 name="accept_terms"
97 value="1"
98 required
99 style="margin-top: 3px"
100 aria-label="Accept Terms of Service and Privacy Policy"
101 />
102 <span>
103 I agree to the{" "}
104 <a href="/legal/terms" target="_blank" rel="noopener">
105 Terms of Service
106 </a>{" "}
107 and{" "}
108 <a href="/legal/privacy" target="_blank" rel="noopener">
109 Privacy Policy
110 </a>
111 .
112 </span>
113 </label>
114 </div>
bb0f894Claude115 <Button type="submit" variant="primary">
06d5ffeClaude116 Create account
bb0f894Claude117 </Button>
118 </Form>
06d5ffeClaude119 <p class="auth-switch">
bb0f894Claude120 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude121 </p>
122 </div>
123 </Layout>
124 );
125});
126
127auth.post("/register", async (c) => {
128 const body = await c.req.parseBody();
129 const username = String(body.username || "").trim();
130 const email = String(body.email || "").trim();
131 const password = String(body.password || "");
132
133 if (!username || !email || !password) {
134 return c.redirect("/register?error=All+fields+are+required");
135 }
136
c63b860Claude137 // Block P3 — Terms acceptance is required. The form's checkbox has
138 // `required` so browsers normally enforce client-side; the server
139 // re-checks for defensive depth (curl, scripted POST, etc.).
140 if (!body.accept_terms) {
141 return c.redirect(
142 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
143 );
144 }
145
06d5ffeClaude146 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
147 return c.redirect(
148 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
149 );
150 }
151
152 if (password.length < 8) {
153 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
154 }
155
156 // Check existing
157 const [existingUser] = await db
158 .select()
159 .from(users)
160 .where(eq(users.username, username))
161 .limit(1);
162 if (existingUser) {
163 return c.redirect("/register?error=Username+already+taken");
164 }
165
7437605Claude166 // B2: usernames share the URL namespace with org slugs; refuse collisions.
167 const [existingOrg] = await db
168 .select({ id: organizations.id })
169 .from(organizations)
170 .where(eq(organizations.slug, username.toLowerCase()))
171 .limit(1);
172 if (existingOrg) {
173 return c.redirect("/register?error=Username+already+taken");
174 }
175
06d5ffeClaude176 const [existingEmail] = await db
177 .select()
178 .from(users)
179 .where(eq(users.email, email))
180 .limit(1);
181 if (existingEmail) {
182 return c.redirect("/register?error=Email+already+registered");
183 }
184
185 const passwordHash = await hashPassword(password);
186
a4f3e24Claude187 // First user ever registered becomes admin automatically
188 const [userCount] = await db
189 .select({ count: sql`count(*)::int` })
190 .from(users);
191 const isFirstUser = (userCount?.count as number) === 0;
192
06d5ffeClaude193 const [user] = await db
194 .insert(users)
c63b860Claude195 .values({
196 username,
197 email,
198 passwordHash,
199 isAdmin: isFirstUser,
200 // P3 — record terms acceptance now. Version bumps when Terms change.
201 termsAcceptedAt: new Date(),
202 termsVersion: "1.0",
203 })
06d5ffeClaude204 .returning();
205
2d985e5Claude206 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
207 // so the operator doesn't have to wait for the next boot's bootstrap pass.
208 await import("../lib/admin-bootstrap").then((m) =>
209 m.ensureEnvAdminOnRegister({ userId: user.id, username })
210 ).catch(() => {});
211
06d5ffeClaude212 // Create session
213 const token = generateSessionToken();
214 await db.insert(sessions).values({
215 userId: user.id,
216 token,
217 expiresAt: sessionExpiry(),
218 });
219
220 setCookie(c, "session", token, sessionCookieOptions());
221
c63b860Claude222 // Block P2 — fire-and-forget email verification. Never blocks registration.
223 import("../lib/email-verification").then((m) => m.startEmailVerification(user.id, email)).catch(() => {});
224
225 // P3 — default landing is /onboarding (the guided first-five-minutes
226 // flow). The `redirect=` query is still honoured for OAuth-style flows.
227 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude228 return c.redirect(redirect);
229});
230
36cc17aClaude231auth.get("/login", softAuth, async (c) => {
232 // Already-authed users hitting the sign-in page get bounced to their
233 // dashboard (or the `redirect=` target if one was supplied).
234 const existing = c.get("user");
06d5ffeClaude235 const error = c.req.query("error");
c63b860Claude236 const success = c.req.query("success");
06d5ffeClaude237 const redirect = c.req.query("redirect") || "";
36cc17aClaude238 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude239 const ssoCfg = await getSsoConfig();
240 const ssoEnabled =
241 !!ssoCfg?.enabled &&
242 !!ssoCfg.authorizationEndpoint &&
243 !!ssoCfg.tokenEndpoint &&
244 !!ssoCfg.userinfoEndpoint &&
245 !!ssoCfg.clientId &&
246 !!ssoCfg.clientSecret;
fc0ca99Claude247 const ssoLabel =
248 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude249 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
250 const githubCfg = await getGithubOauthConfig();
251 const githubEnabled =
252 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
134750bClaude253 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude254 return c.html(
36cc17aClaude255 <Layout title="Sign in" user={null}>
06d5ffeClaude256 <div class="auth-container">
257 <h2>Sign in</h2>
258 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude259 {success && (
260 <div class="auth-success">{decodeURIComponent(success)}</div>
261 )}
0316dbbClaude262 <Form
b9968e3Claude263 method="post"
06d5ffeClaude264 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude265 csrfToken={csrf}
06d5ffeClaude266 >
bb0f894Claude267 <FormGroup label="Username or email" htmlFor="username">
268 <Input
06d5ffeClaude269 type="text"
270 name="username"
271 required
272 placeholder="username or email"
273 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]274 aria-label="Username or email"
06d5ffeClaude275 />
bb0f894Claude276 </FormGroup>
277 <FormGroup label="Password" htmlFor="password">
278 <Input
06d5ffeClaude279 type="password"
280 name="password"
281 required
282 placeholder="Password"
283 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]284 aria-label="Password"
06d5ffeClaude285 />
bb0f894Claude286 </FormGroup>
c63b860Claude287 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
288 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User289 {/* BLOCK Q2 — magic-link sign-in. */}
290 <span style="margin:0 6px;color:var(--text-muted)">·</span>
291 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude292 </div>
bb0f894Claude293 <Button type="submit" variant="primary">
06d5ffeClaude294 Sign in
bb0f894Claude295 </Button>
296 </Form>
46d6165Claude297 {githubEnabled && (
298 <div class="auth-sso">
299 <div class="auth-divider">or</div>
300 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
301 </div>
302 )}
09be7bfClaude303 {ssoEnabled && (
304 <div class="auth-sso">
305 <div class="auth-divider">or</div>
306 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
307 </div>
308 )}
fa06ad2Claude309 <div class="auth-passkey">
310 <div class="auth-divider">or</div>
311 <button type="button" id="pk-signin-btn" class="btn">
312 Sign in with passkey
313 </button>
314 <div
315 id="pk-signin-status"
316 class="auth-status"
317 aria-live="polite"
318 ></div>
319 </div>
06d5ffeClaude320 <p class="auth-switch">
bb0f894Claude321 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude322 </p>
2df1f8cClaude323 <script
324 dangerouslySetInnerHTML={{
325 __html: /* js */ `
326 (function () {
327 const btn = document.getElementById('pk-signin-btn');
328 const status = document.getElementById('pk-signin-status');
329 const userInput = document.getElementById('username');
330 const redirect = ${JSON.stringify(redirect || "/")};
331 if (!btn) return;
332 function b64uToBuf(s) {
333 s = s.replace(/-/g,'+').replace(/_/g,'/');
334 while (s.length % 4) s += '=';
335 const bin = atob(s);
336 const buf = new Uint8Array(bin.length);
337 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
338 return buf.buffer;
339 }
340 function bufToB64u(buf) {
341 const bytes = new Uint8Array(buf);
342 let bin = '';
343 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
344 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
345 }
346 btn.addEventListener('click', async function () {
347 if (!window.PublicKeyCredential) {
348 status.textContent = 'Passkeys not supported in this browser.';
349 return;
350 }
351 status.textContent = 'Preparing…';
352 try {
353 const username = (userInput && userInput.value || '').trim();
354 const optsRes = await fetch('/api/passkeys/auth/options', {
355 method: 'POST',
356 headers: { 'content-type': 'application/json' },
357 body: JSON.stringify(username ? { username: username } : {})
358 });
359 if (!optsRes.ok) throw new Error('options failed');
360 const { options, sessionKey } = await optsRes.json();
361 options.challenge = b64uToBuf(options.challenge);
362 if (options.allowCredentials) {
363 options.allowCredentials = options.allowCredentials.map(function (c) {
364 return Object.assign({}, c, { id: b64uToBuf(c.id) });
365 });
366 }
367 status.textContent = 'Touch your authenticator…';
368 const cred = await navigator.credentials.get({ publicKey: options });
369 const resp = {
370 id: cred.id,
371 rawId: bufToB64u(cred.rawId),
372 type: cred.type,
373 response: {
374 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
375 authenticatorData: bufToB64u(cred.response.authenticatorData),
376 signature: bufToB64u(cred.response.signature),
377 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
378 },
379 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
380 };
381 const verifyRes = await fetch('/api/passkeys/auth/verify', {
382 method: 'POST',
383 headers: { 'content-type': 'application/json' },
384 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
385 });
386 if (!verifyRes.ok) {
387 const j = await verifyRes.json().catch(function () { return {}; });
388 throw new Error(j.error || 'verify failed');
389 }
390 status.textContent = 'Signed in. Redirecting…';
391 window.location.href = redirect;
392 } catch (e) {
393 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
394 }
395 });
396 })();
397 `,
398 }}
399 />
06d5ffeClaude400 </div>
401 </Layout>
402 );
403});
404
405auth.post("/login", async (c) => {
406 const body = await c.req.parseBody();
407 const identifier = String(body.username || "").trim();
408 const password = String(body.password || "");
409 const redirect = c.req.query("redirect") || "/";
410
411 if (!identifier || !password) {
412 return c.redirect("/login?error=All+fields+are+required");
413 }
414
415 // Find user by username or email
416 const isEmail = identifier.includes("@");
417 const [user] = await db
418 .select()
419 .from(users)
420 .where(
421 isEmail
422 ? eq(users.email, identifier)
423 : eq(users.username, identifier)
424 )
425 .limit(1);
426
427 if (!user) {
428 return c.redirect("/login?error=Invalid+credentials");
429 }
430
431 const valid = await verifyPassword(password, user.passwordHash);
432 if (!valid) {
433 return c.redirect("/login?error=Invalid+credentials");
434 }
435
7298a17Claude436 // B4: if the user has TOTP enabled, issue a pending-2fa session and
437 // redirect to the code prompt.
438 const [totp] = await db
439 .select({ enabledAt: userTotp.enabledAt })
440 .from(userTotp)
441 .where(eq(userTotp.userId, user.id))
442 .limit(1);
443 const needs2fa = !!(totp && totp.enabledAt);
444
06d5ffeClaude445 const token = generateSessionToken();
446 await db.insert(sessions).values({
447 userId: user.id,
448 token,
449 expiresAt: sessionExpiry(),
7298a17Claude450 requires2fa: needs2fa,
06d5ffeClaude451 });
452
453 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude454
455 // Block P5 — If account was scheduled for deletion but user signed back
456 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
457 if (user.deletedAt) {
458 await cancelAccountDeletion(user.id);
459 }
460
7298a17Claude461 if (needs2fa) {
462 return c.redirect(
463 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
464 );
465 }
06d5ffeClaude466 return c.redirect(redirect);
467});
468
7298a17Claude469// --- 2FA verify (B4) ---
470auth.get("/login/2fa", async (c) => {
471 const token = getCookie(c, "session");
472 if (!token) return c.redirect("/login");
473 const error = c.req.query("error");
474 const redirect = c.req.query("redirect") || "/";
475 return c.html(
36cc17aClaude476 <Layout title="Two-factor authentication" user={null}>
7298a17Claude477 <div class="auth-container">
478 <h2>Enter your code</h2>
479 <p
480 class="auth-switch"
481 style="margin-bottom: 16px; margin-top: 0"
482 >
483 Open your authenticator app and enter the 6-digit code. Lost your
484 device? Paste a recovery code instead.
485 </p>
486 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
487 <form
b9968e3Claude488 method="post"
7298a17Claude489 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
490 >
134750bClaude491 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude492 <div class="form-group">
493 <label for="code">Code</label>
494 <input
495 type="text"
496 id="code"
497 name="code"
498 required
499 autocomplete="one-time-code"
500 inputmode="numeric"
501 maxLength={24}
502 placeholder="123456 or xxxx-xxxx-xxxx"
503 />
504 </div>
505 <button type="submit" class="btn btn-primary">
506 Verify
507 </button>
508 </form>
509 <p class="auth-switch">
510 <a href="/logout">Cancel</a>
511 </p>
512 </div>
513 </Layout>
514 );
515});
516
517auth.post("/login/2fa", async (c) => {
518 const token = getCookie(c, "session");
519 if (!token) return c.redirect("/login");
520 const body = await c.req.parseBody();
521 const code = String(body.code || "").trim();
522 const redirect = c.req.query("redirect") || "/";
523
524 if (!code) {
525 return c.redirect(
526 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
527 );
528 }
529
530 try {
531 const [session] = await db
532 .select()
533 .from(sessions)
534 .where(eq(sessions.token, token))
535 .limit(1);
536 if (
537 !session ||
538 new Date(session.expiresAt) < new Date() ||
539 !session.requires2fa
540 ) {
541 return c.redirect("/login");
542 }
543
544 const [totp] = await db
545 .select()
546 .from(userTotp)
547 .where(eq(userTotp.userId, session.userId))
548 .limit(1);
549 if (!totp || !totp.enabledAt) {
550 // User doesn't have 2FA actually enabled — clear the flag and let
551 // them in. This can only happen if 2FA was disabled in another
552 // session between password check and code prompt.
553 await db
554 .update(sessions)
555 .set({ requires2fa: false })
556 .where(eq(sessions.token, token));
557 return c.redirect(redirect);
558 }
559
560 // Try TOTP code first.
561 const isSix = /^\d{6}$/.test(code);
562 let ok = false;
563 if (isSix) {
564 ok = await verifyTotpCode(totp.secret, code);
565 }
566 // Fall through to recovery code.
567 if (!ok) {
568 const hash = await hashRecoveryCode(code);
569 const [rec] = await db
570 .select()
571 .from(userRecoveryCodes)
572 .where(
573 and(
574 eq(userRecoveryCodes.userId, session.userId),
575 eq(userRecoveryCodes.codeHash, hash),
576 isNull(userRecoveryCodes.usedAt)
577 )
578 )
579 .limit(1);
580 if (rec) {
581 await db
582 .update(userRecoveryCodes)
583 .set({ usedAt: new Date() })
584 .where(eq(userRecoveryCodes.id, rec.id));
585 ok = true;
586 }
587 }
588
589 if (!ok) {
590 return c.redirect(
591 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
592 );
593 }
594
595 await db
596 .update(sessions)
597 .set({ requires2fa: false })
598 .where(eq(sessions.token, token));
599 await db
600 .update(userTotp)
601 .set({ lastUsedAt: new Date() })
602 .where(eq(userTotp.userId, session.userId));
603
604 return c.redirect(redirect);
605 } catch (err) {
606 console.error("[auth] 2fa verify:", err);
607 return c.redirect(
608 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
609 );
610 }
611});
612
06d5ffeClaude613auth.get("/logout", async (c) => {
614 deleteCookie(c, "session", { path: "/" });
615 return c.redirect("/");
616});
617
618// --- API ---
619
620auth.post("/api/auth/register", async (c) => {
621 const body = await c.req.json<{
622 username: string;
623 email: string;
624 password: string;
625 }>();
626
627 if (!body.username || !body.email || !body.password) {
628 return c.json({ error: "username, email, and password are required" }, 400);
629 }
630
631 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
632 return c.json({ error: "Invalid username" }, 400);
633 }
634
635 if (body.password.length < 8) {
636 return c.json({ error: "Password must be at least 8 characters" }, 400);
637 }
638
639 const [existing] = await db
640 .select()
641 .from(users)
642 .where(eq(users.username, body.username))
643 .limit(1);
644 if (existing) {
645 return c.json({ error: "Username already taken" }, 409);
646 }
647
648 const passwordHash = await hashPassword(body.password);
649 const [user] = await db
650 .insert(users)
651 .values({
652 username: body.username,
653 email: body.email,
654 passwordHash,
655 })
656 .returning();
657
658 const token = generateSessionToken();
659 await db.insert(sessions).values({
660 userId: user.id,
661 token,
662 expiresAt: sessionExpiry(),
663 });
664
665 return c.json(
666 {
667 user: { id: user.id, username: user.username, email: user.email },
668 token,
669 },
670 201
671 );
672});
673
674auth.post("/api/auth/login", async (c) => {
675 const body = await c.req.json<{ username: string; password: string }>();
676
677 if (!body.username || !body.password) {
678 return c.json({ error: "username and password are required" }, 400);
679 }
680
681 const isEmail = body.username.includes("@");
682 const [user] = await db
683 .select()
684 .from(users)
685 .where(
686 isEmail
687 ? eq(users.email, body.username)
688 : eq(users.username, body.username)
689 )
690 .limit(1);
691
692 if (!user) return c.json({ error: "Invalid credentials" }, 401);
693
694 const valid = await verifyPassword(body.password, user.passwordHash);
695 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
696
697 const token = generateSessionToken();
698 await db.insert(sessions).values({
699 userId: user.id,
700 token,
701 expiresAt: sessionExpiry(),
702 });
703
704 return c.json({
705 user: { id: user.id, username: user.username, email: user.email },
706 token,
707 });
708});
709
fc0ca99Claude710/**
711 * Pick a friendly provider name for the "Sign in with X" button when the
712 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
713 * Falls back to undefined so the caller can default to a literal "SSO".
714 */
715function inferSsoProviderName(
716 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
717): string | undefined {
718 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
719 .filter((s): s is string => !!s)
720 .join(" ")
721 .toLowerCase();
722 if (!urls) return undefined;
723 if (urls.includes("google")) return "Google";
724 if (urls.includes("okta")) return "Okta";
725 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
726 if (urls.includes("auth0.com")) return "Auth0";
727 if (urls.includes("authentik")) return "Authentik";
728 return undefined;
729}
730
06d5ffeClaude731export default auth;