From ebd25a94ea97c7864cb8cc1d5a1983de845fef2c Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Thu, 20 Aug 2026 19:53:29 +0200 Subject: [PATCH] fix: sso email resolving and switcher for sso --- .../sso/__tests__/sso.utils.test.ts | 49 +++++++++++++++++++ .../tv-modules/sso/providers/saml.provider.ts | 12 ++--- api/src/tv-modules/sso/sso.utils.ts | 21 ++++++-- .../organizations/parts/OrgSsoConfigCard.vue | 36 ++++++++++++-- web/src/locales/de.ts | 2 + web/src/locales/en.ts | 2 + web/src/locales/es.ts | 2 + web/src/locales/ru.ts | 2 + 8 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 api/src/tv-modules/sso/__tests__/sso.utils.test.ts diff --git a/api/src/tv-modules/sso/__tests__/sso.utils.test.ts b/api/src/tv-modules/sso/__tests__/sso.utils.test.ts new file mode 100644 index 0000000..f4702fe --- /dev/null +++ b/api/src/tv-modules/sso/__tests__/sso.utils.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { deriveSamlEmail } from '../sso.utils' + +const EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' +const PERSISTENT_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' +const EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + +describe('deriveSamlEmail', () => { + it('takes the email attribute and lowercases it', () => { + expect(deriveSamlEmail({ email: 'User@Company.com', nameID: 'abc' })).toBe('user@company.com') + }) + + it('falls back to the xmlsoap emailaddress claim', () => { + expect(deriveSamlEmail({ [EMAIL_CLAIM]: 'a@b.com', nameID: 'abc' })).toBe('a@b.com') + }) + + it('uses nameID only when the NameID Format is emailAddress', () => { + expect(deriveSamlEmail({ + nameID: 'user@company.com', + nameIDFormat: EMAIL_NAMEID_FORMAT, + })).toBe('user@company.com') + }) + + it('does not use nameID for a non-email NameID Format', () => { + expect(deriveSamlEmail({ + nameID: 'user@company.com', + nameIDFormat: PERSISTENT_NAMEID_FORMAT, + })).toBeNull() + }) + + it('does not use nameID when no format is provided', () => { + expect(deriveSamlEmail({ nameID: 'user@company.com' })).toBeNull() + }) + + it('prefers the email attribute over an emailAddress-format nameID', () => { + expect(deriveSamlEmail({ + email: 'attr@company.com', + nameID: 'name@company.com', + nameIDFormat: EMAIL_NAMEID_FORMAT, + })).toBe('attr@company.com') + }) + + it('returns null for a blank or non-string email attribute', () => { + expect(deriveSamlEmail({ email: ' ', nameID: 'abc' })).toBeNull() + expect(deriveSamlEmail({ email: 123, nameID: 'abc' })).toBeNull() + expect(deriveSamlEmail({ nameID: 'abc' })).toBeNull() + expect(deriveSamlEmail({})).toBeNull() + }) +}) diff --git a/api/src/tv-modules/sso/providers/saml.provider.ts b/api/src/tv-modules/sso/providers/saml.provider.ts index f0f093e..80d2107 100644 --- a/api/src/tv-modules/sso/providers/saml.provider.ts +++ b/api/src/tv-modules/sso/providers/saml.provider.ts @@ -2,6 +2,7 @@ import { SAML, ValidateInResponseTo } from '@node-saml/node-saml' import type { Request, Response } from 'express' import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas' import { PublicApiUrl } from '../../../modules/public-url' +import { deriveSamlEmail } from '../sso.utils' import type { SamlOptionsArgs } from '../types' import type { SsoProvider, SsoAuthResult } from './sso-provider.interface' import { SamlDbCacheProvider } from './saml-cache-provider' @@ -72,14 +73,13 @@ export class SamlProvider implements SsoProvider { throw new Error('SAML response missing nameID') } - const email = ( - profile.email - ?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] - ?? profile.nameID - ) as string + const email = deriveSamlEmail(profile as Record) + if (!email) { + throw new Error('SAML response missing email attribute') + } return { - email: email.toLowerCase(), + email, externalId: profile.nameID, displayName: (profile.displayName ?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']) as string | undefined, diff --git a/api/src/tv-modules/sso/sso.utils.ts b/api/src/tv-modules/sso/sso.utils.ts index 92b1457..203a7a8 100644 --- a/api/src/tv-modules/sso/sso.utils.ts +++ b/api/src/tv-modules/sso/sso.utils.ts @@ -14,6 +14,21 @@ export function generateDomainVerifyToken(): string { return `tvdom_${randomBytes(32).toString('hex')}` } +const SAML_EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' +const SAML_EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + +export function deriveSamlEmail(profile: Record): string | null { + const fromAttribute = profile.email ?? profile[SAML_EMAIL_CLAIM] + if (typeof fromAttribute === 'string' && fromAttribute.trim()) { + return fromAttribute.trim().toLowerCase() + } + if (profile.nameIDFormat === SAML_EMAIL_NAMEID_FORMAT + && typeof profile.nameID === 'string' && profile.nameID.trim()) { + return profile.nameID.trim().toLowerCase() + } + return null +} + export function trustedSsoDomains(): string[] { const raw = process.env.SSO_TRUSTED_DOMAINS if (!raw?.trim()) return [] @@ -80,9 +95,9 @@ export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Pro const urls = process.env.NODE_ENV === 'production' ? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`] : [ - `https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`, - `http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`, - ] + `https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`, + `http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`, + ] for (const url of urls) { const urlError = validateMetadataUrl(url) diff --git a/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue b/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue index 5058d3a..307acf0 100644 --- a/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue +++ b/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue @@ -10,9 +10,17 @@

- + {{ config.enabled ? t('sso.enabled') : t('sso.disabled') }} - + +