diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index bc3e79b..6e0875b 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -1,206 +1,197 @@ -import { type } from 'arktype'; -import log from '~/utils/log'; -import DockerIntegration from './integration/docker'; -import KubernetesIntegration from './integration/kubernetes'; -import ProcIntegration from './integration/proc'; -import { deprecatedField } from './utils'; +import { type } from "arktype"; + +import log from "~/utils/log"; + +import DockerIntegration from "./integration/docker"; +import KubernetesIntegration from "./integration/kubernetes"; +import ProcIntegration from "./integration/proc"; +import { deprecatedField } from "./utils"; export const pathSupportedKeys = [ - 'server.cookie_secret', - 'oidc.client_secret', - 'oidc.headscale_api_key', - 'integration.agent.pre_authkey', + "server.cookie_secret", + "oidc.client_secret", + "oidc.headscale_api_key", + "integration.agent.pre_authkey", ] as const; const serverConfig = type({ - host: 'string.ip = "0.0.0.0"', - port: 'number.integer = 3000', - base_url: 'string.url?', - data_path: 'string.lower = "/var/lib/headplane/"', - info_secret: 'string?', + host: 'string.ip = "0.0.0.0"', + port: "number.integer = 3000", + base_url: "string.url?", + data_path: 'string.lower = "/var/lib/headplane/"', + info_secret: "string?", - cookie_secret: '(32 <= string <= 32)', - cookie_secure: 'boolean = true', - cookie_domain: 'string.lower?', - cookie_max_age: 'number.integer = 86400', + cookie_secret: "(32 <= string <= 32)", + cookie_secure: "boolean = true", + cookie_domain: "string.lower?", + cookie_max_age: "number.integer = 86400", }); const partialServerConfig = type({ - host: 'string.ip?', - port: 'number.integer?', - base_url: 'string.url?', - data_path: 'string.lower?', - info_secret: 'string?', + host: "string.ip?", + port: "number.integer?", + base_url: "string.url?", + data_path: "string.lower?", + info_secret: "string?", - cookie_secret: '(32 <= string <= 32)?', - cookie_secure: 'boolean?', - cookie_domain: 'string.lower?', - cookie_max_age: 'number.integer?', + cookie_secret: "(32 <= string <= 32)?", + cookie_secure: "boolean?", + cookie_domain: "string.lower?", + cookie_max_age: "number.integer?", }); const headscaleConfig = type({ - url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), - public_url: type('string.url') - .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) - .optional(), - config_path: 'string.lower?', - config_strict: 'boolean = true', - dns_records_path: 'string.lower?', - tls_cert_path: 'string.lower?', + url: type("string.url").pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)), + public_url: type("string.url") + .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) + .optional(), + config_path: "string.lower?", + config_strict: "boolean = true", + dns_records_path: "string.lower?", + tls_cert_path: "string.lower?", }); const partialHeadscaleConfig = type({ - url: type('string.url') - .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) - .optional(), - public_url: type('string.url') - .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) - .optional(), - config_path: 'string.lower?', - config_strict: 'boolean?', - dns_records_path: 'string.lower?', - tls_cert_path: 'string.lower?', + url: type("string.url") + .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) + .optional(), + public_url: type("string.url") + .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) + .optional(), + config_path: "string.lower?", + config_strict: "boolean?", + dns_records_path: "string.lower?", + tls_cert_path: "string.lower?", }); const oidcConfig = type({ - issuer: 'string.url', - client_id: 'string', - client_secret: 'string', - headscale_api_key: 'string', - use_pkce: 'boolean = false', - redirect_uri: type('string.url') - .pipe((value, ctx) => { - log.warn( - 'config', - '%s is deprecated and will be removed in 0.7.0', - ctx.propString, - ); + enabled: "boolean = true", + issuer: "string.url", + client_id: "string", + client_secret: "string", + headscale_api_key: "string", + use_pkce: "boolean = false", + redirect_uri: type("string.url") + .pipe((value, ctx) => { + log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString); - const cleanedValue = new URL(value.trim()); - if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) { - cleanedValue.pathname = cleanedValue.pathname.replace( - `${__PREFIX__}/oidc/callback`, - '/', - ); + const cleanedValue = new URL(value.trim()); + if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) { + cleanedValue.pathname = cleanedValue.pathname.replace(`${__PREFIX__}/oidc/callback`, "/"); - log.warn( - 'config', - 'Please migrate to using `server.base_url` with a value of "%s"', - cleanedValue.toString(), - ); - } + log.warn( + "config", + 'Please migrate to using `server.base_url` with a value of "%s"', + cleanedValue.toString(), + ); + } - return cleanedValue.toString(); - }) - .optional(), - disable_api_key_login: 'boolean = false', - scope: 'string = "openid email profile"', - profile_picture_source: '"oidc" | "gravatar" = "oidc"', - extra_params: 'Record?', + return cleanedValue.toString(); + }) + .optional(), + disable_api_key_login: "boolean = false", + scope: 'string = "openid email profile"', + profile_picture_source: '"oidc" | "gravatar" = "oidc"', + extra_params: "Record?", - authorization_endpoint: 'string.url?', - token_endpoint: 'string.url?', - userinfo_endpoint: 'string.url?', - token_endpoint_auth_method: - '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', + authorization_endpoint: "string.url?", + token_endpoint: "string.url?", + userinfo_endpoint: "string.url?", + token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', - // Old/deprecated options - user_storage_file: 'string.lower = "/var/lib/headplane/users.json"', - strict_validation: type('unknown').narrow(deprecatedField()).optional(), + // Old/deprecated options + user_storage_file: 'string.lower = "/var/lib/headplane/users.json"', + strict_validation: type("unknown").narrow(deprecatedField()).optional(), }); const partialOidcConfig = type({ - issuer: 'string.url?', - client_id: 'string?', - client_secret: 'string?', - use_pkce: 'boolean?', - headscale_api_key: 'string?', - redirect_uri: 'string.url?', - disable_api_key_login: 'boolean?', - scope: 'string?', - extra_params: 'Record?', - profile_picture_source: '"oidc" | "gravatar"?', + enabled: "boolean?", + issuer: "string.url?", + client_id: "string?", + client_secret: "string?", + use_pkce: "boolean?", + headscale_api_key: "string?", + redirect_uri: "string.url?", + disable_api_key_login: "boolean?", + scope: "string?", + extra_params: "Record?", + profile_picture_source: '"oidc" | "gravatar"?', - authorization_endpoint: 'string.url?', - token_endpoint: 'string.url?', - userinfo_endpoint: 'string.url?', - token_endpoint_auth_method: - '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', + authorization_endpoint: "string.url?", + token_endpoint: "string.url?", + userinfo_endpoint: "string.url?", + token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', - // Old/deprecated options - user_storage_file: 'string.lower?', - strict_validation: type('unknown').narrow(deprecatedField()).optional(), + // Old/deprecated options + user_storage_file: "string.lower?", + strict_validation: type("unknown").narrow(deprecatedField()).optional(), }); const agentConfig = type({ - enabled: 'boolean', - host_name: 'string = "headplane-agent"', - pre_authkey: 'string', - cache_ttl: 'number.integer = 180000', - cache_path: 'string = "/var/lib/headplane/agent_cache.json"', - executable_path: 'string = "/usr/libexec/headplane/agent"', - work_dir: 'string = "/var/lib/headplane/agent"', + enabled: "boolean", + host_name: 'string = "headplane-agent"', + pre_authkey: "string", + cache_ttl: "number.integer = 180000", + cache_path: 'string = "/var/lib/headplane/agent_cache.json"', + executable_path: 'string = "/usr/libexec/headplane/agent"', + work_dir: 'string = "/var/lib/headplane/agent"', }); const partialAgentConfig = type({ - enabled: 'boolean?', - host_name: 'string?', - pre_authkey: 'string?', - cache_ttl: 'number.integer?', - cache_path: 'string?', - executable_path: 'string?', - work_dir: 'string?', + enabled: "boolean?", + host_name: "string?", + pre_authkey: "string?", + cache_ttl: "number.integer?", + cache_path: "string?", + executable_path: "string?", + work_dir: "string?", }); const integrationConfig = type({ - docker: DockerIntegration.configSchema.full, - kubernetes: KubernetesIntegration.configSchema.full, - proc: ProcIntegration.configSchema.full, - agent: agentConfig.optional(), + docker: DockerIntegration.configSchema.full, + kubernetes: KubernetesIntegration.configSchema.full, + proc: ProcIntegration.configSchema.full, + agent: agentConfig.optional(), }).partial(); export const partialIntegrationConfig = type({ - docker: DockerIntegration.configSchema.partial, - kubernetes: KubernetesIntegration.configSchema.partial, - proc: ProcIntegration.configSchema.partial, - agent: partialAgentConfig.optional(), + docker: DockerIntegration.configSchema.partial, + kubernetes: KubernetesIntegration.configSchema.partial, + proc: ProcIntegration.configSchema.partial, + agent: partialAgentConfig.optional(), }).partial(); export const headplaneConfig = type({ - debug: 'boolean = false', - server: serverConfig, - headscale: headscaleConfig, - oidc: oidcConfig.optional(), - integration: integrationConfig.optional(), -}).onDeepUndeclaredKey('delete'); + debug: "boolean = false", + server: serverConfig, + headscale: headscaleConfig, + oidc: oidcConfig.optional(), + integration: integrationConfig.optional(), +}).onDeepUndeclaredKey("delete"); export const partialHeadplaneConfig = type({ - debug: 'boolean?', - server: partialServerConfig.optional(), - headscale: partialHeadscaleConfig.optional(), - oidc: partialOidcConfig.optional(), - integration: partialIntegrationConfig.optional(), + debug: "boolean?", + server: partialServerConfig.optional(), + headscale: partialHeadscaleConfig.optional(), + oidc: partialOidcConfig.optional(), + integration: partialIntegrationConfig.optional(), }); export type HeadplaneConfig = typeof headplaneConfig.infer; export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer; -type DotNotationToObjects< - T extends string, - V, -> = T extends `${infer K}.${infer Rest}` - ? { [P in K]?: DotNotationToObjects } - : { [P in `${T}_path`]?: V }; +type DotNotationToObjects = T extends `${infer K}.${infer Rest}` + ? { [P in K]?: DotNotationToObjects } + : { [P in `${T}_path`]?: V }; type ObjectDeepMerge = T extends object - ? { - [K in keyof T]: T[K] extends object ? ObjectDeepMerge : T[K]; - } - : T; + ? { + [K in keyof T]: T[K] extends object ? ObjectDeepMerge : T[K]; + } + : T; type ConfigWithPathKeys = ObjectDeepMerge< - DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined> + DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined> >; -export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & - ConfigWithPathKeys; +export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys; diff --git a/app/server/index.ts b/app/server/index.ts index 74d9ea7..aedfc30 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -76,13 +76,14 @@ const appLoadContext = { hsApi, agents, integration: await loadIntegration(config.integration), - oidcConnector: config.oidc - ? createLazyOidcConnector( - config.server.base_url, - config.oidc, - hsApi.getRuntimeClient(config.oidc.headscale_api_key), - ) - : undefined, + oidcConnector: + config.oidc && config.oidc.enabled !== false + ? createLazyOidcConnector( + config.server.base_url, + config.oidc, + hsApi.getRuntimeClient(config.oidc.headscale_api_key), + ) + : undefined, db, }; diff --git a/config.example.yaml b/config.example.yaml index 0ab2d31..d2dd7e4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -167,58 +167,62 @@ integration: # OIDC Configuration for simpler authentication # (This is optional, but recommended for the best experience) # oidc: - # The OIDC issuer URL - # issuer: "https://accounts.google.com" +# Set to false to define OIDC config without enabling it. +# Useful for Helm charts or generating docs from config files. +# enabled: true - # If you are using OIDC, you need to generate an API key - # that can be used to authenticate other sessions when signing in. - # - # This can be done with `headscale apikeys create --expiration 999d` - # headscale_api_key: "" +# The OIDC issuer URL +# issuer: "https://accounts.google.com" - # If your OIDC provider does not support discovery (does not have the URL at - # `/.well-known/openid-configuration`), you need to manually set endpoints. - # This also works to override endpoints if you so desire or if your OIDC - # discovery is missing certain endpoints (ie GitHub). - # For some typical providers, see https://headplane.net/features/sso. - # authorization_endpoint: "" - # token_endpoint: "" - # userinfo_endpoint: "" +# If you are using OIDC, you need to generate an API key +# that can be used to authenticate other sessions when signing in. +# +# This can be done with `headscale apikeys create --expiration 999d` +# headscale_api_key: "" - # The authentication method to use when communicating with the token endpoint. - # This is fully optional and Headplane will attempt to auto-detect the best - # method and fall back to `client_secret_basic` if unsure. - # token_endpoint_auth_method: "client_secret_post" +# If your OIDC provider does not support discovery (does not have the URL at +# `/.well-known/openid-configuration`), you need to manually set endpoints. +# This also works to override endpoints if you so desire or if your OIDC +# 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 - # For the best experience please ensure this is *identical* to the client_id - # you are using for Headscale. because - # client_id: "your-client-id" +# The authentication method to use when communicating with the token endpoint. +# This is fully optional and Headplane will attempt to auto-detect the best +# method and fall back to `client_secret_basic` if unsure. +# token_endpoint_auth_method: "client_secret_post" - # The client secret for the OIDC client - # You may also provide `client_secret_path` instead to read a value from disk. - # See https://headplane.net/configuration/#sensitive-values - # client_secret: "" +# The client ID for the OIDC client +# For the best experience please ensure this is *identical* to the client_id +# you are using for Headscale. because +# client_id: "your-client-id" - # Whether to use PKCE when authenticating users. This is recommended as it - # 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 +# The client secret for the OIDC client +# You may also provide `client_secret_path` instead to read a value from disk. +# See https://headplane.net/configuration/#sensitive-values +# client_secret: "" - # If you want to disable traditional login via Headscale API keys - # disable_api_key_login: false +# Whether to use PKCE when authenticating users. This is recommended as it +# 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 - # 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" +# If you want to disable traditional login via Headscale API keys +# disable_api_key_login: false - # The scopes to request when authenticating users. The default is below. - # scope: "openid email profile" +# By default profile pictures are pulled from the OIDC provider when +# 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 - # 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 +# The scopes to request when authenticating users. The default is below. +# scope: "openid email profile" + +# Extra query parameters can be passed to the authorization endpoint +# 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 diff --git a/tests/unit/config/oidc-enabled.test.ts b/tests/unit/config/oidc-enabled.test.ts new file mode 100644 index 0000000..70965a7 --- /dev/null +++ b/tests/unit/config/oidc-enabled.test.ts @@ -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"); + }); +});