Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions pods/authProviders/src/oidcNameSplit.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
70 changes: 70 additions & 0 deletions pods/authProviders/src/oidcNameSplit.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
10 changes: 7 additions & 3 deletions pods/authProviders/src/openid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down