feat: better error handling for oidc api key

This also ships with a better error page
This commit is contained in:
Aarnav Tale
2025-11-28 17:54:33 -05:00
parent c0c4ecf631
commit d3d7c7cc0e
16 changed files with 476 additions and 256 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
children: string | string[] | number;
}
export default function Code({ isCopyable, children, className }: CodeProps) {
-89
View File
@@ -1,89 +0,0 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
interface Props {
type?: 'full' | 'embedded';
}
export function getErrorMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('Unhandled AggregateError');
}
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getErrorMessage(error);
return (
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className="flex items-center gap-4">
<AlertCircle className="w-8 h-8 text-red-500" />
<div className="flex justify-between items-center gap-2 w-full">
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
</div>
</div>
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
<Card.Text
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data : message}
</Card.Text>
</Card>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
),
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.statusText}</strong>
</>
),
};
}
if (!(error instanceof Error)) {
return {
title: 'Unexpected Error',
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please
consider reporting filing an issue on the{' '}
<Link
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
// Traverse the error chain to find the root cause
let rootError = error;
console.log('error', error.cause != null);
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
console.log('setting rootError', rootError.message);
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
+17 -10
View File
@@ -1,24 +1,27 @@
import { Outlet, redirect } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import { ErrorBanner } from '~/components/error-banner';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
import log from '~/utils/log';
import type { Route } from './+types/dashboard';
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
await pruneEphemeralNodes({ context, request, ...rest });
const healthy = await api.isHealthy();
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI whether or not the OIDC auth key is wrong if enabled
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
if (healthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
if (isDataUnauthorizedError(error)) {
log.warn(
'auth',
'Logging out %s due to expired API key',
session.user.name,
);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
@@ -41,6 +44,10 @@ export default function Layout() {
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-fit mx-auto overscroll-contain my-24">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (
context.oidc &&
typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
) {
+8 -3
View File
@@ -9,12 +9,13 @@ import {
} from 'react-router';
import '@fontsource-variable/inter';
import { ExternalScripts } from 'remix-utils/external-scripts';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import type { Route } from './+types/root';
import { ErrorBanner } from './components/error-banner';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
@@ -56,8 +57,12 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
);
}
export function ErrorBoundary() {
return <ErrorPopup />;
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-screen h-screen flex items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
export default function App() {
+39 -17
View File
@@ -31,7 +31,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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) {
if (!context.oidc || typeof context.oidc === 'string') {
throw data(
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
{
@@ -44,8 +44,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
return {
isOidcEnabled: context.oidc !== undefined,
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcEnabled: context.oidc !== undefined,
oidcErrorMessage:
typeof context.oidc === 'string' ? context.oidc : undefined,
urlState,
};
}
@@ -53,7 +55,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isOidcEnabled, isCookieSecureEnabled, urlState } = loaderData;
const { isOidcEnabled, isCookieSecureEnabled, oidcErrorMessage, urlState } =
loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
@@ -92,25 +95,40 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
return (
<div className="flex w-screen h-screen items-center justify-center">
<div>
{showCookieWarning ? (
{showCookieWarning || oidcErrorMessage ? (
<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
Configuration Issue(s)
</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<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>
<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>
</Card>
) : undefined}
<Card className="max-w-md m-4 sm:m-0">
@@ -141,7 +159,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
</Form>
{isOidcEnabled ? (
<RemixLink to="/oidc/start">
<Button className="w-full mt-2" variant="light">
<Button
className="w-full mt-2"
isDisabled={oidcErrorMessage !== undefined}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
+2 -2
View File
@@ -20,7 +20,7 @@ export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
if (!context.oidc || typeof context.oidc === 'string') {
throw new Error('OIDC is not enabled');
}
@@ -92,7 +92,7 @@ export async function loader({
// 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!,
api_key: context.config.oidc!.headscale_api_key,
user,
}),
},
+5 -1
View File
@@ -11,7 +11,11 @@ export async function loader({
return redirect('/machines');
} catch {}
if (!context.oidc || !context.config.oidc) {
if (
!context.oidc ||
typeof context.oidc === 'string' ||
!context.config.oidc
) {
throw new Error('OIDC is not enabled');
}
+5 -1
View File
@@ -10,7 +10,11 @@ import { LoadContext } from '~/server';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
return {
config: context.hs.writable(),
oidc: context.oidc,
oidc: context.oidc
? typeof context.oidc === 'string'
? undefined
: context.oidc
: undefined,
};
}
+57
View File
@@ -0,0 +1,57 @@
import type { HeadscaleConnectionError } from './error';
/**
* Represents an error returned by the Headscale API.
*/
export interface HeadscaleAPIError {
requestUrl: `${string} ${string}`;
statusCode: number;
rawData: string;
data: Record<string, unknown> | null;
}
/**
* Type guard to check if an error is a HeadscaleAPIError.
* @param error - The error to check.
* @returns True if the error is a HeadscaleAPIError, false otherwise.
*/
export function isApiError(error: unknown): error is HeadscaleAPIError {
return (
error != null &&
typeof error === 'object' &&
'requestUrl' in error &&
'statusCode' in error &&
'rawData' in error &&
'data' in error
);
}
/**
* Type guard to check if an error is a HeadscaleConnectionError.
* @param error - The error to check.
* @returns True if the error is a HeadscaleConnectionError, false otherwise.
*/
export function isConnectionError(
error: unknown,
): error is HeadscaleConnectionError {
return (
error != null &&
typeof error === 'object' &&
'requestUrl' in error &&
'errorCode' in error &&
'errorMessage' in error &&
'extraData' in error
);
}
export function isDataUnauthorizedError(error: unknown): boolean {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
typeof error.data === 'object' &&
error.data != null &&
'statusCode' in error.data &&
error.data.statusCode === 401
);
}
+61 -112
View File
@@ -1,123 +1,72 @@
import { data } from 'react-router';
import { errors } from 'undici';
/**
* Helper function that determines if an error is a Node.js exception
* @param - The error to check
* @returns True if the error is a Node.js exception, false otherwise
*/
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
if (typeof error !== 'object' || error === null) {
return false;
}
const keys = Object.keys(error as Record<string, unknown>);
return keys.includes('code') && keys.includes('errno');
return (
error != null &&
typeof error === 'object' &&
'code' in error &&
'errno' in error
);
}
export function friendlyError(givenError: unknown) {
let error: unknown = givenError;
/**
* A friendly error representation for Headscale connection issues.
*/
export interface HeadscaleConnectionError {
requestUrl: string;
errorCode: string;
errorMessage: string;
extraData: Record<string, unknown> | null;
}
/**
* Convert an Undici error into a friendly HeadscaleAPIError.
* This is used to avoid exposing rough error edges to the user.
*
* @param error - The Undici error to convert.
* @param requestUrl - The URL of the request that caused the error.
* @returns A friendly HeadscaleAPIError.
*/
export function undiciToFriendlyError(
error: unknown,
requestUrl: string,
): HeadscaleConnectionError {
// MARK: Do we need to go deeper into causes here?
if (error instanceof AggregateError) {
error = error.errors[0];
}
switch (true) {
case error instanceof errors.BodyTimeoutError:
case error instanceof errors.ConnectTimeoutError:
case error instanceof errors.HeadersTimeoutError:
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
case error instanceof errors.SocketError:
case error instanceof errors.SecureProxyConnectionError:
case error instanceof errors.ClientClosedError:
case error instanceof errors.ClientDestroyedError:
case error instanceof errors.RequestAbortedError:
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
case error instanceof errors.InvalidArgumentError:
case error instanceof errors.InvalidReturnValueError:
case error instanceof errors.NotSupportedError:
return data('Unable to make a request (this is most likely a bug)', {
statusText: 'Internal Server Error',
status: 500,
});
case error instanceof errors.HeadersOverflowError:
case error instanceof errors.RequestContentLengthMismatchError:
case error instanceof errors.ResponseContentLengthMismatchError:
case error instanceof errors.ResponseExceededMaxSizeError:
return data('The Headscale API returned a malformed response', {
statusText: 'Bad Gateway',
status: 502,
});
case isNodeNetworkError(error):
if (error.code === 'ECONNREFUSED') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENOTFOUND') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EAI_AGAIN') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ETIMEDOUT') {
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
}
if (error.code === 'ECONNRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EPIPE') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETUNREACH') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
default:
return data((error as Error).message ?? 'An unknown error occurred', {
statusText: 'Internal Server Error',
status: 500,
});
if (error instanceof errors.UndiciError) {
return {
requestUrl,
errorCode: error.code,
errorMessage: error.message,
extraData: null,
};
}
if (isNodeNetworkError(error)) {
return {
requestUrl,
errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR',
errorMessage: error.message,
extraData: {
syscall: error.syscall,
path: error.path,
errno: error.errno,
},
};
}
return {
requestUrl,
errorCode: 'UNKNOWN_ERROR',
errorMessage: 'An unknown error occured',
extraData: null,
};
}
+29 -7
View File
@@ -2,11 +2,12 @@ import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dereference } from '@readme/openapi-parser';
import type { OpenAPIV2 } from 'openapi-types';
import { data } from 'react-router';
import { Agent, type Dispatcher, request } from 'undici';
import log from '~/utils/log';
import endpointSets, { RuntimeApiClient } from './endpoints';
import { friendlyError } from './error';
import ResponseError from './response-error';
import { undiciToFriendlyError } from './error';
import { HeadscaleAPIError } from './error-client';
import { detectApiVersion, isAtLeast, type Version } from './version';
/**
@@ -133,7 +134,11 @@ export async function createHeadscaleInterface(
return res;
} catch (error) {
throw friendlyError(error);
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: 'Bad Gateway',
});
}
};
@@ -183,10 +188,27 @@ export async function createHeadscaleInterface(
apiPath,
res.statusCode,
);
throw new ResponseError(
res.statusCode,
await res.body.text(),
`${method} ${apiPath}`,
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{
status: 502,
statusText: 'Bad Gateway',
},
);
}
@@ -3,6 +3,7 @@
export default class ResponseError extends Error {
status: number;
response: string;
requestUrl: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string, requestUrl: string) {
@@ -10,6 +11,7 @@ export default class ResponseError extends Error {
this.name = 'ResponseError';
this.status = status;
this.response = response;
this.requestUrl = requestUrl;
try {
// Try to parse the response as JSON to get a response object
+12 -6
View File
@@ -36,6 +36,11 @@ const agents = await createHeadplaneAgent(
db,
);
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.
@@ -67,14 +72,15 @@ const appLoadContext = {
},
}),
hsApi: await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
),
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
oidc: config.oidc
? await configureOidcAuth(
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
};
+35 -6
View File
@@ -1,10 +1,31 @@
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
}
export async function configureOidcAuth(config: OidcConfig) {
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
let clientAuthMethod: oidc.ClientAuth;
switch (config.token_endpoint_auth_method) {
@@ -18,6 +39,7 @@ export async function configureOidcAuth(config: OidcConfig) {
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
break;
default:
// MARK: Throwing because this is a developer skill issue
throw new Error('Invalid client authentication method');
}
@@ -41,7 +63,7 @@ export async function configureOidcAuth(config: OidcConfig) {
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return;
return 'OIDC provider did not return `authorization_endpoint`, please check logs';
}
if (!meta.token_endpoint) {
@@ -51,7 +73,7 @@ export async function configureOidcAuth(config: OidcConfig) {
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return;
return 'OIDC provider did not return `token_endpoint`, please check logs';
}
if (!meta.userinfo_endpoint) {
@@ -61,7 +83,7 @@ export async function configureOidcAuth(config: OidcConfig) {
);
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;
return 'OIDC provider did not return `user_info`, please check logs';
}
if (meta.token_endpoint_auth_methods_supported) {
@@ -80,7 +102,14 @@ export async function configureOidcAuth(config: OidcConfig) {
'Supported methods: %s',
meta.token_endpoint_auth_methods_supported.join(', '),
);
return;
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(' ');
}
}
@@ -127,7 +156,7 @@ export async function configureOidcAuth(config: OidcConfig) {
'config',
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
);
return;
return 'OIDC provider could not be configured, please check logs.';
}
oidcClient = new oidc.Configuration(