feat: cleanup oidc logic and surface errors better

This commit is contained in:
Aarnav Tale
2025-12-04 00:44:31 -05:00
parent d363ed5486
commit ba61656fb0
14 changed files with 624 additions and 628 deletions
+123
View File
@@ -0,0 +1,123 @@
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';
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#help"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
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;
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_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 '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;
}
+63
View File
@@ -0,0 +1,63 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
export function OidcErrorNotice({ code }: { code: string }) {
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">Configuration Issue(s)</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
}
function getErrorMessage(code: string) {
switch (code) {
case 'error_no_query':
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the
required parameters. Please ensure your SSO provider is configured
correctly.
</Card.Text>
);
case 'error_no_session':
case 'error_invalid_session':
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data.
Ensure that your Headplane cookie configuration is correct and that
your browser is accepting cookies.
</Card.Text>
);
case 'error_no_sub':
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure
your SSO provider is correctly configured to provide the{' '}
<Code>sub</Code> claim.
</Card.Text>
);
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please tray again later.
Headplane logs may provide more information.
</Card.Text>
);
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again
later.
</Card.Text>
);
}
}
+41 -49
View File
@@ -12,10 +12,13 @@ import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
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';
export async function loader({ request, context }: Route.LoaderArgs) {
try {
@@ -26,28 +29,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get('s') ?? undefined;
// OIDC config cannot be undefined if an OIDC client is set
// Also check if we are in a logout state and skip redirect if we are
const ssoOnly = context.config.oidc?.disable_api_key_login;
if (urlState !== 'logout' && ssoOnly) {
// This shouldn't be possible, but still a safe sanity check
if (!context.oidc || typeof context.oidc === 'string') {
throw data(
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
{
status: 400,
},
);
}
// 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 isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? context.oidcConnector!.errors
: [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcEnabled: context.oidc !== undefined,
oidcErrorMessage:
typeof context.oidc === 'string' ? context.oidc : undefined,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
@@ -55,8 +51,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isOidcEnabled, isCookieSecureEnabled, oidcErrorMessage, urlState } =
loaderData;
const {
isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
@@ -95,40 +96,31 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
return (
<div className="flex w-screen h-screen items-center justify-center">
<div>
{showCookieWarning || oidcErrorMessage ? (
{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(s)
Configuration Issue
</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<div className="flex flex-col gap-2">
{showCookieWarning ? (
<Card.Text className="text-sm text-red-600 dark:text-red-400">
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}
{oidcErrorMessage ? (
<Card.Text className="text-sm text-red-600 dark:text-red-400">
{oidcErrorMessage}{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</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">
@@ -157,11 +149,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Sign In
</Button>
</Form>
{isOidcEnabled ? (
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="w-full mt-2"
isDisabled={oidcErrorMessage !== undefined}
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
+96 -78
View File
@@ -1,75 +1,99 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/config-schema';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
import log from '~/utils/log';
import type { OidcCookieState } from './oidc-start';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc || typeof context.oidc === 'string') {
throw new Error('OIDC is not enabled');
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
return redirect('/login?s=error_no_query');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const data: OidcFlowSession | null = await cookie.parse(
const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (data === null) {
console.warn('OIDC flow session not found');
return redirect('/login');
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
const { code_verifier, state, nonce, redirect_uri } = data;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
const { state, nonce } = oidcCookieState;
if (!state || !nonce) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
let user = await finishAuthFlow(context.oidc, flowOptions);
user = {
...user,
picture: setOidcPictureForSource(
user,
context.config.oidc?.profile_picture_source ?? 'oidc',
),
};
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
request,
{
expectedState: state,
expectedNonce: nonce,
},
);
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,
);
// 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 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() })
@@ -80,52 +104,46 @@ export async function loader({
.insert(users)
.values({
id: ulid(),
sub: user.subject,
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/machines', {
return redirect('/', {
headers: {
'Set-Cookie': await context.sessions.createSession({
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
api_key: context.config.oidc!.headscale_api_key,
user,
api_key: context.oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
type PictureSource = NonNullable<
HeadplaneConfig['oidc']
>['profile_picture_source'];
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
// Already set by default in the callback, so we can just return it
if (source === 'oidc') {
return user.picture;
}
if (source === 'gravatar') {
if (!user.email) {
return undefined;
if (error instanceof oidc.ResponseBodyError) {
log.error(
'auth',
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
}
const emailHash = user.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 (error instanceof oidc.AuthorizationResponseError) {
log.error(
'auth',
'Got an OIDC authorization response error: %s',
error.error,
);
}
return undefined;
if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
}
return redirect('/login?s=error_auth_failed');
}
}
+55 -22
View File
@@ -1,6 +1,16 @@
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import type { LoadContext } from '~/server';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export interface OidcCookieState {
nonce: string;
state: string;
}
export async function loader({
request,
@@ -8,40 +18,63 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
return redirect('/machines');
return redirect('/');
} catch {}
if (
!context.oidc ||
typeof context.oidc === 'string' ||
!context.config.oidc
) {
throw new Error('OIDC is not enabled');
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
context.config.oidc.scope,
context.config.oidc.extra_params,
);
return redirect(data.url, {
const nonce = oidc.randomNonce();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri: redirectUri,
state,
nonce,
});
return redirect(url.href, {
status: 302,
headers: {
'Set-Cookie': await cookie.serialize({
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
}),
state,
nonce,
} satisfies OidcCookieState),
},
});
}
function getRedirectUri(req: Request) {
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;
}
+8 -16
View File
@@ -1,26 +1,18 @@
import { ArrowRight } from 'lucide-react';
import {
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import { LoadContext } from '~/server';
import type { Route } from './+types/overview';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
oidc: context.oidc
? typeof context.oidc === 'string'
? undefined
: context.oidc
: undefined,
isOidcEnabled: context.oidcConnector?.isValid ?? false,
};
}
export default function Page() {
const { config, oidc } = useLoaderData<typeof loader>();
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-2/3">
@@ -51,7 +43,7 @@ export default function Page() {
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{config && oidc ? (
{config && isOidcEnabled ? (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
-33
View File
@@ -1,33 +0,0 @@
# Headplane Server
This code is responsible for all code that is necessary *before* any
web server is started. It is the only part of the code that contains
many side-effects (in this case, importing a module may run code).
# Hierarchy
```
server
├── index.ts: Loads everything and starts the web server.
├── agent/
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
│ ├── env.ts: Checks the environment variables for custom overrides.
├── config/
│ ├── integration/
│ │ ├── abstract.ts: Defines the abstract class for integrations.
│ │ ├── docker.ts: Contains the Docker integration.
│ │ ├── index.ts: Determines the correct integration to use (if any).
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
│ │ ├── proc.ts: Contains the Proc integration.
│ ├── env.ts: Checks the environment variables for custom overrides.
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
│ ├── schema.ts: Defines the schema for the Headplane configuration.
├── headscale/
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
│ ├── api-error.ts: Contains the ResponseError definition.
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it.
+2 -2
View File
@@ -12,7 +12,7 @@ export const pathSupportedKeys = [
] as const;
const serverConfig = type({
host: 'string.ip = "127.0.0.1"',
host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000',
data_path: 'string.lower = "/var/lib/headplane/"',
@@ -65,8 +65,8 @@ const oidcConfig = type({
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"',
extra_params: 'Record<string, string>?',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
+4 -3
View File
@@ -8,7 +8,6 @@ import { createDbClient } from './db/client.server';
import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { configureOidcAuth } from './web/oidc';
import { createSessionStorage } from './web/sessions';
declare global {
@@ -40,6 +39,8 @@ const hsApi = await createHeadscaleInterface(
export type LoadContext = typeof appLoadContext;
import 'react-router';
import { createOidcConnector } from './web/oidc-connector';
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
}
@@ -68,8 +69,8 @@ const appLoadContext = {
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidc: config.oidc
? await configureOidcAuth(
oidcConnector: config.oidc
? await createOidcConnector(
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
+197
View File
@@ -0,0 +1,197 @@
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'
| '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;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
export async function createOidcConnector(
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
//
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,
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;
try {
const client = await oidc.discovery(
new URL(config.issuer),
config.client_id,
);
metadata = client.serverMetadata();
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.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,
};
}
const errors: OidcConnectorError[] = [];
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;
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(
{
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
);
return oidcClient;
}
-186
View File
@@ -1,186 +0,0 @@
import * as oidc from 'openid-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../config/schema';
import type { RuntimeApiClient } from '../headscale/api/endpoints';
import { isDataUnauthorizedError } from '../headscale/api/error-client';
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
export type OidcConfigError = string;
export async function configureOidcAuth(
config: OidcConfig,
client: RuntimeApiClient,
): Promise<oidc.Configuration | OidcConfigError> {
// Don't waste any of our time if the OIDC API key is invalid
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
return [
'The supplied API key for OIDC is invalid.',
'OIDC will be disabled until a valid API key is given',
].join(' ');
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401
}
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
let clientAuthMethod: oidc.ClientAuth;
switch (config.token_endpoint_auth_method) {
case 'client_secret_basic':
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
break;
case 'client_secret_post':
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
break;
case 'client_secret_jwt':
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
break;
default:
// MARK: Throwing because this is a developer skill issue
throw new Error('Invalid client authentication method');
}
let oidcClient: oidc.Configuration;
try {
const discovery = await oidc.discovery(
new URL(config.issuer),
config.client_id,
config.client_secret!, // TODO: Fix this config schema
clientAuthMethod,
);
const meta = discovery.serverMetadata();
if (!meta.authorization_endpoint) {
log.error(
'config',
'Issuer discovery did not return `authorization_endpoint`',
);
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return 'OIDC provider did not return `authorization_endpoint`, please check logs';
}
if (!meta.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return 'OIDC provider did not return `token_endpoint`, please check logs';
}
if (!meta.userinfo_endpoint) {
log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support user info endpoint');
log.error('config', 'You may need to set this manually in the config');
return 'OIDC provider did not return `user_info`, please check logs';
}
if (meta.token_endpoint_auth_methods_supported) {
if (
!meta.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method,
)
) {
log.error(
'config',
'OIDC server does not support client authentication method %s',
config.token_endpoint_auth_method,
);
log.error(
'config',
'Supported methods: %s',
meta.token_endpoint_auth_methods_supported.join(', '),
);
return [
'Headplane is expecting the following client authencation method:',
config.token_endpoint_auth_method,
'while the OIDC server only supports',
`${meta.token_endpoint_auth_methods_supported.join(', ')}.`,
'OIDC wil be disabled until configured correctly.',
].join(' ');
}
}
log.debug('config', 'OIDC discovery successful');
log.debug(
'config',
'Authorization endpoint: %s',
meta.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
// Manually construct the endpoints to coalesce with config if needed
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint:
config.authorization_endpoint || meta.authorization_endpoint,
token_endpoint: config.token_endpoint || meta.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
} catch (err) {
log.error('config', 'OIDC discovery failed: %s', err);
log.debug('config', 'Error details: %o', err);
log.error(
'config',
'This may be an error, or the server may not support discovery',
);
if (
!config.authorization_endpoint ||
!config.token_endpoint ||
!config.userinfo_endpoint
) {
log.error(
'config',
'Endpoints are not fully configured, cannot continue',
);
log.error(
'config',
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
);
return 'OIDC provider could not be configured, please check logs.';
}
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint: config.authorization_endpoint,
token_endpoint: config.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
log.debug('config', 'Using manually configured endpoints');
log.debug(
'config',
'Authorization endpoint: %s',
config.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
}
log.info('config', 'Successfully configured OIDC authentication');
return oidcClient;
}
-204
View File
@@ -1,204 +0,0 @@
import * as client from 'openid-client';
import { Configuration, IDToken, UserInfoResponse } from 'openid-client';
import log from '~/utils/log';
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
// (This can ALWAYS be overridden through the OidcConfig)
export function getRedirectUri(req: Request) {
const base = __PREFIX__ ?? '/admin'; // Fallback
const url = new URL(`${base}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
log.error('auth', 'Unable to find a host header');
log.error('auth', 'Ensure either Host or X-Forwarded-Host is set');
throw new Error('Could not determine reverse proxy host');
}
const proto = req.headers.get('X-Forwarded-Proto');
if (!proto) {
log.warn('auth', 'No X-Forwarded-Proto header found');
log.warn('auth', 'Assuming your Headplane instance runs behind HTTP');
}
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
scope: string,
extra_params: Record<string, string> = {},
) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const params: Record<string, string> = {
...extra_params,
scope,
redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: client.randomState(),
};
if (!config.serverMetadata().supportsPKCE()) {
params.nonce = client.randomNonce();
}
const url = client.buildAuthorizationUrl(config, params);
return {
url: url.href,
codeVerifier,
state: params.state,
nonce: params.nonce ?? '<none>',
};
}
interface FlowOptions {
redirect_uri: string;
code_verifier: string;
state: string;
nonce?: string;
}
export interface FlowUser {
subject: string;
name: string;
email: string | undefined;
username: string | undefined;
picture: string | undefined;
}
export async function finishAuthFlow(
config: Configuration,
options: FlowOptions,
): Promise<FlowUser> {
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
{
pkceCodeVerifier: options.code_verifier,
expectedNonce: options.nonce,
expectedState: options.state,
idTokenExpected: true,
},
);
const claims = tokens.claims();
if (!claims?.sub) {
throw new Error('No subject found in OIDC claims');
}
const user = await client.fetchUserInfo(
config,
tokens.access_token,
claims.sub,
);
return {
subject: user.sub,
name: getName(user, claims),
email: user.email ?? claims.email?.toString(),
username: calculateUsername(claims, user),
picture: user.picture,
};
}
function calculateUsername(claims: IDToken, user: UserInfoResponse) {
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
if (user.email) {
return user.email.split('@')[0];
}
if (claims.email && typeof claims.email === 'string') {
return claims.email.split('@')[0];
}
return;
}
function getName(user: client.UserInfoResponse, claims: client.IDToken) {
if (user.name) {
return user.name;
}
if (claims.name && typeof claims.name === 'string') {
return claims.name;
}
if (user.given_name && user.family_name) {
return `${user.given_name} ${user.family_name}`;
}
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
return 'Anonymous';
}
export function formatError(error: unknown) {
if (error instanceof client.ResponseBodyError) {
return {
code: error.code,
error: {
name: error.error,
description: error.error_description,
},
};
}
if (error instanceof client.AuthorizationResponseError) {
return {
code: error.code,
error: {
name: error.error,
description: error.error_description,
},
};
}
if (error instanceof client.WWWAuthenticateChallengeError) {
return {
code: error.code,
error: {
name: error.name,
description: error.message,
challenges: error.cause,
},
};
}
log.error('auth', 'Unknown error: %s', error);
return {
code: 500,
error: {
name: 'Internal Server Error',
description: 'An unknown error occurred',
},
};
}
+2 -2
View File
@@ -7,7 +7,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"test:unit": "vitest run --project unit",
@@ -41,7 +41,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20251116.1",
"@typescript/native-preview": "7.0.0-dev.20251203.1",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.3",
"@uiw/react-codemirror": "4.25.3",
+33 -33
View File
@@ -78,8 +78,8 @@ importers:
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.5)
'@typescript/native-preview':
specifier: 7.0.0-dev.20251116.1
version: 7.0.0-dev.20251116.1
specifier: 7.0.0-dev.20251203.1
version: 7.0.0-dev.20251203.1
'@uiw/codemirror-theme-github':
specifier: 4.25.1
version: 4.25.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
@@ -2344,43 +2344,43 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-h9IiIyFUzFVOTFCI5DsE0mP70is3lFk6iujnZWpB9Xi2JG2g8H7ltXoBPL43ANPG3OEHQA+N7lEb/t4TliaVFg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-gMPW/y89KANC0fIqdudxwsxUOHTOyujaOGxyj4IOaFLIP+8/gofawsmdf9HVniPq4xCT7tMpiqa/b9btxJ5nGw==}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-y18xG8zbI8ryCybDRdKc9d7o9eSR38aGNZt02vpEHnPfWgJUP89lTJKhUDm0S2LoEDgVo3ur0Or4jlJh38rPmQ==}
'@typescript/native-preview-darwin-x64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-BG/bCAZcTGNM4bQMwY4hI0l70QaxQW9qwJ04GZJL02LAnaQVai8o5X/ghU6awkXFt9CTXdXBWn+hKNb+IiHG+w==}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-eQH1xdStw2yGqLCQtBjNvc5sYI/tYe2qIvpCpmR8LbMWLs5518vYnOrLXMs+PlAZ1zEvnY0qlxm9/4HBacrRpg==}
'@typescript/native-preview-linux-arm64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-UGrYYbYbyjlklDubWE93E84WU6jrCGsjpJ2+/GFf4keB3IUrvg9lRqgQ3DUYW4p5kXJR+YC42HnG+OXkN+s6Pw==}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-hyXKS9An1gs+vJI4adTT6COiMbqDy3sHxc7qd2f3FhK4n1Up+Lpo8Psy0615rt0Zu35hKNLtiMZx+ymyrqgQ2A==}
'@typescript/native-preview-linux-arm@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-iGrXfVUWtXgiaiVX7FhFVYFz3qFklta2kQEx0VX+km/tBVFMt+egI36tflKYuR7UE5n+0kboKldtyKgmfGrrjQ==}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-CRKIu9IlvgmK5rDrLu+WviUy2lrqCFP4p5n3dtpzZuobgwI2TYuRduGDDq+qXgfdAVUpQkZOHUUQvF9PmjrNmw==}
'@typescript/native-preview-linux-x64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-s3VZtFQGktU1ph0q3v8T2tVsgTrRoiaWFknt2vrErxKnzfQgChWOlM0o7Geaj/y1dnrOGWO/cHwMCSb7vd2fgw==}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-FljNjkaA/0KMRUkp/ZTqcQNkqc8PNDgxSKm1lirtdkWgVXXurxyKIkKjZdOgxZchOWDwuqy5ZW4epe2ysTm5lQ==}
'@typescript/native-preview-win32-arm64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-rcaW7Kn7Ja8J17wmc9UuOjf0LmlqPQYYnqQTdh/kj72FcK4l+8P7b1LcViQFcsOAiIcRZBKrEVZnZXNQxYdHMQ==}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-a2aDDFlgpPU0vYgmDkeABsWYdKGv1bYtggZ9FpDls8m7FmxdY0i/5SyPhkp/fDgW6qEEiOfcd4e/32zubcl3Gg==}
'@typescript/native-preview-win32-x64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-+DSMCGE7VZWjYzbWDuIenBW2tUclUqAGi7pcOgGq3BpwPEqyVhdQh5ggOk087xgshBv5Wj/yAdXA9gQFAFxpEQ==}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20251116.1':
resolution: {integrity: sha512-ZfMvOGyzDSN3PxUYOSbx3jqszetxLSzLQkS+37oY40gX1YnGK8oZNf4ljVdGqYWpyRloYVREB8YbAY+UxLZxhQ==}
'@typescript/native-preview@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-u6kHGmbkB4WQ2XjQUVq6PixV92biRclTBAq8r09L/MGzsiVREdYzf/Bf1W4aTDcDSu6UQ3hjtBR6hROQRPrMXQ==}
hasBin: true
'@uiw/codemirror-extensions-basic-setup@4.25.3':
@@ -7045,36 +7045,36 @@ snapshots:
dependencies:
'@types/node': 24.10.1
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251116.1':
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20251116.1':
'@typescript/native-preview-darwin-x64@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20251116.1':
'@typescript/native-preview-linux-arm64@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20251116.1':
'@typescript/native-preview-linux-arm@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20251116.1':
'@typescript/native-preview-linux-x64@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20251116.1':
'@typescript/native-preview-win32-arm64@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20251116.1':
'@typescript/native-preview-win32-x64@7.0.0-dev.20251203.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20251116.1':
'@typescript/native-preview@7.0.0-dev.20251203.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20251116.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20251116.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20251116.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20251116.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20251116.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20251116.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20251116.1
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20251203.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20251203.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20251203.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20251203.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20251203.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20251203.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20251203.1
'@uiw/codemirror-extensions-basic-setup@4.25.3(@codemirror/autocomplete@6.18.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/common@1.3.0))(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
dependencies: