feat: add lazy retrying oidc connector

This commit is contained in:
Aarnav Tale
2026-01-16 00:33:10 -05:00
parent 2618cdd9f6
commit 403f9c9b0b
9 changed files with 984 additions and 911 deletions
+119 -111
View File
@@ -1,123 +1,131 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { AlertCircle, CloudOff } from "lucide-react";
export function OidcConfigErrorNotice({
errors,
}: {
errors: OidcConnectorError[];
}) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '}
<ul className="list-disc list-inside mt-2 mb-1">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/Link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
<Card className="m-4 mb-4 max-w-md border border-yellow-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-yellow-500">SSO Temporarily Unavailable</Card.Title>
<CloudOff className="mb-2 h-6 w-6 text-yellow-500" />
</div>
<Card.Text className="text-sm">
Unable to reach the identity provider. Single Sign-On will be available once the provider is
reachable again. You can still sign in with an API key.
</Card.Text>
</Card>
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
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">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{" "}
<ul className="mt-2 mb-1 list-inside list-disc">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
for (const error of errors) {
switch (error) {
case 'INVALID_API_KEY':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure
that <Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure that{" "}
<Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
case 'MISSING_AUTHORIZATION_ENDPOINT':
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.
</Card.Text>
),
});
break;
case "MISSING_AUTHORIZATION_ENDPOINT":
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.
</Card.Text>
),
});
break;
case 'MISSING_TOKEN_ENDPOINT':
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.
</Card.Text>
),
});
break;
case "MISSING_TOKEN_ENDPOINT":
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.
</Card.Text>
),
});
break;
case 'MISSING_USERINFO_ENDPOINT':
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_USERINFO_ENDPOINT":
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 "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.
</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.
</Card.Text>
),
});
break;
}
}
return messages;
return messages;
}
+136 -145
View File
@@ -1,165 +1,156 @@
import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
Form,
Link as RemixLink,
redirect,
useSearchParams,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page';
import { loginAction } from './action';
import { OidcConfigErrorNotice } from './config-error';
import Logout from './logout';
import { OidcErrorNotice } from './oidc-error';
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Form, Link as RemixLink, redirect, useSearchParams } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/machines');
} catch {}
try {
await context.sessions.auth(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get('s') ?? undefined;
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
return redirect('/oidc/start');
}
const oidcConnector = await context.oidcConnector?.get();
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? (context.oidcConnector?.errors ?? [])
: [];
// 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") {
return redirect("/oidc/start");
}
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const {
isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
setShowCookieWarning(true);
}
});
if (isCookieSecureEnabled && window.location.protocol !== "https:") {
setShowCookieWarning(true);
}
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [urlState, params]);
window.history.replaceState(null, "", newUrl);
}
}, [urlState, params]);
if (urlState === 'logout') {
return <Logout />;
}
if (urlState === "logout") {
return <Logout />;
}
return (
<div className="flex w-screen h-screen items-center justify-center">
<div>
{urlState?.startsWith('error_') ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">
Configuration Issue
</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is
being served over an insecure connection and login will not work
correctly.{' '}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="w-full mt-2"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
return (
<div className="flex h-screen w-screen items-center justify-center">
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<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">
<Card.Title className="text-red-500">Configuration Issue</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="m-4 max-w-md sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate one by running{" "}
<Code>headscale apikeys create</Code> in your terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="mb-2 text-sm text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="mt-2 w-full"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
}
+136 -152
View File
@@ -1,171 +1,155 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import * as oidc from 'openid-client';
import { data, redirect } from 'react-router';
import { ulid } from 'ulidx';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import log from '~/utils/log';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-callback';
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { ulid } from "ulidx";
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login?s=error_no_query');
}
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect("/login?s=error_no_query");
}
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
if (oidcCookieState == null) {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
if (oidcCookieState == null) {
log.warn("auth", "Called OIDC callback without session cookie");
return redirect("/login?s=error_no_session");
}
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn("auth", "OIDC session cookie is missing required fields");
return redirect("/login?s=error_invalid_session");
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
callbackUrl,
{
expectedState: state,
expectedNonce: nonce,
...(context.oidcConnector.usePKCE
? { pkceCodeVerifier: verifier }
: {}),
},
);
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 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(
context.oidcConnector.client,
tokens.access_token,
claims.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'));
// 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 =
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!userInfo.email) {
return undefined;
}
const picture =
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`;
})()
: userInfo.picture;
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`;
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/', {
headers: {
'Set-Cookie': await context.sessions.createSession({
api_key: context.oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error(
'auth',
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
api_key: oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} 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',
context.oidcConnector?.usePKCE,
);
// 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 (!context.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),
);
}
return redirect('/login?s=error_auth_failed');
}
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),
);
}
return redirect("/login?s=error_auth_failed");
}
}
+66 -69
View File
@@ -1,84 +1,81 @@
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';
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";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/');
} catch {}
try {
await context.sessions.auth(request);
return redirect("/");
} catch {}
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
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 nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri,
state,
nonce,
...(context.oidcConnector.usePKCE
? {
code_challenge_method: 'S256',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
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, {
status: 302,
headers: {
'Set-Cookie': await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
}),
},
});
return redirect(url.href, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
}),
},
});
}
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.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;
}
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');
}
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,
},
);
}
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;
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+68 -71
View File
@@ -1,76 +1,73 @@
import { ArrowRight } from 'lucide-react';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import type { Route } from './+types/overview';
import { ArrowRight } from "lucide-react";
import { Link as RemixLink } from "react-router";
import Link from "~/components/Link";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
isOidcEnabled: context.oidcConnector?.isValid ?? false,
};
const oidcConnector = await context.oidcConnector?.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
};
}
export default function Page({
loaderData: { config, isOidcEnabled },
}: Route.ComponentProps) {
return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more
features, I'll be adding them here. If you require any features, feel
free to open an issue on the GitHub repository.
</p>
</div>
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily
add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<div className="text-lg font-medium flex items-center">
Manage Auth Keys
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can
be used to limit access to your Tailnet to only certain users or
groups and Headplane will also respect these settings when
authenticating.{' '}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center">
Manage Restrictions
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
export default function Page({ loaderData: { config, isOidcEnabled } }: Route.ComponentProps) {
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more features, I'll be
adding them here. If you require any features, feel free to open an issue on the GitHub
repository.
</p>
</div>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<div className="flex items-center text-lg font-medium">
Manage Auth Keys
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Authentication Restrictions</h1>
<p>
Headscale supports restricting OIDC authentication to only allow certain email
domains, groups, or users to authenticate. This can be used to limit access to your
Tailnet to only certain users or groups and Headplane will also respect these settings
when authenticating.{" "}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="flex items-center text-lg font-medium">
Manage Restrictions
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
}
+98 -103
View File
@@ -1,142 +1,137 @@
import { join } from 'node:path';
import { exit, versions } from 'node:process';
import { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/load';
import { createDbClient } from './db/client.server';
import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { createSessionStorage } from './web/sessions';
import { join } from "node:path";
import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log";
import { loadIntegration } from "./config/integration";
import { loadConfig } from "./config/load";
import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createHeadplaneAgent } from "./hp-agent";
import { createSessionStorage } from "./web/sessions";
declare global {
const __PREFIX__: string;
const __VERSION__: string;
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
log.info('server', 'Running Node.js %s', versions.node);
log.info("server", "Running Node.js %s", versions.node);
let config: HeadplaneConfig;
try {
config = await loadConfig();
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error('server', 'Unable to load configuration: %s', error.message);
}
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
}
exit(1);
exit(1);
}
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
const agents = await createHeadplaneAgent(
config.integration?.agent,
config.headscale.url,
db,
);
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
const agents = await createHeadplaneAgent(config.integration?.agent, config.headscale.url, db);
const hsApi = await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
);
const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path);
// We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext;
import 'react-router';
import { HeadplaneConfig } from './config/config-schema';
import { ConfigError } from './config/error';
import { createOidcConnector } from './web/oidc-connector';
import "react-router";
import { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { createLazyOidcConnector } from "./web/oidc-connector";
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
const appLoadContext = {
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? await createOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
};
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
export default createHonoServer({
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
},
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port);
},
listeningListener(info) {
log.info("server", "Running on %s:%s", info.address, info.port);
},
});
process.on('SIGINT', () => {
log.info('server', 'Received SIGINT, shutting down...');
process.exit(0);
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
process.exit(0);
});
process.on('SIGTERM', () => {
log.info('server', 'Received SIGTERM, shutting down...');
process.exit(0);
process.on("SIGTERM", () => {
log.info("server", "Received SIGTERM, shutting down...");
process.exit(0);
});
+255 -193
View File
@@ -1,41 +1,116 @@
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';
import * as oidc from "openid-client";
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
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'
| 'UNKNOWN_ERROR';
| "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[];
};
| {
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.
@@ -46,63 +121,63 @@ export type OidcConnector =
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
export async function createOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
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.',
);
}
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,
};
}
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,
};
}
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
}
// 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,
};
}
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,
};
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,
};
}
/**
@@ -113,105 +188,95 @@ export async function createOidcConnector(
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
config: OidcConfig,
): Promise<oidc.Configuration | OidcConnectorError[]> {
let metadata: oidc.ServerMetadata;
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',
);
}
let metadata: oidc.ServerMetadata;
let discoveryFailed = false;
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'];
}
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.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 != 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('preferred_username') &&
!metadata.claims_supported.includes('email')
) {
log.warn(
'config',
'OIDC provider does not support `preferred_username` or `email` claims',
);
}
}
} catch {
log.error(
'config',
'Failed to auto-configure OIDC endpoints via discovery',
);
log.warn(
'config',
'OIDC server may not support discovery, using manual config',
);
metadata = {
issuer: config.issuer,
};
}
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",
);
}
}
const errors: OidcConnectorError[] = [];
const authorization_endpoint =
config.authorization_endpoint ?? metadata.authorization_endpoint;
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("oidc", "Failed to reach OIDC provider for discovery, will retry on next request");
discoveryFailed = true;
metadata = {
issuer: config.issuer,
};
}
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
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 userinfo_endpoint =
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint;
if (!authorization_endpoint) {
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
}
if (discoveryFailed && hasMissingEndpoints) {
return ["DISCOVERY_FAILED"];
}
if (!token_endpoint) {
errors.push('MISSING_TOKEN_ENDPOINT');
}
const errors: OidcConnectorError[] = [];
if (!userinfo_endpoint) {
errors.push('MISSING_USERINFO_ENDPOINT');
}
if (!authorization_endpoint) {
errors.push("MISSING_AUTHORIZATION_ENDPOINT");
}
if (errors.length > 0) {
return errors;
}
if (!token_endpoint) {
errors.push("MISSING_TOKEN_ENDPOINT");
}
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
if (!userinfo_endpoint) {
errors.push("MISSING_USERINFO_ENDPOINT");
}
return oidcClient;
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;
}
/**
@@ -222,39 +287,36 @@ async function discoveryCoalesce(
* @returns The client authentication method for the token endpoint.
*/
function negotiateTokenEndpointAuthMethod(
config: OidcConfig,
metadata: oidc.ServerMetadata,
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);
}
}
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);
}
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_post")) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes('client_secret_jwt')) {
return oidc.ClientSecretJwt(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);
log.warn("config", "Falling back to client_secret_post for token endpoint authentication");
return oidc.ClientSecretPost(config.client_secret);
}
+39 -40
View File
@@ -1,49 +1,48 @@
import { createCookie } from 'react-router';
import type { HeadplaneConfig } from '~/server/config/config-schema';
import { createCookie } from "react-router";
import type { HeadplaneConfig } from "~/server/config/config-schema";
export interface OidcStateCookie {
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
}
export function createOidcStateCookie(config: HeadplaneConfig) {
const cookie = createCookie('__oidc_state', {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
const cookie = createCookie("__oidc_state", {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
parse: async (
cookieHeader: string | null,
): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== 'object' ||
typeof parsed.nonce !== 'string' ||
typeof parsed.state !== 'string' ||
typeof parsed.verifier !== 'string' ||
typeof parsed.redirect_uri !== 'string'
) {
return null;
}
parse: async (cookieHeader: string | null): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== "object" ||
typeof parsed.nonce !== "string" ||
typeof parsed.state !== "string" ||
typeof parsed.verifier !== "string" ||
typeof parsed.redirect_uri !== "string"
) {
return null;
}
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
}