diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index 72db633de..b00270838 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -64,6 +64,9 @@ data: {{- if .Values.env.OIDC_ADMIN_VALUE }} OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }} {{- end }} + {{- if .Values.env.OIDC_USERNAME_CLAIM }} + OIDC_USERNAME_CLAIM: {{ .Values.env.OIDC_USERNAME_CLAIM | quote }} + {{- end }} {{- if .Values.env.OIDC_SCOPE }} OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 6d3d05737..74a4d319a 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -71,6 +71,8 @@ env: # OIDC claim used to identify admin users. # OIDC_ADMIN_VALUE: "" # Value of the OIDC claim that grants admin role. + # OIDC_USERNAME_CLAIM: "" + # Claim a new account's username comes from, e.g. "preferred_username". Unset: name, then preferred_username. # OIDC_SCOPE: "openid email profile groups" # Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM. # OIDC_DISCOVERY_URL: "" diff --git a/docker-compose.yml b/docker-compose.yml index be23de36f..169e5c6b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,7 @@ services: # - OIDC_ONLY=false # Set true to force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, cannot be changed at runtime # - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users # - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role +# - OIDC_USERNAME_CLAIM=preferred_username # Claim a new account's username comes from (default: name, then preferred_username) # - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM) # - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik) # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist diff --git a/server/.env.example b/server/.env.example index d783aa7f3..3db7b8ce1 100644 --- a/server/.env.example +++ b/server/.env.example @@ -37,6 +37,7 @@ OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button OIDC_ONLY=true # Disable local password auth entirely (SSO only). Equivalent to setting password_login=false and password_registration=false in Admin > Settings. OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role +OIDC_USERNAME_CLAIM= # Claim a new account's username comes from, e.g. preferred_username. Unset: name, then preferred_username OIDC_DISCOVERY_URL= # Override the auto-constructed OIDC discovery endpoint. Useful for providers (e.g. Authentik) that expose it at a non-standard path. Example: https://auth.example.com/application/o/trek/.well-known/openid-configuration OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM) diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 961c5f031..68e996a49 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -206,6 +206,9 @@ export function deriveOidc(raw: RawEnv) { only: parseBool(raw.OIDC_ONLY) === true, adminClaim: raw.OIDC_ADMIN_CLAIM || 'groups', adminValue: raw.OIDC_ADMIN_VALUE, + // Unset keeps the old order (name, then preferred_username), so an instance + // that never sets it names new accounts exactly as before (#1677). + usernameClaim: raw.OIDC_USERNAME_CLAIM?.trim() || undefined, }; } diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index 33f31d233..b13dcd2a3 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -97,6 +97,7 @@ export const envSchema = z.object({ OIDC_ONLY: boolStr, OIDC_ADMIN_CLAIM: anyString, OIDC_ADMIN_VALUE: anyString, + OIDC_USERNAME_CLAIM: anyString, // SMTP SMTP_HOST: anyString, diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index 0183c54bb..d2017ad16 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -607,6 +607,20 @@ export class OidcService implements OnModuleDestroy { // Find or create user by OIDC sub / email // ------------------------------------------------------------------------- + /** + * The claim a new account's username comes from (#1677). OIDC_USERNAME_CLAIM + * names one, typically `preferred_username` for providers whose `name` is the + * full "Jane Doe"; when it is unset, or the provider leaves that claim empty, + * the old order applies. Only read when an account is created: a username the + * user has changed since is never overwritten on a later login. + */ + private usernameSource(userInfo: OidcUserInfo): string | undefined { + const claimKey = readEnv().oidc.usernameClaim; + const claimed = claimKey ? userInfo[claimKey] : undefined; + if (typeof claimed === 'string' && claimed.trim()) return claimed.trim(); + return userInfo.name || userInfo.preferred_username; + } + findOrCreateUser( userInfo: OidcUserInfo, config: OidcConfig, @@ -617,7 +631,7 @@ export class OidcService implements OnModuleDestroy { // `oidc_error=' + result.error` pass-through if reached here. if (!userInfo.email) return { error: 'no_email' }; const email = userInfo.email.trim().toLowerCase(); - const name = userInfo.name || userInfo.preferred_username || email.split('@')[0]; + const name = this.usernameSource(userInfo) || email.split('@')[0]; const sub = userInfo.sub; const picture = safeOidcPicture(userInfo.picture); diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts index 0861f7838..81a1694eb 100644 --- a/server/tests/unit/app-config/derive.test.ts +++ b/server/tests/unit/app-config/derive.test.ts @@ -146,6 +146,12 @@ describe('deriveOidc', () => { expect(deriveOidc({ OIDC_ADMIN_CLAIM: 'roles' }).adminClaim).toBe('roles'); }); + it('usernameClaim stays unset unless a claim is named (#1677)', () => { + expect(deriveOidc({}).usernameClaim).toBeUndefined(); + expect(deriveOidc({ OIDC_USERNAME_CLAIM: ' ' }).usernameClaim).toBeUndefined(); + expect(deriveOidc({ OIDC_USERNAME_CLAIM: ' preferred_username ' }).usernameClaim).toBe('preferred_username'); + }); + it('OIDC_ONLY coerces the boolean-like family', () => { expect(deriveOidc({ OIDC_ONLY: 'True' }).only).toBe(true); expect(deriveOidc({ OIDC_ONLY: '1' }).only).toBe(true); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index bbf8b759a..8dca6f2d5 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -145,6 +145,7 @@ beforeEach(() => { resetTestDb(testDb); delete process.env.OIDC_ADMIN_VALUE; delete process.env.OIDC_ADMIN_CLAIM; + delete process.env.OIDC_USERNAME_CLAIM; delete process.env.NODE_ENV; }); @@ -529,6 +530,48 @@ describe('findOrCreateUser', () => { expect((result as { user: any }).user.role).toBe('admin'); }); + it('OIDC-SVC-022b: without OIDC_USERNAME_CLAIM the username comes from name, as before (#1677)', () => { + createUser(testDb, { email: 'someone@example.com' }); + const result = svc.findOrCreateUser( + { sub: 'sub-jane-1', email: 'jane@example.com', name: 'Jane Doe', preferred_username: 'jane' }, + MOCK_CONFIG + ); + expect((result as { user: { username: string } }).user.username).toBe('JaneDoe'); + }); + + it('OIDC-SVC-022c: OIDC_USERNAME_CLAIM picks the claim the username is built from (#1677)', () => { + createUser(testDb, { email: 'someone@example.com' }); + process.env.OIDC_USERNAME_CLAIM = 'preferred_username'; + const result = svc.findOrCreateUser( + { sub: 'sub-jane-2', email: 'jane2@example.com', name: 'Jane Doe', preferred_username: 'jane.d' }, + MOCK_CONFIG + ); + expect((result as { user: { username: string } }).user.username).toBe('jane.d'); + }); + + it('OIDC-SVC-022d: a named claim the provider leaves empty falls back to name, then the email (#1677)', () => { + createUser(testDb, { email: 'someone@example.com' }); + process.env.OIDC_USERNAME_CLAIM = 'nickname'; + const withName = svc.findOrCreateUser( + { sub: 'sub-jane-3', email: 'jane3@example.com', name: 'Jane Three', nickname: ' ' }, + MOCK_CONFIG + ); + expect((withName as { user: { username: string } }).user.username).toBe('JaneThree'); + const bare = svc.findOrCreateUser({ sub: 'sub-jane-4', email: 'j.four@example.com' }, MOCK_CONFIG); + expect((bare as { user: { username: string } }).user.username).toBe('j.four'); + }); + + it('OIDC-SVC-022e: a later login never renames the account to the claim', () => { + const { user } = createUser(testDb, { email: 'kept@example.com', username: 'chosen' }); + testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run('sub-kept', MOCK_CONFIG.issuer, user.id); + process.env.OIDC_USERNAME_CLAIM = 'preferred_username'; + const result = svc.findOrCreateUser( + { sub: 'sub-kept', email: 'kept@example.com', name: 'Kept', preferred_username: 'idp-name' }, + MOCK_CONFIG + ); + expect((result as { user: { username: string } }).user.username).toBe('chosen'); + }); + it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => { createUser(testDb, { email: 'existing@example.com' }); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 38c60e0fe..c2da00ca6 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -192,6 +192,7 @@ For setup instructions, see [OIDC-SSO](OIDC-SSO). | `OIDC_ONLY` | Force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, cannot be changed at runtime. First SSO login becomes admin on a fresh instance. Since the login page then has no "Remember me" switch, every SSO session gets the `SESSION_DURATION_REMEMBER` lifetime; shorten that variable for shorter sessions, `SESSION_DURATION` does not apply here. | `false` | | `OIDC_ADMIN_CLAIM` | OIDC claim inspected for the admin role. Only takes effect once `OIDC_ADMIN_VALUE` is set. | `groups` | | `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role (e.g. `app-trek-admins`) | — | +| `OIDC_USERNAME_CLAIM` | Claim a new account's username is built from, e.g. `preferred_username` when your provider sends the full name in `name`. Only used when the account is created; an empty claim falls back to the default order. | `name`, then `preferred_username` | | `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` | | `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Required for providers with a non-standard path (e.g. Authentik) | — | diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md index a59b12ba3..70eb42491 100644 --- a/wiki/Install-Helm.md +++ b/wiki/Install-Helm.md @@ -124,6 +124,7 @@ env: # OIDC_ONLY: "false" # force SSO-only mode; disables password login # OIDC_ADMIN_CLAIM: "" # OIDC claim used to identify admin users # OIDC_ADMIN_VALUE: "" # value of that claim that grants admin role + # OIDC_USERNAME_CLAIM: "" # claim a new account's username comes from, e.g. preferred_username # OIDC_SCOPE: "openid email profile groups" # OIDC_DISCOVERY_URL: "" # override for providers with non-standard discovery paths (e.g. Authentik) # VAPID_PUBLIC_KEY: "" # Web Push: public half of your own key pair; leave unset to use the generated one diff --git a/wiki/OIDC-SSO.md b/wiki/OIDC-SSO.md index b827250e7..3bc93f25a 100644 --- a/wiki/OIDC-SSO.md +++ b/wiki/OIDC-SSO.md @@ -55,6 +55,7 @@ For example: `https://trek.example.com/api/auth/oidc/callback` | `OIDC_ONLY` | Set to `true` to disable local password login and password registration. SSO login and SSO registration remain governed by their own toggles. This is an environment-variable-only setting and cannot be toggled at runtime via the admin panel. | | `OIDC_ADMIN_CLAIM` | OIDC claim to inspect for admin role mapping. Defaults to `groups`. The claim value may be an array or a plain string. The claim only reaches TREK if one of the scopes in `OIDC_SCOPE` carries it — see *Admin role mapping* below. **Env var only — not configurable via the admin panel.** | | `OIDC_ADMIN_VALUE` | Value that must be present in `OIDC_ADMIN_CLAIM` to grant the admin role. If unset, claim-based role mapping is disabled. When set, the role is re-evaluated on every login. **Env var only — not configurable via the admin panel.** | +| `OIDC_USERNAME_CLAIM` | Claim the username of a new account is built from. Defaults to `name`, then `preferred_username`, then the part of the email before the `@`. Set it to `preferred_username` for providers like Pocket ID whose `name` is the full "Jane Doe". Only read when the account is created, so a username changed later in TREK stays; when the provider leaves the claim empty, the default order applies. **Env var only, not configurable via the admin panel.** | | `OIDC_SCOPE` | Overrides the default scope list sent to the provider. Defaults to `openid email profile`. Ensure `openid` and `email` are always included, plus whichever scope carries your `OIDC_ADMIN_CLAIM`. **Env var only — not configurable via the admin panel.** | | `OIDC_DISCOVERY_URL` | Full URL to the OIDC discovery document. Use this for providers with non-standard discovery paths (e.g. Authentik tenants). If unset, discovery is attempted at `/.well-known/openid-configuration`. The discovery document is cached for 1 hour. | @@ -109,7 +110,7 @@ OIDC can also be configured without environment variables via **Admin → Settin Environment variables take priority over database settings when both are present. -The following variables are **env var only** and have no admin panel equivalent: `OIDC_ONLY`, `OIDC_SCOPE`, `OIDC_ADMIN_CLAIM`, `OIDC_ADMIN_VALUE`. +The following variables are **env var only** and have no admin panel equivalent: `OIDC_ONLY`, `OIDC_SCOPE`, `OIDC_ADMIN_CLAIM`, `OIDC_ADMIN_VALUE`, `OIDC_USERNAME_CLAIM`. The `OIDC_ONLY` env var always overrides the panel's login-method toggles. To disable password login at runtime without `OIDC_ONLY`, use the **password_login** and **password_registration** toggles in Admin → Settings instead.