From 696f49d4121817dc6d0a32ebb86d3a6a63f1425c Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 21 Jun 2026 18:59:14 +0000 Subject: [PATCH 1/2] fix(authProviders): strict heuristic for Authentik-default given_name shape Authentik's default 'profile' scope mapping has no first/last separation and emits given_name = full display name. Huly's openid.ts previously used given_name verbatim, then derived last_name from name.split(' '), producing duplicated last names like "Florian Preininger Preininger". Now uses a strict normalized heuristic: when given_name.trim() === name.trim() AND family_name is missing/blank, fall back to splitting full-name on whitespace. Otherwise use given_name/family_name as-is. Per Codex Recommended (plan-review 2026-06-21): NO broad "given_name contains family_name" fallback. That would mangle legitimate compound names like "Anna Lena Schmidt". Logic extracted into pure helper splitOidcName(claims) at pods/authProviders/src/oidcNameSplit.ts for testability. 10 test cases cover Authentik-default, conformant IdP, single-name, compound-surname, whitespace, missing-name, multi-space, and empty-input. Upstream-PR-candidate: this is a defensive fix that benefits any non-Authentik IdP that also has the no-separation quirk. Signed-off-by: Michael Uray Signed-off-by: Michael Uray --- pods/authProviders/src/oidcNameSplit.test.ts | 130 +++++++++++++++++++ pods/authProviders/src/oidcNameSplit.ts | 66 ++++++++++ pods/authProviders/src/openid.ts | 10 +- 3 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 pods/authProviders/src/oidcNameSplit.test.ts create mode 100644 pods/authProviders/src/oidcNameSplit.ts 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..44cebb90a19 --- /dev/null +++ b/pods/authProviders/src/oidcNameSplit.ts @@ -0,0 +1,66 @@ +// +// 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( From bc7264c11a9907104823c0a18476a568ec65b69d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 2 Jul 2026 06:13:32 +0000 Subject: [PATCH 2/2] chore: merge develop and apply repo formatting CI's formatting job runs the repo format step on the PR merge commit and fails when files drifted on develop since this branch's base. Merging develop and committing the format output makes the post-format diff empty again. No functional changes beyond the develop merge. Signed-off-by: Michael Uray --- pods/authProviders/src/oidcNameSplit.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pods/authProviders/src/oidcNameSplit.ts b/pods/authProviders/src/oidcNameSplit.ts index 44cebb90a19..5265de789f0 100644 --- a/pods/authProviders/src/oidcNameSplit.ts +++ b/pods/authProviders/src/oidcNameSplit.ts @@ -56,11 +56,15 @@ export function splitOidcName (claims: OidcNameClaims): SplitName { const first: string = isAuthentikDefaultShape ? (nameParts[0] ?? '') - : (givenRaw !== '' ? givenRaw : (nameParts[0] ?? '')) + : givenRaw !== '' + ? givenRaw + : (nameParts[0] ?? '') const last: string = isAuthentikDefaultShape ? nameParts.slice(1).join(' ') - : (familyRaw !== '' ? familyRaw : nameParts.slice(1).join(' ')) + : familyRaw !== '' + ? familyRaw + : nameParts.slice(1).join(' ') return { first, last } }