diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ada15..024ebc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index 2b8d056..0a19fe0 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -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, { diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index 409bb1b..7c08629 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -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?', + authorization_endpoint: 'string.url?', + token_endpoint: 'string.url?', + userinfo_endpoint: 'string.url?', }) .narrow((obj: Record, 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?', + authorization_endpoint: 'string.url?', + token_endpoint: 'string.url?', + userinfo_endpoint: 'string.url?', }); const headscaleConfig = type({ diff --git a/app/server/index.ts b/app/server/index.ts index 12d18ee..257d36d 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -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, }; diff --git a/app/server/web/oidc.ts b/app/server/web/oidc.ts index 7d566bd..d3b5424 100644 --- a/app/server/web/oidc.ts +++ b/app/server/web/oidc.ts @@ -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; - 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, -) { - // 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; } diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index ed31162..70e772e 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -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 = {}, ) { const codeVerifier = client.randomPKCECodeVerifier(); const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); const params: Record = { + ...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(); } diff --git a/config.example.yaml b/config.example.yaml index 659e6c5..249e9d9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/nix/package.nix b/nix/package.nix index 657384e..bd96504 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -28,7 +28,7 @@ in pnpmDeps = pnpm_10.fetchDeps { inherit (finalAttrs) pname version src; - hash = "sha256-Xi4IZEFFHx2Wp7r1qleGj8BHZOKLWsSfFT4UKbF+9Y8="; + hash = "sha256-mNVDgVfGNnBRtUVacVwjyww/XWqFTlayHtv6TLgXTT4="; fetcherVersion = 1; }; diff --git a/package.json b/package.json index db2b8d9..cfe3a58 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f003445..a55783f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)