diff --git a/app/routes/login.tsx b/app/routes/login.tsx index a240cfe..64f34ec 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -27,11 +27,7 @@ export async function loader({ request }: LoaderFunctionArgs) { // Only set if OIDC is properly enabled anyways if (context.oidc?.disableKeyLogin) { - return startOidc( - context.oidc.issuer, - context.oidc.client, - request, - ) + return startOidc(context.oidc, request) } return { @@ -46,16 +42,13 @@ export async function action({ request }: ActionFunctionArgs) { if (oidcStart) { const context = await loadContext() - const issuer = context.oidc?.issuer - const id = context.oidc?.client - if (!issuer || !id) { + if (!context.oidc) { throw new Error('An invalid OIDC configuration was provided') } // We know it exists here because this action only happens on OIDC - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return startOidc(issuer, id, request) + return startOidc(context.oidc, request) } const apiKey = String(formData.get('api-key')) diff --git a/app/routes/oidc.callback.tsx b/app/routes/oidc.callback.tsx index 7c1d492..ed40b87 100644 --- a/app/routes/oidc.callback.tsx +++ b/app/routes/oidc.callback.tsx @@ -9,10 +9,5 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Error('An invalid OIDC configuration was provided') } - return finishOidc( - context.oidc.issuer, - context.oidc.client, - context.oidc.secret, - request, - ) + return finishOidc(context.oidc, request) } diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index df84684..b2924f7 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -1,36 +1,42 @@ import { redirect } from '@remix-run/node' import { authorizationCodeGrantRequest, - calculatePKCECodeChallenge, type Client, + calculatePKCECodeChallenge, + type Client, discoveryRequest, generateRandomCodeVerifier, generateRandomNonce, generateRandomState, - getValidatedIdTokenClaims, isOAuth2Error, + getValidatedIdTokenClaims, + isOAuth2Error, parseWwwAuthenticateChallenges, processAuthorizationCodeOpenIDResponse, processDiscoveryResponse, - validateAuthResponse } from 'oauth4webapi' + validateAuthResponse, +} from 'oauth4webapi' import { post } from '~/utils/headscale' import { commitSession, getSession } from '~/utils/sessions' -export async function startOidc(issuer: string, client: string, request: Request) { - const session = await getSession(request.headers.get('Cookie')) +import { HeadplaneContext } from './config/headplane' + +type OidcConfig = NonNullable + +export async function startOidc(oidc: OidcConfig, req: Request) { + const session = await getSession(req.headers.get('Cookie')) if (session.has('hsApiKey')) { return redirect('/', { status: 302, headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Set-Cookie': await commitSession(session) - } + 'Set-Cookie': await commitSession(session), + }, }) } - const issuerUrl = new URL(issuer) + const issuerUrl = new URL(oidc.issuer) const oidcClient = { - client_id: client, - token_endpoint_auth_method: 'client_secret_basic' + client_id: oidc.client, + token_endpoint_auth_method: 'client_secret_basic', } satisfies Client const response = await discoveryRequest(issuerUrl) @@ -44,9 +50,9 @@ export async function startOidc(issuer: string, client: string, request: Request const verifier = generateRandomCodeVerifier() const challenge = await calculatePKCECodeChallenge(verifier) - const callback = new URL('/admin/oidc/callback', request.url) - callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:' - callback.hostname = request.headers.get('Host') ?? '' + const callback = new URL('/admin/oidc/callback', req.url) + callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:' + callback.hostname = req.headers.get('Host') ?? '' const authUrl = new URL(processed.authorization_endpoint) authUrl.searchParams.set('client_id', oidcClient.client_id) @@ -65,29 +71,27 @@ export async function startOidc(issuer: string, client: string, request: Request return redirect(authUrl.href, { status: 302, headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Set-Cookie': await commitSession(session) - } + 'Set-Cookie': await commitSession(session), + }, }) } -export async function finishOidc(issuer: string, client: string, secret: string, request: Request) { - const session = await getSession(request.headers.get('Cookie')) +export async function finishOidc(oidc: OidcConfig, req: Request) { + const session = await getSession(req.headers.get('Cookie')) if (session.has('hsApiKey')) { return redirect('/', { status: 302, headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Set-Cookie': await commitSession(session) - } + 'Set-Cookie': await commitSession(session), + }, }) } - const issuerUrl = new URL(issuer) + const issuerUrl = new URL(oidc.issuer) const oidcClient = { - client_id: client, - client_secret: secret, - token_endpoint_auth_method: 'client_secret_basic' + client_id: oidc.client, + client_secret: oidc.secret, + token_endpoint_auth_method: 'client_secret_basic', } satisfies Client const response = await discoveryRequest(issuerUrl) @@ -103,22 +107,41 @@ export async function finishOidc(issuer: string, client: string, secret: string, throw new Error('No OIDC state found in the session') } - const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state) + const parameters = validateAuthResponse( + processed, + oidcClient, + new URL(req.url), + state, + ) + if (isOAuth2Error(parameters)) { throw new Error('Invalid response from the OIDC provider') } - const callback = new URL('/admin/oidc/callback', request.url) - callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:' - callback.hostname = request.headers.get('Host') ?? '' + const callback = new URL('/admin/oidc/callback', req.url) + callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:' + callback.hostname = req.headers.get('Host') ?? '' + + const tokenResponse = await authorizationCodeGrantRequest( + processed, + oidcClient, + parameters, + callback.href, + verifier, + ) - const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier) const challenges = parseWwwAuthenticateChallenges(tokenResponse) if (challenges) { throw new Error('Recieved a challenge from the OIDC provider') } - const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce) + const result = await processAuthorizationCodeOpenIDResponse( + processed, + oidcClient, + tokenResponse, + nonce, + ) + if (isOAuth2Error(result)) { throw new Error('Invalid response from the OIDC provider') } @@ -126,21 +149,24 @@ export async function finishOidc(issuer: string, client: string, secret: string, const claims = getValidatedIdTokenClaims(result) const expDate = new Date(claims.exp * 1000).toISOString() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, { - expiration: expDate - }) + const keyResponse = await post<{ apiKey: string }>( + 'v1/apikey', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + process.env.API_KEY!, + { + expiration: expDate, + }, + ) session.set('hsApiKey', keyResponse.apiKey) session.set('user', { name: claims.name ? String(claims.name) : 'Anonymous', - email: claims.email ? String(claims.email) : undefined + email: claims.email ? String(claims.email) : undefined, }) return redirect('/machines', { headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Set-Cookie': await commitSession(session) - } + 'Set-Cookie': await commitSession(session), + }, }) }