feat: support oidc client_secret_path with env interpolation
This commit is contained in:
parent
9c8a2c0120
commit
01f432cedc
@ -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">
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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?',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user