import { redirect } from '@remix-run/node' import { authorizationCodeGrantRequest, calculatePKCECodeChallenge, type Client, discoveryRequest, generateRandomCodeVerifier, generateRandomNonce, generateRandomState, getValidatedIdTokenClaims, isOAuth2Error, parseWwwAuthenticateChallenges, processAuthorizationCodeOpenIDResponse, processDiscoveryResponse, 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')) if (session.has('hsApiKey')) { return redirect('/', { status: 302, headers: { // eslint-disable-next-line @typescript-eslint/naming-convention 'Set-Cookie': await commitSession(session) } }) } const issuerUrl = new URL(issuer) const oidcClient = { client_id: client, token_endpoint_auth_method: 'client_secret_basic' } satisfies Client const response = await discoveryRequest(issuerUrl) const processed = await processDiscoveryResponse(issuerUrl, response) if (!processed.authorization_endpoint) { throw new Error('No authorization endpoint found on the OIDC provider') } const state = generateRandomState() const nonce = generateRandomNonce() const verifier = generateRandomCodeVerifier() const challenge = await calculatePKCECodeChallenge(verifier) const callback = new URL('/admin/oidc/callback', request.url) const authUrl = new URL(processed.authorization_endpoint) authUrl.searchParams.set('client_id', oidcClient.client_id) authUrl.searchParams.set('response_type', 'code') authUrl.searchParams.set('redirect_uri', callback.href) authUrl.searchParams.set('scope', 'openid profile email') authUrl.searchParams.set('code_challenge', challenge) authUrl.searchParams.set('code_challenge_method', 'S256') authUrl.searchParams.set('state', state) authUrl.searchParams.set('nonce', nonce) session.set('authState', state) session.set('authNonce', nonce) session.set('authVerifier', verifier) return redirect(authUrl.href, { status: 302, headers: { // eslint-disable-next-line @typescript-eslint/naming-convention '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')) if (session.has('hsApiKey')) { return redirect('/', { status: 302, headers: { // eslint-disable-next-line @typescript-eslint/naming-convention 'Set-Cookie': await commitSession(session) } }) } const issuerUrl = new URL(issuer) const oidcClient = { client_id: client, client_secret: secret, token_endpoint_auth_method: 'client_secret_basic' } satisfies Client const response = await discoveryRequest(issuerUrl) const processed = await processDiscoveryResponse(issuerUrl, response) if (!processed.authorization_endpoint) { throw new Error('No authorization endpoint found on the OIDC provider') } const state = session.get('authState') const nonce = session.get('authNonce') const verifier = session.get('authVerifier') if (!state || !nonce || !verifier) { throw new Error('No OIDC state found in the session') } const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state) if (isOAuth2Error(parameters)) { throw new Error('Invalid response from the OIDC provider') } const callback = new URL('/admin/oidc/callback', request.url) 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) if (isOAuth2Error(result)) { throw new Error('Invalid response from the OIDC provider') } 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 }) session.set('hsApiKey', keyResponse.apiKey) return redirect('/machines', { headers: { // eslint-disable-next-line @typescript-eslint/naming-convention 'Set-Cookie': await commitSession(session) } }) }