mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-29 22:08:52 +00:00
fix: sso email resolving and switcher for sso
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -854,6 +854,8 @@ export default {
|
||||
callbackUrlAutoHint: 'Можно оставить пустым — адрес будет определён автоматически',
|
||||
enabled: 'Включён',
|
||||
disabled: 'Выключен',
|
||||
toggleFailed: 'Не удалось изменить статус SSO',
|
||||
enableRequiresVerifiedDomain: 'Перед включением провайдера подтвердите домен почты',
|
||||
callbackUrlLabel: 'Callback URL (укажи в настройках IdP)',
|
||||
copied: 'Скопировано',
|
||||
copyFailed: 'Не удалось скопировать',
|
||||
|
||||
Reference in New Issue
Block a user