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') }}
-
+
+