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