From 3db69def36acbb0d9af671b8392cd47ebb10dda3 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Mon, 14 Apr 2025 16:01:06 -0400 Subject: [PATCH] fix: handle the login form state and report errors correctly on api key login --- app/routes.ts | 2 +- app/routes/auth/login.tsx | 186 ------------------------------- app/routes/auth/login/action.ts | 106 ++++++++++++++++++ app/routes/auth/login/logout.tsx | 15 +++ app/routes/auth/login/page.tsx | 134 ++++++++++++++++++++++ 5 files changed, 256 insertions(+), 187 deletions(-) delete mode 100644 app/routes/auth/login.tsx create mode 100644 app/routes/auth/login/action.ts create mode 100644 app/routes/auth/login/logout.tsx create mode 100644 app/routes/auth/login/page.tsx diff --git a/app/routes.ts b/app/routes.ts index d5f7666..b921718 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -6,7 +6,7 @@ export default [ route('/healthz', 'routes/util/healthz.ts'), // Authentication Routes - route('/login', 'routes/auth/login.tsx'), + route('/login', 'routes/auth/login/page.tsx'), route('/logout', 'routes/auth/logout.ts'), route('/oidc/callback', 'routes/auth/oidc-callback.ts'), route('/oidc/start', 'routes/auth/oidc-start.ts'), diff --git a/app/routes/auth/login.tsx b/app/routes/auth/login.tsx deleted file mode 100644 index 02803f4..0000000 --- a/app/routes/auth/login.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect } from 'react'; -import { - type ActionFunctionArgs, - type LoaderFunctionArgs, - redirect, - useSearchParams, -} from 'react-router'; -import { Form, useActionData, useLoaderData } from 'react-router'; -import Button from '~/components/Button'; -import Card from '~/components/Card'; -import Code from '~/components/Code'; -import Input from '~/components/Input'; -import type { LoadContext } from '~/server'; -import type { Key } from '~/types'; - -export async function loader({ - request, - context, -}: LoaderFunctionArgs) { - const qp = new URL(request.url).searchParams; - const state = qp.get('s'); - - try { - const session = await context.sessions.auth(request); - if (session.has('api_key')) { - return redirect('/machines'); - } - } catch {} - - const disableApiKeyLogin = context.config.oidc?.disable_api_key_login; - if (context.oidc && disableApiKeyLogin) { - // Prevents automatic redirect loop if OIDC is enabled and API key login is disabled - // Since logging out would just log back in based on the redirects - - if (state !== 'logout') { - return redirect('/oidc/start'); - } - } - - return { - oidc: context.oidc, - disableApiKeyLogin, - state, - }; -} - -export async function action({ - request, - context, -}: ActionFunctionArgs) { - const formData = await request.formData(); - const oidcStart = formData.get('oidc-start'); - const session = await context.sessions.getOrCreate(request); - - if (oidcStart) { - if (!context.oidc) { - throw new Error('OIDC is not enabled'); - } - - return redirect('/oidc/start'); - } - - const apiKey = String(formData.get('api-key')); - - // Test the API key - try { - const apiKeys = await context.client.get<{ apiKeys: Key[] }>( - 'v1/apikey', - apiKey, - ); - - const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix)); - if (!key) { - return { - error: 'Invalid API key', - }; - } - - const expiry = new Date(key.expiration); - const expiresIn = expiry.getTime() - Date.now(); - const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24); - - session.set('state', 'auth'); - session.set('api_key', apiKey); - session.set('user', { - subject: 'unknown-non-oauth', - name: key.prefix, - email: `${expiresDays.toString()} days`, - }); - - return redirect('/machines', { - headers: { - 'Set-Cookie': await context.sessions.commit(session, { - maxAge: expiresIn, - }), - }, - }); - } catch { - return { - error: 'Invalid API key', - }; - } -} - -export default function Page() { - const { state, disableApiKeyLogin, oidc } = useLoaderData(); - const actionData = useActionData(); - const [params] = useSearchParams(); - - useEffect(() => { - // State is a one time thing, we need to remove it after it has - // been consumed to prevent logic loops. - if (state !== null) { - const searchParams = new URLSearchParams(params); - searchParams.delete('s'); - - // Replacing because it's not a navigation, just a cleanup of the URL - // We can't use the useSearchParams method since it revalidates - // which will trigger a full reload - const newUrl = searchParams.toString() - ? `{${window.location.pathname}?${searchParams.toString()}` - : window.location.pathname; - - window.history.replaceState(null, '', newUrl); - } - }, [state, params]); - - if (state === 'logout') { - return ( -
- - You have been logged out - - You can now close this window. If you would like to log in again, - please refresh the page. - - -
- ); - } - - return ( -
- - Welcome to Headplane - {!disableApiKeyLogin ? ( -
- - Enter an API key to authenticate with Headplane. You can generate - one by running headscale apikeys create in your - terminal. - - - {actionData?.error ? ( -

{actionData.error}

- ) : undefined} - - -
- ) : undefined} - {oidc ? ( -
- - -
- ) : undefined} -
-
- ); -} diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts new file mode 100644 index 0000000..ad5c2dd --- /dev/null +++ b/app/routes/auth/login/action.ts @@ -0,0 +1,106 @@ +import { ActionFunctionArgs, data, redirect } from 'react-router'; +import { LoadContext } from '~/server'; +import ResponseError from '~/server/headscale/api-error'; +import { Key } from '~/types'; +import log from '~/utils/log'; + +export async function loginAction({ + request, + context, +}: ActionFunctionArgs) { + const formData = await request.formData(); + const apiKey = formData.has('api_key') + ? String(formData.get('api_key')) + : undefined; + + if (apiKey === undefined) { + log.warn('auth', 'Request made without API key'); + log.warn( + 'auth', + 'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly', + ); + throw data('Missing `api_key`', { status: 400 }); + } + + if (apiKey.length === 0) { + log.warn('auth', 'Request made with empty API key'); + log.warn( + 'auth', + 'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly', + ); + throw data('Received an empty `api_key`', { status: 400 }); + } + + try { + const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>( + 'v1/apikey', + apiKey, + ); + + // We don't need to check for 0 API keys because this request cannot + // be authenticated correctly without an API key + const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix)); + if (!lookup) { + return { + success: false, + message: 'API key was not found in the Headscale database', + }; + } + + if (lookup.expiration === null || lookup.expiration === undefined) { + log.error('auth', 'Got an API key without an expiration'); + throw data('API key is malformed', { status: 500 }); + } + + const expiry = new Date(lookup.expiration); + if (expiry.getTime() < Date.now()) { + return { + success: false, + message: 'API key has expired', + }; + } + + // Set the session + const session = await context.sessions.getOrCreate(request); + const expiresDays = Math.round( + (expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24, + ); + + session.set('state', 'auth'); + session.set('api_key', apiKey); + session.set('user', { + subject: 'unknown-non-oauth', + name: `${lookup.prefix}...`, + email: `expires@${expiresDays.toString()}-days`, + }); + + return redirect('/machines', { + headers: { + 'Set-Cookie': await context.sessions.commit(session, { + maxAge: expiry.getTime() - Date.now(), + }), + }, + }); + } catch (error) { + if (error instanceof ResponseError) { + // TODO: What in gods name is wrong with the headscale API? + if ( + error.status === 401 || + error.status === 403 || + (error.status === 500 && error.response.trim() === 'Unauthorized') + ) { + return { + success: false, + message: 'API key is invalid (it may be incorrect or expired)', + }; + } + } + + log.error('auth', 'Error while validating API key: %s', error); + log.debug('auth', 'Error details: %o', error); + return { + success: false, + message: 'Error while validating API key (see logs for details)', + }; + } +} diff --git a/app/routes/auth/login/logout.tsx b/app/routes/auth/login/logout.tsx new file mode 100644 index 0000000..bdde65f --- /dev/null +++ b/app/routes/auth/login/logout.tsx @@ -0,0 +1,15 @@ +import Card from '~/components/Card'; + +export default function Logout() { + return ( +
+ + You have been logged out + + You can now close this window. If you would like to log in again, + please refresh the page. + + +
+ ); +} diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx new file mode 100644 index 0000000..3f8cb61 --- /dev/null +++ b/app/routes/auth/login/page.tsx @@ -0,0 +1,134 @@ +import { useEffect } from 'react'; +import { + ActionFunctionArgs, + Form, + LoaderFunctionArgs, + Link as RemixLink, + data, + redirect, + useActionData, + useLoaderData, + useSearchParams, +} from 'react-router'; +import Button from '~/components/Button'; +import Card from '~/components/Card'; +import Code from '~/components/Code'; +import Input from '~/components/Input'; +import type { LoadContext } from '~/server'; +import { useLiveData } from '~/utils/live-data'; +import { loginAction } from './action'; +import Logout from './logout'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + try { + const session = await context.sessions.auth(request); + if (session.has('api_key')) { + return redirect('/machines'); + } + } catch {} + + const qp = new URL(request.url).searchParams; + const state = 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 (state !== 'logout' && ssoOnly) { + // This shouldn't be possible, but still a safe sanity check + if (!context.oidc) { + throw data( + '`oidc.disable_api_key_login` was set without a valid OIDC configuration', + { + status: 400, + }, + ); + } + + return redirect('/oidc/start'); + } + + return { + oidc: context.oidc, + state, + }; +} + +export async function action(request: ActionFunctionArgs) { + return loginAction(request); +} + +export default function Page() { + const { state, oidc } = useLoaderData(); + const formData = useActionData(); + const [params] = useSearchParams(); + const { pause } = useLiveData(); + + useEffect(() => { + // This page does NOT need stale while revalidate logic + pause(); + }); + + useEffect(() => { + // State is a one time thing, we need to remove it after it has + // been consumed to prevent logic loops. + if (state !== null) { + const searchParams = new URLSearchParams(params); + searchParams.delete('s'); + + // Replacing because it's not a navigation, just a cleanup of the URL + // We can't use the useSearchParams method since it revalidates + // which will trigger a full reload + const newUrl = searchParams.toString() + ? `{${window.location.pathname}?${searchParams.toString()}` + : window.location.pathname; + + window.history.replaceState(null, '', newUrl); + } + }, [state, params]); + + if (state === 'logout') { + return ; + } + + return ( +
+ + Welcome to Headplane +
+ + Enter an API key to authenticate with Headplane. You can generate + one by running headscale apikeys create in your + terminal. + + + {formData?.success === false ? ( + + {formData.message} + + ) : undefined} + +
+ {oidc ? ( + + + + ) : undefined} +
+
+ ); +}