feat(oidc): OIDC_USERNAME_CLAIM picks the claim a new account's username comes from

Providers like Pocket ID send the full name in 'name', which became 'JaneDoe'. OIDC_USERNAME_CLAIM=preferred_username builds the username from that claim instead. Unset, or with the claim empty, the old order applies (name, preferred_username, the email's local part), and it is only read when an account is created. (#1677)
This commit is contained in:
Maurice
2026-10-01 09:25:23 +02:00
parent ba3dc65926
commit 1fee38e8f7
12 changed files with 79 additions and 2 deletions
+3
View File
@@ -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 }}
+2
View File
@@ -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: ""
+1
View File
@@ -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
+1
View File
@@ -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)
+3
View File
@@ -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,
};
}
+1
View File
@@ -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,
+15 -1
View File
@@ -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);
@@ -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);
@@ -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();
+1
View File
@@ -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) | — |
+1
View File
@@ -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
+2 -1
View File
@@ -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 `<OIDC_ISSUER>/.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.