feat: overhaul oidc work

This commit is contained in:
Aarnav Tale 2025-08-28 22:49:55 -04:00
parent bda9dedbfd
commit eb4669498a
No known key found for this signature in database
10 changed files with 200 additions and 139 deletions

View File

@ -22,6 +22,10 @@
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
- OIDC now allows passing many custom parameters:
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
### 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.

View File

@ -25,7 +25,8 @@ export async function loader({
const data = await beginAuthFlow(
context.oidc,
redirectUri,
context.config.oidc.token_endpoint_auth_method,
context.config.oidc.scope,
context.config.oidc.extra_params,
);
return redirect(data.url, {

View File

@ -65,6 +65,11 @@ const oidcConfig = type({
headscale_api_key_path: 'string?',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
strict_validation: stringToBool.default(true),
scope: 'string = "openid email profile"',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal =
@ -97,6 +102,11 @@ const partialOidcConfig = type({
headscale_api_key_path: 'string?',
profile_picture_source: '("oidc" | "gravatar")?',
strict_validation: stringToBool.default(true),
scope: 'string?',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
});
const headscaleConfig = type({

View File

@ -9,7 +9,7 @@ import { createDbClient } from './db/client.server';
import { createApiClient } from './headscale/api-client';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { createOidcClient } from './web/oidc';
import { configureOidcAuth } from './web/oidc';
import { createSessionStorage } from './web/sessions';
declare global {
@ -68,7 +68,7 @@ const appLoadContext = {
agents,
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
db,
};

View File

@ -1,90 +1,37 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client';
import * as oidc from 'openid-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/schema';
import { HeadplaneConfig } from '../config/schema';
async function loadClientSecret(path: string) {
// We need to interpolate environment variables into the path
// Path formatting can be like ${ENV_NAME}/path/to/secret
const matches = path.match(/\${(.*?)}/g);
let resolvedPath = path;
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
if (matches) {
for (const match of matches) {
const env = match.slice(2, -1);
const value = process.env[env];
if (!value) {
log.error('config', 'Environment variable %s is not set', env);
return;
}
log.debug('config', 'Interpolating %s with %s', match, value);
resolvedPath = resolvedPath.replace(match, value);
}
}
try {
log.debug('config', 'Reading client secret from %s', resolvedPath);
const secret = await readFile(resolvedPath, 'utf-8');
if (secret.trim().length === 0) {
log.error('config', 'Empty OIDC client secret');
return;
}
return secret.trim();
} catch (error) {
log.error('config', 'Failed to read client secret from %s', path);
log.error('config', 'Error: %s', error);
log.debug('config', 'Error details: %o', error);
}
}
function clientAuthMethod(
method: string,
): (secret: string) => client.ClientAuth {
switch (method) {
case 'client_secret_post':
return client.ClientSecretPost;
export async function configureOidcAuth(config: OidcConfig) {
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
let clientAuthMethod: oidc.ClientAuth;
switch (config.token_endpoint_auth_method) {
case 'client_secret_basic':
return client.ClientSecretBasic;
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
break;
case 'client_secret_post':
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
break;
case 'client_secret_jwt':
return client.ClientSecretJwt;
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
break;
default:
throw new Error('Invalid client authentication method');
}
}
// Loads and configures an OIDC client to support OIDC authentication.
// This runs under the assumption the OIDC configuration exists and is valid.
// If it is invalid, Headplane automatically disables it.
//
// TODO: Support custom endpoints instead of relying on OIDC discovery.
// This will enable us to support servers like GitHub that do not support
// nor advertise a .well-known endpoint.
export async function createOidcClient(
config: NonNullable<HeadplaneConfig['oidc']>,
) {
// const secret = await loadClientSecret(oidc);
const secret = config.client_secret_path
? await loadClientSecret(config.client_secret_path)
: config.client_secret;
if (!secret) {
log.error('config', 'Missing an OIDC client secret');
return;
}
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
let oidcClient: oidc.Configuration;
try {
const oidc = await client.discovery(
const discovery = await oidc.discovery(
new URL(config.issuer),
config.client_id,
secret,
clientAuthMethod(config.token_endpoint_auth_method)(secret),
config.client_secret!, // TODO: Fix this config schema
clientAuthMethod,
);
const metadata = oidc.serverMetadata();
if (!metadata.authorization_endpoint) {
const meta = discovery.serverMetadata();
if (!meta.authorization_endpoint) {
log.error(
'config',
'Issuer discovery did not return `authorization_endpoint`',
@ -93,70 +40,118 @@ export async function createOidcClient(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return;
}
if (!metadata.token_endpoint) {
if (!meta.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error('config', 'OIDC server does not support token exchange');
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return;
}
// If this field is missing, assume the server supports all response types
// and that we can continue safely.
if (metadata.response_types_supported) {
if (!metadata.response_types_supported.includes('code')) {
log.error(
'config',
'Issuer discovery `response_types_supported` does not include `code`',
);
log.error('config', 'OIDC server does not support code flow');
return;
}
if (!meta.userinfo_endpoint) {
log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support user info endpoint');
log.error('config', 'You may need to set this manually in the config');
return;
}
if (metadata.token_endpoint_auth_methods_supported) {
if (meta.token_endpoint_auth_methods_supported) {
if (
!metadata.token_endpoint_auth_methods_supported.includes(
!meta.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method,
)
) {
log.error(
'config',
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
'OIDC server does not support client authentication method %s',
config.token_endpoint_auth_method,
);
log.error(
'config',
'OIDC server does not support %s',
config.token_endpoint_auth_method,
'Supported methods: %s',
meta.token_endpoint_auth_methods_supported.join(', '),
);
return;
}
}
if (!metadata.userinfo_endpoint) {
log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support userinfo endpoint');
return;
}
log.debug('config', 'OIDC client created successfully');
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
log.debug('config', 'OIDC discovery successful');
log.debug(
'config',
'Authorization endpoint: %s',
metadata.authorization_endpoint,
meta.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
return oidc;
} catch (error) {
log.error('config', 'Failed to discover OIDC issuer');
log.error('config', 'Error: %s', error);
log.debug('config', 'Error details: %o', error);
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
// Manually construct the endpoints to coalesce with config if needed
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint:
config.authorization_endpoint || meta.authorization_endpoint,
token_endpoint: config.token_endpoint || meta.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
} catch (err) {
log.error('config', 'OIDC discovery failed: %s', err);
log.debug('config', 'Error details: %o', err);
log.error(
'config',
'This may be an error, or the server may not support discovery',
);
if (
!config.authorization_endpoint ||
!config.token_endpoint ||
!config.userinfo_endpoint
) {
log.error(
'config',
'Endpoints are not fully configured, cannot continue',
);
log.error(
'config',
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
);
return;
}
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint: config.authorization_endpoint,
token_endpoint: config.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
log.debug('config', 'Using manually configured endpoints');
log.debug(
'config',
'Authorization endpoint: %s',
config.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
}
log.info('config', 'Successfully configured OIDC authentication');
return oidcClient;
}

View File

@ -33,22 +33,21 @@ export function getRedirectUri(req: Request) {
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
token_endpoint_auth_method: string,
scope: string,
extra_params: Record<string, string> = {},
) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const params: Record<string, string> = {
...extra_params,
scope,
redirect_uri,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
token_endpoint_auth_method,
state: client.randomState(),
};
// PKCE is backwards compatible with non-PKCE servers
// so if we don't support it, just set our nonce
if (!config.serverMetadata().supportsPKCE()) {
params.nonce = client.randomNonce();
}

View File

@ -136,6 +136,18 @@ integration:
# (This is optional, but recommended for the best experience)
oidc:
issuer: "https://accounts.google.com"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see the documentation.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The client ID for the OIDC client
client_id: "your-client-id"
# The client secret for the OIDC client
@ -146,6 +158,9 @@ oidc:
# with systemd's `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# Defaults to 'openid email profile'
# scope: "openid email profile"
disable_api_key_login: false
token_endpoint_auth_method: "client_secret_post"
@ -166,3 +181,9 @@ oidc:
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google

View File

@ -28,7 +28,7 @@ in
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-Xi4IZEFFHx2Wp7r1qleGj8BHZOKLWsSfFT4UKbF+9Y8=";
hash = "sha256-mNVDgVfGNnBRtUVacVwjyww/XWqFTlayHtv6TLgXTT4=";
fetcherVersion = 1;
};

View File

@ -44,10 +44,10 @@
"dotenv": "17.2.1",
"drizzle-orm": "0.44.4",
"isbot": "5.1.30",
"jose": "6.0.13",
"jose": "6.1.0",
"lucide-react": "^0.540.0",
"mime": "^4.0.7",
"openid-client": "6.6.4",
"openid-client": "6.7.0",
"react": "19.1.1",
"react-aria": "3.42.0",
"react-codemirror-merge": "4.25.1",

67
pnpm-lock.yaml generated
View File

@ -98,8 +98,8 @@ importers:
specifier: 5.1.30
version: 5.1.30
jose:
specifier: 6.0.13
version: 6.0.13
specifier: 6.1.0
version: 6.1.0
lucide-react:
specifier: ^0.540.0
version: 0.540.0(react@19.1.1)
@ -107,8 +107,8 @@ importers:
specifier: ^4.0.7
version: 4.0.7
openid-client:
specifier: 6.6.4
version: 6.6.4
specifier: 6.7.0
version: 6.7.0
react:
specifier: 19.1.1
version: 19.1.1
@ -138,7 +138,7 @@ importers:
version: 3.40.0(react@19.1.1)
remix-utils:
specifier: ^8.8.0
version: 8.8.0(react-router@7.8.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)
version: 8.8.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(react-router@7.8.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)
tailwind-merge:
specifier: 3.3.1
version: 3.3.1
@ -1038,6 +1038,18 @@ packages:
resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@oxc-project/runtime@0.82.2':
resolution: {integrity: sha512-cYxcj5CPn/vo5QSpCZcYzBiLidU5+GlFSqIeNaMgBDtcVRBsBJHZg3pHw999W6nHamFQ1EHuPPByB26tjaJiJw==}
engines: {node: '>=6.9.0'}
@ -2898,8 +2910,8 @@ packages:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
jose@6.0.13:
resolution: {integrity: sha512-Yms4GpbmdANamS51kKK6w4hRlKx8KTxbWyAAKT/MhUMtqbIqh5mb2HjhTNUbk7TFL8/MBB5zWSDohL7ed4k/UA==}
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
@ -3232,8 +3244,8 @@ packages:
resolution: {integrity: sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
oauth4webapi@3.7.0:
resolution: {integrity: sha512-Q52wTPUWPsVLVVmTViXPQFMW2h2xv2jnDGxypjpelCFKaOjLsm7AxYuOk1oQgFm95VNDbuggasu9htXrz6XwKw==}
oauth4webapi@3.8.0:
resolution: {integrity: sha512-RSu64fjsBIs6D7s9g5LOCnOohOkI0nnPtlIp/4rrHj2Vb8jGepq+fujkv2Srw4tuw9qa02aETXQzyQUle8nfnQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -3244,8 +3256,8 @@ packages:
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
openid-client@6.6.4:
resolution: {integrity: sha512-PLWVhRksRnNH05sqeuCX/PR+1J70NyZcAcPske+FeF732KKONd3v0p5Utx1ro1iLfCglH8B3/+dA1vqIHDoIiA==}
openid-client@6.7.0:
resolution: {integrity: sha512-mA8Cex9wGK6Jaqq4vd1sPDK/a+cQ6G+lExVJ9IRjwInN4NcEEntYqLAZsBhZOyHwDx7V1nmPKOvnxOcdLtSpoA==}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -4775,7 +4787,7 @@ snapshots:
js-yaml: 4.1.0
jsonpath-plus: 10.3.0
node-fetch: 2.7.0
openid-client: 6.6.4
openid-client: 6.7.0
rfc4648: 1.5.4
socks-proxy-agent: 8.0.5
stream-buffers: 3.0.3
@ -4904,6 +4916,23 @@ snapshots:
dependencies:
which: 3.0.1
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
optional: true
'@oslojs/binary@1.0.0':
optional: true
'@oslojs/crypto@1.0.1':
dependencies:
'@oslojs/asn1': 1.0.0
'@oslojs/binary': 1.0.0
optional: true
'@oslojs/encoding@1.1.0':
optional: true
'@oxc-project/runtime@0.82.2': {}
'@oxc-project/types@0.82.2': {}
@ -7081,7 +7110,7 @@ snapshots:
jiti@2.5.1: {}
jose@6.0.13: {}
jose@6.1.0: {}
js-base64@3.7.7: {}
@ -7362,7 +7391,7 @@ snapshots:
npm-package-arg: 10.1.0
semver: 7.7.2
oauth4webapi@3.7.0: {}
oauth4webapi@3.8.0: {}
once@1.4.0:
dependencies:
@ -7376,10 +7405,10 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
openid-client@6.6.4:
openid-client@6.7.0:
dependencies:
jose: 6.0.13
oauth4webapi: 3.7.0
jose: 6.1.0
oauth4webapi: 3.8.0
package-json-from-dist@1.0.1: {}
@ -7666,10 +7695,12 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
remix-utils@8.8.0(react-router@7.8.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1):
remix-utils@8.8.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(react-router@7.8.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1):
dependencies:
type-fest: 4.41.0
optionalDependencies:
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
react: 19.1.1
react-router: 7.8.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)