diff --git a/.changeset/selfhost-sso-userinfo.md b/.changeset/selfhost-sso-userinfo.md new file mode 100644 index 0000000000..92d86bc649 --- /dev/null +++ b/.changeset/selfhost-sso-userinfo.md @@ -0,0 +1,6 @@ +--- +"@executor-js/host-selfhost": patch +"executor": patch +--- + +Self-host SSO reads `email_verified` from the provider's UserInfo endpoint when the ID token omits it, so identity providers that issue thin ID tokens, such as a stock Okta tenant, can admit users. diff --git a/apps/host-selfhost/src/auth/sso-userinfo.test.ts b/apps/host-selfhost/src/auth/sso-userinfo.test.ts new file mode 100644 index 0000000000..7b778104e6 --- /dev/null +++ b/apps/host-selfhost/src/auth/sso-userinfo.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; + +import { isAdmitted, ssoProviderConfig } from "./sso"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ssoProviderConfig", () => { + // A stock Okta tenant issues an ID token with `email` but no + // `email_verified`; the claim is only served from UserInfo (#1972). The + // Okta emulator always signs `email_verified` into its ID tokens, so the two + // IdP responses are stubbed here. + it("reads email_verified from UserInfo when the ID token omits it", async () => { + const sso = { + providerId: "okta", + providerName: "Okta", + discoveryUrl: "https://idp.example/.well-known/openid-configuration", + clientId: "client-id", + clientSecret: "client-secret", + allowedDomains: ["example.com"], + }; + vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + if (url === sso.discoveryUrl) { + return Response.json({ userinfo_endpoint: "https://idp.example/userinfo" }); + } + return new Headers(init?.headers).get("authorization") === "Bearer access-token" + ? Response.json({ sub: "alice", email: "alice@example.com", email_verified: true }) + : new Response(null, { status: 401 }); + }); + const claims = { sub: "alice", email: "alice@example.com" }; + const idToken = `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature`; + + const user = await ssoProviderConfig(sso).getUserInfo({ idToken, accessToken: "access-token" }); + + expect(user).toMatchObject({ id: "alice", email: "alice@example.com", emailVerified: true }); + expect(isAdmitted(sso, user!)).toBe(true); + }); +}); diff --git a/apps/host-selfhost/src/auth/sso.ts b/apps/host-selfhost/src/auth/sso.ts index 7d2d00a3df..4a35ebf082 100644 --- a/apps/host-selfhost/src/auth/sso.ts +++ b/apps/host-selfhost/src/auth/sso.ts @@ -1,5 +1,88 @@ import { type SsoConfig } from "../config"; +type OAuthTokens = { readonly idToken?: string; readonly accessToken?: string }; + +type OidcClaims = { + readonly sub?: string; + readonly email?: string; + readonly email_verified?: boolean; + readonly name?: string; + readonly picture?: string; +}; + +// The IdP's JSON is cast, not validated, so a claim is usable only when it is +// a non-empty string. +const claimString = (value: string | undefined): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +// Decode the claims payload only. The genericOAuth plugin already receives the +// ID token from its validated OAuth callback; this is not token validation. +const decodeIdTokenClaims = (idToken: string): OidcClaims | null => { + const payload = idToken.split(".")[1]; + if (!payload) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a malformed third-party JWT payload must decline the profile, not fail the OAuth callback + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: genericOAuth provides a validated JWT; only its optional claims payload is decoded here + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as OidcClaims; + } catch { + return null; + } +}; + +// An ID token whose email claims are incomplete is resolved through UserInfo, +// because admission requires a verified email. A supplied ID token must carry +// a subject, and UserInfo claims are used only when their subject matches it. +export const ssoUserInfo = async (discoveryUrl: string, tokens: OAuthTokens) => { + let idSub: string | null = null; + if (tokens.idToken) { + const claims = decodeIdTokenClaims(tokens.idToken); + const sub = claims === null ? null : claimString(claims.sub); + if (claims === null || sub === null) return null; + idSub = sub; + const email = claimString(claims.email); + if (email !== null && claims.email_verified !== undefined) { + return { + ...claims, + id: sub, + email, + emailVerified: claims.email_verified === true, + name: claims.name, + image: claims.picture, + }; + } + } + + if (!tokens.accessToken) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: an unavailable IdP must decline the profile rather than reject the OAuth callback + try { + const discoveryResponse = await fetch(discoveryUrl); + if (!discoveryResponse.ok) return null; + const discovery = (await discoveryResponse.json()) as { userinfo_endpoint?: string }; + if (!discovery.userinfo_endpoint) return null; + + const profileResponse = await fetch(discovery.userinfo_endpoint, { + headers: { authorization: `Bearer ${tokens.accessToken}` }, + }); + if (!profileResponse.ok) return null; + const profile = (await profileResponse.json()) as OidcClaims; + const sub = claimString(profile.sub); + const email = claimString(profile.email); + if (sub === null || email === null) return null; + if (idSub !== null && sub !== idSub) return null; + + return { + ...profile, + id: sub, + email, + emailVerified: profile.email_verified === true, + name: profile.name, + image: profile.picture, + }; + } catch { + return null; + } +}; + // Better Auth serves OAuth sign-in callbacks at `/oauth2/callback/:providerId` // (genericOAuth) and `/callback/:providerId` (built-in social providers) — the // only paths an IdP-initiated user creation arrives on, so this splits "a @@ -38,6 +121,7 @@ export const ssoProviderConfig = (sso: SsoConfig) => ({ clientId: sso.clientId, clientSecret: sso.clientSecret, discoveryUrl: sso.discoveryUrl, + getUserInfo: (tokens: OAuthTokens) => ssoUserInfo(sso.discoveryUrl, tokens), scopes: ["openid", "email", "profile"], pkce: true, ...(sso.providerId === "google" && sso.allowedDomains.length === 1