From d110dd2bcb3f416cdf9ea7d4a0547f68607464bf Mon Sep 17 00:00:00 2001 From: croatialu Date: Thu, 16 Apr 2026 18:07:57 +0800 Subject: [PATCH] feat: add OIDC subject claim fallbacks --- app/server/config/config-schema.ts | 2 ++ app/server/index.ts | 1 + config.example.yaml | 7 +++++++ docs/features/sso.md | 17 +++++++++++++++++ tests/unit/config/config-file.test.ts | 18 ++++++++++++++++++ 5 files changed, 45 insertions(+) diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 2343bed..c726b2e 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -98,6 +98,7 @@ const oidcConfig = type({ .optional(), disable_api_key_login: "boolean = false", scope: 'string = "openid email profile"', + subject_claims: "string[]?", profile_picture_source: '"oidc" | "gravatar" = "oidc"', extra_params: "Record?", @@ -120,6 +121,7 @@ const partialOidcConfig = type({ redirect_uri: "string.url?", disable_api_key_login: "boolean?", scope: "string?", + subject_claims: "string[]?", extra_params: "Record?", profile_picture_source: '"oidc" | "gravatar"?', diff --git a/app/server/index.ts b/app/server/index.ts index 4d803b1..d64b5a5 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -115,6 +115,7 @@ const appLoadContext = { : config.oidc.token_endpoint_auth_method, usePkce: config.oidc.use_pkce, scope: config.oidc.scope, + subjectClaims: config.oidc.subject_claims, extraParams: config.oidc.extra_params, profilePictureSource: config.oidc.profile_picture_source, }), diff --git a/config.example.yaml b/config.example.yaml index 5a2201a..87721a0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -217,6 +217,13 @@ integration: # The scopes to request when authenticating users. The default is below. # scope: "openid email profile" +# Optional fallback claims to use when your provider does not return a standard +# OIDC `sub` claim. Headplane always checks `sub` first, then each claim here +# in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback. +# subject_claims: +# - "open_id" +# - "email" + # 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. diff --git a/docs/features/sso.md b/docs/features/sso.md index 5363a65..4a2179c 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -70,6 +70,7 @@ oidc: # token_endpoint: "" # userinfo_endpoint: "" # scope: "openid email profile" + # subject_claims: ["open_id", "email"] # extra_params: # foo: "bar" ``` @@ -78,6 +79,22 @@ Headplane automatically discovers OIDC endpoints from your issuer's `/.well-known/openid-configuration`. If your IdP does not support discovery, you'll need to set the endpoints manually. +### Non-standard Subject Claims + +Some providers do not return the standard OIDC `sub` claim in the ID token. +Headplane always uses `sub` first, but you can configure fallback claims with +`oidc.subject_claims`. + +For Feishu/Lark, the recommended configuration is: + +```yaml +oidc: + subject_claims: ["open_id", "email"] +``` + +This keeps identity matching stable by preferring `open_id` and only falling +back to `email` if needed. + ### PKCE ::: warning diff --git a/tests/unit/config/config-file.test.ts b/tests/unit/config/config-file.test.ts index c474a06..0b7454f 100644 --- a/tests/unit/config/config-file.test.ts +++ b/tests/unit/config/config-file.test.ts @@ -112,6 +112,24 @@ describe("Configuration YAML file loading", () => { expect(config.oidc?.enabled).toBe(false); }); + test("oidc.subject_claims can be configured from YAML", async () => { + const filePath = "/config/oidc-subject-claims.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + subject_claims: ["open_id", "email"], + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]); + }); + test("partial oidc config with enabled field can be parsed", async () => { const filePath = "/config/oidc-partial.yaml"; writeYaml(filePath, {