fix: sso email resolving and switcher for sso

This commit is contained in:
Nikolai Giman
2026-08-20 19:53:29 +02:00
parent 4b142619c0
commit ebd25a94ea
8 changed files with 113 additions and 13 deletions
@@ -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()
})
})
@@ -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<string, unknown>)
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,
+18 -3
View File
@@ -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, unknown>): 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)
@@ -10,9 +10,17 @@
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="config.enabled ? 'success' : 'neutral'">
<span
class="text-xs"
:class="config.enabled ? 'text-success' : 'text-dimmed'"
>
{{ config.enabled ? t('sso.enabled') : t('sso.disabled') }}
</UBadge>
</span>
<USwitch
:model-value="!!config.enabled"
:loading="toggling"
@update:model-value="toggleEnabled"
/>
<UButton
icon="i-lucide-pencil"
size="xs"
@@ -58,18 +66,20 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { $tvApi } from '@/plugins/axios'
import type { SsoConfig } from 'taskview-api'
import OrgSsoScimSection from './OrgSsoScimSection.vue'
import OrgSsoDomainSection from './OrgSsoDomainSection.vue'
defineProps<{
const props = defineProps<{
config: SsoConfig
callbackUrl: string
scimEndpointUrl: string
}>()
defineEmits<{
const emit = defineEmits<{
edit: []
delete: []
updated: []
@@ -78,6 +88,24 @@ defineEmits<{
const { t } = useI18n()
const toast = useToast()
const toggling = ref(false)
async function toggleEnabled(enabled: boolean) {
toggling.value = true
try {
await $tvApi.sso.updateConfig(props.config.id, { enabled: enabled ? 1 : 0 })
emit('updated')
} catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
toast.add({
title: status === 403 ? t('sso.enableRequiresVerifiedDomain') : t('sso.toggleFailed'),
color: 'error',
})
} finally {
toggling.value = false
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
+2
View File
@@ -867,6 +867,8 @@ export default {
callbackUrlAutoHint: 'Leer lassen, um die URL automatisch zu ermitteln',
enabled: 'Aktiviert',
disabled: 'Deaktiviert',
toggleFailed: 'SSO-Status konnte nicht geändert werden',
enableRequiresVerifiedDomain: 'Bestätigen Sie die E-Mail-Domain, bevor Sie diesen Anbieter aktivieren',
callbackUrlLabel: 'Callback-URL (in Ihrem IdP verwenden)',
copied: 'In die Zwischenablage kopiert',
copyFailed: 'Kopieren fehlgeschlagen',
+2
View File
@@ -881,6 +881,8 @@ export default {
callbackUrlAutoHint: 'Leave empty to detect automatically',
enabled: 'Enabled',
disabled: 'Disabled',
toggleFailed: 'Failed to change SSO status',
enableRequiresVerifiedDomain: 'Verify the email domain before enabling this provider',
callbackUrlLabel: 'Callback URL (use this in your IdP)',
copied: 'Copied to clipboard',
copyFailed: 'Failed to copy',
+2
View File
@@ -867,6 +867,8 @@ export default {
callbackUrlAutoHint: 'Déjalo vacío para detectarla automáticamente',
enabled: 'Activado',
disabled: 'Desactivado',
toggleFailed: 'No se pudo cambiar el estado de SSO',
enableRequiresVerifiedDomain: 'Verifica el dominio de correo antes de activar este proveedor',
callbackUrlLabel: 'URL de Callback (úsala en tu IdP)',
copied: 'Copiado al portapapeles',
copyFailed: 'Error al copiar',
+2
View File
@@ -854,6 +854,8 @@ export default {
callbackUrlAutoHint: 'Можно оставить пустым — адрес будет определён автоматически',
enabled: 'Включён',
disabled: 'Выключен',
toggleFailed: 'Не удалось изменить статус SSO',
enableRequiresVerifiedDomain: 'Перед включением провайдера подтвердите домен почты',
callbackUrlLabel: 'Callback URL (укажи в настройках IdP)',
copied: 'Скопировано',
copyFailed: 'Не удалось скопировать',