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.tsxBlame728 lines · 2 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>
289 </div>
bb0f894Claude290 <Button type="submit" variant="primary">
06d5ffeClaude291 Sign in
bb0f894Claude292 </Button>
293 </Form>
46d6165Claude294 {githubEnabled && (
295 <div class="auth-sso">
296 <div class="auth-divider">or</div>
297 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
298 </div>
299 )}
09be7bfClaude300 {ssoEnabled && (
301 <div class="auth-sso">
302 <div class="auth-divider">or</div>
303 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
304 </div>
305 )}
fa06ad2Claude306 <div class="auth-passkey">
307 <div class="auth-divider">or</div>
308 <button type="button" id="pk-signin-btn" class="btn">
309 Sign in with passkey
310 </button>
311 <div
312 id="pk-signin-status"
313 class="auth-status"
314 aria-live="polite"
315 ></div>
316 </div>
06d5ffeClaude317 <p class="auth-switch">
bb0f894Claude318 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude319 </p>
2df1f8cClaude320 <script
321 dangerouslySetInnerHTML={{
322 __html: /* js */ `
323 (function () {
324 const btn = document.getElementById('pk-signin-btn');
325 const status = document.getElementById('pk-signin-status');
326 const userInput = document.getElementById('username');
327 const redirect = ${JSON.stringify(redirect || "/")};
328 if (!btn) return;
329 function b64uToBuf(s) {
330 s = s.replace(/-/g,'+').replace(/_/g,'/');
331 while (s.length % 4) s += '=';
332 const bin = atob(s);
333 const buf = new Uint8Array(bin.length);
334 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
335 return buf.buffer;
336 }
337 function bufToB64u(buf) {
338 const bytes = new Uint8Array(buf);
339 let bin = '';
340 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
341 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
342 }
343 btn.addEventListener('click', async function () {
344 if (!window.PublicKeyCredential) {
345 status.textContent = 'Passkeys not supported in this browser.';
346 return;
347 }
348 status.textContent = 'Preparing…';
349 try {
350 const username = (userInput && userInput.value || '').trim();
351 const optsRes = await fetch('/api/passkeys/auth/options', {
352 method: 'POST',
353 headers: { 'content-type': 'application/json' },
354 body: JSON.stringify(username ? { username: username } : {})
355 });
356 if (!optsRes.ok) throw new Error('options failed');
357 const { options, sessionKey } = await optsRes.json();
358 options.challenge = b64uToBuf(options.challenge);
359 if (options.allowCredentials) {
360 options.allowCredentials = options.allowCredentials.map(function (c) {
361 return Object.assign({}, c, { id: b64uToBuf(c.id) });
362 });
363 }
364 status.textContent = 'Touch your authenticator…';
365 const cred = await navigator.credentials.get({ publicKey: options });
366 const resp = {
367 id: cred.id,
368 rawId: bufToB64u(cred.rawId),
369 type: cred.type,
370 response: {
371 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
372 authenticatorData: bufToB64u(cred.response.authenticatorData),
373 signature: bufToB64u(cred.response.signature),
374 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
375 },
376 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
377 };
378 const verifyRes = await fetch('/api/passkeys/auth/verify', {
379 method: 'POST',
380 headers: { 'content-type': 'application/json' },
381 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
382 });
383 if (!verifyRes.ok) {
384 const j = await verifyRes.json().catch(function () { return {}; });
385 throw new Error(j.error || 'verify failed');
386 }
387 status.textContent = 'Signed in. Redirecting…';
388 window.location.href = redirect;
389 } catch (e) {
390 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
391 }
392 });
393 })();
394 `,
395 }}
396 />
06d5ffeClaude397 </div>
398 </Layout>
399 );
400});
401
402auth.post("/login", async (c) => {
403 const body = await c.req.parseBody();
404 const identifier = String(body.username || "").trim();
405 const password = String(body.password || "");
406 const redirect = c.req.query("redirect") || "/";
407
408 if (!identifier || !password) {
409 return c.redirect("/login?error=All+fields+are+required");
410 }
411
412 // Find user by username or email
413 const isEmail = identifier.includes("@");
414 const [user] = await db
415 .select()
416 .from(users)
417 .where(
418 isEmail
419 ? eq(users.email, identifier)
420 : eq(users.username, identifier)
421 )
422 .limit(1);
423
424 if (!user) {
425 return c.redirect("/login?error=Invalid+credentials");
426 }
427
428 const valid = await verifyPassword(password, user.passwordHash);
429 if (!valid) {
430 return c.redirect("/login?error=Invalid+credentials");
431 }
432
7298a17Claude433 // B4: if the user has TOTP enabled, issue a pending-2fa session and
434 // redirect to the code prompt.
435 const [totp] = await db
436 .select({ enabledAt: userTotp.enabledAt })
437 .from(userTotp)
438 .where(eq(userTotp.userId, user.id))
439 .limit(1);
440 const needs2fa = !!(totp && totp.enabledAt);
441
06d5ffeClaude442 const token = generateSessionToken();
443 await db.insert(sessions).values({
444 userId: user.id,
445 token,
446 expiresAt: sessionExpiry(),
7298a17Claude447 requires2fa: needs2fa,
06d5ffeClaude448 });
449
450 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude451
452 // Block P5 — If account was scheduled for deletion but user signed back
453 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
454 if (user.deletedAt) {
455 await cancelAccountDeletion(user.id);
456 }
457
7298a17Claude458 if (needs2fa) {
459 return c.redirect(
460 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
461 );
462 }
06d5ffeClaude463 return c.redirect(redirect);
464});
465
7298a17Claude466// --- 2FA verify (B4) ---
467auth.get("/login/2fa", async (c) => {
468 const token = getCookie(c, "session");
469 if (!token) return c.redirect("/login");
470 const error = c.req.query("error");
471 const redirect = c.req.query("redirect") || "/";
472 return c.html(
36cc17aClaude473 <Layout title="Two-factor authentication" user={null}>
7298a17Claude474 <div class="auth-container">
475 <h2>Enter your code</h2>
476 <p
477 class="auth-switch"
478 style="margin-bottom: 16px; margin-top: 0"
479 >
480 Open your authenticator app and enter the 6-digit code. Lost your
481 device? Paste a recovery code instead.
482 </p>
483 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
484 <form
b9968e3Claude485 method="post"
7298a17Claude486 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
487 >
134750bClaude488 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude489 <div class="form-group">
490 <label for="code">Code</label>
491 <input
492 type="text"
493 id="code"
494 name="code"
495 required
496 autocomplete="one-time-code"
497 inputmode="numeric"
498 maxLength={24}
499 placeholder="123456 or xxxx-xxxx-xxxx"
500 />
501 </div>
502 <button type="submit" class="btn btn-primary">
503 Verify
504 </button>
505 </form>
506 <p class="auth-switch">
507 <a href="/logout">Cancel</a>
508 </p>
509 </div>
510 </Layout>
511 );
512});
513
514auth.post("/login/2fa", async (c) => {
515 const token = getCookie(c, "session");
516 if (!token) return c.redirect("/login");
517 const body = await c.req.parseBody();
518 const code = String(body.code || "").trim();
519 const redirect = c.req.query("redirect") || "/";
520
521 if (!code) {
522 return c.redirect(
523 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
524 );
525 }
526
527 try {
528 const [session] = await db
529 .select()
530 .from(sessions)
531 .where(eq(sessions.token, token))
532 .limit(1);
533 if (
534 !session ||
535 new Date(session.expiresAt) < new Date() ||
536 !session.requires2fa
537 ) {
538 return c.redirect("/login");
539 }
540
541 const [totp] = await db
542 .select()
543 .from(userTotp)
544 .where(eq(userTotp.userId, session.userId))
545 .limit(1);
546 if (!totp || !totp.enabledAt) {
547 // User doesn't have 2FA actually enabled — clear the flag and let
548 // them in. This can only happen if 2FA was disabled in another
549 // session between password check and code prompt.
550 await db
551 .update(sessions)
552 .set({ requires2fa: false })
553 .where(eq(sessions.token, token));
554 return c.redirect(redirect);
555 }
556
557 // Try TOTP code first.
558 const isSix = /^\d{6}$/.test(code);
559 let ok = false;
560 if (isSix) {
561 ok = await verifyTotpCode(totp.secret, code);
562 }
563 // Fall through to recovery code.
564 if (!ok) {
565 const hash = await hashRecoveryCode(code);
566 const [rec] = await db
567 .select()
568 .from(userRecoveryCodes)
569 .where(
570 and(
571 eq(userRecoveryCodes.userId, session.userId),
572 eq(userRecoveryCodes.codeHash, hash),
573 isNull(userRecoveryCodes.usedAt)
574 )
575 )
576 .limit(1);
577 if (rec) {
578 await db
579 .update(userRecoveryCodes)
580 .set({ usedAt: new Date() })
581 .where(eq(userRecoveryCodes.id, rec.id));
582 ok = true;
583 }
584 }
585
586 if (!ok) {
587 return c.redirect(
588 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
589 );
590 }
591
592 await db
593 .update(sessions)
594 .set({ requires2fa: false })
595 .where(eq(sessions.token, token));
596 await db
597 .update(userTotp)
598 .set({ lastUsedAt: new Date() })
599 .where(eq(userTotp.userId, session.userId));
600
601 return c.redirect(redirect);
602 } catch (err) {
603 console.error("[auth] 2fa verify:", err);
604 return c.redirect(
605 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
606 );
607 }
608});
609
06d5ffeClaude610auth.get("/logout", async (c) => {
611 deleteCookie(c, "session", { path: "/" });
612 return c.redirect("/");
613});
614
615// --- API ---
616
617auth.post("/api/auth/register", async (c) => {
618 const body = await c.req.json<{
619 username: string;
620 email: string;
621 password: string;
622 }>();
623
624 if (!body.username || !body.email || !body.password) {
625 return c.json({ error: "username, email, and password are required" }, 400);
626 }
627
628 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
629 return c.json({ error: "Invalid username" }, 400);
630 }
631
632 if (body.password.length < 8) {
633 return c.json({ error: "Password must be at least 8 characters" }, 400);
634 }
635
636 const [existing] = await db
637 .select()
638 .from(users)
639 .where(eq(users.username, body.username))
640 .limit(1);
641 if (existing) {
642 return c.json({ error: "Username already taken" }, 409);
643 }
644
645 const passwordHash = await hashPassword(body.password);
646 const [user] = await db
647 .insert(users)
648 .values({
649 username: body.username,
650 email: body.email,
651 passwordHash,
652 })
653 .returning();
654
655 const token = generateSessionToken();
656 await db.insert(sessions).values({
657 userId: user.id,
658 token,
659 expiresAt: sessionExpiry(),
660 });
661
662 return c.json(
663 {
664 user: { id: user.id, username: user.username, email: user.email },
665 token,
666 },
667 201
668 );
669});
670
671auth.post("/api/auth/login", async (c) => {
672 const body = await c.req.json<{ username: string; password: string }>();
673
674 if (!body.username || !body.password) {
675 return c.json({ error: "username and password are required" }, 400);
676 }
677
678 const isEmail = body.username.includes("@");
679 const [user] = await db
680 .select()
681 .from(users)
682 .where(
683 isEmail
684 ? eq(users.email, body.username)
685 : eq(users.username, body.username)
686 )
687 .limit(1);
688
689 if (!user) return c.json({ error: "Invalid credentials" }, 401);
690
691 const valid = await verifyPassword(body.password, user.passwordHash);
692 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
693
694 const token = generateSessionToken();
695 await db.insert(sessions).values({
696 userId: user.id,
697 token,
698 expiresAt: sessionExpiry(),
699 });
700
701 return c.json({
702 user: { id: user.id, username: user.username, email: user.email },
703 token,
704 });
705});
706
fc0ca99Claude707/**
708 * Pick a friendly provider name for the "Sign in with X" button when the
709 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
710 * Falls back to undefined so the caller can default to a literal "SSO".
711 */
712function inferSsoProviderName(
713 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
714): string | undefined {
715 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
716 .filter((s): s is string => !!s)
717 .join(" ")
718 .toLowerCase();
719 if (!urls) return undefined;
720 if (urls.includes("google")) return "Google";
721 if (urls.includes("okta")) return "Okta";
722 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
723 if (urls.includes("auth0.com")) return "Auth0";
724 if (urls.includes("authentik")) return "Authentik";
725 return undefined;
726}
727
06d5ffeClaude728export default auth;