feat: replace openid-client with clean-room oidc system

This commit is contained in:
Aarnav Tale
2026-04-03 16:36:27 -04:00
parent 4cd0c1e206
commit 1259642f8a
15 changed files with 1860 additions and 571 deletions
+13 -39
View File
@@ -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>
),
});
+11 -7
View File
@@ -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} />
+42 -135
View File
@@ -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,
}),
},
});
}
+13 -57
View File
@@ -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;
}
+1 -2
View File
@@ -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
View File
@@ -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,
+669
View File
@@ -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");
}
+9
View File
@@ -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 };
}
-321
View File
@@ -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);
}
-1
View File
@@ -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",
-3
View File
@@ -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
+167
View File
@@ -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");
});
});
+30
View File
@@ -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 };
}
+878
View File
@@ -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();
});
});
+8
View File
@@ -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",