From d3d7c7cc0e31e27667b82b6d7b29de517efe51f4 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 28 Nov 2025 17:54:33 -0500 Subject: [PATCH] feat: better error handling for oidc api key This also ships with a better error page --- app/components/Code.tsx | 2 +- app/components/Error.tsx | 89 --------- app/components/error-banner.tsx | 202 +++++++++++++++++++++ app/layouts/dashboard.tsx | 27 ++- app/layouts/shell.tsx | 2 +- app/root.tsx | 11 +- app/routes/auth/login/page.tsx | 56 ++++-- app/routes/auth/oidc-callback.ts | 4 +- app/routes/auth/oidc-start.ts | 6 +- app/routes/settings/overview.tsx | 6 +- app/server/headscale/api/error-client.ts | 57 ++++++ app/server/headscale/api/error.ts | 173 +++++++----------- app/server/headscale/api/index.ts | 36 +++- app/server/headscale/api/response-error.ts | 2 + app/server/index.ts | 18 +- app/server/web/oidc.ts | 41 ++++- 16 files changed, 476 insertions(+), 256 deletions(-) delete mode 100644 app/components/Error.tsx create mode 100644 app/components/error-banner.tsx create mode 100644 app/server/headscale/api/error-client.ts diff --git a/app/components/Code.tsx b/app/components/Code.tsx index 17e8676..ac7b53b 100644 --- a/app/components/Code.tsx +++ b/app/components/Code.tsx @@ -5,7 +5,7 @@ import toast from '~/utils/toast'; export interface CodeProps extends HTMLProps { isCopyable?: boolean; - children: string | string[]; + children: string | string[] | number; } export default function Code({ isCopyable, children, className }: CodeProps) { diff --git a/app/components/Error.tsx b/app/components/Error.tsx deleted file mode 100644 index 90167f1..0000000 --- a/app/components/Error.tsx +++ /dev/null @@ -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 ( -
- -
- -
- {title} - {routing && {`${error.status}`}} -
-
-
- - {routing ? error.data : message} - -
-
- ); -} diff --git a/app/components/error-banner.tsx b/app/components/error-banner.tsx new file mode 100644 index 0000000..7edf0b3 --- /dev/null +++ b/app/components/error-banner.tsx @@ -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: ( + + There was an error communicating with the Headscale API. +
+ The server responded with a status code of{' '} + {statusCode}, indicating a server-side issue. + Please check the Headscale server status and try again later. +
+ ), + }; + } + + const authError = + error.data.statusCode === 401 || error.data.statusCode === 403; + + return { + title: 'Invalid response from Headscale API', + jsxMessage: ( + <> + + 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. + + )} + + + + Error Details + +
+							{JSON.stringify(error.data, null, 2)}
+						
+ + ), + }; + } + + if (isConnectionError(error.data)) { + const { requestUrl, errorCode, errorMessage, extraData } = error.data; + return { + title: 'Cannot connect to Headscale API', + jsxMessage: ( + <> + + Headplane was unable to reach the Headscale API. Please check your + network setup and configuration to ensure Headplane is able to + connect. + + + Error Details + +
+							{requestUrl}
+							
+ {errorCode}: {errorMessage} + {extraData != null && ( + <> +
+
+ {JSON.stringify(extraData, null, 2)} + + )} +
+ + ), + }; + } + + return { + title: `Error ${error.status}`, + jsxMessage: ( + <> + There was an error processing your request. +
+ Status Code: {error.status} +
+ Status Text: {error.statusText} + + ), + }; + } + + if (!(error instanceof Error)) { + return { + title: 'Unexpected Error', + jsxMessage: ( + <> + + An unexpected error occurred which is most likely a bug. Please + consider reporting filing an issue on the{' '} + + Headplane GitHub + {' '} + repository with the details below. + + + Error Details + +
+						{JSON.stringify(error, null, 2)}
+					
+ + ), + }; + } + + // 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 ( + +
+ {title} + +
+ {jsxMessage} +
+ ); +} diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index eecd2d2..8b9a9e6 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -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 ; +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + return ( +
+ +
+ ); } diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 6a6c67b..ae5b376 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -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') ) { diff --git a/app/root.tsx b/app/root.tsx index 25e3130..17b95e8 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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 ; +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + return ( +
+ +
+ ); } export default function App() { diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index bdd2611..f5d2096 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -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 (
- {showCookieWarning ? ( + {showCookieWarning || oidcErrorMessage ? (
- Configuration Issue + Configuration Issue(s)
- - Headplane is configured to use secure cookies, but this site is - being served over an insecure connection and login will not work - correctly.{' '} - - Learn more. - - +
+ {showCookieWarning ? ( + + Headplane is configured to use secure cookies, but this site + is being served over an insecure connection and login will not + work correctly.{' '} + + Learn more. + + + ) : undefined} + {oidcErrorMessage ? ( + + {oidcErrorMessage}{' '} + + Learn more. + + + ) : undefined} +
) : undefined} @@ -141,7 +159,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) { {isOidcEnabled ? ( - diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 08c1eed..f36d484 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -20,7 +20,7 @@ export async function loader({ request, context, }: LoaderFunctionArgs) { - 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, }), }, diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index 0a19fe0..bb25585 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -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'); } diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx index 5c9f135..0687815 100644 --- a/app/routes/settings/overview.tsx +++ b/app/routes/settings/overview.tsx @@ -10,7 +10,11 @@ import { LoadContext } from '~/server'; export async function loader({ context }: LoaderFunctionArgs) { return { config: context.hs.writable(), - oidc: context.oidc, + oidc: context.oidc + ? typeof context.oidc === 'string' + ? undefined + : context.oidc + : undefined, }; } diff --git a/app/server/headscale/api/error-client.ts b/app/server/headscale/api/error-client.ts new file mode 100644 index 0000000..7c6a9ff --- /dev/null +++ b/app/server/headscale/api/error-client.ts @@ -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 | 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 + ); +} diff --git a/app/server/headscale/api/error.ts b/app/server/headscale/api/error.ts index beb0978..a58cb30 100644 --- a/app/server/headscale/api/error.ts +++ b/app/server/headscale/api/error.ts @@ -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); - 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 | 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, + }; } diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts index 22bbb30..3705c89 100644 --- a/app/server/headscale/api/index.ts +++ b/app/server/headscale/api/index.ts @@ -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; + } catch { + return null; + } + })(); + + throw data( + { + requestUrl: `${method} ${apiPath}`, + statusCode: res.statusCode, + rawData, + data: jsonData, + } satisfies HeadscaleAPIError, + { + status: 502, + statusText: 'Bad Gateway', + }, ); } diff --git a/app/server/headscale/api/response-error.ts b/app/server/headscale/api/response-error.ts index 527662e..4802572 100644 --- a/app/server/headscale/api/response-error.ts +++ b/app/server/headscale/api/response-error.ts @@ -3,6 +3,7 @@ export default class ResponseError extends Error { status: number; response: string; + requestUrl: string; responseObject?: Record; 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 diff --git a/app/server/index.ts b/app/server/index.ts index 46fef69..268ea9f 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -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, }; diff --git a/app/server/web/oidc.ts b/app/server/web/oidc.ts index d3b5424..0c43b6f 100644 --- a/app/server/web/oidc.ts +++ b/app/server/web/oidc.ts @@ -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; +export type OidcConfigError = string; + +export async function configureOidcAuth( + config: OidcConfig, + client: RuntimeApiClient, +): Promise { + // 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(