mirror of
https://github.com/tale/headplane.git
synced 2026-08-20 18:02:17 +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);
|
||||
}
|
||||
Reference in New Issue
Block a user