From ba61656fb02a66a7ca906dd9b067cd92ef652dce Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Thu, 4 Dec 2025 00:44:31 -0500 Subject: [PATCH] feat: cleanup oidc logic and surface errors better --- app/routes/auth/login/config-error.tsx | 123 +++++++++++++++ app/routes/auth/login/oidc-error.tsx | 63 ++++++++ app/routes/auth/login/page.tsx | 90 +++++------ app/routes/auth/oidc-callback.ts | 174 +++++++++++---------- app/routes/auth/oidc-start.ts | 77 +++++++--- app/routes/settings/overview.tsx | 24 +-- app/server/README.md | 33 ---- app/server/config/config-schema.ts | 4 +- app/server/index.ts | 7 +- app/server/web/oidc-connector.ts | 197 ++++++++++++++++++++++++ app/server/web/oidc.ts | 186 ---------------------- app/utils/oidc.ts | 204 ------------------------- package.json | 4 +- pnpm-lock.yaml | 66 ++++---- 14 files changed, 624 insertions(+), 628 deletions(-) create mode 100644 app/routes/auth/login/config-error.tsx create mode 100644 app/routes/auth/login/oidc-error.tsx delete mode 100644 app/server/README.md create mode 100644 app/server/web/oidc-connector.ts delete mode 100644 app/server/web/oidc.ts delete mode 100644 app/utils/oidc.ts diff --git a/app/routes/auth/login/config-error.tsx b/app/routes/auth/login/config-error.tsx new file mode 100644 index 0000000..06c4483 --- /dev/null +++ b/app/routes/auth/login/config-error.tsx @@ -0,0 +1,123 @@ +import { AlertCircle } from 'lucide-react'; +import Card from '~/components/Card'; +import Code from '~/components/Code'; +import Link from '~/components/Link'; +import { OidcConnectorError } from '~/server/web/oidc-connector'; + +export function OidcConfigErrorNotice({ + errors, +}: { + errors: OidcConnectorError[]; +}) { + return ( + +
+ Authentication Error + +
+ + The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '} +
    + {mapOidcErrorsToMessages(errors).map((code) => ( +
  • {code.node}
  • + ))} +
{' '} + + Learn more + +
+
+ ); +} + +function mapOidcErrorsToMessages(errors: OidcConnectorError[]) { + const messages: { + key: string; + node: React.ReactNode; + }[] = []; + + for (const error of errors) { + switch (error) { + case 'INVALID_API_KEY': + messages.push({ + key: error, + node: ( + + The provided API key for OIDC authentication is invalid. Ensure + that oidc.headscale_api_key is a valid API key. + + ), + }); + break; + + case 'MISSING_AUTHORIZATION_ENDPOINT': + messages.push({ + key: error, + node: ( + + The OIDC provided does not have a configured{' '} + authorization_endpoint. Ensure discovery URL or + manual configuration is correct. + + ), + }); + break; + + case 'MISSING_TOKEN_ENDPOINT': + messages.push({ + key: error, + node: ( + + The OIDC provided does not have a configured{' '} + token_endpoint. Ensure discovery URL or manual + configuration is correct. + + ), + }); + break; + + case 'MISSING_USERINFO_ENDPOINT': + messages.push({ + key: error, + node: ( + + The OIDC provided does not have a configured{' '} + user_endpoint. Ensure discovery URL or manual + configuration is correct. + + ), + }); + break; + + case 'MISSING_REQUIRED_CLAIMS': + messages.push({ + key: error, + node: ( + + The OIDC provider does not support the sub claim, + which is required for authentication. Your OIDC provider may be + misconfigured. + + ), + }); + break; + + case 'UNKNOWN_ERROR': + messages.push({ + key: error, + node: ( + + An unknown error occurred during OIDC configuration. Please check + the Headplane logs for more information. + + ), + }); + break; + } + } + + return messages; +} diff --git a/app/routes/auth/login/oidc-error.tsx b/app/routes/auth/login/oidc-error.tsx new file mode 100644 index 0000000..90acc5b --- /dev/null +++ b/app/routes/auth/login/oidc-error.tsx @@ -0,0 +1,63 @@ +import { AlertCircle } from 'lucide-react'; +import Card from '~/components/Card'; +import Code from '~/components/Code'; + +export function OidcErrorNotice({ code }: { code: string }) { + return ( + +
+ Configuration Issue(s) + +
+ {getErrorMessage(code)} +
+ ); +} + +function getErrorMessage(code: string) { + switch (code) { + case 'error_no_query': + return ( + + The SSO provider did not correctly redirect back to Headplane with the + required parameters. Please ensure your SSO provider is configured + correctly. + + ); + + case 'error_no_session': + case 'error_invalid_session': + return ( + + Unable to complete SSO login due to missing or invalid session data. + Ensure that your Headplane cookie configuration is correct and that + your browser is accepting cookies. + + ); + + case 'error_no_sub': + return ( + + The SSO provider did not return a valid user identifier. Please ensure + your SSO provider is correctly configured to provide the{' '} + sub claim. + + ); + + case 'error_auth_failed': + return ( + + Authentication with the SSO provider failed. Please tray again later. + Headplane logs may provide more information. + + ); + + default: + return ( + + An unknown error occurred during OIDC authentication. Please try again + later. + + ); + } +} diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index f5d2096..a06179b 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -12,10 +12,13 @@ import Card from '~/components/Card'; import Code from '~/components/Code'; import Input from '~/components/Input'; import Link from '~/components/Link'; +import { OidcConnectorError } from '~/server/web/oidc-connector'; import { useLiveData } from '~/utils/live-data'; import type { Route } from './+types/page'; import { loginAction } from './action'; +import { OidcConfigErrorNotice } from './config-error'; import Logout from './logout'; +import { OidcErrorNotice } from './oidc-error'; export async function loader({ request, context }: Route.LoaderArgs) { try { @@ -26,28 +29,21 @@ export async function loader({ request, context }: Route.LoaderArgs) { const qp = new URL(request.url).searchParams; const urlState = qp.get('s') ?? undefined; - // OIDC config cannot be undefined if an OIDC client is set - // Also check if we are in a logout state and skip redirect if we are - const ssoOnly = context.config.oidc?.disable_api_key_login; - if (urlState !== 'logout' && ssoOnly) { - // This shouldn't be possible, but still a safe sanity check - if (!context.oidc || typeof context.oidc === 'string') { - throw data( - '`oidc.disable_api_key_login` was set without a valid OIDC configuration', - { - status: 400, - }, - ); - } - + // MARK: This works because the OIDC connector will always return false + // for `isExclusive` if the OIDC config isn't usable. + if (context.oidcConnector?.isExclusive && urlState !== 'logout') { return redirect('/oidc/start'); } + const isOidcConnectorEnabled = context.oidcConnector?.isValid; + const oidcErrorCodes = !isOidcConnectorEnabled + ? context.oidcConnector!.errors + : []; + return { isCookieSecureEnabled: context.config.server.cookie_secure, - isOidcEnabled: context.oidc !== undefined, - oidcErrorMessage: - typeof context.oidc === 'string' ? context.oidc : undefined, + isOidcConnectorEnabled, + oidcErrorCodes, urlState, }; } @@ -55,8 +51,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { export const action = loginAction; export default function Page({ loaderData, actionData }: Route.ComponentProps) { - const { isOidcEnabled, isCookieSecureEnabled, oidcErrorMessage, urlState } = - loaderData; + const { + isCookieSecureEnabled, + isOidcConnectorEnabled, + oidcErrorCodes, + urlState, + } = loaderData; + const [showCookieWarning, setShowCookieWarning] = useState(false); const [params] = useSearchParams(); const { pause } = useLiveData(); @@ -95,40 +96,31 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) { return (
- {showCookieWarning || oidcErrorMessage ? ( + {urlState?.startsWith('error_') ? ( + + ) : oidcErrorCodes.length > 0 ? ( + + ) : showCookieWarning ? (
- Configuration Issue(s) + Configuration Issue
-
- {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} -
+ {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}
) : undefined} @@ -157,11 +149,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) { Sign In - {isOidcEnabled ? ( + {isOidcConnectorEnabled ? (