headplane/app/utils/oidc.ts

204 lines
5.5 KiB
TypeScript
Raw Normal View History

2024-12-31 10:30:14 +05:30
import { redirect } from 'react-router';
2024-03-25 17:51:11 -04:00
import {
authorizationCodeGrantRequest,
2024-05-21 23:57:03 -04:00
calculatePKCECodeChallenge,
2024-12-31 10:31:50 +05:30
type Client,
2024-03-25 17:51:11 -04:00
discoveryRequest,
generateRandomCodeVerifier,
generateRandomNonce,
generateRandomState,
2024-05-21 23:57:03 -04:00
getValidatedIdTokenClaims,
isOAuth2Error,
2024-03-25 17:51:11 -04:00
parseWwwAuthenticateChallenges,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
2024-05-21 23:57:03 -04:00
validateAuthResponse,
2024-12-31 10:30:14 +05:30
} from 'oauth4webapi';
2024-03-25 17:51:11 -04:00
2024-12-31 10:30:14 +05:30
import { post } from '~/utils/headscale';
import { commitSession, getSession } from '~/utils/sessions';
import log from '~/utils/log';
2024-03-25 17:51:11 -04:00
2024-12-31 10:31:50 +05:30
import type { HeadplaneContext } from './config/headplane';
2024-05-21 23:57:03 -04:00
2024-12-31 10:30:14 +05:30
type OidcConfig = NonNullable<HeadplaneContext['oidc']>;
2024-05-21 23:57:03 -04:00
export async function startOidc(oidc: OidcConfig, req: Request) {
2024-12-31 10:30:14 +05:30
const session = await getSession(req.headers.get('Cookie'));
2024-03-25 17:51:11 -04:00
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
2024-05-21 23:57:03 -04:00
'Set-Cookie': await commitSession(session),
},
2024-12-31 10:30:14 +05:30
});
2024-03-25 17:51:11 -04:00
}
2024-12-31 10:30:14 +05:30
const issuerUrl = new URL(oidc.issuer);
2024-03-25 17:51:11 -04:00
const oidcClient = {
2024-05-21 23:57:03 -04:00
client_id: oidc.client,
token_endpoint_auth_method: oidc.method,
2024-12-31 10:30:14 +05:30
} satisfies Client;
2024-03-25 17:51:11 -04:00
2024-12-31 10:30:14 +05:30
const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response);
2024-03-25 17:51:11 -04:00
if (!processed.authorization_endpoint) {
2024-12-31 10:30:14 +05:30
throw new Error('No authorization endpoint found on the OIDC provider');
2024-03-25 17:51:11 -04:00
}
2024-12-31 10:30:14 +05:30
const state = generateRandomState();
const nonce = generateRandomNonce();
const verifier = generateRandomCodeVerifier();
const challenge = await calculatePKCECodeChallenge(verifier);
const callback = new URL('/admin/oidc/callback', req.url);
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:';
callback.host = req.headers.get('Host') ?? '';
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);
2024-03-25 17:51:11 -04:00
return redirect(authUrl.href, {
status: 302,
headers: {
2024-05-21 23:57:03 -04:00
'Set-Cookie': await commitSession(session),
},
2024-12-31 10:30:14 +05:30
});
2024-03-25 17:51:11 -04:00
}
2024-05-21 23:57:03 -04:00
export async function finishOidc(oidc: OidcConfig, req: Request) {
2024-12-31 10:30:14 +05:30
const session = await getSession(req.headers.get('Cookie'));
2024-03-25 17:51:11 -04:00
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
2024-05-21 23:57:03 -04:00
'Set-Cookie': await commitSession(session),
},
2024-12-31 10:30:14 +05:30
});
2024-03-25 17:51:11 -04:00
}
2024-12-31 10:30:14 +05:30
const issuerUrl = new URL(oidc.issuer);
2024-03-25 17:51:11 -04:00
const oidcClient = {
2024-05-21 23:57:03 -04:00
client_id: oidc.client,
client_secret: oidc.secret,
token_endpoint_auth_method: oidc.method,
2024-12-31 10:30:14 +05:30
} satisfies Client;
2024-03-25 17:51:11 -04:00
2024-12-31 10:30:14 +05:30
const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response);
2024-03-25 17:51:11 -04:00
if (!processed.authorization_endpoint) {
2024-12-31 10:30:14 +05:30
throw new Error('No authorization endpoint found on the OIDC provider');
2024-03-25 17:51:11 -04:00
}
2024-12-31 10:30:14 +05:30
const state = session.get('authState');
const nonce = session.get('authNonce');
const verifier = session.get('authVerifier');
2024-03-25 17:51:11 -04:00
if (!state || !nonce || !verifier) {
2024-12-31 10:30:14 +05:30
throw new Error('No OIDC state found in the session');
2024-03-25 17:51:11 -04:00
}
2024-05-21 23:57:03 -04:00
const parameters = validateAuthResponse(
processed,
oidcClient,
new URL(req.url),
state,
2024-12-31 10:30:14 +05:30
);
2024-05-21 23:57:03 -04:00
2024-03-25 17:51:11 -04:00
if (isOAuth2Error(parameters)) {
2024-12-31 10:30:14 +05:30
throw new Error('Invalid response from the OIDC provider');
2024-03-25 17:51:11 -04:00
}
2024-12-31 10:30:14 +05:30
const callback = new URL('/admin/oidc/callback', req.url);
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:';
callback.host = req.headers.get('Host') ?? '';
2024-05-21 23:57:03 -04:00
const tokenResponse = await authorizationCodeGrantRequest(
processed,
oidcClient,
parameters,
callback.href,
verifier,
2024-12-31 10:30:14 +05:30
);
2024-12-31 10:30:14 +05:30
const challenges = parseWwwAuthenticateChallenges(tokenResponse);
2024-03-25 17:51:11 -04:00
if (challenges) {
2024-12-31 10:30:14 +05:30
throw new Error('Recieved a challenge from the OIDC provider');
2024-03-25 17:51:11 -04:00
}
2024-05-21 23:57:03 -04:00
const result = await processAuthorizationCodeOpenIDResponse(
processed,
oidcClient,
tokenResponse,
nonce,
2024-12-31 10:30:14 +05:30
);
2024-05-21 23:57:03 -04:00
2024-03-25 17:51:11 -04:00
if (isOAuth2Error(result)) {
2024-12-31 10:30:14 +05:30
throw new Error('Invalid response from the OIDC provider');
2024-03-25 17:51:11 -04:00
}
2024-12-31 10:30:14 +05:30
const claims = getValidatedIdTokenClaims(result);
const expDate = new Date(claims.exp * 1000).toISOString();
2024-03-25 17:51:11 -04:00
2024-05-21 23:57:03 -04:00
const keyResponse = await post<{ apiKey: string }>(
'v1/apikey',
oidc.rootKey,
2024-05-21 23:57:03 -04:00
{
expiration: expDate,
},
2024-12-31 10:30:14 +05:30
);
2024-03-25 17:51:11 -04:00
2024-12-31 10:30:14 +05:30
session.set('hsApiKey', keyResponse.apiKey);
2024-03-30 02:44:06 -04:00
session.set('user', {
name: claims.name ? String(claims.name) : 'Anonymous',
2024-05-21 23:57:03 -04:00
email: claims.email ? String(claims.email) : undefined,
2024-12-31 10:30:14 +05:30
});
2024-03-30 02:44:06 -04:00
2024-03-25 17:51:11 -04:00
return redirect('/machines', {
headers: {
2024-05-21 23:57:03 -04:00
'Set-Cookie': await commitSession(session),
},
2024-12-31 10:30:14 +05:30
});
2024-03-25 17:51:11 -04:00
}
// Runs at application startup to validate the OIDC configuration
export async function testOidc(issuer: string, client: string, secret: string) {
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_post',
2024-12-31 10:30:14 +05:30
} satisfies Client;
2024-12-31 10:30:14 +05:30
const issuerUrl = new URL(issuer);
try {
2024-12-31 10:30:14 +05:30
log.debug('OIDC', 'Checking OIDC well-known endpoint');
const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response);
if (!processed.authorization_endpoint) {
2024-12-31 10:30:14 +05:30
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider');
return false;
}
2024-12-31 10:30:14 +05:30
log.debug(
'OIDC',
'Found auth endpoint: %s',
processed.authorization_endpoint,
);
return true;
} catch (e) {
2024-12-31 10:30:14 +05:30
log.debug('OIDC', 'Validation failed: %s', e.message);
return false;
}
}