add oidc.enabled flag for helm and config generation

allows defining oidc config without enabling it
This commit is contained in:
drifterza
2026-02-24 02:07:44 +02:00
parent 9183ec2942
commit 30dd718d68
4 changed files with 362 additions and 201 deletions
+140 -149
View File
@@ -1,206 +1,197 @@
import { type } from 'arktype'; import { type } from "arktype";
import log from '~/utils/log';
import DockerIntegration from './integration/docker'; import log from "~/utils/log";
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc'; import DockerIntegration from "./integration/docker";
import { deprecatedField } from './utils'; import KubernetesIntegration from "./integration/kubernetes";
import ProcIntegration from "./integration/proc";
import { deprecatedField } from "./utils";
export const pathSupportedKeys = [ export const pathSupportedKeys = [
'server.cookie_secret', "server.cookie_secret",
'oidc.client_secret', "oidc.client_secret",
'oidc.headscale_api_key', "oidc.headscale_api_key",
'integration.agent.pre_authkey', "integration.agent.pre_authkey",
] as const; ] as const;
const serverConfig = type({ const serverConfig = type({
host: 'string.ip = "0.0.0.0"', host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000', port: "number.integer = 3000",
base_url: 'string.url?', base_url: "string.url?",
data_path: 'string.lower = "/var/lib/headplane/"', data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: 'string?', info_secret: "string?",
cookie_secret: '(32 <= string <= 32)', cookie_secret: "(32 <= string <= 32)",
cookie_secure: 'boolean = true', cookie_secure: "boolean = true",
cookie_domain: 'string.lower?', cookie_domain: "string.lower?",
cookie_max_age: 'number.integer = 86400', cookie_max_age: "number.integer = 86400",
}); });
const partialServerConfig = type({ const partialServerConfig = type({
host: 'string.ip?', host: "string.ip?",
port: 'number.integer?', port: "number.integer?",
base_url: 'string.url?', base_url: "string.url?",
data_path: 'string.lower?', data_path: "string.lower?",
info_secret: 'string?', info_secret: "string?",
cookie_secret: '(32 <= string <= 32)?', cookie_secret: "(32 <= string <= 32)?",
cookie_secure: 'boolean?', cookie_secure: "boolean?",
cookie_domain: 'string.lower?', cookie_domain: "string.lower?",
cookie_max_age: 'number.integer?', cookie_max_age: "number.integer?",
}); });
const headscaleConfig = type({ const headscaleConfig = type({
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), url: type("string.url").pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)),
public_url: type('string.url') public_url: type("string.url")
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(), .optional(),
config_path: 'string.lower?', config_path: "string.lower?",
config_strict: 'boolean = true', config_strict: "boolean = true",
dns_records_path: 'string.lower?', dns_records_path: "string.lower?",
tls_cert_path: 'string.lower?', tls_cert_path: "string.lower?",
}); });
const partialHeadscaleConfig = type({ const partialHeadscaleConfig = type({
url: type('string.url') url: type("string.url")
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(), .optional(),
public_url: type('string.url') public_url: type("string.url")
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(), .optional(),
config_path: 'string.lower?', config_path: "string.lower?",
config_strict: 'boolean?', config_strict: "boolean?",
dns_records_path: 'string.lower?', dns_records_path: "string.lower?",
tls_cert_path: 'string.lower?', tls_cert_path: "string.lower?",
}); });
const oidcConfig = type({ const oidcConfig = type({
issuer: 'string.url', enabled: "boolean = true",
client_id: 'string', issuer: "string.url",
client_secret: 'string', client_id: "string",
headscale_api_key: 'string', client_secret: "string",
use_pkce: 'boolean = false', headscale_api_key: "string",
redirect_uri: type('string.url') use_pkce: "boolean = false",
.pipe((value, ctx) => { redirect_uri: type("string.url")
log.warn( .pipe((value, ctx) => {
'config', log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString);
'%s is deprecated and will be removed in 0.7.0',
ctx.propString,
);
const cleanedValue = new URL(value.trim()); const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) { if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace( cleanedValue.pathname = cleanedValue.pathname.replace(`${__PREFIX__}/oidc/callback`, "/");
`${__PREFIX__}/oidc/callback`,
'/',
);
log.warn( log.warn(
'config', "config",
'Please migrate to using `server.base_url` with a value of "%s"', 'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(), cleanedValue.toString(),
); );
} }
return cleanedValue.toString(); return cleanedValue.toString();
}) })
.optional(), .optional(),
disable_api_key_login: 'boolean = false', disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"', scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"', profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: 'Record<string, string>?', extra_params: "Record<string, string>?",
authorization_endpoint: 'string.url?', authorization_endpoint: "string.url?",
token_endpoint: 'string.url?', token_endpoint: "string.url?",
userinfo_endpoint: 'string.url?', userinfo_endpoint: "string.url?",
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"?',
// Old/deprecated options // Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"', user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type('unknown').narrow(deprecatedField()).optional(), strict_validation: type("unknown").narrow(deprecatedField()).optional(),
}); });
const partialOidcConfig = type({ const partialOidcConfig = type({
issuer: 'string.url?', enabled: "boolean?",
client_id: 'string?', issuer: "string.url?",
client_secret: 'string?', client_id: "string?",
use_pkce: 'boolean?', client_secret: "string?",
headscale_api_key: 'string?', use_pkce: "boolean?",
redirect_uri: 'string.url?', headscale_api_key: "string?",
disable_api_key_login: 'boolean?', redirect_uri: "string.url?",
scope: 'string?', disable_api_key_login: "boolean?",
extra_params: 'Record<string, string>?', scope: "string?",
profile_picture_source: '"oidc" | "gravatar"?', extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
authorization_endpoint: 'string.url?', authorization_endpoint: "string.url?",
token_endpoint: 'string.url?', token_endpoint: "string.url?",
userinfo_endpoint: 'string.url?', userinfo_endpoint: "string.url?",
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"?',
// Old/deprecated options // Old/deprecated options
user_storage_file: 'string.lower?', user_storage_file: "string.lower?",
strict_validation: type('unknown').narrow(deprecatedField()).optional(), strict_validation: type("unknown").narrow(deprecatedField()).optional(),
}); });
const agentConfig = type({ const agentConfig = type({
enabled: 'boolean', enabled: "boolean",
host_name: 'string = "headplane-agent"', host_name: 'string = "headplane-agent"',
pre_authkey: 'string', pre_authkey: "string",
cache_ttl: 'number.integer = 180000', cache_ttl: "number.integer = 180000",
cache_path: 'string = "/var/lib/headplane/agent_cache.json"', cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"', executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"', work_dir: 'string = "/var/lib/headplane/agent"',
}); });
const partialAgentConfig = type({ const partialAgentConfig = type({
enabled: 'boolean?', enabled: "boolean?",
host_name: 'string?', host_name: "string?",
pre_authkey: 'string?', pre_authkey: "string?",
cache_ttl: 'number.integer?', cache_ttl: "number.integer?",
cache_path: 'string?', cache_path: "string?",
executable_path: 'string?', executable_path: "string?",
work_dir: 'string?', work_dir: "string?",
}); });
const integrationConfig = type({ const integrationConfig = type({
docker: DockerIntegration.configSchema.full, docker: DockerIntegration.configSchema.full,
kubernetes: KubernetesIntegration.configSchema.full, kubernetes: KubernetesIntegration.configSchema.full,
proc: ProcIntegration.configSchema.full, proc: ProcIntegration.configSchema.full,
agent: agentConfig.optional(), agent: agentConfig.optional(),
}).partial(); }).partial();
export const partialIntegrationConfig = type({ export const partialIntegrationConfig = type({
docker: DockerIntegration.configSchema.partial, docker: DockerIntegration.configSchema.partial,
kubernetes: KubernetesIntegration.configSchema.partial, kubernetes: KubernetesIntegration.configSchema.partial,
proc: ProcIntegration.configSchema.partial, proc: ProcIntegration.configSchema.partial,
agent: partialAgentConfig.optional(), agent: partialAgentConfig.optional(),
}).partial(); }).partial();
export const headplaneConfig = type({ export const headplaneConfig = type({
debug: 'boolean = false', debug: "boolean = false",
server: serverConfig, server: serverConfig,
headscale: headscaleConfig, headscale: headscaleConfig,
oidc: oidcConfig.optional(), oidc: oidcConfig.optional(),
integration: integrationConfig.optional(), integration: integrationConfig.optional(),
}).onDeepUndeclaredKey('delete'); }).onDeepUndeclaredKey("delete");
export const partialHeadplaneConfig = type({ export const partialHeadplaneConfig = type({
debug: 'boolean?', debug: "boolean?",
server: partialServerConfig.optional(), server: partialServerConfig.optional(),
headscale: partialHeadscaleConfig.optional(), headscale: partialHeadscaleConfig.optional(),
oidc: partialOidcConfig.optional(), oidc: partialOidcConfig.optional(),
integration: partialIntegrationConfig.optional(), integration: partialIntegrationConfig.optional(),
}); });
export type HeadplaneConfig = typeof headplaneConfig.infer; export type HeadplaneConfig = typeof headplaneConfig.infer;
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer; export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
type DotNotationToObjects< type DotNotationToObjects<T extends string, V> = T extends `${infer K}.${infer Rest}`
T extends string, ? { [P in K]?: DotNotationToObjects<Rest, V> }
V, : { [P in `${T}_path`]?: V };
> = T extends `${infer K}.${infer Rest}`
? { [P in K]?: DotNotationToObjects<Rest, V> }
: { [P in `${T}_path`]?: V };
type ObjectDeepMerge<T> = T extends object type ObjectDeepMerge<T> = T extends object
? { ? {
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K]; [K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
} }
: T; : T;
type ConfigWithPathKeys = ObjectDeepMerge< type ConfigWithPathKeys = ObjectDeepMerge<
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined> DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
>; >;
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;
ConfigWithPathKeys;
+8 -7
View File
@@ -76,13 +76,14 @@ const appLoadContext = {
hsApi, hsApi,
agents, agents,
integration: await loadIntegration(config.integration), integration: await loadIntegration(config.integration),
oidcConnector: config.oidc oidcConnector:
? createLazyOidcConnector( config.oidc && config.oidc.enabled !== false
config.server.base_url, ? createLazyOidcConnector(
config.oidc, config.server.base_url,
hsApi.getRuntimeClient(config.oidc.headscale_api_key), config.oidc,
) hsApi.getRuntimeClient(config.oidc.headscale_api_key),
: undefined, )
: undefined,
db, db,
}; };
+49 -45
View File
@@ -167,58 +167,62 @@ integration:
# OIDC Configuration for simpler authentication # OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience) # (This is optional, but recommended for the best experience)
# oidc: # oidc:
# The OIDC issuer URL # Set to false to define OIDC config without enabling it.
# issuer: "https://accounts.google.com" # Useful for Helm charts or generating docs from config files.
# enabled: true
# If you are using OIDC, you need to generate an API key # The OIDC issuer URL
# that can be used to authenticate other sessions when signing in. # issuer: "https://accounts.google.com"
#
# This can be done with `headscale apikeys create --expiration 999d`
# headscale_api_key: "<your-headscale-api-key>"
# If your OIDC provider does not support discovery (does not have the URL at # If you are using OIDC, you need to generate an API key
# `/.well-known/openid-configuration`), you need to manually set endpoints. # that can be used to authenticate other sessions when signing in.
# This also works to override endpoints if you so desire or if your OIDC #
# discovery is missing certain endpoints (ie GitHub). # This can be done with `headscale apikeys create --expiration 999d`
# For some typical providers, see https://headplane.net/features/sso. # headscale_api_key: "<your-headscale-api-key>"
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint. # If your OIDC provider does not support discovery (does not have the URL at
# This is fully optional and Headplane will attempt to auto-detect the best # `/.well-known/openid-configuration`), you need to manually set endpoints.
# method and fall back to `client_secret_basic` if unsure. # This also works to override endpoints if you so desire or if your OIDC
# token_endpoint_auth_method: "client_secret_post" # discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The client ID for the OIDC client # The authentication method to use when communicating with the token endpoint.
# For the best experience please ensure this is *identical* to the client_id # This is fully optional and Headplane will attempt to auto-detect the best
# you are using for Headscale. because # method and fall back to `client_secret_basic` if unsure.
# client_id: "your-client-id" # token_endpoint_auth_method: "client_secret_post"
# The client secret for the OIDC client # The client ID for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk. # For the best experience please ensure this is *identical* to the client_id
# See https://headplane.net/configuration/#sensitive-values # you are using for Headscale. because
# client_secret: "<your-client-secret>" # client_id: "your-client-id"
# Whether to use PKCE when authenticating users. This is recommended as it # The client secret for the OIDC client
# adds an extra layer of security to the authentication process. Enabling this # You may also provide `client_secret_path` instead to read a value from disk.
# means your OIDC provider must support PKCE and it must be enabled on the # See https://headplane.net/configuration/#sensitive-values
# client. # client_secret: "<your-client-secret>"
# use_pkce: true
# If you want to disable traditional login via Headscale API keys # Whether to use PKCE when authenticating users. This is recommended as it
# disable_api_key_login: false # adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# By default profile pictures are pulled from the OIDC provider when # If you want to disable traditional login via Headscale API keys
# we go to fetch the userinfo endpoint. Optionally, this can be set to # disable_api_key_login: false
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# The scopes to request when authenticating users. The default is below. # By default profile pictures are pulled from the OIDC provider when
# scope: "openid email profile" # 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 # The scopes to request when authenticating users. The default is below.
# by setting them here. This is useful for providers that require any kind # scope: "openid email profile"
# of custom hinting.
# extra_params: # Extra query parameters can be passed to the authorization endpoint
# prompt: "select_account" # Example: force account selection on Google # 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
+165
View File
@@ -0,0 +1,165 @@
import { dump } from "js-yaml";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { loadConfig, loadConfigEnv, loadConfigFile } from "~/server/config/load";
import { clearFakeFiles, createFakeFile } from "../setup/overlay-fs";
const writeYaml = (filePath: string, content: unknown) => {
const yamlContent = dump(content);
createFakeFile(filePath, yamlContent);
};
const baseConfig = {
headscale: {
url: "http://localhost:8080",
},
server: {
cookie_secret: "thirtytwo-character-cookiesecret",
},
};
const fullOidcConfig = {
enabled: true,
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
};
describe("OIDC enabled configuration", () => {
beforeAll(() => {
clearFakeFiles();
});
test("oidc.enabled defaults to true when oidc section is present", async () => {
const filePath = "/config/oidc-default-enabled.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(true);
});
test("oidc.enabled can be explicitly set to true", async () => {
const filePath = "/config/oidc-explicit-true.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: fullOidcConfig,
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(true);
});
test("oidc.enabled can be set to false to disable OIDC", async () => {
const filePath = "/config/oidc-disabled.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
...fullOidcConfig,
enabled: false,
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
});
test("oidc section can be defined with enabled: false for templating purposes", async () => {
const filePath = "/config/oidc-templating.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
enabled: false,
issuer: "https://example.com",
client_id: "placeholder-client-id",
client_secret: "placeholder-client-secret",
headscale_api_key: "placeholder-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
expect(config.oidc?.issuer).toBe("https://example.com");
expect(config.oidc?.client_id).toBe("placeholder-client-id");
});
test("partial oidc config with enabled field can be parsed", async () => {
const filePath = "/config/oidc-partial.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
enabled: false,
},
});
// This should parse without error at the partial config level
const partialConfig = await loadConfigFile(filePath);
expect(partialConfig?.oidc?.enabled).toBe(false);
});
test("config without oidc section has undefined oidc", async () => {
const filePath = "/config/no-oidc.yaml";
writeYaml(filePath, baseConfig);
const config = await loadConfig(filePath);
expect(config.oidc).toBeUndefined();
});
});
// Environment variable tests for oidc.enabled
const envVarSnapshot = { ...process.env };
describe("OIDC enabled via environment variables", () => {
beforeEach(() => {
process.env = { ...envVarSnapshot };
});
test("oidc.enabled can be set via HEADPLANE_OIDC__ENABLED env var", async () => {
process.env.HEADPLANE_OIDC__ENABLED = "true";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
const config = await loadConfigEnv();
expect(config?.oidc?.enabled).toBe(true);
expect(config?.oidc?.issuer).toBe("https://accounts.google.com");
});
test("oidc.enabled=false can be set via env var", async () => {
process.env.HEADPLANE_OIDC__ENABLED = "false";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
const config = await loadConfigEnv();
expect(config?.oidc?.enabled).toBe(false);
expect(config?.oidc?.issuer).toBe("https://accounts.google.com");
});
test("oidc.enabled can be set via env var to disable full OIDC config", async () => {
process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080";
process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret";
process.env.HEADPLANE_OIDC__ENABLED = "false";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
process.env.HEADPLANE_OIDC__CLIENT_SECRET = "my-client-secret";
process.env.HEADPLANE_OIDC__HEADSCALE_API_KEY = "my-api-key";
const config = await loadConfig("./non-existent-path.yaml");
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
// All other OIDC fields should still be present
expect(config.oidc?.issuer).toBe("https://accounts.google.com");
expect(config.oidc?.client_id).toBe("my-client-id");
});
});