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
+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(