diff --git a/app/routes/auth/login.tsx b/app/routes/auth/login.tsx index 107fd48..d7fa760 100644 --- a/app/routes/auth/login.tsx +++ b/app/routes/auth/login.tsx @@ -11,6 +11,7 @@ import Input from '~/components/Input'; import type { Key } from '~/types'; import { pull } from '~/utils/headscale'; import { noContext } from '~/utils/log'; +import { oidcEnabled } from '~/utils/oidc'; import { commitSession, getSession } from '~/utils/sessions.server'; import type { AppContext } from '~server/context/app'; @@ -33,12 +34,12 @@ export async function loader({ // Only set if OIDC is properly enabled anyways const ctx = context.context; - if (ctx.oidc?.disable_api_key_login) { + if (oidcEnabled() && ctx.oidc?.disable_api_key_login) { return redirect('/oidc/start'); } return { - oidc: ctx.oidc?.issuer, + oidc: oidcEnabled(), apiKey: !ctx.oidc?.disable_api_key_login, }; } @@ -132,7 +133,7 @@ export default function Page() { ) : undefined} - {data.oidc ? ( + {data.oidc === true ? (
{!data.apiKey ? ( diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index 8a69023..2b662c8 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -1,10 +1,15 @@ +import { readFile } from 'node:fs/promises'; import * as client from 'openid-client'; -import log from '~/utils/log'; import type { AppContext } from '~server/context/app'; +import log from '~server/utils/log'; type OidcConfig = NonNullable; declare global { const __PREFIX__: string; + const __oidc_context: { + valid: boolean; + secret: string; + }; } // We try our best to infer the callback URI of our Headplane instance @@ -35,6 +40,57 @@ export function getRedirectUri(req: Request) { return url.href; } +let oidcSecret: string | undefined = undefined; +export function getOidcSecret() { + return oidcSecret; +} + +async function resolveClientSecret(oidc: OidcConfig) { + if (!oidc.client_secret && !oidc.client_secret_path) { + return; + } + + if (oidc.client_secret_path) { + // We need to interpolate environment variables into the path + // Path formatting can be like ${ENV_NAME}/path/to/secret + let path = oidc.client_secret_path; + const matches = path.match(/\${(.*?)}/g); + + if (matches) { + for (const match of matches) { + const env = match.slice(2, -1); + const value = process.env[env]; + if (!value) { + log.error('CFGX', 'Environment variable %s is not set', env); + return; + } + + log.debug('CFGX', 'Interpolating %s with %s', match, value); + path = path.replace(match, value); + } + } + + try { + log.debug('CFGX', 'Reading client secret from %s', path); + const secret = await readFile(path, 'utf-8'); + if (secret.trim().length === 0) { + log.error('CFGX', 'Empty OIDC client secret'); + return; + } + + oidcSecret = secret; + } catch (error) { + log.error('CFGX', 'Failed to read client secret from %s', path); + log.error('CFGX', 'Error: %s', error); + log.debug('CFGX', 'Error details: %o', error); + } + } + + if (oidc.client_secret) { + oidcSecret = oidc.client_secret; + } +} + function clientAuthMethod( method: string, ): (secret: string) => client.ClientAuth { @@ -55,7 +111,7 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) { new URL(oidc.issuer), oidc.client_id, oidc.client_secret, - clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret), + clientAuthMethod(oidc.token_endpoint_auth_method)(__oidc_context.secret), ); const codeVerifier = client.randomPKCECodeVerifier(); @@ -97,7 +153,7 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) { new URL(oidc.issuer), oidc.client_id, oidc.client_secret, - clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret), + clientAuthMethod(oidc.token_endpoint_auth_method)(__oidc_context.secret), ); let subject: string; @@ -203,13 +259,27 @@ export function formatError(error: unknown) { }; } +export function oidcEnabled() { + return __oidc_valid; +} + export async function testOidc(oidc: OidcConfig) { + await resolveClientSecret(oidc); + if (!oidcSecret) { + log.debug( + 'OIDC', + 'Cannot validate OIDC configuration without a client secret', + ); + return false; + } + log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer); + const secret = await resolveClientSecret(oidc); const config = await client.discovery( new URL(oidc.issuer), oidc.client_id, oidc.client_secret, - clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret), + clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret), ); const meta = config.serverMetadata(); @@ -240,13 +310,9 @@ export async function testOidc(oidc: OidcConfig) { 'OIDC server does not support %s', oidc.token_endpoint_auth_method, ); + return false; } - } else { - log.warn( - 'OIDC', - 'OIDC server does not advertise token_endpoint_auth_methods_supported', - ); } log.debug('OIDC', 'OIDC configuration is valid'); diff --git a/config.example.yaml b/config.example.yaml index 4aeff9c..df4ac7a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -73,7 +73,15 @@ integration: oidc: issuer: "https://accounts.google.com" client_id: "your-client-id" + + # The client secret for the OIDC client + # Either this or `client_secret_path` must be set for OIDC to work client_secret: "" + # You can alternatively set `client_secret_path` to read the secret from disk. + # The path specified can resolve environment variables, making integration + # with systemd's `LoadCredential` straightforward: + # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" + disable_api_key_login: false token_endpoint_auth_method: "client_secret_post" diff --git a/server/context/loader.ts b/server/context/loader.ts index 91fcadf..f97b703 100644 --- a/server/context/loader.ts +++ b/server/context/loader.ts @@ -3,7 +3,7 @@ import { env } from 'node:process'; import { type } from 'arktype'; import dotenv from 'dotenv'; import { parseDocument } from 'yaml'; -import { testOidc } from '~/utils/oidc'; +import { getOidcSecret, testOidc } from '~/utils/oidc'; import log, { hpServer_loadLogger } from '~server/utils/log'; import mutex from '~server/utils/mutex'; import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser'; @@ -20,6 +20,11 @@ declare namespace globalThis { config_strict?: boolean; }; + let __oidc_context: { + valid: boolean; + secret: string; + }; + let __integration_context: HeadplaneConfig['integration']; } @@ -113,8 +118,27 @@ export async function hp_loadConfig() { process.exit(1); } - if (config.oidc?.strict_validation) { - testOidc(config.oidc); + // OIDC Related Checks + if (config.oidc) { + if (!config.oidc.client_secret && !config.oidc.client_secret_path) { + log.error('CFGX', 'OIDC configuration is missing a secret, disabling'); + log.error( + 'CFGX', + 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`', + ); + } + + if (config.oidc?.strict_validation) { + const result = await testOidc(config.oidc); + if (!result) { + log.error('CFGX', 'OIDC configuration failed validation, disabling'); + } + + globalThis.__oidc_context = { + valid: result, + secret: getOidcSecret() ?? '', + }; + } } globalThis.__cookie_context = { diff --git a/server/context/parser.ts b/server/context/parser.ts index 303c6e7..b324990 100644 --- a/server/context/parser.ts +++ b/server/context/parser.ts @@ -24,7 +24,8 @@ const serverConfig = type({ const oidcConfig = type({ issuer: 'string.url', client_id: 'string', - client_secret: 'string', + client_secret: 'string?', + client_secret_path: 'string?', token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"', redirect_uri: 'string.url?',