mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 16:07:07 +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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user