feat: support oidc client_secret_path with env interpolation

This commit is contained in:
Aarnav Tale 2025-03-11 18:15:56 -04:00
parent 9c8a2c0120
commit 01f432cedc
No known key found for this signature in database
5 changed files with 116 additions and 16 deletions

View File

@ -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() {
</Button>
</Form>
) : undefined}
{data.oidc ? (
{data.oidc === true ? (
<Form method="POST">
{!data.apiKey ? (
<Card.Text className="mb-6">

View File

@ -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<AppContext['context']['oidc']>;
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');

View File

@ -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: "<your-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"

View File

@ -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 = {

View File

@ -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?',