From 1259642f8a9c6f8527c77439ac6ec4d05372a77b Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 3 Apr 2026 16:36:27 -0400 Subject: [PATCH] feat: replace openid-client with clean-room oidc system --- app/routes/auth/login/config-error.tsx | 52 +- app/routes/auth/login/page.tsx | 18 +- app/routes/auth/oidc-callback.ts | 177 ++--- app/routes/auth/oidc-start.ts | 70 +- app/routes/settings/overview.tsx | 3 +- app/server/index.ts | 25 +- app/server/oidc/provider.ts | 669 +++++++++++++++++++ app/server/result.ts | 9 + app/server/web/oidc-connector.ts | 321 --------- package.json | 1 - pnpm-lock.yaml | 3 - tests/integration/oidc/oidc.test.ts | 167 +++++ tests/integration/oidc/start-dex.ts | 30 + tests/unit/oidc/provider.test.ts | 878 +++++++++++++++++++++++++ vitest.config.ts | 8 + 15 files changed, 1860 insertions(+), 571 deletions(-) create mode 100644 app/server/oidc/provider.ts create mode 100644 app/server/result.ts delete mode 100644 app/server/web/oidc-connector.ts create mode 100644 tests/integration/oidc/oidc.test.ts create mode 100644 tests/integration/oidc/start-dex.ts create mode 100644 tests/unit/oidc/provider.test.ts diff --git a/app/routes/auth/login/config-error.tsx b/app/routes/auth/login/config-error.tsx index 788416d..efed2d2 100644 --- a/app/routes/auth/login/config-error.tsx +++ b/app/routes/auth/login/config-error.tsx @@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react"; import Card from "~/components/card"; import Code from "~/components/code"; import Link from "~/components/link"; -import type { OidcConnectorError } from "~/server/web/oidc-connector"; +import type { OidcErrorCode } from "~/server/oidc/provider"; export function OidcDiscoveryFailedNotice() { return ( @@ -20,7 +20,7 @@ export function OidcDiscoveryFailedNotice() { ); } -export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) { +export function OidcConfigErrorNotice({ errors }: { errors: OidcErrorCode[] }) { return (
@@ -42,7 +42,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] ); } -function mapOidcErrorsToMessages(errors: OidcConnectorError[]) { +function mapOidcErrorsToMessages(errors: OidcErrorCode[]) { const messages: { key: string; node: React.ReactNode; @@ -50,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) { for (const error of errors) { switch (error) { - case "INVALID_API_KEY": { + case "invalid_api_key": { messages.push({ key: error, node: ( @@ -63,65 +63,39 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) { break; } - case "MISSING_AUTHORIZATION_ENDPOINT": { + case "missing_endpoints": { messages.push({ key: error, node: ( - The OIDC provided does not have a configured authorization_endpoint. - Ensure discovery URL or manual configuration is correct. + The OIDC provider is missing required endpoints. Ensure the discovery URL is correct + or provide manual endpoint overrides in your configuration. ), }); break; } - case "MISSING_TOKEN_ENDPOINT": { + case "discovery_failed": { messages.push({ key: error, node: ( - The OIDC provided does not have a configured token_endpoint. Ensure - discovery URL or manual configuration is correct. + Unable to reach the OIDC provider for discovery. SSO will retry on the next login + attempt. ), }); break; } - case "MISSING_USERINFO_ENDPOINT": { + default: { messages.push({ key: error, node: ( - The OIDC provided does not have a configured user_endpoint. Ensure - discovery URL or manual configuration is correct. - - ), - }); - break; - } - - case "MISSING_REQUIRED_CLAIMS": { - messages.push({ - key: error, - node: ( - - The OIDC provider does not support the sub claim, which is required for - authentication. Your OIDC provider may be misconfigured. - - ), - }); - break; - } - - case "UNKNOWN_ERROR": { - messages.push({ - key: error, - node: ( - - An unknown error occurred during OIDC configuration. Please check the Headplane logs - for more information. + An unknown OIDC configuration error occurred. Please check the Headplane logs for more + information. ), }); diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index af43b41..166ebdc 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -24,16 +24,20 @@ export async function loader({ request, context }: Route.LoaderArgs) { const qp = new URL(request.url).searchParams; const urlState = qp.get("s") ?? undefined; - const oidcConnector = await context.oidc?.connector.get(); + const oidcService = context.oidc?.service; + const oidcStatus = oidcService + ? await oidcService.discover().then( + (r) => (r.ok ? oidcService.status() : oidcService.status()), + () => oidcService.status(), + ) + : undefined; - // MARK: This works because the OIDC connector will always return false - // For `isExclusive` if the OIDC config isn't usable. - if (oidcConnector?.isExclusive && urlState !== "logout") { + if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") { return redirect("/oidc/start"); } - const isOidcConnectorEnabled = oidcConnector?.isValid; - const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : []; + const isOidcConnectorEnabled = oidcStatus?.state === "ready"; + const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : []; return { isCookieSecureEnabled: context.config.server.cookie_secure, @@ -88,7 +92,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
{urlState?.startsWith("error_") ? ( - ) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? ( + ) : oidcErrorCodes.includes("discovery_failed") ? ( ) : oidcErrorCodes.length > 0 ? ( diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index c555fcf..d27224c 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -1,6 +1,3 @@ -import { createHash } from "node:crypto"; - -import * as oidc from "openid-client"; import { data, redirect } from "react-router"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; @@ -10,8 +7,8 @@ import { createOidcStateCookie } from "~/utils/oidc-state"; import type { Route } from "./+types/oidc-callback"; export async function loader({ request, context }: Route.LoaderArgs) { - const oidcConnector = await context.oidc?.connector.get(); - if (!oidcConnector?.isValid) { + const service = context.oidc?.service; + if (!service) { throw data("OIDC is not enabled or misconfigured", { status: 501 }); } @@ -34,138 +31,48 @@ export async function loader({ request, context }: Route.LoaderArgs) { return redirect("/login?s=error_invalid_session"); } - try { - const callbackUrl = new URL(redirect_uri); - const currentUrl = new URL(request.url); - callbackUrl.search = currentUrl.search; + const flowState = { + state, + nonce, + codeVerifier: verifier, + redirectUri: redirect_uri, + }; - const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, { - expectedState: state, - expectedNonce: nonce, - ...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}), - }); - - const claims = tokens.claims(); - if (claims?.sub == null) { - log.warn("auth", "No subject found in OIDC claims"); - return redirect("/login?s=error_no_sub"); - } - - const userInfo = await oidc.fetchUserInfo( - oidcConnector.client, - tokens.access_token, - claims.sub, - ); - - // We have defaults that closely follow what Headscale uses, maybe we - // can make it configurable in the future, but for now we only need the - // `sub` claim. - const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user"; - const name = - userInfo.name ?? - (userInfo.given_name && userInfo.family_name - ? `${userInfo.given_name} ${userInfo.family_name}` - : (userInfo.preferred_username ?? "SSO User")); - - const picture = await (async () => { - if (context.config.oidc?.profile_picture_source === "gravatar") { - if (!userInfo.email) { - return undefined; - } - - const emailHash = userInfo.email.trim().toLowerCase(); - const hash = createHash("sha256").update(emailHash).digest("hex"); - return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; - } - - if (!userInfo.picture) { - return undefined; - } - - try { - const response = await fetch(userInfo.picture, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - - if (response.ok) { - const contentType = response.headers.get("content-type"); - if (contentType?.startsWith("image/")) { - const buffer = await response.arrayBuffer(); - const base64 = Buffer.from(buffer).toString("base64"); - return `data:${contentType};base64,${base64}`; - } - } - } catch {} - - return userInfo.picture; - })(); - - const userId = await context.auth.findOrCreateUser(claims.sub, { - name, - email: userInfo.email, - picture, - }); - - try { - const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!); - const hsUsers = await hsApi.getUsers(); - const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email); - if (hsUser) { - await context.auth.linkHeadscaleUser(userId, hsUser.id); - } - } catch (error) { - log.warn("auth", "Failed to link Headscale user: %s", String(error)); - } - - return redirect("/", { - headers: { - "Set-Cookie": await context.auth.createOidcSession(userId, { - name, - email: userInfo.email, - username, - }), - }, - }); - } catch (error) { - if (error instanceof oidc.ResponseBodyError) { - log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause)); - - // Check for PKCE-related errors - if ( - error.error.toLowerCase().includes("code_verifier") || - error.error.toLowerCase().includes("code verifier") || - error.error.toLowerCase().includes("pkce") - ) { - log.error( - "auth", - "PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s", - oidcConnector.usePKCE, - ); - - if (!oidcConnector.usePKCE) { - log.error( - "auth", - "Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE", - ); - } - } - } else if (error instanceof oidc.AuthorizationResponseError) { - log.error("auth", "Got an OIDC authorization response error: %s", error.error); - } else if (error instanceof oidc.WWWAuthenticateChallengeError) { - log.error("auth", "Got an OIDC WWW-Authenticate challenge error"); - } else if (error instanceof oidc.ClientError) { - log.error( - "auth", - "Got an OIDC authorization client error: %s", - error.cause instanceof Error ? error.cause.message : String(error.cause), - ); - } else { - log.error( - "auth", - "Got an OIDC error: %s", - error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error), - ); + const result = await service.handleCallback(url.searchParams, flowState); + if (!result.ok) { + log.error("auth", "OIDC callback failed [%s]: %s", result.error.code, result.error.message); + if (result.error.hint) { + log.error("auth", "Hint: %s", result.error.hint); } return redirect("/login?s=error_auth_failed"); } + + const identity = result.value; + + const userId = await context.auth.findOrCreateUser(identity.subject, { + name: identity.name, + email: identity.email, + picture: identity.picture, + }); + + try { + const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!); + const hsUsers = await hsApi.getUsers(); + const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email); + if (hsUser) { + await context.auth.linkHeadscaleUser(userId, hsUser.id); + } + } catch (error) { + log.warn("auth", "Failed to link Headscale user: %s", String(error)); + } + + return redirect("/", { + headers: { + "Set-Cookie": await context.auth.createOidcSession(userId, { + name: identity.name, + email: identity.email, + username: identity.username, + }), + }, + }); } diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index 3c55d4e..60886c3 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -1,7 +1,5 @@ -import * as oidc from "openid-client"; import { data, redirect } from "react-router"; -import { HeadplaneConfig } from "~/server/config/config-schema"; import { createOidcStateCookie } from "~/utils/oidc-state"; import type { Route } from "./+types/oidc-start"; @@ -12,70 +10,28 @@ export async function loader({ request, context }: Route.LoaderArgs) { return redirect("/"); } catch {} - const oidcConnector = await context.oidc?.connector.get(); - if (!oidcConnector?.isValid) { + const service = context.oidc?.service; + if (!service) { throw data("OIDC is not enabled or misconfigured", { status: 501 }); } + const result = await service.startFlow(); + if (!result.ok) { + return redirect(`/login?s=${result.error.code}`); + } + + const { url, flowState } = result.value; const cookie = createOidcStateCookie(context.config); - const redirect_uri = getRedirectUri(context.config, request); - const nonce = oidc.randomNonce(); - const verifier = oidc.randomPKCECodeVerifier(); - const state = oidc.randomState(); - - const url = oidc.buildAuthorizationUrl(oidcConnector.client, { - ...oidcConnector.extraParams, - scope: oidcConnector.scope, - redirect_uri, - state, - nonce, - ...(oidcConnector.usePKCE - ? { - code_challenge_method: "S256", - code_challenge: await oidc.calculatePKCECodeChallenge(verifier), - } - : {}), - }); - - return redirect(url.href, { + return redirect(url, { status: 302, headers: { "Set-Cookie": await cookie.serialize({ - state, - nonce, - verifier, - redirect_uri, + state: flowState.state, + nonce: flowState.nonce, + verifier: flowState.codeVerifier, + redirect_uri: flowState.redirectUri, }), }, }); } - -function getRedirectUri(config: HeadplaneConfig, req: Request): string { - if (config.server.base_url != null) { - const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url); - return url.href; - } - - if (config.oidc?.redirect_uri != null) { - const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri); - return url.href; - } - - const url = new URL(`${__PREFIX__}/oidc/callback`, req.url); - let host = req.headers.get("Host"); - if (!host) { - host = req.headers.get("X-Forwarded-Host"); - } - - if (!host) { - throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", { - status: 500, - }); - } - - const proto = req.headers.get("X-Forwarded-Proto"); - url.protocol = proto ?? "http:"; - url.host = host; - return url.href; -} diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx index d936868..8ded5c0 100644 --- a/app/routes/settings/overview.tsx +++ b/app/routes/settings/overview.tsx @@ -6,10 +6,9 @@ import PageError from "~/components/page-error"; import type { Route } from "./+types/overview"; export async function loader({ context }: Route.LoaderArgs) { - const oidcConnector = await context.oidc?.connector.get(); return { config: context.hs.writable(), - isOidcEnabled: oidcConnector?.isValid ?? false, + isOidcEnabled: context.oidc?.service.status().state === "ready", }; } diff --git a/app/server/index.ts b/app/server/index.ts index 9389640..20d87fc 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -65,7 +65,7 @@ export type LoadContext = typeof appLoadContext; import "react-router"; import { HeadplaneConfig } from "./config/config-schema"; import { ConfigError } from "./config/error"; -import { createLazyOidcConnector } from "./web/oidc-connector"; +import { createOidcService } from "./oidc/provider"; declare module "react-router" { interface AppLoadContext extends LoadContext {} @@ -101,11 +101,24 @@ const appLoadContext = { oidc: config.oidc && config.oidc.enabled !== false && headscaleApiKey ? { - connector: createLazyOidcConnector( - config.server.base_url, - config.oidc, - hsApi.getRuntimeClient(headscaleApiKey), - ), + service: createOidcService({ + issuer: config.oidc.issuer, + clientId: config.oidc.client_id, + clientSecret: config.oidc.client_secret, + baseUrl: config.server.base_url ?? "", + authorizationEndpoint: config.oidc.authorization_endpoint, + tokenEndpoint: config.oidc.token_endpoint, + userinfoEndpoint: config.oidc.userinfo_endpoint, + tokenEndpointAuthMethod: + config.oidc.token_endpoint_auth_method === "client_secret_jwt" + ? undefined + : config.oidc.token_endpoint_auth_method, + usePkce: config.oidc.use_pkce, + scope: config.oidc.scope, + extraParams: config.oidc.extra_params, + profilePictureSource: config.oidc.profile_picture_source, + }), + disableApiKeyLogin: config.oidc.disable_api_key_login, } : undefined, db, diff --git a/app/server/oidc/provider.ts b/app/server/oidc/provider.ts new file mode 100644 index 0000000..797f864 --- /dev/null +++ b/app/server/oidc/provider.ts @@ -0,0 +1,669 @@ +import { createHash, randomBytes } from "node:crypto"; + +import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose"; +import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose"; + +import { type Result, err, ok } from "~/server/result"; +import log from "~/utils/log"; + +export interface OidcConfig { + issuer: string; + clientId: string; + clientSecret: string; + baseUrl: string; + + authorizationEndpoint?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + jwksUri?: string; + + tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post"; + + usePkce?: boolean; + scope?: string; + extraParams?: Record; + profilePictureSource?: "oidc" | "gravatar"; +} + +export interface ResolvedEndpoints { + authorizationEndpoint: string; + tokenEndpoint: string; + jwksUri: string; + userinfoEndpoint?: string; + endSessionEndpoint?: string; +} + +export interface OidcFlowState { + state: string; + nonce: string; + codeVerifier: string; + redirectUri: string; +} + +export interface OidcIdentity { + issuer: string; + subject: string; + name: string; + username: string; + email?: string; + picture?: string; +} + +export type OidcErrorCode = + | "discovery_failed" + | "missing_endpoints" + | "invalid_api_key" + | "state_mismatch" + | "nonce_mismatch" + | "token_exchange_failed" + | "invalid_client" + | "pkce_error" + | "invalid_id_token" + | "missing_sub" + | "userinfo_failed"; + +export interface OidcError { + code: OidcErrorCode; + message: string; + hint?: string; +} + +type JwksResolver = ( + protectedHeader?: JWSHeaderParameters, + token?: FlattenedJWSInput, +) => Promise; + +export interface OidcService { + status(): + | { state: "ready"; endpoints: ResolvedEndpoints } + | { state: "pending" } + | { state: "error"; error: OidcError }; + + discover(): Promise>; + startFlow(): Promise>; + + handleCallback( + callbackParams: URLSearchParams, + flowState: OidcFlowState, + ): Promise>; + + invalidate(): void; + reload(config: OidcConfig): void; +} + +interface OidcClaims extends JWTPayload { + nonce?: string; + name?: string; + given_name?: string; + family_name?: string; + preferred_username?: string; + email?: string; + picture?: string; +} + +interface TokenResponse { + access_token: string; + id_token?: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; +} + +interface TokenErrorResponse { + error: string; + error_description?: string; +} + +export function createOidcService(initialConfig: OidcConfig): OidcService { + let config = Object.freeze({ ...initialConfig }); + + let endpoints: ResolvedEndpoints | undefined; + let lastError: OidcError | undefined; + let jwks: JwksResolver | undefined; + let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined = + initialConfig.tokenEndpointAuthMethod; + + function status(): ReturnType { + if (lastError) { + return { state: "error", error: lastError }; + } + + if (endpoints) { + return { state: "ready", endpoints }; + } + + return { state: "pending" }; + } + + async function discover(): Promise> { + if (endpoints) { + return ok(endpoints); + } + + const fullManual = config.authorizationEndpoint && config.tokenEndpoint && config.jwksUri; + if (fullManual) { + endpoints = { + authorizationEndpoint: config.authorizationEndpoint!, + tokenEndpoint: config.tokenEndpoint!, + jwksUri: config.jwksUri!, + userinfoEndpoint: config.userinfoEndpoint, + }; + + lastError = undefined; + jwks = createRemoteJWKSet(new URL(endpoints.jwksUri)); + log.debug("auth", "OIDC endpoints configured manually, skipping discovery"); + return ok(endpoints); + } + + let discoveryUrl: string; + try { + const issuerUrl = new URL(config.issuer); + if (issuerUrl.pathname === "/" || issuerUrl.pathname === "") { + discoveryUrl = new URL("/.well-known/openid-configuration", issuerUrl).href; + } else { + discoveryUrl = new URL( + `${issuerUrl.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`, + issuerUrl, + ).href; + } + } catch { + const error: OidcError = { + code: "discovery_failed", + message: `Invalid issuer URL: ${config.issuer}`, + }; + + lastError = error; + return err(error); + } + + let metadata: Record; + try { + const response = await fetch(discoveryUrl, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + const error: OidcError = { + code: "discovery_failed", + message: `Discovery endpoint returned ${response.status}: ${discoveryUrl}`, + hint: "Check that your issuer URL is correct and that the identity provider is online.", + }; + + lastError = error; + return err(error); + } + + metadata = (await response.json()) as Record; + } catch (cause) { + const error: OidcError = { + code: "discovery_failed", + message: `Failed to reach OIDC discovery endpoint: ${cause instanceof Error ? cause.message : String(cause)}`, + hint: "Unable to reach your identity provider. SSO will automatically retry on the next login attempt.", + }; + + lastError = error; + return err(error); + } + + if (typeof metadata.issuer === "string" && metadata.issuer !== config.issuer) { + log.debug( + "auth", + "Discovery issuer %s does not match configured issuer %s", + metadata.issuer, + config.issuer, + ); + } + + const authorizationEndpoint = + config.authorizationEndpoint ?? (metadata.authorization_endpoint as string | undefined); + const tokenEndpoint = config.tokenEndpoint ?? (metadata.token_endpoint as string | undefined); + const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined); + const userinfoEndpoint = + config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined); + const endSessionEndpoint = metadata.end_session_endpoint as string | undefined; + + if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) { + const missing: string[] = []; + if (!authorizationEndpoint) missing.push("authorization_endpoint"); + if (!tokenEndpoint) missing.push("token_endpoint"); + if (!jwksUri) missing.push("jwks_uri"); + + const error: OidcError = { + code: "missing_endpoints", + message: `Discovery is missing required endpoints: ${missing.join(", ")}`, + hint: "Your identity provider did not return all required endpoints. You can set them manually in your Headplane config.", + }; + + lastError = error; + return err(error); + } + + endpoints = { + authorizationEndpoint, + tokenEndpoint, + jwksUri, + userinfoEndpoint, + endSessionEndpoint, + }; + + lastError = undefined; + jwks = createRemoteJWKSet(new URL(endpoints.jwksUri)); + log.debug("auth", "OIDC discovery completed successfully"); + return ok(endpoints); + } + + async function startFlow(): Promise< + Result<{ url: string; flowState: OidcFlowState }, OidcError> + > { + const resolved = await discover(); + if (!resolved.ok) { + return resolved; + } + + const usePkce = config.usePkce !== false; + const scope = config.scope ?? "openid email profile"; + const redirectUri = new URL(`${__PREFIX__}/oidc/callback`, config.baseUrl).href; + + const state = generateRandom(); + const nonce = generateRandom(); + const codeVerifier = generateRandom(64); + + const params = new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope, + state, + nonce, + }); + + if (usePkce) { + const codeChallenge = computeS256Challenge(codeVerifier); + params.set("code_challenge", codeChallenge); + params.set("code_challenge_method", "S256"); + } + + if (config.extraParams) { + for (const [key, value] of Object.entries(config.extraParams)) { + params.set(key, value); + } + } + + const url = `${resolved.value.authorizationEndpoint}?${params.toString()}`; + const flowState: OidcFlowState = { state, nonce, codeVerifier, redirectUri }; + + return ok({ url, flowState }); + } + + async function handleCallback( + callbackParams: URLSearchParams, + flowState: OidcFlowState, + ): Promise> { + const resolved = await discover(); + if (!resolved.ok) { + return resolved; + } + + const callbackError = callbackParams.get("error"); + if (callbackError) { + const desc = callbackParams.get("error_description") ?? ""; + return err({ + code: "token_exchange_failed", + message: `Provider returned error: ${callbackError} — ${desc}`, + hint: desc || undefined, + }); + } + + const code = callbackParams.get("code"); + if (!code) { + return err({ + code: "token_exchange_failed", + message: "Callback is missing the authorization code", + }); + } + + const returnedState = callbackParams.get("state"); + if (returnedState !== flowState.state) { + return err({ + code: "state_mismatch", + message: `State mismatch: expected ${flowState.state}, got ${returnedState}`, + hint: "Please try signing in again. If this keeps happening, your reverse proxy may be interfering with cookies.", + }); + } + + // Token exchange with auth method retry, hopefully this stops new GitHub issues about this + const tokenResult = await exchangeCode(resolved.value, code, flowState); + if (!tokenResult.ok) { + return tokenResult; + } + + const tokens = tokenResult.value; + if (!tokens.id_token) { + return err({ + code: "token_exchange_failed", + message: "Token response is missing id_token", + hint: "Your identity provider did not return an ID token. Make sure the 'openid' scope is included in your OIDC client configuration.", + }); + } + + // ID token verification + const verifyResult = await verifyIdToken(tokens.id_token, flowState.nonce); + if (!verifyResult.ok) { + return verifyResult; + } + + const claims = verifyResult.value; + const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims); + return ok(buildIdentity(enriched)); + } + + async function exchangeCode( + ep: ResolvedEndpoints, + code: string, + flowState: OidcFlowState, + ): Promise> { + const usePkce = config.usePkce !== false; + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: flowState.redirectUri, + ...(usePkce ? { code_verifier: flowState.codeVerifier } : {}), + }); + + const methodToTry = resolvedAuthMethod ?? "client_secret_post"; + const result = await fetchToken(ep.tokenEndpoint, body, methodToTry); + + if (!result.ok && !resolvedAuthMethod) { + const isClientError = + result.error.code === "invalid_client" || + (result.error.code === "token_exchange_failed" && + result.error.message.includes("invalid_client")); + + if (isClientError) { + const fallback = + methodToTry === "client_secret_post" + ? ("client_secret_basic" as const) + : ("client_secret_post" as const); + + log.debug("auth", "Token exchange failed with %s, retrying with %s", methodToTry, fallback); + const retryResult = await fetchToken(ep.tokenEndpoint, body, fallback); + if (retryResult.ok) { + resolvedAuthMethod = fallback; + log.debug("auth", "Auth method %s succeeded, caching for future requests", fallback); + } + + return retryResult; + } + } + + if (result.ok && !resolvedAuthMethod) { + resolvedAuthMethod = methodToTry; + } + + return result; + } + + async function fetchToken( + tokenEndpoint: string, + body: URLSearchParams, + method: "client_secret_basic" | "client_secret_post", + ): Promise> { + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + + if (method === "client_secret_post") { + body.set("client_id", config.clientId); + body.set("client_secret", config.clientSecret); + } else { + const credentials = btoa( + `${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`, + ); + + headers.Authorization = `Basic ${credentials}`; + } + + let response: Response; + try { + response = await fetch(tokenEndpoint, { + method: "POST", + headers, + body: body.toString(), + signal: AbortSignal.timeout(10_000), + }); + } catch (cause) { + return err({ + code: "token_exchange_failed", + message: `Failed to reach token endpoint: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } + + let json: unknown; + try { + json = await response.json(); + } catch { + return err({ + code: "token_exchange_failed", + message: `Token endpoint returned non-JSON response (status ${response.status})`, + }); + } + + const responseBody = json as Record; + if (!response.ok || typeof responseBody.error === "string") { + const tokenError = responseBody as unknown as TokenErrorResponse; + const errorDesc = tokenError.error_description ?? ""; + + if (tokenError.error === "invalid_client") { + return err({ + code: "invalid_client", + message: `invalid_client: ${errorDesc}`, + hint: "Your identity provider rejected the client credentials. Try setting oidc.token_endpoint_auth_method to 'client_secret_post' or 'client_secret_basic' in your config.", + }); + } + + // Praying on hopes and dreams, but this *might* help (MAYBE) + const isPkceError = + tokenError.error.toLowerCase().includes("pkce") || + tokenError.error.toLowerCase().includes("code_verifier") || + tokenError.error.toLowerCase().includes("code verifier") || + errorDesc.toLowerCase().includes("pkce") || + errorDesc.toLowerCase().includes("code_verifier") || + errorDesc.toLowerCase().includes("code verifier"); + + if (isPkceError) { + const usePkce = config.usePkce !== false; + return err({ + code: "pkce_error", + message: `PKCE error: ${tokenError.error} — ${errorDesc}. Current use_pkce=${usePkce}`, + hint: usePkce + ? "Your identity provider may not support PKCE. Try setting oidc.use_pkce to false in your config." + : "Your identity provider may require PKCE. Try setting oidc.use_pkce to true in your config.", + }); + } + + return err({ + code: "token_exchange_failed", + message: `Token exchange error: ${tokenError.error} — ${errorDesc}`, + }); + } + + if (typeof responseBody.access_token !== "string") { + return err({ + code: "token_exchange_failed", + message: "Token response is missing access_token", + }); + } + + return ok({ + access_token: responseBody.access_token as string, + id_token: responseBody.id_token as string | undefined, + token_type: responseBody.token_type as string | undefined, + expires_in: responseBody.expires_in as number | undefined, + refresh_token: responseBody.refresh_token as string | undefined, + }); + } + + async function verifyIdToken( + idToken: string, + expectedNonce: string, + ): Promise> { + if (!jwks) { + return err({ + code: "invalid_id_token", + message: "JWKS resolver is not initialized — endpoints must be resolved first", + }); + } + + try { + const { payload } = await jwtVerify(idToken, jwks, { + issuer: config.issuer, + audience: config.clientId, + clockTolerance: 60, + }); + + if (!payload.sub) { + return err({ + code: "missing_sub", + message: "ID token is missing the 'sub' claim", + hint: "Your identity provider did not return a user identifier. Check that your OIDC client is configured to include the 'sub' claim.", + }); + } + + if (payload.nonce !== expectedNonce) { + return err({ + code: "nonce_mismatch", + message: `Nonce mismatch: expected ${expectedNonce}, got ${payload.nonce}`, + hint: "Please try signing in again. This can happen with stale browser sessions.", + }); + } + + return ok(payload); + } catch (cause) { + if (cause instanceof joseErrors.JWTClaimValidationFailed) { + return err({ + code: "invalid_id_token", + message: `JWT claim validation failed: ${cause.claim} — ${cause.reason}`, + }); + } + + if (cause instanceof joseErrors.JWTExpired) { + return err({ + code: "invalid_id_token", + message: "ID token is expired", + }); + } + + if (cause instanceof joseErrors.JWSSignatureVerificationFailed) { + return err({ + code: "invalid_id_token", + message: "ID token signature verification failed", + hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.", + }); + } + + return err({ + code: "invalid_id_token", + message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } + } + + async function enrichWithUserInfo( + ep: ResolvedEndpoints, + accessToken: string, + claims: OidcClaims, + ): Promise { + const needsEnrichment = !claims.name && !claims.email && !claims.picture; + if (!needsEnrichment || !ep.userinfoEndpoint) { + return claims; + } + + try { + const response = await fetch(ep.userinfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + log.debug("auth", "UserInfo endpoint returned %d, skipping enrichment", response.status); + return claims; + } + + const userInfo = (await response.json()) as Record; + return { + ...claims, + name: claims.name ?? (userInfo.name as string | undefined), + given_name: claims.given_name ?? (userInfo.given_name as string | undefined), + family_name: claims.family_name ?? (userInfo.family_name as string | undefined), + preferred_username: + claims.preferred_username ?? (userInfo.preferred_username as string | undefined), + email: claims.email ?? (userInfo.email as string | undefined), + picture: claims.picture ?? (userInfo.picture as string | undefined), + }; + } catch (cause) { + log.debug( + "auth", + "UserInfo fetch failed (non-fatal): %s", + cause instanceof Error ? cause.message : String(cause), + ); + + return claims; + } + } + + function buildIdentity(claims: OidcClaims): OidcIdentity { + const name = + claims.name ?? + (claims.given_name && claims.family_name + ? `${claims.given_name} ${claims.family_name}` + : (claims.preferred_username ?? "SSO User")); + + const username = claims.preferred_username ?? claims.email?.split("@")[0] ?? "user"; + + let picture: string | undefined; + if (config.profilePictureSource === "gravatar") { + if (claims.email) { + const hash = createHash("sha256").update(claims.email.trim().toLowerCase()).digest("hex"); + picture = `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; + } + } else { + picture = claims.picture; + } + + return { + issuer: config.issuer, + subject: claims.sub!, + name, + username, + email: claims.email, + picture, + }; + } + + function invalidate(): void { + endpoints = undefined; + lastError = undefined; + jwks = undefined; + resolvedAuthMethod = config.tokenEndpointAuthMethod; + } + + function reload(newConfig: OidcConfig): void { + config = Object.freeze({ ...newConfig }); + invalidate(); + } + + return { status, discover, startFlow, handleCallback, invalidate, reload }; +} + +function generateRandom(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} + +function computeS256Challenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} diff --git a/app/server/result.ts b/app/server/result.ts new file mode 100644 index 0000000..39e40a2 --- /dev/null +++ b/app/server/result.ts @@ -0,0 +1,9 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} diff --git a/app/server/web/oidc-connector.ts b/app/server/web/oidc-connector.ts deleted file mode 100644 index 39707ca..0000000 --- a/app/server/web/oidc-connector.ts +++ /dev/null @@ -1,321 +0,0 @@ -import * as oidc from "openid-client"; - -import log from "~/utils/log"; - -import type { HeadplaneConfig } from "../config/config-schema"; -import type { RuntimeApiClient } from "../headscale/api/endpoints"; -import { isDataUnauthorizedError } from "../headscale/api/error-client"; - -export type OidcConfig = NonNullable; - -/** - * Errors that can occur during OIDC connector setup and validation. - */ -export type OidcConnectorError = - | "INVALID_API_KEY" - | "MISSING_AUTHORIZATION_ENDPOINT" - | "MISSING_TOKEN_ENDPOINT" - | "MISSING_USERINFO_ENDPOINT" - | "MISSING_REQUIRED_CLAIMS" - | "DISCOVERY_FAILED" - | "UNKNOWN_ERROR"; - -/** - * Represents a "configured" OIDC setup for Headplane. - * This may include mis-configured versions too and will surface error messages. - */ -export type OidcConnector = - | { - isValid: true; - isExclusive: boolean; - usePKCE: boolean; - client: oidc.Configuration; - apiKey: string; - scope: string; - extraParams?: Record; - } - | { - isValid: false; - isExclusive: false; - errors: OidcConnectorError[]; - }; - -/** - * A lazy OIDC connector that retries initialization on failure. - * This allows OIDC to recover from transient startup failures (e.g., network issues, - * OIDC provider temporarily unavailable) without requiring a server restart. - */ -export interface LazyOidcConnector { - /** - * Get the current OIDC connector state. - * If a previous attempt failed, this will retry initialization. - * Successful results are cached until invalidated. - */ - get(): Promise; - - /** - * Force a re-initialization of the OIDC connector on the next get() call. - * Useful for manually triggering a retry after configuration changes. - */ - invalidate(): void; -} - -/** - * Creates a lazy OIDC connector that retries on failure. - * Successful initialization is cached; failed attempts are retried on each get() call. - * - * @param baseUrl The base URL of the Headplane server. - * @param config The OIDC configuration. - * @param client The Headscale runtime API client. - * @returns A lazy OIDC connector that retries on failure. - */ -export function createLazyOidcConnector( - baseUrl: string | undefined, - config: OidcConfig, - client: RuntimeApiClient, -): LazyOidcConnector { - let cachedConnector: OidcConnector | undefined; - let initPromise: Promise | undefined; - - return { - async get(): Promise { - if (cachedConnector?.isValid) { - return cachedConnector; - } - - if (initPromise) { - return initPromise; - } - - initPromise = createOidcConnector(baseUrl, config, client); - try { - const connector = await initPromise; - if (connector.isValid) { - cachedConnector = connector; - log.info("auth", "OIDC connector initialized successfully"); - } else { - log.warn("auth", "OIDC connector initialization failed, will retry on next request"); - } - return connector; - } finally { - // Clear the promise so we can retry on next call if it failed - initPromise = undefined; - } - }, - - invalidate(): void { - cachedConnector = undefined; - initPromise = undefined; - log.info("auth", "OIDC connector cache invalidated"); - }, - }; -} - -/** - * Creates an OIDC connector based on the configuration and Headscale API. - * This will attempt to validate the configuration and return any errors. - * - * @param baseUrl The base URL of the Headplane server. - * @param config The OIDC configuration. - * @param client The Headscale runtime API client. - * @returns An OIDC connector with validation status. - */ -async function createOidcConnector( - baseUrl: string | undefined, - config: OidcConfig, - client: RuntimeApiClient, -): Promise { - if (baseUrl == null && config.redirect_uri == null) { - log.warn( - "config", - "OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.", - ); - } - - const errors: OidcConnectorError[] = []; - if (!config.headscale_api_key) { - errors.push("INVALID_API_KEY"); - return { - isValid: false, - isExclusive: false, - errors, - }; - } - - try { - await client.getApiKeys(); - } catch (error) { - if (isDataUnauthorizedError(error)) { - errors.push("INVALID_API_KEY"); - return { - isValid: false, - isExclusive: false, - errors, - }; - } - - // MARK: Otherwise assume the API key is valid since the API request - // failed for another reason that isn't 401 and we are optimistic - } - - const oidcClientOrErrors = await discoveryCoalesce(config); - if (Array.isArray(oidcClientOrErrors)) { - errors.push(...oidcClientOrErrors); - return { - isValid: false, - isExclusive: false, - errors, - }; - } - - return { - isValid: true, - isExclusive: config.disable_api_key_login, - usePKCE: config.use_pkce, - client: oidcClientOrErrors, - apiKey: config.headscale_api_key, - scope: config.scope, - extraParams: config.extra_params, - }; -} - -/** - * Runs OIDC discovery and coalesces the results with the provided config. - * We treat the manually supplied values as overrides to discovery. - * - * @param config The OIDC configuration. - * @returns The coalesced OIDC configuration or an array of errors. - */ -async function discoveryCoalesce( - config: OidcConfig, -): Promise { - let metadata: oidc.ServerMetadata; - let discoveryFailed = false; - - try { - const client = await oidc.discovery(new URL(config.issuer), config.client_id); - metadata = client.serverMetadata(); - if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) { - log.warn("config", "OIDC provider does not support PKCE, but it is enabled in the config"); - } - - if (metadata.claims_supported != null) { - if (!metadata.claims_supported.includes("sub")) { - log.error("config", "OIDC provider does not support `sub` claim"); - return ["MISSING_REQUIRED_CLAIMS"]; - } - - if (!metadata.claims_supported.includes("name")) { - if ( - !( - metadata.claims_supported.includes("given_name") && - metadata.claims_supported.includes("family_name") - ) - ) { - log.warn( - "config", - "OIDC provider does not support `name`, `given_name`, or `family_name` claims", - ); - } - } - - if ( - !metadata.claims_supported.includes("preferred_username") && - !metadata.claims_supported.includes("email") - ) { - log.warn("config", "OIDC provider does not support `preferred_username` or `email` claims"); - } - } - } catch { - log.warn("auth", "Failed to reach OIDC provider for discovery, will retry on next request"); - discoveryFailed = true; - metadata = { - issuer: config.issuer, - }; - } - - const authorization_endpoint = config.authorization_endpoint ?? metadata.authorization_endpoint; - const token_endpoint = config.token_endpoint ?? metadata.token_endpoint; - const userinfo_endpoint = config.userinfo_endpoint ?? metadata.userinfo_endpoint; - - const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint; - - if (discoveryFailed && hasMissingEndpoints) { - return ["DISCOVERY_FAILED"]; - } - - const errors: OidcConnectorError[] = []; - - if (!authorization_endpoint) { - errors.push("MISSING_AUTHORIZATION_ENDPOINT"); - } - - if (!token_endpoint) { - errors.push("MISSING_TOKEN_ENDPOINT"); - } - - if (!userinfo_endpoint) { - errors.push("MISSING_USERINFO_ENDPOINT"); - } - - if (errors.length > 0) { - return errors; - } - - const oidcClient = new oidc.Configuration( - { - ...metadata, - issuer: config.issuer, - authorization_endpoint, - token_endpoint, - userinfo_endpoint, - }, - config.client_id, - config.client_secret, - negotiateTokenEndpointAuthMethod(config, metadata), - ); - - return oidcClient; -} - -/** - * Determines the token endpoint authentication method based on config and metadata. - * - * @param config The OIDC configuration. - * @param metadata The OIDC server metadata. - * @returns The client authentication method for the token endpoint. - */ -function negotiateTokenEndpointAuthMethod( - config: OidcConfig, - metadata: oidc.ServerMetadata, -): oidc.ClientAuth { - if (config.token_endpoint_auth_method != null) { - switch (config.token_endpoint_auth_method) { - case "client_secret_basic": - return oidc.ClientSecretBasic(config.client_secret); - case "client_secret_post": - return oidc.ClientSecretPost(config.client_secret); - case "client_secret_jwt": - return oidc.ClientSecretJwt(config.client_secret); - } - } - - const supported = metadata.token_endpoint_auth_methods_supported; - if (supported != null && supported.length > 0) { - // Prefer client_secret_basic (spec default), otherwise use first available - if (supported.includes("client_secret_basic")) { - return oidc.ClientSecretBasic(config.client_secret); - } - - if (supported.includes("client_secret_post")) { - return oidc.ClientSecretPost(config.client_secret); - } - - if (supported.includes("client_secret_jwt")) { - return oidc.ClientSecretJwt(config.client_secret); - } - } - - log.warn("config", "Falling back to client_secret_post for token endpoint authentication"); - return oidc.ClientSecretPost(config.client_secret); -} diff --git a/package.json b/package.json index c33f468..b3492af 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "lucide-react": "^0.575.0", "mime": "^4.1.0", "openapi-types": "^12.1.3", - "openid-client": "6.8.2", "react": "19.2.4", "react-codemirror-merge": "4.25.5", "react-dom": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1495f64..4593822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,9 +97,6 @@ importers: openapi-types: specifier: ^12.1.3 version: 12.1.3 - openid-client: - specifier: 6.8.2 - version: 6.8.2 react: specifier: 19.2.4 version: 19.2.4 diff --git a/tests/integration/oidc/oidc.test.ts b/tests/integration/oidc/oidc.test.ts new file mode 100644 index 0000000..30e7416 --- /dev/null +++ b/tests/integration/oidc/oidc.test.ts @@ -0,0 +1,167 @@ +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { createOidcService, type OidcConfig } from "~/server/oidc/provider"; + +import { type DexEnv, startDex } from "./start-dex"; + +vi.mock("~/utils/log", () => ({ + default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +let dex: DexEnv; + +beforeAll(async () => { + dex = await startDex(); +}, 60_000); + +afterAll(async () => { + await dex?.container.stop({ remove: true, removeVolumes: true }); +}); + +function dexConfig(overrides?: Partial): OidcConfig { + // Dex's issuer inside the container is http://0.0.0.0:5556 but we + // connect via the mapped port. We provide manual endpoint overrides + // pointing to the external URL so the service can actually reach them, + // while the issuer stays as configured in Dex for JWT validation. + return { + issuer: "http://0.0.0.0:5556", + clientId: "test-client", + clientSecret: "test-secret", + baseUrl: "http://localhost", + authorizationEndpoint: `${dex.issuerUrl}/auth`, + tokenEndpoint: `${dex.issuerUrl}/token`, + userinfoEndpoint: `${dex.issuerUrl}/userinfo`, + jwksUri: `${dex.issuerUrl}/keys`, + ...overrides, + }; +} + +describe("discovery against real Dex", () => { + test("resolves endpoints via manual overrides", async () => { + const svc = createOidcService(dexConfig()); + const result = await svc.discover(); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.authorizationEndpoint).toContain("/auth"); + expect(result.value.tokenEndpoint).toContain("/token"); + expect(result.value.jwksUri).toContain("/keys"); + }); + + test("fetches real discovery document from Dex", async () => { + const svc = createOidcService({ + issuer: dex.issuerUrl, + clientId: "test-client", + clientSecret: "test-secret", + baseUrl: "http://localhost", + }); + + const result = await svc.discover(); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + // Dex returns endpoints with the internal issuer + expect(result.value.authorizationEndpoint).toContain("/auth"); + expect(result.value.tokenEndpoint).toContain("/token"); + expect(result.value.jwksUri).toContain("/keys"); + }); + + test("status is ready after discovery", async () => { + const svc = createOidcService(dexConfig()); + await svc.discover(); + expect(svc.status().state).toBe("ready"); + }); +}); + +describe("startFlow against real Dex", () => { + test("builds a valid authorization URL", async () => { + const svc = createOidcService(dexConfig()); + const result = await svc.startFlow(); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + const url = new URL(result.value.url); + expect(url.pathname).toBe("/auth"); + expect(url.searchParams.get("client_id")).toBe("test-client"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/admin/oidc/callback"); + expect(url.searchParams.get("scope")).toContain("openid"); + }); + + test("PKCE challenge is included by default", async () => { + const svc = createOidcService(dexConfig()); + const result = await svc.startFlow(); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + const url = new URL(result.value.url); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + }); +}); + +describe("handleCallback error handling against real Dex", () => { + test("invalid authorization code returns error", async () => { + const svc = createOidcService(dexConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const params = new URLSearchParams({ + code: "invalid-code", + state: flowState.state, + }); + + const result = await svc.handleCallback(params, flowState); + expect(result.ok).toBe(false); + }); + + test("state mismatch detected before hitting Dex", async () => { + const svc = createOidcService(dexConfig()); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const params = new URLSearchParams({ + code: "any-code", + state: "tampered-state", + }); + + const result = await svc.handleCallback(params, flowResult.value.flowState); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("state_mismatch"); + }); +}); + +describe("invalidate and rediscovery against real Dex", () => { + test("invalidate forces rediscovery", async () => { + const svc = createOidcService(dexConfig()); + await svc.discover(); + expect(svc.status().state).toBe("ready"); + + svc.invalidate(); + expect(svc.status().state).toBe("pending"); + + const result = await svc.discover(); + expect(result.ok).toBe(true); + expect(svc.status().state).toBe("ready"); + }); +}); diff --git a/tests/integration/oidc/start-dex.ts b/tests/integration/oidc/start-dex.ts new file mode 100644 index 0000000..65cdaab --- /dev/null +++ b/tests/integration/oidc/start-dex.ts @@ -0,0 +1,30 @@ +import tc from "testcontainers"; + +export interface DexEnv { + container: tc.StartedTestContainer; + issuerUrl: string; +} + +export async function startDex(): Promise { + const container = await new tc.GenericContainer("dexidp/dex:v2.41.1") + .withExposedPorts(5556) + .withEnvironment({ + DEX_ISSUER: "http://0.0.0.0:5556", + DEX_ENABLE_PASSWORD_DB: "true", + DEX_OAUTH2_SKIP_APPROVAL_SCREEN: "true", + }) + .withWaitStrategy(tc.Wait.forLogMessage("listening on", 1).withStartupTimeout(30_000)) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(5556); + + // Dex's issuer is configured as http://0.0.0.0:5556 inside the + // container. The external URL uses the mapped port. Discovery + // will return endpoints with the internal issuer, but that's fine + // for testing discovery + startFlow. The issuer mismatch is + // expected and logged at debug level. + const issuerUrl = `http://${host}:${port}`; + + return { container, issuerUrl }; +} diff --git a/tests/unit/oidc/provider.test.ts b/tests/unit/oidc/provider.test.ts new file mode 100644 index 0000000..74fef95 --- /dev/null +++ b/tests/unit/oidc/provider.test.ts @@ -0,0 +1,878 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +import { SignJWT, exportJWK, generateKeyPair } from "jose"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { createOidcService, type OidcConfig } from "~/server/oidc/provider"; + +vi.mock("~/utils/log", () => ({ + default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +let server: Server; +let baseUrl: string; +let privateKey: CryptoKey; +let publicJwk: Record; + +const CLIENT_ID = "test-client"; +const CLIENT_SECRET = "test-secret"; + +let tokenHandler: (req: IncomingMessage, res: ServerResponse) => void; +let userinfoHandler: ((req: IncomingMessage, res: ServerResponse) => void) | undefined; + +async function signIdToken(claims: Record, nonce?: string) { + const jwt = new SignJWT({ nonce, ...claims }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer(baseUrl) + .setAudience(CLIENT_ID) + .setIssuedAt() + .setExpirationTime("5m"); + + return jwt.sign(privateKey); +} + +// You would think this is a lot better in 2026, but no +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + + req.on("end", () => resolve(body)); + }); +} + +beforeAll(async () => { + const keyPair = await generateKeyPair("RS256"); + privateKey = keyPair.privateKey as CryptoKey; + const exported = await exportJWK(keyPair.publicKey); + publicJwk = { ...exported, kid: "test-key", use: "sig", alg: "RS256" }; + + server = createServer(async (req, res) => { + const url = new URL(req.url!, "http://localhost"); + + if (url.pathname === "/.well-known/openid-configuration") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + userinfo_endpoint: `${baseUrl}/userinfo`, + jwks_uri: `${baseUrl}/jwks`, + end_session_endpoint: `${baseUrl}/logout`, + }), + ); + + return; + } + + if (url.pathname === "/jwks") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ keys: [publicJwk] })); + return; + } + + if (url.pathname === "/token") { + tokenHandler(req, res); + return; + } + + if (url.pathname === "/userinfo" && userinfoHandler) { + userinfoHandler(req, res); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (typeof addr === "object" && addr) { + baseUrl = `http://127.0.0.1:${addr.port}`; + } + + resolve(); + }); + }); +}); + +afterAll(() => { + server?.close(); +}); + +function testConfig(overrides?: Partial): OidcConfig { + return { + issuer: baseUrl, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + baseUrl: "https://headplane.example.com", + ...overrides, + }; +} + +describe("status", () => { + test("returns pending before discovery", () => { + const svc = createOidcService(testConfig()); + expect(svc.status().state).toBe("pending"); + }); + + test("returns ready after successful discovery", async () => { + const svc = createOidcService(testConfig()); + await svc.discover(); + expect(svc.status().state).toBe("ready"); + }); + + test("returns error after failed discovery", async () => { + const svc = createOidcService(testConfig({ issuer: "http://127.0.0.1:1" })); + await svc.discover(); + const status = svc.status(); + + expect(status.state).toBe("error"); + if (status.state === "error") { + expect(status.error.code).toBe("discovery_failed"); + } + }); +}); + +describe("discover", () => { + test("resolves endpoints from discovery document", async () => { + const svc = createOidcService(testConfig()); + const result = await svc.discover(); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.value.authorizationEndpoint).toBe(`${baseUrl}/authorize`); + expect(result.value.tokenEndpoint).toBe(`${baseUrl}/token`); + expect(result.value.jwksUri).toBe(`${baseUrl}/jwks`); + expect(result.value.userinfoEndpoint).toBe(`${baseUrl}/userinfo`); + expect(result.value.endSessionEndpoint).toBe(`${baseUrl}/logout`); + } + }); + + test("caches successful discovery", async () => { + const svc = createOidcService(testConfig()); + const first = await svc.discover(); + const second = await svc.discover(); + expect(first).toStrictEqual(second); + }); + + test("skips discovery when all endpoints are manual", async () => { + const svc = createOidcService( + testConfig({ + issuer: "http://127.0.0.1:1", + authorizationEndpoint: "http://example.com/auth", + tokenEndpoint: "http://example.com/token", + jwksUri: "http://example.com/jwks", + }), + ); + + const result = await svc.discover(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.authorizationEndpoint).toBe("http://example.com/auth"); + } + }); + + test("returns missing_endpoints when discovery is incomplete", async () => { + const incomplete = createServer((_, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + issuer: "http://localhost", + authorization_endpoint: "http://localhost/auth", + }), + ); + }); + + await new Promise((resolve) => incomplete.listen(0, "127.0.0.1", resolve)); + const addr = incomplete.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + + const svc = createOidcService(testConfig({ issuer: `http://127.0.0.1:${port}` })); + const result = await svc.discover(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("missing_endpoints"); + } + + incomplete.close(); + }); + + test("retries after failure on next call", async () => { + const svc = createOidcService(testConfig({ issuer: "http://127.0.0.1:1" })); + const r1 = await svc.discover(); + expect(r1.ok).toBe(false); + + svc.reload(testConfig()); + const r2 = await svc.discover(); + expect(r2.ok).toBe(true); + }); + + test("config overrides take precedence over discovery", async () => { + const svc = createOidcService( + testConfig({ + authorizationEndpoint: "http://override.example.com/auth", + }), + ); + + const result = await svc.discover(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.authorizationEndpoint).toBe("http://override.example.com/auth"); + expect(result.value.tokenEndpoint).toBe(`${baseUrl}/token`); + } + }); +}); + +describe("invalidate and reload", () => { + test("invalidate resets to pending", async () => { + const svc = createOidcService(testConfig()); + await svc.discover(); + expect(svc.status().state).toBe("ready"); + + svc.invalidate(); + expect(svc.status().state).toBe("pending"); + }); + + test("reload clears state and applies new config", async () => { + const svc = createOidcService(testConfig()); + await svc.discover(); + + svc.reload(testConfig({ issuer: "http://127.0.0.1:1" })); + expect(svc.status().state).toBe("pending"); + + const result = await svc.discover(); + expect(result.ok).toBe(false); + }); +}); + +describe("startFlow", () => { + test("builds authorization URL with required params", async () => { + const svc = createOidcService(testConfig()); + const result = await svc.startFlow(); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + const url = new URL(result.value.url); + expect(`${url.origin}${url.pathname}`).toBe(`${baseUrl}/authorize`); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe(CLIENT_ID); + expect(url.searchParams.get("scope")).toBe("openid email profile"); + expect(url.searchParams.get("state")).toBe(result.value.flowState.state); + expect(url.searchParams.get("nonce")).toBe(result.value.flowState.nonce); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://headplane.example.com/admin/oidc/callback", + ); + }); + + test("includes PKCE challenge by default", async () => { + const svc = createOidcService(testConfig()); + const result = await svc.startFlow(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const url = new URL(result.value.url); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + expect(result.value.flowState.codeVerifier).toBeTruthy(); + }); + + test("omits PKCE when disabled", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const result = await svc.startFlow(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const url = new URL(result.value.url); + expect(url.searchParams.has("code_challenge")).toBe(false); + expect(url.searchParams.has("code_challenge_method")).toBe(false); + }); + + test("uses custom scope", async () => { + const svc = createOidcService(testConfig({ scope: "openid email" })); + const result = await svc.startFlow(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const url = new URL(result.value.url); + expect(url.searchParams.get("scope")).toBe("openid email"); + }); + + test("passes extra_params", async () => { + const svc = createOidcService( + testConfig({ + extraParams: { prompt: "select_account", hd: "example.com" }, + }), + ); + + const result = await svc.startFlow(); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const url = new URL(result.value.url); + expect(url.searchParams.get("prompt")).toBe("select_account"); + expect(url.searchParams.get("hd")).toBe("example.com"); + }); + + test("generates unique state and nonce per call", async () => { + const svc = createOidcService(testConfig()); + const r1 = await svc.startFlow(); + const r2 = await svc.startFlow(); + + expect(r1.ok && r2.ok).toBe(true); + if (!r1.ok || !r2.ok) return; + + expect(r1.value.flowState.state).not.toBe(r2.value.flowState.state); + expect(r1.value.flowState.nonce).not.toBe(r2.value.flowState.nonce); + }); +}); + +describe("handleCallback", () => { + test("successful flow returns identity", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) throw new Error("startFlow failed"); + const { flowState } = flowResult.value; + + const idToken = await signIdToken( + { + sub: "user-123", + name: "Test User", + email: "test@example.com", + preferred_username: "testuser", + }, + flowState.nonce, + ); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.issuer).toBe(baseUrl); + expect(result.value.subject).toBe("user-123"); + expect(result.value.name).toBe("Test User"); + expect(result.value.email).toBe("test@example.com"); + expect(result.value.username).toBe("testuser"); + }); + + test("state mismatch returns error", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) throw new Error("startFlow failed"); + const { flowState } = flowResult.value; + + const params = new URLSearchParams({ code: "test-code", state: "wrong-state" }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("state_mismatch"); + }); + + test("provider error in callback params", async () => { + const svc = createOidcService(testConfig()); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) throw new Error("startFlow failed"); + + const params = new URLSearchParams({ + error: "access_denied", + error_description: "User denied", + }); + + const result = await svc.handleCallback(params, flowResult.value.flowState); + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("token_exchange_failed"); + }); + + test("missing authorization code", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + + const params = new URLSearchParams({ state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("token_exchange_failed"); + }); + + test("nonce mismatch returns error", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ sub: "user-123" }, "wrong-nonce"); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("nonce_mismatch"); + }); + + test("missing sub claim returns error", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const jwt = new SignJWT({ nonce: flowState.nonce }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer(baseUrl) + .setAudience(CLIENT_ID) + .setIssuedAt() + .setExpirationTime("5m"); + + const idToken = await jwt.sign(privateKey); + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("missing_sub"); + }); + + test("invalid_client triggers auth method retry", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce); + + let callCount = 0; + tokenHandler = async (req, res) => { + callCount++; + const body = await readBody(req); + const bodyParams = new URLSearchParams(body); + + if (callCount === 1 && bodyParams.has("client_secret")) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_client", error_description: "Use basic auth" })); + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "mock-access-token", + id_token: idToken, + token_type: "Bearer", + }), + ); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + expect(callCount).toBe(2); + }); + + test("token exchange uses client_secret_post by default", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce); + + let receivedAuth: string | undefined; + let receivedBody = ""; + tokenHandler = async (req, res) => { + receivedAuth = req.headers.authorization; + receivedBody = await readBody(req); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" })); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + await svc.handleCallback(params, flowState); + + expect(receivedAuth).toBeUndefined(); + const bodyParams = new URLSearchParams(receivedBody); + expect(bodyParams.get("client_id")).toBe(CLIENT_ID); + expect(bodyParams.get("client_secret")).toBe(CLIENT_SECRET); + }); + + test("explicit client_secret_basic sends Authorization header", async () => { + const svc = createOidcService( + testConfig({ + usePkce: false, + tokenEndpointAuthMethod: "client_secret_basic", + }), + ); + + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce); + + let receivedAuth: string | undefined; + tokenHandler = async (req, res) => { + receivedAuth = req.headers.authorization; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" })); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "test-code", state: flowState.state }); + await svc.handleCallback(params, flowState); + + expect(receivedAuth).toBeDefined(); + expect(receivedAuth!.startsWith("Basic ")).toBe(true); + }); +}); + +describe("identity resolution", () => { + async function flowWithClaims( + claims: Record, + configOverrides?: Partial, + ) { + const svc = createOidcService(testConfig({ usePkce: false, ...configOverrides })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ ...claims }, flowState.nonce); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" })); + }; + + userinfoHandler = undefined; + const params = new URLSearchParams({ code: "c", state: flowState.state }); + return svc.handleCallback(params, flowState); + } + + test("uses name claim directly", async () => { + const result = await flowWithClaims({ sub: "u1", name: "Alice Smith" }); + expect(result.ok && result.value.name).toBe("Alice Smith"); + }); + + test("falls back to given_name + family_name", async () => { + const result = await flowWithClaims({ + sub: "u1", + given_name: "Alice", + family_name: "Smith", + }); + + expect(result.ok && result.value.name).toBe("Alice Smith"); + }); + + test("falls back to preferred_username for name", async () => { + const result = await flowWithClaims({ sub: "u1", preferred_username: "asmith" }); + expect(result.ok && result.value.name).toBe("asmith"); + }); + + test("falls back to SSO User", async () => { + const result = await flowWithClaims({ sub: "u1" }); + expect(result.ok && result.value.name).toBe("SSO User"); + }); + + test("username from preferred_username", async () => { + const result = await flowWithClaims({ sub: "u1", preferred_username: "alice" }); + expect(result.ok && result.value.username).toBe("alice"); + }); + + test("username falls back to email local part", async () => { + const result = await flowWithClaims({ sub: "u1", email: "alice@example.com" }); + expect(result.ok && result.value.username).toBe("alice"); + }); + + test("username falls back to 'user'", async () => { + const result = await flowWithClaims({ sub: "u1" }); + expect(result.ok && result.value.username).toBe("user"); + }); + + test("gravatar picture from email", async () => { + const result = await flowWithClaims( + { sub: "u1", email: "test@example.com" }, + { profilePictureSource: "gravatar" }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.picture).toMatch(/gravatar\.com\/avatar\//); + }); + + test("oidc picture from claims", async () => { + const result = await flowWithClaims({ + sub: "u1", + picture: "https://example.com/photo.jpg", + }); + + expect(result.ok && result.value.picture).toBe("https://example.com/photo.jpg"); + }); +}); + +describe("userinfo enrichment", () => { + test("enriches missing claims from userinfo", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ sub: "user-123" }, flowState.nonce); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" })); + }; + + userinfoHandler = (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + sub: "user-123", + name: "From UserInfo", + email: "userinfo@example.com", + }), + ); + }; + + const params = new URLSearchParams({ code: "c", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.name).toBe("From UserInfo"); + expect(result.value.email).toBe("userinfo@example.com"); + }); + + test("skips userinfo when id token has all claims", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken( + { + sub: "user-123", + name: "From Token", + email: "token@example.com", + picture: "https://example.com/pic.jpg", + }, + flowState.nonce, + ); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" })); + }; + + let userinfoCalledCount = 0; + userinfoHandler = (_req, res) => { + userinfoCalledCount++; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ name: "Should Not Use" })); + }; + + const params = new URLSearchParams({ code: "c", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.name).toBe("From Token"); + expect(userinfoCalledCount).toBe(0); + }); + + test("userinfo failure does not block login", async () => { + const svc = createOidcService(testConfig({ usePkce: false })); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + const idToken = await signIdToken({ sub: "user-123" }, flowState.nonce); + + tokenHandler = async (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" })); + }; + + userinfoHandler = (_req, res) => { + res.writeHead(500); + res.end("Internal Server Error"); + }; + + const params = new URLSearchParams({ code: "c", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.value.subject).toBe("user-123"); + expect(result.value.name).toBe("SSO User"); + }); +}); + +describe("pkce detection", () => { + test("detects pkce error from provider response", async () => { + const svc = createOidcService(testConfig()); + const flowResult = await svc.startFlow(); + if (!flowResult.ok) { + throw new Error("startFlow failed"); + } + + const { flowState } = flowResult.value; + tokenHandler = async (_req, res) => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "invalid_request", + error_description: "code_verifier is required", + }), + ); + }; + + const params = new URLSearchParams({ code: "c", state: flowState.state }); + const result = await svc.handleCallback(params, flowState); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + + expect(result.error.code).toBe("pkce_error"); + }); +}); + +describe("path-based issuers", () => { + test("handles issuer with path correctly", async () => { + const pathServer = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + issuer: "http://localhost/realms/test", + authorization_endpoint: "http://localhost/realms/test/auth", + token_endpoint: "http://localhost/realms/test/token", + jwks_uri: "http://localhost/realms/test/jwks", + }), + ); + }); + + await new Promise((resolve) => pathServer.listen(0, "127.0.0.1", resolve)); + const addr = pathServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + + const svc = createOidcService( + testConfig({ + issuer: `http://127.0.0.1:${port}/realms/test`, + }), + ); + + const result = await svc.discover(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.authorizationEndpoint).toBe("http://localhost/realms/test/auth"); + } + + pathServer.close(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 952c11e..8cd8a52 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,14 @@ export default defineConfig({ testTimeout: 60_000, }, }, + { + extends: true, + test: { + name: "integration:oidc", + include: ["tests/integration/oidc/**/*.test.ts"], + testTimeout: 60_000, + }, + }, ], env: { HEADPLANE_DEBUG_LOG: "true",