CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
sso.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.
| edf7c36 | 1 | /** |
| 2 | * Block I10 — Enterprise SSO (OIDC) routes. | |
| 3 | * | |
| 4 | * GET /admin/sso — site-admin config page | |
| 5 | * POST /admin/sso — save config | |
| 6 | * GET /login/sso — begin OIDC flow (redirect to IdP) | |
| 7 | * GET /login/sso/callback — handle IdP redirect, create session | |
| 8 | * POST /settings/sso/unlink — user drops their SSO link | |
| 9 | */ | |
| 10 | ||
| 11 | import { Hono } from "hono"; | |
| 12 | import { eq } from "drizzle-orm"; | |
| 13 | import { getCookie, setCookie, deleteCookie } from "hono/cookie"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { ssoUserLinks } from "../db/schema"; | |
| 16 | import { Layout } from "../views/layout"; | |
| 17 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 18 | import type { AuthEnv } from "../middleware/auth"; | |
| 19 | import { isSiteAdmin } from "../lib/admin"; | |
| 20 | import { audit } from "../lib/notify"; | |
| 21 | import { | |
| 22 | buildAuthorizeUrl, | |
| 23 | exchangeCode, | |
| 24 | fetchUserinfo, | |
| 25 | findOrCreateUserFromSso, | |
| 26 | getSsoConfig, | |
| 27 | issueSsoSession, | |
| 28 | randomToken, | |
| 29 | ssoRedirectUri, | |
| 30 | upsertSsoConfig, | |
| 31 | } from "../lib/sso"; | |
| 32 | import { sessionCookieOptions } from "../lib/auth"; | |
| 33 | ||
| 34 | const sso = new Hono<AuthEnv>(); | |
| 35 | sso.use("*", softAuth); | |
| 36 | ||
| 37 | // Re-export the shared cookie options under the SSO namespace (buildable types) | |
| 38 | // — defined here so we don't double-import under the same name. | |
| 39 | function ssoStateCookieOpts(): { | |
| 40 | httpOnly: boolean; | |
| 41 | secure: boolean; | |
| 42 | sameSite: "Lax"; | |
| 43 | path: string; | |
| 44 | maxAge: number; | |
| 45 | } { | |
| 46 | return { | |
| 47 | httpOnly: true, | |
| 48 | secure: process.env.NODE_ENV === "production", | |
| 49 | sameSite: "Lax", | |
| 50 | path: "/", | |
| 51 | maxAge: 600, // 10 min to complete the flow | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | // ---------------------------------------------------------------------------- | |
| 56 | // Admin config page | |
| 57 | // ---------------------------------------------------------------------------- | |
| 58 | ||
| 59 | async function adminGate(c: any): Promise<{ user: any } | Response> { | |
| 60 | const user = c.get("user"); | |
| 61 | if (!user) return c.redirect("/login?next=/admin/sso"); | |
| 62 | if (!(await isSiteAdmin(user.id))) { | |
| 63 | return c.html( | |
| 64 | <Layout title="Forbidden" user={user}> | |
| 65 | <div class="empty-state"> | |
| 66 | <h2>403 — Not a site admin</h2> | |
| 67 | <p>You don't have permission to configure SSO.</p> | |
| 68 | </div> | |
| 69 | </Layout>, | |
| 70 | 403 | |
| 71 | ); | |
| 72 | } | |
| 73 | return { user }; | |
| 74 | } | |
| 75 | ||
| 76 | sso.get("/admin/sso", requireAuth, async (c) => { | |
| 77 | const g = await adminGate(c); | |
| 78 | if (g instanceof Response) return g; | |
| 79 | const { user } = g; | |
| 80 | ||
| 81 | const cfg = await getSsoConfig(); | |
| 82 | const success = c.req.query("success"); | |
| 83 | const error = c.req.query("error"); | |
| 84 | const redirectUri = ssoRedirectUri(); | |
| 85 | ||
| 86 | return c.html( | |
| 87 | <Layout title="SSO — Admin" user={user}> | |
| 88 | <div class="settings-container" style="max-width:780px"> | |
| 89 | <h2>Enterprise SSO (OpenID Connect)</h2> | |
| 90 | <p style="color:var(--text-muted)"> | |
| 91 | Configure a single site-wide OIDC provider. Users will see a | |
| 92 | "Sign in with{" "} | |
| 93 | <code>{cfg?.providerName || "SSO"}</code>" button on /login. | |
| 94 | </p> | |
| 95 | <div class="panel" style="padding:12px;margin-bottom:16px"> | |
| 96 | <div | |
| 97 | style="font-size:12px;text-transform:uppercase;color:var(--text-muted)" | |
| 98 | > | |
| 99 | Redirect URI — paste this into your IdP | |
| 100 | </div> | |
| 101 | <code style="font-size:13px">{redirectUri}</code> | |
| 102 | </div> | |
| 103 | ||
| 104 | {success && ( | |
| 105 | <div class="auth-success">{decodeURIComponent(success)}</div> | |
| 106 | )} | |
| 107 | {error && <div class="auth-error">{decodeURIComponent(error)}</div>} | |
| 108 | ||
| fc0ca99 | 109 | <div |
| 110 | class="panel" | |
| 111 | style="padding:14px 16px;margin-bottom:14px;display:flex;gap:10px;flex-wrap:wrap;align-items:center" | |
| 112 | > | |
| 113 | <span style="font-size:13px;color:var(--text-muted)"> | |
| 114 | Quick fill from a preset: | |
| 115 | </span> | |
| 116 | <button | |
| 117 | type="button" | |
| 118 | class="btn btn-sm" | |
| 119 | onclick="window.gluecronSsoPreset('google')" | |
| 120 | > | |
| 121 | Google Workspace | |
| 122 | </button> | |
| 123 | <button | |
| 124 | type="button" | |
| 125 | class="btn btn-sm" | |
| 126 | onclick="window.gluecronSsoPreset('okta')" | |
| 127 | > | |
| 128 | Okta | |
| 129 | </button> | |
| 130 | <button | |
| 131 | type="button" | |
| 132 | class="btn btn-sm" | |
| 133 | onclick="window.gluecronSsoPreset('auth0')" | |
| 134 | > | |
| 135 | Auth0 | |
| 136 | </button> | |
| 137 | <button | |
| 138 | type="button" | |
| 139 | class="btn btn-sm" | |
| 140 | onclick="window.gluecronSsoPreset('azure')" | |
| 141 | > | |
| 142 | Microsoft Entra | |
| 143 | </button> | |
| 144 | </div> | |
| 145 | <script | |
| 146 | dangerouslySetInnerHTML={{ | |
| 147 | __html: /* js */ ` | |
| 148 | window.gluecronSsoPreset = function (provider) { | |
| 149 | var P = { | |
| 150 | google: { | |
| 151 | provider_name: 'Google', | |
| 152 | issuer: 'https://accounts.google.com', | |
| 153 | authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', | |
| 154 | token_endpoint: 'https://oauth2.googleapis.com/token', | |
| 155 | userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo', | |
| 156 | scopes: 'openid email profile', | |
| 157 | }, | |
| 158 | okta: { | |
| 159 | provider_name: 'Okta', | |
| 160 | issuer: 'https://YOUR-TENANT.okta.com', | |
| 161 | authorization_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/authorize', | |
| 162 | token_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/token', | |
| 163 | userinfo_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/userinfo', | |
| 164 | scopes: 'openid email profile', | |
| 165 | }, | |
| 166 | auth0: { | |
| 167 | provider_name: 'Auth0', | |
| 168 | issuer: 'https://YOUR-TENANT.auth0.com', | |
| 169 | authorization_endpoint: 'https://YOUR-TENANT.auth0.com/authorize', | |
| 170 | token_endpoint: 'https://YOUR-TENANT.auth0.com/oauth/token', | |
| 171 | userinfo_endpoint: 'https://YOUR-TENANT.auth0.com/userinfo', | |
| 172 | scopes: 'openid email profile', | |
| 173 | }, | |
| 174 | azure: { | |
| 175 | provider_name: 'Microsoft', | |
| 176 | issuer: 'https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0', | |
| 177 | authorization_endpoint: 'https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/authorize', | |
| 178 | token_endpoint: 'https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/token', | |
| 179 | userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo', | |
| 180 | scopes: 'openid email profile', | |
| 181 | }, | |
| 182 | }; | |
| 183 | var p = P[provider]; | |
| 184 | if (!p) return; | |
| 185 | for (var k in p) { | |
| 186 | var el = document.getElementById(k); | |
| 187 | if (el) el.value = p[k]; | |
| 188 | } | |
| 189 | }; | |
| 190 | `, | |
| 191 | }} | |
| 192 | /> | |
| 193 | ||
| edf7c36 | 194 | <form |
| 001af43 | 195 | method="post" |
| edf7c36 | 196 | action="/admin/sso" |
| 197 | class="panel" | |
| 198 | style="padding:16px" | |
| 199 | > | |
| 200 | <label | |
| 201 | style="display:flex;gap:8px;align-items:center;margin-bottom:12px" | |
| 202 | > | |
| 203 | <input | |
| 204 | type="checkbox" | |
| 205 | name="enabled" | |
| 206 | value="1" | |
| 207 | checked={!!cfg?.enabled} | |
| e1c0fbe | 208 | aria-label="Enable SSO sign-in on /login" |
| edf7c36 | 209 | /> |
| 210 | <span>Enable SSO sign-in on /login</span> | |
| 211 | </label> | |
| 212 | <div class="form-group"> | |
| 213 | <label for="provider_name">Button label</label> | |
| 214 | <input | |
| 215 | type="text" | |
| 216 | id="provider_name" | |
| 217 | name="provider_name" | |
| 218 | value={cfg?.providerName || "SSO"} | |
| 219 | maxLength={120} | |
| 220 | placeholder="Okta" | |
| 221 | /> | |
| 222 | </div> | |
| 223 | <div class="form-group"> | |
| 224 | <label for="issuer">Issuer URL</label> | |
| 225 | <input | |
| 226 | type="text" | |
| 227 | id="issuer" | |
| 228 | name="issuer" | |
| 229 | value={cfg?.issuer || ""} | |
| 230 | placeholder="https://example.okta.com" | |
| 231 | /> | |
| 232 | </div> | |
| 233 | <div class="form-group"> | |
| 234 | <label for="authorization_endpoint">Authorization endpoint</label> | |
| 235 | <input | |
| 236 | type="text" | |
| 237 | id="authorization_endpoint" | |
| 238 | name="authorization_endpoint" | |
| 239 | value={cfg?.authorizationEndpoint || ""} | |
| 240 | placeholder="https://example.okta.com/oauth2/v1/authorize" | |
| 241 | /> | |
| 242 | </div> | |
| 243 | <div class="form-group"> | |
| 244 | <label for="token_endpoint">Token endpoint</label> | |
| 245 | <input | |
| 246 | type="text" | |
| 247 | id="token_endpoint" | |
| 248 | name="token_endpoint" | |
| 249 | value={cfg?.tokenEndpoint || ""} | |
| 250 | placeholder="https://example.okta.com/oauth2/v1/token" | |
| 251 | /> | |
| 252 | </div> | |
| 253 | <div class="form-group"> | |
| 254 | <label for="userinfo_endpoint">Userinfo endpoint</label> | |
| 255 | <input | |
| 256 | type="text" | |
| 257 | id="userinfo_endpoint" | |
| 258 | name="userinfo_endpoint" | |
| 259 | value={cfg?.userinfoEndpoint || ""} | |
| 260 | placeholder="https://example.okta.com/oauth2/v1/userinfo" | |
| 261 | /> | |
| 262 | </div> | |
| 263 | <div class="form-group"> | |
| 264 | <label for="client_id">Client ID</label> | |
| 265 | <input | |
| 266 | type="text" | |
| 267 | id="client_id" | |
| 268 | name="client_id" | |
| 269 | value={cfg?.clientId || ""} | |
| 270 | autocomplete="off" | |
| 271 | /> | |
| 272 | </div> | |
| 273 | <div class="form-group"> | |
| 274 | <label for="client_secret">Client secret</label> | |
| 275 | <input | |
| 276 | type="password" | |
| 277 | id="client_secret" | |
| 278 | name="client_secret" | |
| 279 | value={cfg?.clientSecret || ""} | |
| 280 | autocomplete="off" | |
| 281 | placeholder={ | |
| 282 | cfg?.clientSecret ? "(stored — leave blank to keep)" : "" | |
| 283 | } | |
| 284 | /> | |
| 285 | </div> | |
| 286 | <div class="form-group"> | |
| 287 | <label for="scopes">OIDC scopes</label> | |
| 288 | <input | |
| 289 | type="text" | |
| 290 | id="scopes" | |
| 291 | name="scopes" | |
| 292 | value={cfg?.scopes || "openid profile email"} | |
| 293 | /> | |
| 294 | </div> | |
| 295 | <div class="form-group"> | |
| 296 | <label for="allowed_email_domains"> | |
| 297 | Allowed email domains (comma-separated, empty = any) | |
| 298 | </label> | |
| 299 | <input | |
| 300 | type="text" | |
| 301 | id="allowed_email_domains" | |
| 302 | name="allowed_email_domains" | |
| 303 | value={cfg?.allowedEmailDomains || ""} | |
| 304 | placeholder="example.com, acme.io" | |
| 305 | /> | |
| 306 | </div> | |
| 307 | <label | |
| 308 | style="display:flex;gap:8px;align-items:center;margin:12px 0" | |
| 309 | > | |
| 310 | <input | |
| 311 | type="checkbox" | |
| 312 | name="auto_create_users" | |
| 313 | value="1" | |
| 314 | checked={cfg ? cfg.autoCreateUsers : true} | |
| e1c0fbe | 315 | aria-label="Auto-create users on first SSO sign-in" |
| edf7c36 | 316 | /> |
| 317 | <span> | |
| 318 | Auto-create local accounts on first SSO sign-in (turn off to | |
| 319 | require admins to pre-provision users) | |
| 320 | </span> | |
| 321 | </label> | |
| 322 | <button type="submit" class="btn btn-primary"> | |
| 323 | Save SSO settings | |
| 324 | </button> | |
| 325 | </form> | |
| 326 | </div> | |
| 327 | </Layout> | |
| 328 | ); | |
| 329 | }); | |
| 330 | ||
| 331 | sso.post("/admin/sso", requireAuth, async (c) => { | |
| 332 | const g = await adminGate(c); | |
| 333 | if (g instanceof Response) return g; | |
| 334 | const { user } = g; | |
| 335 | ||
| 336 | const body = await c.req.parseBody(); | |
| 337 | const existing = await getSsoConfig(); | |
| 338 | const secretSubmitted = String(body.client_secret || ""); | |
| 339 | const result = await upsertSsoConfig({ | |
| 340 | enabled: String(body.enabled || "") === "1", | |
| 341 | providerName: String(body.provider_name || "SSO"), | |
| 342 | issuer: String(body.issuer || ""), | |
| 343 | authorizationEndpoint: String(body.authorization_endpoint || ""), | |
| 344 | tokenEndpoint: String(body.token_endpoint || ""), | |
| 345 | userinfoEndpoint: String(body.userinfo_endpoint || ""), | |
| 346 | clientId: String(body.client_id || ""), | |
| 347 | clientSecret: | |
| 348 | secretSubmitted.trim().length === 0 && existing?.clientSecret | |
| 349 | ? existing.clientSecret | |
| 350 | : secretSubmitted, | |
| 351 | scopes: String(body.scopes || "openid profile email"), | |
| 352 | allowedEmailDomains: String(body.allowed_email_domains || ""), | |
| 353 | autoCreateUsers: String(body.auto_create_users || "") === "1", | |
| 354 | }); | |
| 355 | ||
| 356 | if (!result.ok) { | |
| 357 | return c.redirect( | |
| 358 | `/admin/sso?error=${encodeURIComponent(result.error)}` | |
| 359 | ); | |
| 360 | } | |
| 361 | ||
| 362 | await audit({ | |
| 363 | userId: user.id, | |
| 364 | action: "admin.sso.configure", | |
| 365 | metadata: { | |
| 366 | enabled: String(body.enabled || "") === "1", | |
| 367 | provider: String(body.provider_name || "SSO"), | |
| 368 | autoCreateUsers: String(body.auto_create_users || "") === "1", | |
| 369 | allowedDomains: String(body.allowed_email_domains || "") || null, | |
| 370 | }, | |
| 371 | }); | |
| 372 | ||
| 373 | return c.redirect( | |
| 374 | `/admin/sso?success=${encodeURIComponent("SSO settings saved.")}` | |
| 375 | ); | |
| 376 | }); | |
| 377 | ||
| 378 | // ---------------------------------------------------------------------------- | |
| 379 | // OIDC flow | |
| 380 | // ---------------------------------------------------------------------------- | |
| 381 | ||
| 382 | sso.get("/login/sso", async (c) => { | |
| 383 | const cfg = await getSsoConfig(); | |
| 384 | if (!cfg || !cfg.enabled) { | |
| 385 | return c.redirect( | |
| 386 | `/login?error=${encodeURIComponent("SSO is not enabled")}` | |
| 387 | ); | |
| 388 | } | |
| 389 | if ( | |
| 390 | !cfg.authorizationEndpoint || | |
| 391 | !cfg.tokenEndpoint || | |
| 392 | !cfg.userinfoEndpoint || | |
| 393 | !cfg.clientId || | |
| 394 | !cfg.clientSecret | |
| 395 | ) { | |
| 396 | return c.redirect( | |
| 397 | `/login?error=${encodeURIComponent("SSO is not fully configured")}` | |
| 398 | ); | |
| 399 | } | |
| 400 | const state = randomToken(16); | |
| 401 | const nonce = randomToken(16); | |
| 402 | const redirectUri = ssoRedirectUri(); | |
| 403 | let target: string; | |
| 404 | try { | |
| 405 | target = buildAuthorizeUrl(cfg, state, nonce, redirectUri); | |
| 406 | } catch (err) { | |
| 407 | return c.redirect( | |
| 408 | `/login?error=${encodeURIComponent( | |
| 409 | err instanceof Error ? err.message : "SSO misconfigured" | |
| 410 | )}` | |
| 411 | ); | |
| 412 | } | |
| 413 | setCookie(c, "sso_state", state, ssoStateCookieOpts()); | |
| 414 | setCookie(c, "sso_nonce", nonce, ssoStateCookieOpts()); | |
| 415 | return c.redirect(target); | |
| 416 | }); | |
| 417 | ||
| 418 | sso.get("/login/sso/callback", async (c) => { | |
| 419 | const cfg = await getSsoConfig(); | |
| 420 | if (!cfg || !cfg.enabled) { | |
| 421 | return c.redirect( | |
| 422 | `/login?error=${encodeURIComponent("SSO is not enabled")}` | |
| 423 | ); | |
| 424 | } | |
| 425 | ||
| 426 | const code = c.req.query("code"); | |
| 427 | const state = c.req.query("state"); | |
| 428 | const errCode = c.req.query("error"); | |
| 429 | if (errCode) { | |
| 430 | return c.redirect( | |
| 431 | `/login?error=${encodeURIComponent( | |
| 432 | `SSO provider error: ${errCode}` | |
| 433 | )}` | |
| 434 | ); | |
| 435 | } | |
| 436 | if (!code || !state) { | |
| 437 | return c.redirect( | |
| 438 | `/login?error=${encodeURIComponent("Missing code or state")}` | |
| 439 | ); | |
| 440 | } | |
| 441 | ||
| 442 | const expectedState = getCookie(c, "sso_state"); | |
| 443 | if (!expectedState || expectedState !== state) { | |
| 444 | return c.redirect( | |
| 445 | `/login?error=${encodeURIComponent( | |
| 446 | "SSO state mismatch. Please try again." | |
| 447 | )}` | |
| 448 | ); | |
| 449 | } | |
| 450 | ||
| 451 | // One-shot cookies — burn them even on failure | |
| 452 | deleteCookie(c, "sso_state", { path: "/" }); | |
| 453 | deleteCookie(c, "sso_nonce", { path: "/" }); | |
| 454 | ||
| 455 | try { | |
| 456 | const tokens = await exchangeCode(cfg, code, ssoRedirectUri()); | |
| 457 | const claims = await fetchUserinfo(cfg, tokens.access_token); | |
| 458 | const result = await findOrCreateUserFromSso(claims, cfg); | |
| 459 | if (!result.ok) { | |
| 460 | return c.redirect(`/login?error=${encodeURIComponent(result.error)}`); | |
| 461 | } | |
| 462 | ||
| 463 | const token = await issueSsoSession(result.user.id); | |
| 464 | setCookie(c, "session", token, sessionCookieOptions()); | |
| 465 | ||
| 466 | await audit({ | |
| 467 | userId: result.user.id, | |
| 468 | action: "auth.sso.login", | |
| 469 | metadata: { | |
| 470 | provider: cfg.providerName, | |
| 471 | sub: claims.sub, | |
| 472 | email: claims.email || null, | |
| 473 | }, | |
| 474 | }); | |
| 475 | ||
| 476 | return c.redirect("/"); | |
| 477 | } catch (err) { | |
| 478 | console.error("[sso] callback error:", err); | |
| fc0ca99 | 479 | const friendly = friendlySsoError(err); |
| 480 | return c.redirect(`/login?error=${encodeURIComponent(friendly)}`); | |
| edf7c36 | 481 | } |
| 482 | }); | |
| 483 | ||
| fc0ca99 | 484 | /** |
| 485 | * Map the raw OIDC failure shape to a one-sentence message safe to render | |
| 486 | * inside an HTML <div>. We never surface the IdP's response body — those | |
| 487 | * have been Google 404 HTML pages, Azure JSON blobs full of object IDs, | |
| 488 | * etc. The raw `err.message` is logged via console.error above so admins | |
| 489 | * can still diagnose from server logs. | |
| 490 | */ | |
| 491 | function friendlySsoError(err: unknown): string { | |
| 492 | const raw = err instanceof Error ? err.message : String(err ?? ""); | |
| 493 | if (raw.includes("token_endpoint")) { | |
| 494 | if (/\b40[01]\b/.test(raw)) { | |
| 495 | return "SSO sign-in failed: the identity provider rejected our token request (HTTP 4xx). Check the Token endpoint URL and Client Secret at /admin/sso."; | |
| 496 | } | |
| 497 | if (/\b404\b/.test(raw)) { | |
| 498 | return "SSO sign-in failed: the Token endpoint URL returned 404. Verify the URL at /admin/sso — for Google it's https://oauth2.googleapis.com/token."; | |
| 499 | } | |
| 500 | if (/\b5\d\d\b/.test(raw)) { | |
| 501 | return "SSO sign-in failed: the identity provider returned a server error. Try again, or check the IdP's status page."; | |
| 502 | } | |
| 503 | return "SSO sign-in failed at the token exchange step. Check /admin/sso configuration."; | |
| 504 | } | |
| 505 | if (raw.includes("userinfo_endpoint")) { | |
| 506 | return "SSO sign-in failed while fetching profile info. Verify the Userinfo endpoint URL at /admin/sso."; | |
| 507 | } | |
| 508 | if (raw.includes("state cookie") || raw.includes("nonce")) { | |
| 509 | return "SSO sign-in expired before you returned to the site. Please try again."; | |
| 510 | } | |
| 511 | if (raw.includes("email") && raw.includes("not allowed")) { | |
| 512 | return "SSO sign-in failed: your email domain is not on the allowlist for this site."; | |
| 513 | } | |
| 514 | return "SSO sign-in failed. Check /admin/sso configuration or try again."; | |
| 515 | } | |
| 516 | ||
| edf7c36 | 517 | // ---------------------------------------------------------------------------- |
| 518 | // User: unlink SSO | |
| 519 | // ---------------------------------------------------------------------------- | |
| 520 | ||
| 521 | sso.post("/settings/sso/unlink", requireAuth, async (c) => { | |
| 522 | const user = c.get("user")!; | |
| 523 | const links = await db | |
| 524 | .select({ id: ssoUserLinks.id }) | |
| 525 | .from(ssoUserLinks) | |
| 526 | .where(eq(ssoUserLinks.userId, user.id)); | |
| 527 | if (links.length === 0) { | |
| 528 | return c.redirect("/settings?error=" + encodeURIComponent("No SSO link")); | |
| 529 | } | |
| 530 | await db.delete(ssoUserLinks).where(eq(ssoUserLinks.userId, user.id)); | |
| 531 | await audit({ | |
| 532 | userId: user.id, | |
| 533 | action: "auth.sso.unlink", | |
| 534 | metadata: { removedLinks: links.length }, | |
| 535 | }); | |
| 536 | return c.redirect( | |
| 537 | "/settings?success=" + encodeURIComponent("SSO link removed.") | |
| 538 | ); | |
| 539 | }); | |
| 540 | ||
| 541 | export default sso; |