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 type { Key } from '~/types';
|
||||||
import { pull } from '~/utils/headscale';
|
import { pull } from '~/utils/headscale';
|
||||||
import { noContext } from '~/utils/log';
|
import { noContext } from '~/utils/log';
|
||||||
|
import { oidcEnabled } from '~/utils/oidc';
|
||||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||||
import type { AppContext } from '~server/context/app';
|
import type { AppContext } from '~server/context/app';
|
||||||
|
|
||||||
@ -33,12 +34,12 @@ export async function loader({
|
|||||||
|
|
||||||
// Only set if OIDC is properly enabled anyways
|
// Only set if OIDC is properly enabled anyways
|
||||||
const ctx = context.context;
|
const ctx = context.context;
|
||||||
if (ctx.oidc?.disable_api_key_login) {
|
if (oidcEnabled() && ctx.oidc?.disable_api_key_login) {
|
||||||
return redirect('/oidc/start');
|
return redirect('/oidc/start');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oidc: ctx.oidc?.issuer,
|
oidc: oidcEnabled(),
|
||||||
apiKey: !ctx.oidc?.disable_api_key_login,
|
apiKey: !ctx.oidc?.disable_api_key_login,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -132,7 +133,7 @@ export default function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{data.oidc ? (
|
{data.oidc === true ? (
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
{!data.apiKey ? (
|
{!data.apiKey ? (
|
||||||
<Card.Text className="mb-6">
|
<Card.Text className="mb-6">
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import * as client from 'openid-client';
|
import * as client from 'openid-client';
|
||||||
import log from '~/utils/log';
|
|
||||||
import type { AppContext } from '~server/context/app';
|
import type { AppContext } from '~server/context/app';
|
||||||
|
import log from '~server/utils/log';
|
||||||
|
|
||||||
type OidcConfig = NonNullable<AppContext['context']['oidc']>;
|
type OidcConfig = NonNullable<AppContext['context']['oidc']>;
|
||||||
declare global {
|
declare global {
|
||||||
const __PREFIX__: string;
|
const __PREFIX__: string;
|
||||||
|
const __oidc_context: {
|
||||||
|
valid: boolean;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// We try our best to infer the callback URI of our Headplane instance
|
// 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;
|
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(
|
function clientAuthMethod(
|
||||||
method: string,
|
method: string,
|
||||||
): (secret: string) => client.ClientAuth {
|
): (secret: string) => client.ClientAuth {
|
||||||
@ -55,7 +111,7 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
|||||||
new URL(oidc.issuer),
|
new URL(oidc.issuer),
|
||||||
oidc.client_id,
|
oidc.client_id,
|
||||||
oidc.client_secret,
|
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();
|
const codeVerifier = client.randomPKCECodeVerifier();
|
||||||
@ -97,7 +153,7 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
|
|||||||
new URL(oidc.issuer),
|
new URL(oidc.issuer),
|
||||||
oidc.client_id,
|
oidc.client_id,
|
||||||
oidc.client_secret,
|
oidc.client_secret,
|
||||||
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret),
|
clientAuthMethod(oidc.token_endpoint_auth_method)(__oidc_context.secret),
|
||||||
);
|
);
|
||||||
|
|
||||||
let subject: string;
|
let subject: string;
|
||||||
@ -203,13 +259,27 @@ export function formatError(error: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function oidcEnabled() {
|
||||||
|
return __oidc_valid;
|
||||||
|
}
|
||||||
|
|
||||||
export async function testOidc(oidc: OidcConfig) {
|
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);
|
log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
|
||||||
|
const secret = await resolveClientSecret(oidc);
|
||||||
const config = await client.discovery(
|
const config = await client.discovery(
|
||||||
new URL(oidc.issuer),
|
new URL(oidc.issuer),
|
||||||
oidc.client_id,
|
oidc.client_id,
|
||||||
oidc.client_secret,
|
oidc.client_secret,
|
||||||
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret),
|
clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret),
|
||||||
);
|
);
|
||||||
|
|
||||||
const meta = config.serverMetadata();
|
const meta = config.serverMetadata();
|
||||||
@ -240,13 +310,9 @@ export async function testOidc(oidc: OidcConfig) {
|
|||||||
'OIDC server does not support %s',
|
'OIDC server does not support %s',
|
||||||
oidc.token_endpoint_auth_method,
|
oidc.token_endpoint_auth_method,
|
||||||
);
|
);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.warn(
|
|
||||||
'OIDC',
|
|
||||||
'OIDC server does not advertise token_endpoint_auth_methods_supported',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('OIDC', 'OIDC configuration is valid');
|
log.debug('OIDC', 'OIDC configuration is valid');
|
||||||
|
|||||||
@ -73,7 +73,15 @@ integration:
|
|||||||
oidc:
|
oidc:
|
||||||
issuer: "https://accounts.google.com"
|
issuer: "https://accounts.google.com"
|
||||||
client_id: "your-client-id"
|
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>"
|
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
|
disable_api_key_login: false
|
||||||
token_endpoint_auth_method: "client_secret_post"
|
token_endpoint_auth_method: "client_secret_post"
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { env } from 'node:process';
|
|||||||
import { type } from 'arktype';
|
import { type } from 'arktype';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { parseDocument } from 'yaml';
|
import { parseDocument } from 'yaml';
|
||||||
import { testOidc } from '~/utils/oidc';
|
import { getOidcSecret, testOidc } from '~/utils/oidc';
|
||||||
import log, { hpServer_loadLogger } from '~server/utils/log';
|
import log, { hpServer_loadLogger } from '~server/utils/log';
|
||||||
import mutex from '~server/utils/mutex';
|
import mutex from '~server/utils/mutex';
|
||||||
import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser';
|
import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser';
|
||||||
@ -20,6 +20,11 @@ declare namespace globalThis {
|
|||||||
config_strict?: boolean;
|
config_strict?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let __oidc_context: {
|
||||||
|
valid: boolean;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
|
||||||
let __integration_context: HeadplaneConfig['integration'];
|
let __integration_context: HeadplaneConfig['integration'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,8 +118,27 @@ export async function hp_loadConfig() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.oidc?.strict_validation) {
|
// OIDC Related Checks
|
||||||
testOidc(config.oidc);
|
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 = {
|
globalThis.__cookie_context = {
|
||||||
|
|||||||
@ -24,7 +24,8 @@ const serverConfig = type({
|
|||||||
const oidcConfig = type({
|
const oidcConfig = type({
|
||||||
issuer: 'string.url',
|
issuer: 'string.url',
|
||||||
client_id: 'string',
|
client_id: 'string',
|
||||||
client_secret: 'string',
|
client_secret: 'string?',
|
||||||
|
client_secret_path: 'string?',
|
||||||
token_endpoint_auth_method:
|
token_endpoint_auth_method:
|
||||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
|
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
|
||||||
redirect_uri: 'string.url?',
|
redirect_uri: 'string.url?',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user