diff --git a/pods/authProviders/src/oidcNameSplit.test.ts b/pods/authProviders/src/oidcNameSplit.test.ts new file mode 100644 index 00000000000..6b17109017e --- /dev/null +++ b/pods/authProviders/src/oidcNameSplit.test.ts @@ -0,0 +1,130 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { splitOidcName } from './oidcNameSplit' + +describe('splitOidcName', () => { + // Case 1 — the bug we are fixing. + it('handles Authentik-default shape (given_name === full name, family_name blank)', () => { + expect( + splitOidcName({ + name: 'Florian Preininger', + given_name: 'Florian Preininger', + family_name: '' + }) + ).toEqual({ first: 'Florian', last: 'Preininger' }) + }) + + // Case 2 — conformant IdP unchanged. + it('keeps conformant IdP claims as-is (given+family present and distinct)', () => { + expect( + splitOidcName({ + name: 'Florian Preininger', + given_name: 'Florian', + family_name: 'Preininger' + }) + ).toEqual({ first: 'Florian', last: 'Preininger' }) + }) + + // Case 3 — single-name user, conformant claims. + it('handles single-name user with given_name only', () => { + expect( + splitOidcName({ + name: 'Cher', + given_name: 'Cher', + family_name: '' + }) + ).toEqual({ first: 'Cher', last: '' }) + }) + + // Case 4 — strict heuristic does NOT trigger; legitimate compound surname preserved. + it('preserves legitimate compound surname when family_name is present', () => { + expect( + splitOidcName({ + name: 'Anna Lena Schmidt', + given_name: 'Anna', + family_name: 'Lena Schmidt' + }) + ).toEqual({ first: 'Anna', last: 'Lena Schmidt' }) + }) + + // Case 5 — whitespace tolerance on all inputs. + it('trims surrounding whitespace before comparing/splitting', () => { + expect( + splitOidcName({ + name: ' Florian Preininger ', + given_name: ' Florian Preininger ', + family_name: '' + }) + ).toEqual({ first: 'Florian', last: 'Preininger' }) + }) + + // Case 6 — falls back to username when name is missing. + it('falls back to username when name is missing', () => { + expect( + splitOidcName({ + name: undefined, + username: 'asmith', + given_name: '', + family_name: '' + }) + ).toEqual({ first: 'asmith', last: '' }) + }) + + // Case 7 — strict heuristic with single-name doesn't degrade. + it('does not degrade single-name user when Authentik-default heuristic triggers', () => { + // Same as case 3; verifies that nameParts.slice(1) returns '' (no crash). + expect( + splitOidcName({ + name: 'Cher', + given_name: 'Cher', + family_name: '' + }) + ).toEqual({ first: 'Cher', last: '' }) + }) + + // Case 8 — compound first name from conformant IdP. + it('preserves compound first name from conformant IdP', () => { + expect( + splitOidcName({ + name: 'Mary Beth Smith', + given_name: 'Mary Beth', + family_name: 'Smith' + }) + ).toEqual({ first: 'Mary Beth', last: 'Smith' }) + }) + + // Case 9 — empty everything must not crash. + it('handles empty inputs without crashing', () => { + expect( + splitOidcName({ + name: '', + given_name: '', + family_name: '' + }) + ).toEqual({ first: '', last: '' }) + }) + + // Case 10 — multiple internal whitespace collapses via split regex. + it('collapses multiple internal whitespace via split regex', () => { + expect( + splitOidcName({ + name: 'Anna Bertha Christine', + given_name: '', + family_name: '' + }) + ).toEqual({ first: 'Anna', last: 'Bertha Christine' }) + }) +}) diff --git a/pods/authProviders/src/oidcNameSplit.ts b/pods/authProviders/src/oidcNameSplit.ts new file mode 100644 index 00000000000..5265de789f0 --- /dev/null +++ b/pods/authProviders/src/oidcNameSplit.ts @@ -0,0 +1,70 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export interface OidcNameClaims { + name?: string + username?: string + given_name?: string + family_name?: string +} + +export interface SplitName { + first: string + last: string +} + +/** + * Split an OIDC user's name into first/last fields, robust against IdPs + * (notably Authentik with its default `profile` scope mapping) that have no + * first/last separation and emit `given_name = full display name` with + * `family_name` missing or blank. + * + * Without this guard, Huly's previous logic would stamp + * `first = "Florian Preininger"` (from `given_name` verbatim) and derive + * `last = "Preininger"` (from `name.split(' ').slice(1)` fallback), producing + * the display name "Florian Preininger Preininger". + * + * Strict heuristic — Authentik-default shape is only assumed when: + * - `given_name.trim()` equals `name.trim()` exactly, AND + * - `family_name.trim()` is empty. + * + * No broad "given_name contains family_name" fallback (Codex plan-review + * 2026-06-21): that would mangle legitimate compound names like + * "Anna Lena Schmidt" / "van der Berg". + */ +export function splitOidcName (claims: OidcNameClaims): SplitName { + const fullName = (claims.name ?? claims.username ?? '').trim() + const givenRaw = (claims.given_name ?? '').trim() + const familyRaw = (claims.family_name ?? '').trim() + const nameParts = fullName.split(/\s+/).filter((s) => s.length > 0) + + // STRICT Authentik-default detection: given_name IS the full display name + // AND family_name is missing/blank. + const isAuthentikDefaultShape = givenRaw !== '' && givenRaw === fullName && familyRaw === '' + + const first: string = isAuthentikDefaultShape + ? (nameParts[0] ?? '') + : givenRaw !== '' + ? givenRaw + : (nameParts[0] ?? '') + + const last: string = isAuthentikDefaultShape + ? nameParts.slice(1).join(' ') + : familyRaw !== '' + ? familyRaw + : nameParts.slice(1).join(' ') + + return { first, last } +} diff --git a/pods/authProviders/src/openid.ts b/pods/authProviders/src/openid.ts index 0a19d99b288..765328c33a9 100644 --- a/pods/authProviders/src/openid.ts +++ b/pods/authProviders/src/openid.ts @@ -19,6 +19,7 @@ import Router from 'koa-router' import { Issuer, Strategy } from 'openid-client' import { Passport } from '.' +import { splitOidcName } from './oidcNameSplit' import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' export function registerOpenid ( @@ -87,9 +88,12 @@ export function registerOpenid ( async (ctx, next) => { const email = ctx.state.user.email const verifiedEmail = (ctx.state.user.email_verified as boolean) ? email : '' - const nameParts = (ctx.state.user.name ?? ctx.state.user.username ?? '').split(' ') - const first: string = ctx.state.user.given_name ?? nameParts[0] ?? '' - const last: string = ctx.state.user.family_name ?? nameParts.slice(1).join(' ') + const { first, last } = splitOidcName({ + name: ctx.state.user.name, + username: ctx.state.user.username, + given_name: ctx.state.user.given_name, + family_name: ctx.state.user.family_name + }) const db = await dbPromise const redirectUrl = await handleProviderAuth(