mirror of
https://github.com/tale/headplane.git
synced 2026-08-04 03:07:43 +00:00
feat: replace openid-client with clean-room oidc system
This commit is contained in:
@@ -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 (
|
||||
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -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: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
|
||||
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.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_TOKEN_ENDPOINT": {
|
||||
case "discovery_failed": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
|
||||
discovery URL or manual configuration is correct.
|
||||
Unable to reach the OIDC provider for discovery. SSO will retry on the next login
|
||||
attempt.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_USERINFO_ENDPOINT": {
|
||||
default: {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
|
||||
discovery URL or manual configuration is correct.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_REQUIRED_CLAIMS": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
|
||||
authentication. Your OIDC provider may be misconfigured.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "UNKNOWN_ERROR": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
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.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
<div>
|
||||
{urlState?.startsWith("error_") ? (
|
||||
<OidcErrorNotice code={urlState} />
|
||||
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
|
||||
) : oidcErrorCodes.includes("discovery_failed") ? (
|
||||
<OidcDiscoveryFailedNotice />
|
||||
) : oidcErrorCodes.length > 0 ? (
|
||||
<OidcConfigErrorNotice errors={oidcErrorCodes} />
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+19
-6
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<CryptoKey>;
|
||||
|
||||
export interface OidcService {
|
||||
status():
|
||||
| { state: "ready"; endpoints: ResolvedEndpoints }
|
||||
| { state: "pending" }
|
||||
| { state: "error"; error: OidcError };
|
||||
|
||||
discover(): Promise<Result<ResolvedEndpoints, OidcError>>;
|
||||
startFlow(): Promise<Result<{ url: string; flowState: OidcFlowState }, OidcError>>;
|
||||
|
||||
handleCallback(
|
||||
callbackParams: URLSearchParams,
|
||||
flowState: OidcFlowState,
|
||||
): Promise<Result<OidcIdentity, OidcError>>;
|
||||
|
||||
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<OidcService["status"]> {
|
||||
if (lastError) {
|
||||
return { state: "error", error: lastError };
|
||||
}
|
||||
|
||||
if (endpoints) {
|
||||
return { state: "ready", endpoints };
|
||||
}
|
||||
|
||||
return { state: "pending" };
|
||||
}
|
||||
|
||||
async function discover(): Promise<Result<ResolvedEndpoints, OidcError>> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
} 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<Result<OidcIdentity, OidcError>> {
|
||||
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<Result<TokenResponse, OidcError>> {
|
||||
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<Result<TokenResponse, OidcError>> {
|
||||
const headers: Record<string, string> = {
|
||||
"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<string, unknown>;
|
||||
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<Result<OidcClaims, OidcError>> {
|
||||
if (!jwks) {
|
||||
return err({
|
||||
code: "invalid_id_token",
|
||||
message: "JWKS resolver is not initialized — endpoints must be resolved first",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<OidcClaims>(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<OidcClaims> {
|
||||
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<string, unknown>;
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
@@ -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<HeadplaneConfig["oidc"]>;
|
||||
|
||||
/**
|
||||
* 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<string, string>;
|
||||
}
|
||||
| {
|
||||
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<OidcConnector>;
|
||||
|
||||
/**
|
||||
* 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<OidcConnector> | undefined;
|
||||
|
||||
return {
|
||||
async get(): Promise<OidcConnector> {
|
||||
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<OidcConnector> {
|
||||
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<oidc.Configuration | OidcConnectorError[]> {
|
||||
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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
-3
@@ -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
|
||||
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import tc from "testcontainers";
|
||||
|
||||
export interface DexEnv {
|
||||
container: tc.StartedTestContainer;
|
||||
issuerUrl: string;
|
||||
}
|
||||
|
||||
export async function startDex(): Promise<DexEnv> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>, 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<string> {
|
||||
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<void>((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>): 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<void>((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<string, unknown>,
|
||||
configOverrides?: Partial<OidcConfig>,
|
||||
) {
|
||||
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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user