Skip to content
Closed
6 changes: 6 additions & 0 deletions .changeset/selfhost-sso-userinfo.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions apps/host-selfhost/src/auth/sso-userinfo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
84 changes: 84 additions & 0 deletions apps/host-selfhost/src/auth/sso.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading