mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 16:07:07 +00:00
feat: better error handling for oidc api key
This also ships with a better error page
This commit is contained in:
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
|
|||||||
|
|
||||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||||
isCopyable?: boolean;
|
isCopyable?: boolean;
|
||||||
children: string | string[];
|
children: string | string[] | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Code({ isCopyable, children, className }: CodeProps) {
|
export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,24 +1,27 @@
|
|||||||
import { Outlet, redirect } from 'react-router';
|
import { Outlet, redirect } from 'react-router';
|
||||||
import { ErrorPopup } from '~/components/Error';
|
import { ErrorBanner } from '~/components/error-banner';
|
||||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
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 log from '~/utils/log';
|
||||||
import type { Route } from './+types/dashboard';
|
import type { Route } from './+types/dashboard';
|
||||||
|
|
||||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
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
|
// MARK: The session should stay valid if Headscale isn't healthy
|
||||||
// TODO: Notify in the logs or the UI whether or not the OIDC auth key is wrong if enabled
|
const healthy = await api.isHealthy();
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
try {
|
try {
|
||||||
await api.getApiKeys();
|
await api.getApiKeys();
|
||||||
|
await pruneEphemeralNodes({ context, request, ...rest });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResponseError) {
|
if (isDataUnauthorizedError(error)) {
|
||||||
log.debug('api', 'API Key validation failed %o', error);
|
log.warn(
|
||||||
|
'auth',
|
||||||
|
'Logging out %s due to expired API key',
|
||||||
|
session.user.name,
|
||||||
|
);
|
||||||
return redirect('/login', {
|
return redirect('/login', {
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.destroySession(),
|
'Set-Cookie': await context.sessions.destroySession(),
|
||||||
@@ -41,6 +44,10 @@ export default function Layout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
return <ErrorPopup type="embedded" />;
|
return (
|
||||||
|
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||||
|
<ErrorBanner className="max-w-2xl" error={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
try {
|
try {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
if (
|
if (
|
||||||
context.oidc &&
|
typeof context.oidc === 'object' &&
|
||||||
session.user.subject !== 'unknown-non-oauth' &&
|
session.user.subject !== 'unknown-non-oauth' &&
|
||||||
!request.url.endsWith('/onboarding')
|
!request.url.endsWith('/onboarding')
|
||||||
) {
|
) {
|
||||||
|
|||||||
+8
-3
@@ -9,12 +9,13 @@ import {
|
|||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import '@fontsource-variable/inter';
|
import '@fontsource-variable/inter';
|
||||||
import { ExternalScripts } from 'remix-utils/external-scripts';
|
import { ExternalScripts } from 'remix-utils/external-scripts';
|
||||||
import { ErrorPopup } from '~/components/Error';
|
|
||||||
import ProgressBar from '~/components/ProgressBar';
|
import ProgressBar from '~/components/ProgressBar';
|
||||||
import ToastProvider from '~/components/ToastProvider';
|
import ToastProvider from '~/components/ToastProvider';
|
||||||
import stylesheet from '~/tailwind.css?url';
|
import stylesheet from '~/tailwind.css?url';
|
||||||
import { LiveDataProvider } from '~/utils/live-data';
|
import { LiveDataProvider } from '~/utils/live-data';
|
||||||
import { useToastQueue } from '~/utils/toast';
|
import { useToastQueue } from '~/utils/toast';
|
||||||
|
import type { Route } from './+types/root';
|
||||||
|
import { ErrorBanner } from './components/error-banner';
|
||||||
|
|
||||||
export const meta: MetaFunction = () => [
|
export const meta: MetaFunction = () => [
|
||||||
{ title: 'Headplane' },
|
{ title: 'Headplane' },
|
||||||
@@ -56,8 +57,12 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
return <ErrorPopup />;
|
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() {
|
export default function App() {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
const ssoOnly = context.config.oidc?.disable_api_key_login;
|
const ssoOnly = context.config.oidc?.disable_api_key_login;
|
||||||
if (urlState !== 'logout' && ssoOnly) {
|
if (urlState !== 'logout' && ssoOnly) {
|
||||||
// This shouldn't be possible, but still a safe sanity check
|
// This shouldn't be possible, but still a safe sanity check
|
||||||
if (!context.oidc) {
|
if (!context.oidc || typeof context.oidc === 'string') {
|
||||||
throw data(
|
throw data(
|
||||||
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
|
'`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 {
|
return {
|
||||||
isOidcEnabled: context.oidc !== undefined,
|
|
||||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||||
|
isOidcEnabled: context.oidc !== undefined,
|
||||||
|
oidcErrorMessage:
|
||||||
|
typeof context.oidc === 'string' ? context.oidc : undefined,
|
||||||
urlState,
|
urlState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
export const action = loginAction;
|
export const action = loginAction;
|
||||||
|
|
||||||
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
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 [showCookieWarning, setShowCookieWarning] = useState(false);
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const { pause } = useLiveData();
|
const { pause } = useLiveData();
|
||||||
@@ -92,25 +95,40 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-screen h-screen items-center justify-center">
|
<div className="flex w-screen h-screen items-center justify-center">
|
||||||
<div>
|
<div>
|
||||||
{showCookieWarning ? (
|
{showCookieWarning || oidcErrorMessage ? (
|
||||||
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Card.Title className="text-red-500">
|
<Card.Title className="text-red-500">
|
||||||
Configuration Issue
|
Configuration Issue(s)
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<Card.Text className="text-sm text-red-600 dark:text-red-400">
|
<div className="flex flex-col gap-2">
|
||||||
Headplane is configured to use secure cookies, but this site is
|
{showCookieWarning ? (
|
||||||
being served over an insecure connection and login will not work
|
<Card.Text className="text-sm text-red-600 dark:text-red-400">
|
||||||
correctly.{' '}
|
Headplane is configured to use secure cookies, but this site
|
||||||
<Link
|
is being served over an insecure connection and login will not
|
||||||
name="Headplane Common Issues"
|
work correctly.{' '}
|
||||||
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
|
<Link
|
||||||
>
|
name="Headplane Common Issues"
|
||||||
Learn more.
|
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
|
||||||
</Link>
|
>
|
||||||
</Card.Text>
|
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>
|
</Card>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Card className="max-w-md m-4 sm:m-0">
|
<Card className="max-w-md m-4 sm:m-0">
|
||||||
@@ -141,7 +159,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
|||||||
</Form>
|
</Form>
|
||||||
{isOidcEnabled ? (
|
{isOidcEnabled ? (
|
||||||
<RemixLink to="/oidc/start">
|
<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
|
Single Sign-On
|
||||||
</Button>
|
</Button>
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function loader({
|
|||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
if (!context.oidc) {
|
if (!context.oidc || typeof context.oidc === 'string') {
|
||||||
throw new Error('OIDC is not enabled');
|
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
|
// keys because they are currently non-deletable in the headscale
|
||||||
// database. Look at this in the future once we have a solution
|
// database. Look at this in the future once we have a solution
|
||||||
// or we have permissioned API keys.
|
// or we have permissioned API keys.
|
||||||
api_key: context.config.oidc?.headscale_api_key!,
|
api_key: context.config.oidc!.headscale_api_key,
|
||||||
user,
|
user,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export async function loader({
|
|||||||
return redirect('/machines');
|
return redirect('/machines');
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (!context.oidc || !context.config.oidc) {
|
if (
|
||||||
|
!context.oidc ||
|
||||||
|
typeof context.oidc === 'string' ||
|
||||||
|
!context.config.oidc
|
||||||
|
) {
|
||||||
throw new Error('OIDC is not enabled');
|
throw new Error('OIDC is not enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { LoadContext } from '~/server';
|
|||||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||||
return {
|
return {
|
||||||
config: context.hs.writable(),
|
config: context.hs.writable(),
|
||||||
oidc: context.oidc,
|
oidc: context.oidc
|
||||||
|
? typeof context.oidc === 'string'
|
||||||
|
? undefined
|
||||||
|
: context.oidc
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,123 +1,72 @@
|
|||||||
import { data } from 'react-router';
|
|
||||||
import { errors } from 'undici';
|
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 {
|
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||||
if (typeof error !== 'object' || error === null) {
|
return (
|
||||||
return false;
|
error != null &&
|
||||||
}
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
const keys = Object.keys(error as Record<string, unknown>);
|
'errno' in error
|
||||||
return keys.includes('code') && keys.includes('errno');
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (error instanceof AggregateError) {
|
||||||
error = error.errors[0];
|
error = error.errors[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (true) {
|
if (error instanceof errors.UndiciError) {
|
||||||
case error instanceof errors.BodyTimeoutError:
|
return {
|
||||||
case error instanceof errors.ConnectTimeoutError:
|
requestUrl,
|
||||||
case error instanceof errors.HeadersTimeoutError:
|
errorCode: error.code,
|
||||||
return data('Timed out waiting for a response from the Headscale API', {
|
errorMessage: error.message,
|
||||||
statusText: 'Request Timeout',
|
extraData: null,
|
||||||
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 (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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { dereference } from '@readme/openapi-parser';
|
import { dereference } from '@readme/openapi-parser';
|
||||||
import type { OpenAPIV2 } from 'openapi-types';
|
import type { OpenAPIV2 } from 'openapi-types';
|
||||||
|
import { data } from 'react-router';
|
||||||
import { Agent, type Dispatcher, request } from 'undici';
|
import { Agent, type Dispatcher, request } from 'undici';
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import endpointSets, { RuntimeApiClient } from './endpoints';
|
import endpointSets, { RuntimeApiClient } from './endpoints';
|
||||||
import { friendlyError } from './error';
|
import { undiciToFriendlyError } from './error';
|
||||||
import ResponseError from './response-error';
|
import { HeadscaleAPIError } from './error-client';
|
||||||
import { detectApiVersion, isAtLeast, type Version } from './version';
|
import { detectApiVersion, isAtLeast, type Version } from './version';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +134,11 @@ export async function createHeadscaleInterface(
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (error) {
|
} 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,
|
apiPath,
|
||||||
res.statusCode,
|
res.statusCode,
|
||||||
);
|
);
|
||||||
throw new ResponseError(
|
|
||||||
res.statusCode,
|
const rawData = await res.body.text();
|
||||||
await res.body.text(),
|
const jsonData = (() => {
|
||||||
`${method} ${apiPath}`,
|
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 {
|
export default class ResponseError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
response: string;
|
response: string;
|
||||||
|
requestUrl: string;
|
||||||
responseObject?: Record<string, unknown>;
|
responseObject?: Record<string, unknown>;
|
||||||
|
|
||||||
constructor(status: number, response: string, requestUrl: string) {
|
constructor(status: number, response: string, requestUrl: string) {
|
||||||
@@ -10,6 +11,7 @@ export default class ResponseError extends Error {
|
|||||||
this.name = 'ResponseError';
|
this.name = 'ResponseError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.response = response;
|
this.response = response;
|
||||||
|
this.requestUrl = requestUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to parse the response as JSON to get a response object
|
// Try to parse the response as JSON to get a response object
|
||||||
|
|||||||
+12
-6
@@ -36,6 +36,11 @@ const agents = await createHeadplaneAgent(
|
|||||||
db,
|
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.
|
// 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
|
// These are usually per-request things that we need access to, like the
|
||||||
// helper that can issue and revoke cookies.
|
// helper that can issue and revoke cookies.
|
||||||
@@ -67,14 +72,15 @@ const appLoadContext = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
hsApi: await createHeadscaleInterface(
|
hsApi,
|
||||||
config.headscale.url,
|
|
||||||
config.headscale.tls_cert_path,
|
|
||||||
),
|
|
||||||
|
|
||||||
agents,
|
agents,
|
||||||
integration: await loadIntegration(config.integration),
|
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,
|
db,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+35
-6
@@ -1,10 +1,31 @@
|
|||||||
import * as oidc from 'openid-client';
|
import * as oidc from 'openid-client';
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import { HeadplaneConfig } from '../config/schema';
|
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 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);
|
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||||
let clientAuthMethod: oidc.ClientAuth;
|
let clientAuthMethod: oidc.ClientAuth;
|
||||||
switch (config.token_endpoint_auth_method) {
|
switch (config.token_endpoint_auth_method) {
|
||||||
@@ -18,6 +39,7 @@ export async function configureOidcAuth(config: OidcConfig) {
|
|||||||
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
|
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
// MARK: Throwing because this is a developer skill issue
|
||||||
throw new Error('Invalid client authentication method');
|
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',
|
'OIDC server does not support authorization code flow',
|
||||||
);
|
);
|
||||||
log.error('config', 'You may need to set this manually in the config');
|
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) {
|
if (!meta.token_endpoint) {
|
||||||
@@ -51,7 +73,7 @@ export async function configureOidcAuth(config: OidcConfig) {
|
|||||||
'OIDC server does not support authorization code flow',
|
'OIDC server does not support authorization code flow',
|
||||||
);
|
);
|
||||||
log.error('config', 'You may need to set this manually in the config');
|
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) {
|
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', 'OIDC server does not support user info endpoint');
|
||||||
log.error('config', 'You may need to set this manually in the config');
|
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) {
|
if (meta.token_endpoint_auth_methods_supported) {
|
||||||
@@ -80,7 +102,14 @@ export async function configureOidcAuth(config: OidcConfig) {
|
|||||||
'Supported methods: %s',
|
'Supported methods: %s',
|
||||||
meta.token_endpoint_auth_methods_supported.join(', '),
|
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',
|
'config',
|
||||||
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
|
'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(
|
oidcClient = new oidc.Configuration(
|
||||||
|
|||||||
Reference in New Issue
Block a user