feat: rework oidc to be more resilient
This includes setting a custom redirect URI, handling errors, and using a better library. As an API decision I've also disabled per session API keys as it clutters up too much.
This commit is contained in:
parent
dfd03e77bb
commit
5569ba4660
@ -9,6 +9,7 @@ export default [
|
|||||||
route('/login', 'routes/auth/login.tsx'),
|
route('/login', 'routes/auth/login.tsx'),
|
||||||
route('/logout', 'routes/auth/logout.ts'),
|
route('/logout', 'routes/auth/logout.ts'),
|
||||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||||
|
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||||
|
|
||||||
// All the main logged-in dashboard routes
|
// All the main logged-in dashboard routes
|
||||||
layout('layouts/dashboard.tsx', [
|
layout('layouts/dashboard.tsx', [
|
||||||
|
|||||||
@ -13,10 +13,15 @@ import TextField from '~/components/TextField';
|
|||||||
import type { Key } from '~/types';
|
import type { Key } from '~/types';
|
||||||
import { loadContext } from '~/utils/config/headplane';
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
import { pull } from '~/utils/headscale';
|
import { pull } from '~/utils/headscale';
|
||||||
import { startOidc } from '~/utils/oidc';
|
import {
|
||||||
|
startOidc,
|
||||||
|
beginAuthFlow,
|
||||||
|
getRedirectUri
|
||||||
|
} from '~/utils/oidc';
|
||||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
|
||||||
const session = await getSession(request.headers.get('Cookie'));
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
if (session.has('hsApiKey')) {
|
if (session.has('hsApiKey')) {
|
||||||
return redirect('/machines', {
|
return redirect('/machines', {
|
||||||
@ -30,7 +35,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
// Only set if OIDC is properly enabled anyways
|
// Only set if OIDC is properly enabled anyways
|
||||||
if (context.oidc?.disableKeyLogin) {
|
if (context.oidc?.disableKeyLogin) {
|
||||||
return startOidc(context.oidc, request);
|
return redirect('/oidc/start');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -42,6 +47,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const oidcStart = formData.get('oidc-start');
|
const oidcStart = formData.get('oidc-start');
|
||||||
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
|
|
||||||
if (oidcStart) {
|
if (oidcStart) {
|
||||||
const context = await loadContext();
|
const context = await loadContext();
|
||||||
@ -50,12 +56,10 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
throw new Error('An invalid OIDC configuration was provided');
|
throw new Error('An invalid OIDC configuration was provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
// We know it exists here because this action only happens on OIDC
|
return redirect('/oidc/start');
|
||||||
return startOidc(context.oidc, request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = String(formData.get('api-key'));
|
const apiKey = String(formData.get('api-key'));
|
||||||
const session = await getSession(request.headers.get('Cookie'));
|
|
||||||
|
|
||||||
// Test the API key
|
// Test the API key
|
||||||
try {
|
try {
|
||||||
@ -71,6 +75,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
|
|
||||||
session.set('hsApiKey', apiKey);
|
session.set('hsApiKey', apiKey);
|
||||||
session.set('user', {
|
session.set('user', {
|
||||||
|
subject: 'unknown-non-oauth',
|
||||||
name: key.prefix,
|
name: key.prefix,
|
||||||
email: `${expiresDays.toString()} days`,
|
email: `${expiresDays.toString()} days`,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,17 +1,77 @@
|
|||||||
import { type LoaderFunctionArgs, data } from 'react-router';
|
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
import { loadContext } from '~/utils/config/headplane';
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
import { finishOidc } from '~/utils/oidc';
|
import { getSession, commitSession } from '~/utils/sessions.server';
|
||||||
|
import { finishAuthFlow, getRedirectUri, formatError } from '~/utils/oidc';
|
||||||
|
import { send } from '~/utils/res';
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
try {
|
// Check if we have 0 query parameters
|
||||||
const context = await loadContext();
|
const url = new URL(request.url);
|
||||||
if (!context.oidc) {
|
if (url.searchParams.toString().length === 0) {
|
||||||
throw new Error('An invalid OIDC configuration was provided');
|
return redirect('/machines');
|
||||||
}
|
}
|
||||||
|
|
||||||
return finishOidc(context.oidc, request);
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
|
if (session.has('hsApiKey')) {
|
||||||
|
return redirect('/machines')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a hold-over from the old code
|
||||||
|
// TODO: Rewrite checkOIDC in the context loader
|
||||||
|
const { oidc } = await loadContext();
|
||||||
|
if (!oidc) {
|
||||||
|
throw new Error('An invalid OIDC configuration was provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcConfig = {
|
||||||
|
issuer: oidc.issuer,
|
||||||
|
clientId: oidc.client,
|
||||||
|
clientSecret: oidc.secret,
|
||||||
|
redirectUri: oidc.redirectUri,
|
||||||
|
tokenEndpointAuthMethod: oidc.method,
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = session.get('oidc_code_verif');
|
||||||
|
const state = session.get('oidc_state');
|
||||||
|
const nonce = session.get('oidc_nonce');
|
||||||
|
|
||||||
|
if (!codeVerifier || !state || !nonce) {
|
||||||
|
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowOptions = {
|
||||||
|
redirect_uri: request.url,
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
nonce: nonce === '<none>' ? undefined : nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await finishAuthFlow(oidcConfig, flowOptions);
|
||||||
|
session.set('user', user);
|
||||||
|
session.unset('oidc_code_verif');
|
||||||
|
session.unset('oidc_state');
|
||||||
|
session.unset('oidc_nonce');
|
||||||
|
|
||||||
|
// TODO: This is breaking, to stop the "over-generation" of API
|
||||||
|
// keys because they are currently non-deletable in the headscale
|
||||||
|
// database. Look at this in the future once we have a solution
|
||||||
|
// or we have permissioned API keys.
|
||||||
|
session.set('hsApiKey', oidc.rootKey);
|
||||||
|
return redirect('/machines', {
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': await commitSession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Gracefully present OIDC errors
|
return new Response(
|
||||||
return data({ error }, { status: 500 });
|
JSON.stringify(formatError(error)),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
app/routes/auth/oidc-start.ts
Normal file
40
app/routes/auth/oidc-start.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { type LoaderFunctionArgs, data, redirect } from 'react-router';
|
||||||
|
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||||
|
import { send } from '~/utils/res';
|
||||||
|
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||||
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const session = await getSession(request.headers.get('Cookie'));
|
||||||
|
if (session.has('hsApiKey')) {
|
||||||
|
return redirect('/machines')
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a hold-over from the old code
|
||||||
|
// TODO: Rewrite checkOIDC in the context loader
|
||||||
|
const { oidc } = await loadContext();
|
||||||
|
if (!oidc) {
|
||||||
|
throw new Error('An invalid OIDC configuration was provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcConfig = {
|
||||||
|
issuer: oidc.issuer,
|
||||||
|
clientId: oidc.client,
|
||||||
|
clientSecret: oidc.secret,
|
||||||
|
redirectUri: oidc.redirectUri,
|
||||||
|
tokenEndpointAuthMethod: oidc.method,
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = oidcConfig.redirectUri ?? getRedirectUri(request);
|
||||||
|
const data = await beginAuthFlow(oidcConfig, redirectUri);
|
||||||
|
session.set('oidc_code_verif', data.codeVerifier);
|
||||||
|
session.set('oidc_state', data.state);
|
||||||
|
session.set('oidc_nonce', data.nonce);
|
||||||
|
|
||||||
|
return redirect(data.url, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': await commitSession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ export interface HeadplaneContext {
|
|||||||
issuer: string;
|
issuer: string;
|
||||||
client: string;
|
client: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
|
redirectUri?: string;
|
||||||
rootKey: string;
|
rootKey: string;
|
||||||
method: string;
|
method: string;
|
||||||
disableKeyLogin: boolean;
|
disableKeyLogin: boolean;
|
||||||
@ -204,10 +205,15 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
let secret = process.env.OIDC_CLIENT_SECRET;
|
let secret = process.env.OIDC_CLIENT_SECRET;
|
||||||
const method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic';
|
const method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic';
|
||||||
const skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true';
|
const skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true';
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
log.debug('CTXT', 'Checking OIDC environment variables');
|
log.debug('CTXT', 'Checking OIDC environment variables');
|
||||||
log.debug('CTXT', 'Issuer: %s', issuer);
|
log.debug('CTXT', 'Issuer: %s', issuer);
|
||||||
log.debug('CTXT', 'Client: %s', client);
|
log.debug('CTXT', 'Client: %s', client);
|
||||||
|
log.debug('CTXT', 'Token Auth Method: %s', method);
|
||||||
|
if (redirectUri) {
|
||||||
|
log.debug('CTXT', 'Redirect URI: %s', redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(issuer ?? client ?? secret) &&
|
(issuer ?? client ?? secret) &&
|
||||||
@ -223,7 +229,17 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
'CTXT',
|
'CTXT',
|
||||||
'Validating OIDC configuration from environment variables',
|
'Validating OIDC configuration from environment variables',
|
||||||
);
|
);
|
||||||
const result = await testOidc(issuer, client, secret);
|
|
||||||
|
// This is a hold-over from the old code
|
||||||
|
// TODO: Rewrite checkOIDC in the context loader
|
||||||
|
const oidcConfig = {
|
||||||
|
issuer: issuer,
|
||||||
|
clientId: client,
|
||||||
|
clientSecret: secret,
|
||||||
|
tokenEndpointAuthMethod: method,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testOidc(oidcConfig)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -236,6 +252,7 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
issuer,
|
issuer,
|
||||||
client,
|
client,
|
||||||
secret,
|
secret,
|
||||||
|
redirectUri,
|
||||||
method,
|
method,
|
||||||
rootKey,
|
rootKey,
|
||||||
disableKeyLogin,
|
disableKeyLogin,
|
||||||
@ -279,7 +296,14 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
|
|
||||||
if (config?.oidc?.only_start_if_oidc_is_available) {
|
if (config?.oidc?.only_start_if_oidc_is_available) {
|
||||||
log.debug('CTXT', 'Validating OIDC configuration from headscale config');
|
log.debug('CTXT', 'Validating OIDC configuration from headscale config');
|
||||||
const result = await testOidc(issuer, client, secret);
|
const oidcConfig = {
|
||||||
|
issuer: issuer,
|
||||||
|
clientId: client,
|
||||||
|
clientSecret: secret,
|
||||||
|
tokenEndpointAuthMethod: method,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testOidc(oidcConfig)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -292,6 +316,7 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
issuer,
|
issuer,
|
||||||
client,
|
client,
|
||||||
secret,
|
secret,
|
||||||
|
redirectUri,
|
||||||
rootKey,
|
rootKey,
|
||||||
method,
|
method,
|
||||||
disableKeyLogin,
|
disableKeyLogin,
|
||||||
|
|||||||
@ -1,24 +1,5 @@
|
|||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
import * as client from 'openid-client';
|
import * as client from 'openid-client';
|
||||||
import {
|
|
||||||
authorizationCodeGrantRequest,
|
|
||||||
calculatePKCECodeChallenge,
|
|
||||||
Client,
|
|
||||||
ClientAuthenticationMethod,
|
|
||||||
discoveryRequest,
|
|
||||||
generateRandomCodeVerifier,
|
|
||||||
generateRandomNonce,
|
|
||||||
generateRandomState,
|
|
||||||
getValidatedIdTokenClaims,
|
|
||||||
isOAuth2Error,
|
|
||||||
parseWwwAuthenticateChallenges,
|
|
||||||
processAuthorizationCodeOpenIDResponse,
|
|
||||||
processDiscoveryResponse,
|
|
||||||
validateAuthResponse,
|
|
||||||
} from 'oauth4webapi';
|
|
||||||
|
|
||||||
import { post } from '~/utils/headscale';
|
|
||||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
|
|
||||||
import type { HeadplaneContext } from './config/headplane';
|
import type { HeadplaneContext } from './config/headplane';
|
||||||
@ -28,35 +9,10 @@ const oidcConfigSchema = z.object({
|
|||||||
issuer: z.string(),
|
issuer: z.string(),
|
||||||
clientId: z.string(),
|
clientId: z.string(),
|
||||||
clientSecret: z.string(),
|
clientSecret: z.string(),
|
||||||
|
redirectUri: z.string().optional(),
|
||||||
tokenEndpointAuthMethod: z
|
tokenEndpointAuthMethod: z
|
||||||
.enum(['client_secret_post', 'client_secret_basic'])
|
.enum(['client_secret_post', 'client_secret_basic', 'client_secret_jwt'])
|
||||||
.default('client_secret_basic'),
|
.default('client_secret_basic'),
|
||||||
idTokenSigningAlg: z
|
|
||||||
.enum([
|
|
||||||
'RS256',
|
|
||||||
'RS384',
|
|
||||||
'RS512',
|
|
||||||
'ES256',
|
|
||||||
'ES384',
|
|
||||||
'ES512',
|
|
||||||
'PS256',
|
|
||||||
'PS384',
|
|
||||||
'PS512',
|
|
||||||
])
|
|
||||||
.default('RS256'),
|
|
||||||
idTokenEncryptionAlg: z
|
|
||||||
.enum(['RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256'])
|
|
||||||
.default('RSA-OAEP'),
|
|
||||||
idTokenEncryptionEnc: z
|
|
||||||
.enum([
|
|
||||||
'A128CBC-HS256',
|
|
||||||
'A192CBC-HS384',
|
|
||||||
'A256CBC-HS512',
|
|
||||||
'A128GCM',
|
|
||||||
'A192GCM',
|
|
||||||
'A256GCM',
|
|
||||||
])
|
|
||||||
.default('A256GCM'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -67,6 +23,7 @@ export type OidcConfig = z.infer<typeof oidcConfigSchema>;
|
|||||||
|
|
||||||
// We try our best to infer the callback URI of our Headplane instance
|
// We try our best to infer the callback URI of our Headplane instance
|
||||||
// By default it is always /<base_path>/oidc/callback
|
// By default it is always /<base_path>/oidc/callback
|
||||||
|
// (This can ALWAYS be overridden through the OidcConfig)
|
||||||
export function getRedirectUri(req: Request) {
|
export function getRedirectUri(req: Request) {
|
||||||
const base = __PREFIX__ ?? '/admin'; // Fallback
|
const base = __PREFIX__ ?? '/admin'; // Fallback
|
||||||
const url = new URL(`${base}/oidc/callback`, req.url);
|
const url = new URL(`${base}/oidc/callback`, req.url);
|
||||||
@ -92,22 +49,38 @@ export function getRedirectUri(req: Request) {
|
|||||||
return url.href;
|
return url.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clientAuthMethod(method: string): (secret: string) => client.ClientAuth {
|
||||||
|
switch (method) {
|
||||||
|
case 'client_secret_post':
|
||||||
|
return client.ClientSecretPost
|
||||||
|
case 'client_secret_basic':
|
||||||
|
return client.ClientSecretBasic
|
||||||
|
case 'client_secret_jwt':
|
||||||
|
return client.ClientSecretJwt
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid client authentication method');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
||||||
const config = await client.discovery(
|
const config = await client.discovery(
|
||||||
oidc.issuer,
|
new URL(oidc.issuer),
|
||||||
oidc.clientId,
|
oidc.clientId,
|
||||||
oidc.clientSecret,
|
oidc.clientSecret,
|
||||||
|
new clientAuthMethod(oidc.tokenEndpointAuthMethod)(oidc.clientSecret),
|
||||||
);
|
);
|
||||||
|
|
||||||
let codeVerifier: string, codeChallenge: string;
|
let codeVerifier: string, codeChallenge: string;
|
||||||
codeVerifier = client.randomPKCECodeVerifier();
|
codeVerifier = client.randomPKCECodeVerifier();
|
||||||
codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
let params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
|
token_endpoint_auth_method: oidc.tokenEndpointAuthMethod,
|
||||||
|
state: client.randomState(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// PKCE is backwards compatible with non-PKCE servers
|
// PKCE is backwards compatible with non-PKCE servers
|
||||||
@ -120,213 +93,122 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
|||||||
return {
|
return {
|
||||||
url: url.href,
|
url: url.href,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
nonce: params.nonce,
|
state: params.state,
|
||||||
|
nonce: params.nonce ?? '<none>',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlowOptions {
|
interface FlowOptions {
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
|
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
|
||||||
const config = await client.discovery(
|
const config = await client.discovery(
|
||||||
oidc.issuer,
|
new URL(oidc.issuer),
|
||||||
oidc.clientId,
|
oidc.clientId,
|
||||||
oidc.clientSecret,
|
oidc.clientSecret,
|
||||||
|
new clientAuthMethod(oidc.tokenEndpointAuthMethod)(oidc.clientSecret),
|
||||||
);
|
);
|
||||||
|
|
||||||
let subject: string, accessToken: string;
|
let subject: string, accessToken: string;
|
||||||
const tokens = await client.authorizationCodeGrant(config, new URL(options.redirect_uri), {
|
const tokens = await client.authorizationCodeGrant(config, new URL(options.redirect_uri), {
|
||||||
pkceCodeVerifier: options.codeVerifier,
|
pkceCodeVerifier: options.codeVerifier,
|
||||||
expectedNonce: options.nonce,
|
expectedNonce: options.nonce,
|
||||||
|
expectedState: options.state,
|
||||||
idTokenExpected: true
|
idTokenExpected: true
|
||||||
})
|
|
||||||
|
|
||||||
console.log(tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
'Set-Cookie': await commitSession(session),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: Properly validate the method is a valid type
|
|
||||||
const method = oidc.method as ClientAuthenticationMethod;
|
|
||||||
const issuerUrl = new URL(oidc.issuer);
|
|
||||||
const oidcClient = {
|
|
||||||
client_id: oidc.client,
|
|
||||||
token_endpoint_auth_method: method,
|
|
||||||
} 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', 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);
|
|
||||||
|
|
||||||
return redirect(authUrl.href, {
|
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await commitSession(session),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export async function finishOidc(oidc: OidcConfig, req: Request) {
|
const claims = tokens.claims();
|
||||||
const session = await getSession(req.headers.get('Cookie'));
|
if (!claims?.sub) {
|
||||||
if (session.has('hsApiKey')) {
|
throw new Error('No subject found in OIDC claims');
|
||||||
return redirect('/', {
|
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await commitSession(session),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Properly validate the method is a valid type
|
const user = await client.fetchUserInfo(
|
||||||
const method = oidc.method as ClientAuthenticationMethod;
|
config,
|
||||||
const issuerUrl = new URL(oidc.issuer);
|
tokens.access_token,
|
||||||
const oidcClient = {
|
claims.sub,
|
||||||
client_id: oidc.client,
|
|
||||||
client_secret: oidc.secret,
|
|
||||||
token_endpoint_auth_method: method,
|
|
||||||
} 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(req.url),
|
|
||||||
state,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isOAuth2Error(parameters)) {
|
return {
|
||||||
throw new Error('Invalid response from the OIDC provider');
|
subject: claims.sub,
|
||||||
}
|
|
||||||
|
|
||||||
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 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();
|
|
||||||
|
|
||||||
const keyResponse = await post<{ apiKey: string }>(
|
|
||||||
'v1/apikey',
|
|
||||||
oidc.rootKey,
|
|
||||||
{
|
|
||||||
expiration: expDate,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
session.set('hsApiKey', keyResponse.apiKey);
|
|
||||||
session.set('user', {
|
|
||||||
name: claims.name ? String(claims.name) : 'Anonymous',
|
name: claims.name ? String(claims.name) : 'Anonymous',
|
||||||
email: claims.email ? String(claims.email) : undefined,
|
email: claims.email ? String(claims.email) : undefined,
|
||||||
});
|
username: claims.preferred_username ? String(claims.preferred_username) : undefined,
|
||||||
|
|
||||||
return redirect('/machines', {
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await commitSession(session),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
} satisfies Client;
|
|
||||||
|
|
||||||
const issuerUrl = new URL(issuer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.debug('OIDC', 'Checking OIDC well-known endpoint');
|
|
||||||
const response = await discoveryRequest(issuerUrl);
|
|
||||||
const processed = await processDiscoveryResponse(issuerUrl, response);
|
|
||||||
if (!processed.authorization_endpoint) {
|
|
||||||
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
'OIDC',
|
|
||||||
'Found auth endpoint: %s',
|
|
||||||
processed.authorization_endpoint,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
log.debug('OIDC', 'Validation failed: %s', e.message);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatError(error: unknown) {
|
||||||
|
if (error instanceof client.ResponseBodyError) {
|
||||||
|
return {
|
||||||
|
code: error.code,
|
||||||
|
error: {
|
||||||
|
name: error.error,
|
||||||
|
description: error.error_description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof client.AuthorizationResponseError) {
|
||||||
|
return {
|
||||||
|
code: error.code,
|
||||||
|
error: {
|
||||||
|
name: error.error,
|
||||||
|
description: error.error_description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof client.WWWAuthenticateChallengeError) {
|
||||||
|
return {
|
||||||
|
code: error.code,
|
||||||
|
error: {
|
||||||
|
name: error.name,
|
||||||
|
description: error.message,
|
||||||
|
challenges: error.cause,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error('OIDC', 'Unknown error: %s', error);
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
error: {
|
||||||
|
name: 'Internal Server Error',
|
||||||
|
description: 'An unknown error occurred',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testOidc(oidc: OidcConfig) {
|
||||||
|
log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
|
||||||
|
const config = await client.discovery(
|
||||||
|
new URL(oidc.issuer),
|
||||||
|
oidc.clientId,
|
||||||
|
oidc.clientSecret,
|
||||||
|
new clientAuthMethod(oidc.tokenEndpointAuthMethod)(oidc.clientSecret),
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = config.serverMetadata();
|
||||||
|
if (meta.authorization_endpoint === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('OIDC', 'Authorization endpoint: %s', meta.authorization_endpoint);
|
||||||
|
log.debug('OIDC', 'Token endpoint: %s', meta.token_endpoint);
|
||||||
|
|
||||||
|
if (meta.response_types_supported.includes('code') === false) {
|
||||||
|
log.error('OIDC', 'OIDC server does not support code flow');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.token_endpoint_auth_methods_supported.includes(oidc.tokenEndpointAuthMethod) === false) {
|
||||||
|
log.error('OIDC', 'OIDC server does not support %s', oidc.tokenEndpointAuthMethod);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('OIDC', 'OIDC configuration is valid');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import { Session, SessionStorage, createCookieSessionStorage } from 'react-route
|
|||||||
|
|
||||||
export type SessionData = {
|
export type SessionData = {
|
||||||
hsApiKey: string;
|
hsApiKey: string;
|
||||||
authState: string;
|
oidc_state: string;
|
||||||
authNonce: string;
|
oidc_code_verif: string;
|
||||||
authVerifier: string;
|
oidc_nonce: string;
|
||||||
user: {
|
user: {
|
||||||
|
subject: string;
|
||||||
name: string;
|
name: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ If you use the Headscale configuration integration, these are not required.
|
|||||||
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
|
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
|
||||||
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
|
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
|
||||||
- **`OIDC_CLIENT_SECRET_METHOD`**: The method used to send the client secret (default: `client_secret_basic`).
|
- **`OIDC_CLIENT_SECRET_METHOD`**: The method used to send the client secret (default: `client_secret_basic`).
|
||||||
|
- **`OIDC_REDIRECT_URI`**: The redirect URI for the OIDC provider (recommended, otherwise guessed).
|
||||||
- **`OIDC_SKIP_CONFIG_VALIDATION`**: Skip the OIDC configuration validation (default: `false`).
|
- **`OIDC_SKIP_CONFIG_VALIDATION`**: Skip the OIDC configuration validation (default: `false`).
|
||||||
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||||
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
|
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"isbot": "^5.1.19",
|
"isbot": "^5.1.19",
|
||||||
"mime": "^4.0.6",
|
"mime": "^4.0.6",
|
||||||
"oauth4webapi": "^2.17.0",
|
"openid-client": "^6.1.7",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-aria-components": "^1.5.0",
|
"react-aria-components": "^1.5.0",
|
||||||
"react-codemirror-merge": "^4.23.7",
|
"react-codemirror-merge": "^4.23.7",
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@react-router/dev": "^7.0.0",
|
"@react-router/dev": "^7.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
|
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
@ -67,9 +67,9 @@ importers:
|
|||||||
mime:
|
mime:
|
||||||
specifier: ^4.0.6
|
specifier: ^4.0.6
|
||||||
version: 4.0.6
|
version: 4.0.6
|
||||||
oauth4webapi:
|
openid-client:
|
||||||
specifier: ^2.17.0
|
specifier: ^6.1.7
|
||||||
version: 2.17.0
|
version: 6.1.7
|
||||||
react:
|
react:
|
||||||
specifier: 19.0.0
|
specifier: 19.0.0
|
||||||
version: 19.0.0
|
version: 19.0.0
|
||||||
@ -126,8 +126,8 @@ importers:
|
|||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.20(postcss@8.4.49)
|
version: 10.4.20(postcss@8.4.49)
|
||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
specifier: 19.0.0-beta-df7b47d-20241124
|
specifier: 19.0.0-beta-55955c9-20241229
|
||||||
version: 19.0.0-beta-df7b47d-20241124
|
version: 19.0.0-beta-55955c9-20241229
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.4.49
|
specifier: ^8.4.49
|
||||||
version: 8.4.49
|
version: 8.4.49
|
||||||
@ -1609,8 +1609,8 @@ packages:
|
|||||||
babel-dead-code-elimination@1.0.8:
|
babel-dead-code-elimination@1.0.8:
|
||||||
resolution: {integrity: sha512-og6HQERk0Cmm+nTT4Od2wbPtgABXFMPaHACjbKLulZIFMkYyXZLkUGuAxdgpMJBrxyt/XFpSz++lNzjbcMnPkQ==}
|
resolution: {integrity: sha512-og6HQERk0Cmm+nTT4Od2wbPtgABXFMPaHACjbKLulZIFMkYyXZLkUGuAxdgpMJBrxyt/XFpSz++lNzjbcMnPkQ==}
|
||||||
|
|
||||||
babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124:
|
babel-plugin-react-compiler@19.0.0-beta-55955c9-20241229:
|
||||||
resolution: {integrity: sha512-93iSASR20HNsotcOTQ+KPL0zpgfRFVWL86AtXpmHp995HuMVnC9femd8Winr3GxkPEh8lEOyaw3nqY4q2HUm5w==}
|
resolution: {integrity: sha512-APpa9fRiG5UN5kxnB/vznaSBKbXwAWZs6QshN3MLntzWa4cUhOxzUSd7Ohmr5sLQaM0ZHjjOg07pw1ZoR7+Oog==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
@ -2138,9 +2138,6 @@ packages:
|
|||||||
oauth-sign@0.9.0:
|
oauth-sign@0.9.0:
|
||||||
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
|
||||||
|
|
||||||
oauth4webapi@2.17.0:
|
|
||||||
resolution: {integrity: sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==}
|
|
||||||
|
|
||||||
oauth4webapi@3.1.4:
|
oauth4webapi@3.1.4:
|
||||||
resolution: {integrity: sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==}
|
resolution: {integrity: sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==}
|
||||||
|
|
||||||
@ -4664,7 +4661,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124:
|
babel-plugin-react-compiler@19.0.0-beta-55955c9-20241229:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.26.3
|
'@babel/types': 7.26.3
|
||||||
|
|
||||||
@ -5050,8 +5047,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@1.21.7: {}
|
jiti@1.21.7: {}
|
||||||
|
|
||||||
jose@5.9.6:
|
jose@5.9.6: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
@ -5183,10 +5179,7 @@ snapshots:
|
|||||||
|
|
||||||
oauth-sign@0.9.0: {}
|
oauth-sign@0.9.0: {}
|
||||||
|
|
||||||
oauth4webapi@2.17.0: {}
|
oauth4webapi@3.1.4: {}
|
||||||
|
|
||||||
oauth4webapi@3.1.4:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
@ -5200,7 +5193,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
jose: 5.9.6
|
jose: 5.9.6
|
||||||
oauth4webapi: 3.1.4
|
oauth4webapi: 3.1.4
|
||||||
optional: true
|
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
|
|||||||
@ -27,5 +27,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
__VERSION__: JSON.stringify(version),
|
__VERSION__: JSON.stringify(version),
|
||||||
|
__PREFIX__: JSON.stringify(prefix),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user