diff --git a/changelog.d/2-features/multi-ingress-cross-IdP-SSO b/changelog.d/2-features/multi-ingress-cross-IdP-SSO new file mode 100644 index 00000000000..c049a5147f8 --- /dev/null +++ b/changelog.d/2-features/multi-ingress-cross-IdP-SSO @@ -0,0 +1,10 @@ +When a team uses multiple SAML IdPs (one per ingress domain) in a multi-ingress +setup, users can now authenticate via any of the team's IdPs even if their +account was originally provisioned under a different one. Spar resolves the +correct account by email-based NameID lookup across all team IdPs and migrates +the user's SSO identity to the authenticating IdP transparently. + +**Important:** Email addresses (`NameID`s) must be unique across configured +IdPs! Otherwise, users may be logged in into wrong accounts! + +Please refer to the documentation for further information. diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 4a269a7fe45..c21da25f5d5 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -1266,7 +1266,8 @@ Given an email address, the SSO code is looked up by these criteria: This is the case for: - Teams with exactly one configured IdP - There is an IdP for the given multi-ingress domain -- The user was created via SCIM +- The user is a SSO user. So it was created via SCIM with SSO enabled (any IdP + configured in SCIM token) OR created via SSO (no SCIM involved). The last condition ensures that team admins cannot get into locked-out situations due to misconfigured IdPs. @@ -1500,6 +1501,60 @@ error. Though, IdPs can be reconfigured as long as this invariant holds. Putting it differently: We require an unambiguous mapping `(team, domain) -> IdP`. +#### Multi-ingress cross-IdP SSO (fallback) + +Terms used below: + +- _Authenticating IdP_ — the external identity provider that issued the SAML + assertion, identified by the `Issuer` URI inside it. +- _IdP configuration_ — backend's IdP representation registered via + `/identity-providers`, storing the issuer URI, the associated multi-ingress + domain, and the team. + +In the normal SSO flow spar looks up the authenticating user by their `(issuer, +NameID)` pair — matching the assertion's issuer against the IdP configuration +the user was provisioned under. + +In a multi-ingress setup each domain has its own IdP configuration with its own +issuer URI. A user provisioned under domain _A_ has their SSO identity tied to +issuer _A_'s URI. When that user later authenticates via domain _B_, the IdP +authentication response's assertion carries issuer _B_'s URI, so the primary +`(issuer, NameID)` lookup finds nothing. Using a shared static issuer across +all domains is not an option because each external identity provider has its +own issuer URI that spar cannot control. + +When this primary lookup finds no user, spar therefore attempts a cross-IdP +migration when multi-ingress is configured: + +1. **NameID must be an email address.** Username-based `NameID`s are rejected + to avoid ambiguity across authenticating IdPs. +2. **The matching IdP configuration is resolved.** Spar looks for an IdP + configuration in the team whose issuer URI and configured domain both match the + assertion's issuer and the incoming `Z-Host` header (exact match). If no exact + match is found and the team has exactly one IdP configuration, that one is used + unconditionally (no issuer or domain check). If neither condition is met, the + login is rejected. +3. **Team-wide user search.** Spar searches all of the team's IdP + configurations for a user whose email NameID matches the subject. This can be + understood as trying to login with all team IdP configurations. This step also + _finds_ the user, before we found them we don't know their IdP. (So, we can't + simply use their IdP first.) +4. **Migrate or provision:** + - _Exactly one match found:_ The user's SSO identity is updated to point to + the IdP configuration for the authenticating IdP's issuer, so subsequent + logins hit the primary lookup directly. This saves the complexity of the IdP + configuration lookup and keeps the backend's representations of the user's + SSO data sound. + - _No match found:_ A new user account is auto-provisioned under the + authenticating IdP's configuration. + - _No matching IdP configuration can be resolved:_ Login is rejected. + +##### Security considerations + +It must be ensured that email `NameID`s are unique across IdPs by IdP +administrators. Otherwise, users are falsely logged in into other user's +accounts! + ### Webapp The webapp runs its own web server (a NodeJS server) to serve static files and the webapp config (based on environment variables). diff --git a/integration/integration.cabal b/integration/integration.cabal index 49fc43bcf5a..17325bb4895 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -207,6 +207,7 @@ library Test.Shape Test.Spar Test.Spar.GetByEmail + Test.Spar.MultiIngressCrossIdpSso Test.Spar.MultiIngressIdp Test.Spar.MultiIngressSSO Test.Spar.STM diff --git a/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs b/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs new file mode 100644 index 00000000000..1de56efc7dd --- /dev/null +++ b/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs @@ -0,0 +1,579 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Spar.MultiIngressCrossIdpSso where + +import API.BrigInternal (getUsersId) +import API.Common (randomEmail, randomHandle) +import API.Spar + ( CreateScimToken (..), + createIdpWithZHostV2, + createScimToken, + createScimUser, + finalizeSamlLoginWithZHost, + getSPMetadataWithZHost, + getSsoCodeByEmailWithZHost, + initiateSamlLoginWithZHostAndLabel, + ) +import Control.Lens ((^.)) +import Data.ByteString.Char8 (unpack) +import Data.Either.Extra +import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.String.Conversions (cs) +import Data.Text (pack) +import qualified Data.UUID as UUID +import GHC.Stack +import qualified SAML2.WebSSO as SAML +import qualified SAML2.WebSSO.Test.MockResponse as SAML +import SAML2.WebSSO.Test.Util (SampleIdP (..)) +import SetupHelpers +import Testlib.Prelude +import qualified Text.XML.DSig as SAML + +ernieDomain, bertDomain, ernieZHost, bertZHost :: String +ernieDomain = "ernie.example.com" +bertDomain = "bert.example.com" +ernieZHost = "nginz-https." <> ernieDomain +bertZHost = "nginz-https." <> bertDomain + +-- | Test that a user provisioned under one IdP can log in via another IdP, +-- with their SSO identity migrating to the new IdP transparently. +-- +-- Covers both SCIM-provisioned and auto-provisioned users, and verifies +-- back-and-forth migration between IdPs. +testCrossIdpSsoMigration :: (HasCallStack) => Bool -> App () +testCrossIdpSsoMigration useSCIM = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + -- Register IdP for Ernie's domain + SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer "ernie" + idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie + idpIdErnie <- asString $ idpErnie.json %. "id" + + -- Register IdP for Bert's domain + SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer "bert" + idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + idpIdBert <- asString $ idpBert.json %. "id" + + ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString + bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString + + (biboEmail, biboNameId) <- randomEmailNameId + + -- Optionally create the user via SCIM (and not automatically) + mScimUserId <- + if useSCIM + then do + -- Create SCIM token associated with Ernie's IdP + scimTok <- createScimToken owner (def {idp = Just idpIdErnie}) + scimToken <- scimTok.json %. "token" & asString + + -- Create SCIM user with the email + scimUser <- randomScimUserWithEmail biboEmail biboEmail + scimUid <- bindResponse (createScimUser domain scimToken scimUser) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "id" >>= asString + + activateEmail domain biboEmail + + pure (Just scimUid) + else pure Nothing + + -- Bibo logs in on Ernie ingress (should succeed) + userIdErnie <- + loginWithSamlWithZHost + (Just ernieZHost) + domain + True -- expect success + tid + biboNameId + (idpIdErnie, (idpMetaErnie, pCredsErnie)) + >>= maybe (error "Expected user ID from SSO login on Ernie domain") pure + . fst + + case mScimUserId of + Just scimUid -> + -- Validate that SCIM-created user matches SSO login user + scimUid `shouldMatch` userIdErnie + Nothing -> + -- Non-SCIM user was auto-provisioned. Activate them. + activateEmail domain biboEmail + + -- Verify user's SSO ID has Ernie's issuer (not Bert's) + getUsersId domain [userIdErnie] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` ernieIssuer + ssoIdTenant `shouldNotMatch` bertIssuer + + -- Verify sso/get-by-email returns Ernie's IdP + getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoCodeStr <- resp.json %. "sso_code" >>= asString + ssoCodeStr `shouldMatch` idpIdErnie + + -- Bibo re-logs in on Ernie (should succeed - proves SSO works on same ingress) + (mUserIdErnieAgain, _) <- + loginWithSamlWithZHost + (Just ernieZHost) + domain + True -- expect success + tid + biboNameId + (idpIdErnie, (idpMetaErnie, pCredsErnie)) + + case mUserIdErnieAgain of + Just uid -> uid `shouldMatch` userIdErnie + Nothing -> error "Expected user ID from re-login on Ernie domain" + + -- Same Bibo logs in on Bert ingress with SAME email + -- This should SUCCEED because of cross-IdP SSO migration. + (mUserIdBert, _) <- + loginWithSamlWithZHost + (Just bertZHost) + domain + True -- expect success + tid + biboNameId + (idpIdBert, (idpMetaBert, pCredsBert)) + + -- Verify the same user ID is returned (cross-IdP SSO migration worked) + case mUserIdBert of + Just uid -> uid `shouldMatch` userIdErnie + Nothing -> error "Expected user ID from cross-IdP SSO login on Bert domain" + + -- Verify user's SSO ID was migrated to Bert's issuer (not Ernie's anymore) + getUsersId domain [userIdErnie] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` bertIssuer + ssoIdTenant `shouldNotMatch` ernieIssuer + + -- Verify sso/get-by-email returns Bert's IdP for Bert's ingress after migration + getSsoCodeByEmailWithZHost domain (Just bertZHost) biboEmail `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoCodeStr <- resp.json %. "sso_code" >>= asString + ssoCodeStr `shouldMatch` idpIdBert + + -- Verify sso/get-by-email returns Ernie's IdP for Ernie's ingress after migration + getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoCodeStr <- resp.json %. "sso_code" >>= asString + ssoCodeStr `shouldMatch` idpIdErnie + + -- Login on Ernie again to show back-and-forth migration works + (mUserIdErnieFinal, _) <- + loginWithSamlWithZHost + (Just ernieZHost) + domain + True -- expect success + tid + biboNameId + (idpIdErnie, (idpMetaErnie, pCredsErnie)) + + case mUserIdErnieFinal of + Just uid -> uid `shouldMatch` userIdErnie + Nothing -> error "Expected user ID from final login on Ernie domain" + + -- Verify user's SSO ID was migrated back to Ernie's IdP + getUsersId domain [userIdErnie] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` ernieIssuer + ssoIdTenant `shouldNotMatch` bertIssuer + + -- Verify sso/get-by-email returns correct IdP by ingress + getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoCodeStr <- resp.json %. "sso_code" >>= asString + ssoCodeStr `shouldMatch` idpIdErnie + + getSsoCodeByEmailWithZHost domain (Just bertZHost) biboEmail `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoCodeStr <- resp.json %. "sso_code" >>= asString + ssoCodeStr `shouldMatch` idpIdBert + +-- | Cross-IdP migration works even when the user's first SSO login is on a different IdP +-- than the one they were SCIM-provisioned under. +testScimUserLoginsDifferentIdP :: (HasCallStack) => App () +testScimUserLoginsDifferentIdP = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + -- Register IdP for Ernie's domain + SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer "ernie" + idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie + idpIdErnie <- asString $ idpErnie.json %. "id" + + -- Register IdP for Bert's domain + SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer "bert" + idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + idpIdBert <- asString $ idpBert.json %. "id" + + ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString + bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString + + (biboEmail, biboNameId) <- randomEmailNameId + + -- Provision SCIM user for Ernie's IdP + scimTok <- createScimToken owner (def {idp = Just idpIdErnie}) + scimToken <- scimTok.json %. "token" & asString + + scimUser <- randomScimUserWithEmail biboEmail biboEmail + biboUid <- bindResponse (createScimUser domain scimToken scimUser) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "id" >>= asString + + activateEmail domain biboEmail + + -- Verify user was created with Ernie's SSO ID + getUsersId domain [biboUid] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` ernieIssuer + + -- Bibo logs in for the FIRST time on Bert's IdP (NOT Ernie!) + -- This tests cross-IdP migration when user has never logged in before (only SCIM provisioned) + userIdBert <- + loginWithSamlWithZHost + (Just bertZHost) + domain + True -- expect success + tid + biboNameId + (idpIdBert, (idpMetaBert, pCredsBert)) + >>= maybe (error "Expected user ID from cross-IdP SSO login on Bert domain") pure + . fst + + -- Verify the same user ID is returned (cross-IdP SSO migration worked) + userIdBert `shouldMatch` biboUid + + -- Verify user's SSO ID was migrated to Bert's issuer + getUsersId domain [userIdBert] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` bertIssuer + ssoIdTenant `shouldNotMatch` ernieIssuer + + -- Login on Ernie to verify back-migration also works + (mUserIdErnie, _) <- + loginWithSamlWithZHost + (Just ernieZHost) + domain + True -- expect success + tid + biboNameId + (idpIdErnie, (idpMetaErnie, pCredsErnie)) + + case mUserIdErnie of + Just uid -> uid `shouldMatch` biboUid + Nothing -> error "Expected user ID from login on Ernie domain" + + -- Verify user's SSO ID was migrated back to Ernie's issuer + getUsersId domain [biboUid] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` ernieIssuer + ssoIdTenant `shouldNotMatch` bertIssuer + +-- | Test cross-domain login when team has a single IdP. +-- +-- As IdPs cannot be deleted when users are bound to them, having a single IdP +-- implies that all SSO users are bound to it. +testSingletonIdpWorksOnAllDomains :: (HasCallStack) => App () +testSingletonIdpWorksOnAllDomains = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + -- Register ONLY ONE IdP for Bert domain + -- This is the key: there's only a single IdP for the team + SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer "bert" + idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + idpIdBert <- asString $ idpBert.json %. "id" + + bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString + + (biboEmail, biboNameId) <- randomEmailNameId + + -- Provision SCIM user for Bert's IdP + scimTok <- createScimToken owner (def {idp = Just idpIdBert}) + scimToken <- scimTok.json %. "token" & asString + + scimUser <- randomScimUserWithEmail biboEmail biboEmail + biboUid <- bindResponse (createScimUser domain scimToken scimUser) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "id" >>= asString + + activateEmail domain biboEmail + + -- Verify user was created with Bert's SSO ID + getUsersId domain [biboUid] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` bertIssuer + + -- User logs in via ERNIE ingress (different domain from IdP registration) + -- Bert's singleton IdP is valid here are well. + userIdFromErnie <- + loginWithSamlWithZHost + (Just ernieZHost) + domain + True -- expect success + tid + biboNameId + (idpIdBert, (idpMetaBert, pCredsBert)) + >>= maybe (error "Expected user ID from cross-domain login") pure + . fst + + userIdFromErnie `shouldMatch` biboUid + + -- Verify user's SSO ID is still Bert's issuer + getUsersId domain [userIdFromErnie] `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + ssoId <- resp.json %. "0.sso_id" + ssoIdTenant <- ssoId %. "tenant" >>= asString + ssoIdTenant `shouldContain` bertIssuer + +-- | Login fails when the authenticating IdP's issuer is not registered for the target domain. +-- (Multiple IdPs configured, so the singleton fallback does not apply.) +testIdpNotFoundError :: (HasCallStack) => App () +testIdpNotFoundError = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + -- Register TWO IdPs: one for Ernie domain, one for Bert domain + SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer "ernie" + idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie + idpIdErnie <- asString $ idpErnie.json %. "id" + ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString + + SampleIdP idpMetaBert _ _ _ <- makeSampleIdPMetadataWithIssuer "bert" + _idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + + -- Ernie's IdP is registered for ernieZHost, so authenticating on bertZHost with Ernie's + -- credentials triggers a "not found" error (multiple IdPs, no singleton fallback). + (_biboEmail, biboNameId) <- randomEmailNameId + + authnReqResp <- buildSamlAuthnResponse domain bertZHost tid idpIdErnie idpMetaErnie pCredsErnie biboNameId + + bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do + resp.status `shouldMatchInt` 200 + let bdy = unpack resp.body + bdy `shouldContain` "wire:sso:error:" + bdy `shouldContain` "\"type\":\"AUTH_ERROR\"" + bdy `shouldContain` "wire:sso:error:not-found" + bdy `shouldContain` "\"label\":\"forbidden\"" + let expectedErrorMsg = + "Could not find IdP: IdP with issuer '\\\"" + <> ernieIssuer + <> "\\\"' for domain '" + <> bertZHost + <> "' is not configured for this team" + bdy `shouldContain` expectedErrorMsg + +-- | Test that a user of one team cannot log in using the IdP of a different team. +-- +-- Team B's IdP must not grant access to Team A, even when the SAML response is otherwise +-- well-formed. +testCrossTeamIdpLoginRejected :: (HasCallStack) => App () +testCrossTeamIdpLoginRejected = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + -- Team A with IdP A on bert domain + (ownerA, tidA, _) <- createTeam domain 1 + SampleIdP idpMetaA pCredsA _ _ <- makeSampleIdPMetadataWithIssuer "team-a" + idpA <- createIdpWithZHostV2 ownerA (Just bertZHost) idpMetaA + idpIdA <- asString $ idpA.json %. "id" + + -- Team B with IdP B on ernie domain + (ownerB, _, _) <- createTeam domain 1 + SampleIdP idpMetaB pCredsB _ _ <- makeSampleIdPMetadataWithIssuer "team-b" + idpB <- createIdpWithZHostV2 ownerB (Just ernieZHost) idpMetaB + idpIdB <- asString $ idpB.json %. "id" + + -- Create Bibo as a user of Team A + (biboEmail, biboNameId) <- randomEmailNameId + _ <- loginWithSamlWithZHost (Just bertZHost) domain True tidA biboNameId (idpIdA, (idpMetaA, pCredsA)) + activateEmail domain biboEmail + + -- Team B's IdP issuer is not registered under Team A, so spar returns 404. + authnReqRespBert <- buildSamlAuthnResponse domain bertZHost tidA idpIdB idpMetaB pCredsB biboNameId + bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tidA authnReqRespBert) $ \resp -> + resp.status `shouldMatchInt` 404 + + authnReqRespErnie <- buildSamlAuthnResponse domain ernieZHost tidA idpIdB idpMetaB pCredsB biboNameId + bindResponse (finalizeSamlLoginWithZHost domain (Just ernieZHost) tidA authnReqRespErnie) $ \resp -> + resp.status `shouldMatchInt` 404 + +-- | Test that non-email NameIDs are rejected in multi-ingress mode. +-- +-- Multi-ingress cross-IdP SSO requires email-based NameIDs to prevent ambiguities. +testNonEmailNameIdRejectedInMultiIngress :: (HasCallStack) => App () +testNonEmailNameIdRejectedInMultiIngress = do + withMultiIngressBackend [bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + -- Register IdP + SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer "bert" + idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + idpIdBert <- asString $ idpBert.json %. "id" + + randomUsername <- randomHandle + let usernameNameId = + fromRight (error "could not create name id") + $ SAML.mkNameID (SAML.mkUNameIDUnspecified (pack randomUsername)) Nothing Nothing Nothing + + authnReqResp <- buildSamlAuthnResponse domain bertZHost tid idpIdBert idpMetaBert pCredsBert usernameNameId + bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do + resp.status `shouldMatchInt` 200 + let bdy = unpack resp.body + bdy `shouldContain` "wire:sso:error:multi-ingress-config-error" + bdy `shouldContain` "Multi-ingress SSO requires email-based NameIDs: Multi-ingress SSO only supports email-based NameIDs for cross-IdP migration. Username-based NameIDs are not allowed." + +-- | Test that SAML responses without a prior authentication request are rejected. +-- +-- A response referencing a request Spar never stored results in a "bad InResponseTo" error. +testUnsolicitedSamlResponseRejected :: (HasCallStack) => App () +testUnsolicitedSamlResponseRejected = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + SampleIdP idpMetaErnie _ _ _ <- makeSampleIdPMetadataWithIssuer "ernie" + void $ createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie + + SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer "bert" + idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + idpIdBert <- asString $ idpBert.json %. "id" + + (_biboEmail, biboNameId) <- randomEmailNameId + + spmeta <- getSPMetadataWithZHost domain (Just bertZHost) tid + let spMetaData = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmeta.body + idpConfig = SAML.IdPConfig (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpIdBert))) idpMetaBert () + -- Create a local authn request (stored in SimpleSP's in-memory store, not in Spar's database) + localReq <- runSimpleSP $ SAML.createAuthnRequest 300 (idpMetaBert ^. SAML.edIssuer) (idpMetaBert ^. SAML.edIssuer) + authnReqResp <- makeAuthnResponse biboNameId pCredsBert idpConfig spMetaData localReq + + -- Spar cannot find the request (no verdict format stored), so it rejects with server error. + -- This is not a user flow, so we can accept any error - even 500 - here. + bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do + resp.status `shouldMatchInt` 500 + resp.json %. "label" `shouldMatch` "server-error" + +-- | Test that SAML responses for one ingress are rejected when submitted to a +-- different ingress. +-- +-- A login request on the ernie ingress must be finalized on the ernie ingress. +-- Finalizing on the bert ingress should fail with a bad recipient error. +testCrossIngressRequestResponseMismatch :: (HasCallStack) => App () +testCrossIngressRequestResponseMismatch = do + withMultiIngressBackend [ernieDomain, bertDomain] $ \domain -> do + (owner, tid, _) <- createTeam domain 1 + + SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer "ernie" + idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie + idpIdErnie <- asString $ idpErnie.json %. "id" + + SampleIdP idpMetaBert _ _ _ <- makeSampleIdPMetadataWithIssuer "bert" + void $ createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert + + (_biboEmail, biboNameId) <- randomEmailNameId + + -- The SAML response's Destination is ernie's ACS (Assertion Consumer Service) URL, + -- i.e. ernie's /sso/finalize-login endpoint. Submitting it to bert's endpoint causes + -- a Destination mismatch ("bad Recipient"). + authnReqResp <- buildSamlAuthnResponse domain ernieZHost tid idpIdErnie idpMetaErnie pCredsErnie biboNameId + + -- Finalize on bert ingress — Destination mismatch, bad recipient + bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do + resp.status `shouldMatchInt` 200 + let bdy = unpack resp.body + bdy `shouldContain` "wire:sso:error:forbidden" + bdy `shouldContain` "bad Recipient" + +-- | Run a test with the standard multi-ingress backend configuration. +-- Takes base domain names (e.g. "ernie.example.com"); the ZHost and SSO/webapp URLs +-- are derived from each base domain. +withMultiIngressBackend :: (HasCallStack) => [String] -> (String -> App ()) -> App () +withMultiIngressBackend baseDomains action = + withModifiedBackend + def + { sparCfg = + removeField "saml.spSsoUri" + >=> removeField "saml.spAppUri" + >=> removeField "saml.contacts" + >=> setField "saml.spDomainConfigs" (object (map mkDomainEntry baseDomains)) + >=> setField "enableIdPByEmailDiscovery" True, + galleyCfg = setField "settings.featureFlags.sso" "enabled-by-default" + } + action + where + mkDomainEntry base = + ("nginz-https." <> base) + .= object + [ "spAppUri" .= ("https://webapp." <> base :: String), + "spSsoUri" .= ("https://nginz-https." <> base <> "/sso" :: String), + "contacts" .= [object ["type" .= ("ContactTechnical" :: String)]] + ] + +-- | Initiate a SAML login and build a signed authn response for the given NameID. +-- Use this when testing error cases that require manual control over the finalize step. +buildSamlAuthnResponse :: + (HasCallStack, MakesValue domain) => + domain -> + String -> + String -> + String -> + SAML.IdPMetadata -> + SAML.SignPrivCreds -> + SAML.NameID -> + App SAML.SignedAuthnResponse +buildSamlAuthnResponse domain mbZHost tid idpId idpMeta pcreds nameId = do + spmeta <- getSPMetadataWithZHost domain (Just mbZHost) tid + authnreq <- initiateSamlLoginWithZHostAndLabel domain (Just mbZHost) Nothing idpId + let spMetaData = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmeta.body + parsedAuthnReq = parseAuthnReqResp authnreq.body + idpConfig = + SAML.IdPConfig + (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpId))) + idpMeta + () + makeAuthnResponse nameId pcreds idpConfig spMetaData parsedAuthnReq + +-- | Generate a random email address and the corresponding email-based SAML NameID. +randomEmailNameId :: (HasCallStack) => App (String, SAML.NameID) +randomEmailNameId = do + email <- randomEmail + let nameId = fromRight (error "could not create name id") $ SAML.emailNameID (pack email) + pure (email, nameId) + +-- | Helper to create IdP metadata with a fixed issuer suffix +makeSampleIdPMetadataWithIssuer :: (HasCallStack) => String -> App SampleIdP +makeSampleIdPMetadataWithIssuer suffix = do + let issuerUri = pack $ "https://issuer.net/_" <> suffix + requriUri = pack $ "https://requri.net/_req_" <> suffix + issuer = SAML.Issuer . fromRight' $ SAML.parseURI' issuerUri + requri = fromRight' $ SAML.parseURI' requriUri + (privcreds, creds, cert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96 + pure $ SampleIdP (SAML.IdPMetadata issuer requri (cert :| [])) privcreds creds cert diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs index 99d1e7249a9..4556999c21e 100644 --- a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs @@ -93,7 +93,7 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = case users of [] -> pure Nothing [user] -> do - if isScimOrSsoUser user + if isSsoUser user then do mbTeam <- getTeamId (userId user) case mbTeam of @@ -121,9 +121,11 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = -- Multiple IdPs: find by domain if host provided _idps' -> findIdPByDomain idps - isScimOrSsoUser :: User -> Bool - isScimOrSsoUser user = - userManagedBy user == ManagedByScim && isJust (userSSOId user) + -- This used to check if the user is SCIM AND SSO! The RFC ("2025-05-12 + -- RFC: Default SSO flow for team by host domain") is not really + -- unambiguous about this. The customer currently provisions non-SCIM. + isSsoUser :: User -> Bool + isSsoUser = isJust . userSSOId findIdPByDomain :: (Member (Logger (Log.Msg -> Log.Msg)) r) => [IP.IdP] -> Sem r (Maybe SAML.IdPId) findIdPByDomain idps = do diff --git a/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs index a0a1b772c8e..b478777b22e 100644 --- a/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs @@ -37,7 +37,6 @@ import System.Logger.Message qualified as Log import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck -import Test.QuickCheck.Gen import Wire.API.Team.Member import Wire.API.User import Wire.API.User.IdentityProvider @@ -313,19 +312,12 @@ spec = describe "IdPSubsystem.Interpreter" $ do result `shouldBe` Right Nothing expectedSevereLogs logs mempty - prop "returns Nothing for non SCIM/SSO user" $ \(teamMember :: TeamMember) user idp userRef email teamId -> do - (userIdentity, userManagedBy) <- - generate $ - ( do - ui <- Test.QuickCheck.Gen.elements [Just (SSOIdentity (UserSSOId userRef) (Just email)), Nothing] - mngtBy :: ManagedBy <- arbitrary - pure (ui, mngtBy) - ) - `suchThat` (\(ui, mngtBy) -> isNothing ui || mngtBy == ManagedByWire) + prop "returns Nothing for non SSO user" $ \(teamMember :: TeamMember) user idp email teamId -> do + userManagedBy <- generate (arbitrary :: Gen ManagedBy) let userWithEmail = user - { userIdentity = userIdentity, + { userIdentity = Nothing, -- No SSO identity userEmailUnvalidated = Just email, userTeam = Just teamId, userManagedBy = userManagedBy diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index d1e255da57c..04b785172ad 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -200,12 +200,14 @@ api opts = apiSSO :: ( Member GalleyAPIAccess r, Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member (Input Opts) r, Member BrigAPIAccess r, Member AssIDStore r, Member VerdictFormatStore r, Member AReqIDStore r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member DefaultSsoCode r, Member IdPConfigStore r, Member IdPSubsystem r, @@ -224,8 +226,8 @@ apiSSO opts = :<|> Named @"sso-team-metadata" (\mbHost tid -> getMetadata (Just tid) mbHost) :<|> Named @"auth-req-precheck" authreqPrecheck :<|> Named @"auth-req" (authreq (maxttlAuthreqDiffTime opts)) - :<|> Named @"auth-resp-legacy" (authresp Nothing) - :<|> Named @"auth-resp" (authresp . Just) + :<|> Named @"auth-resp-legacy" (authresp opts.saml Nothing) + :<|> Named @"auth-resp" (authresp opts.saml . Just) :<|> Named @"sso-settings" ssoSettings :<|> Named @"sso-get-by-email" getSsoCodeByEmail @@ -382,7 +384,7 @@ validateRedirectURL uri = do authresp :: forall r. ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member (Input Opts) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, @@ -390,6 +392,7 @@ authresp :: Member VerdictFormatStore r, Member AReqIDStore r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member SAML2 r, Member SamlProtocolSettings r, @@ -397,11 +400,12 @@ authresp :: Member Reporter r, Member SAMLUserStore r ) => + SAML.Config -> Maybe TeamId -> SAML.AuthnResponseBody -> Maybe Text -> Sem r Void -authresp mbtid arbody mbHost = do +authresp samlConfig mbtid arbody mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") @@ -419,9 +423,9 @@ authresp mbtid arbody mbHost = do go _assertions idp (SAML.AccessDenied (shouldRedirectToInit -> True)) = do -- redirect back to idp for idp-initiated login. redirectToInit idp - go assertions verdict idp = do + go assertions idp verdict = do -- handle the verdict - SAML.ResponseVerdict result <- verdictHandler assertions idp verdict + SAML.ResponseVerdict result <- verdictHandler assertions verdict idp samlConfig mbHost throw @SparError $ SAML.CustomServant result -- Whenever at least one of the denied reasons is `DeniedNoInResponseTo`, try again. diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 8ec7c658523..58e277480be 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -39,6 +39,7 @@ import Bilge import qualified Cassandra as Cas import Control.Exception (assert) import Control.Lens hiding ((.=)) +import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) import Data.Aeson as Aeson (encode, object, (.=)) import Data.Aeson.Text as Aeson (encodeToLazyText) import Data.ByteString (toStrict) @@ -85,6 +86,8 @@ import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore import Spar.Sem.VerdictFormatStore (VerdictFormatStore) import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore +import System.Logger (Msg) +import qualified System.Logger as Log import qualified System.Logger as TinyLog import URI.ByteString as URI import Web.Cookie (SetCookie, renderSetCookie) @@ -276,12 +279,13 @@ validateEmail _ _ _ = pure () verdictHandler :: (HasCallStack) => ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, Member AReqIDStore r, Member VerdictFormatStore r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member (Error SparError) r, Member Reporter r, @@ -290,12 +294,14 @@ verdictHandler :: NonEmpty SAML.Assertion -> SAML.AccessVerdict -> IdP -> + SAML.Config -> + Maybe Text -> Sem r SAML.ResponseVerdict -verdictHandler aresp verdict idp = do +verdictHandler aresp verdict idp samlConfig mbHost = do -- [3/4.1.4.2] -- [...] If the containing message is in response to an , then -- the InResponseTo attribute MUST match the request's ID. - Logger.log Logger.Debug $ "entering verdictHandler: " <> show (aresp, verdict) + Logger.debug $ Log.msg ("entering verdictHandler" :: String) . Log.field "aresp" (show aresp) . Log.field "verdict" (show verdict) reqid <- do let xs = SAML.assertionToInResponseTo `mapM` aresp case NonEmpty.nub <$> xs of @@ -305,13 +311,13 @@ verdictHandler aresp verdict idp = do format :: Maybe VerdictFormat <- VerdictFormatStore.get reqid resp <- case format of Just (VerdictFormatWeb mlabel) -> - verdictHandlerResult verdict idp mlabel >>= verdictHandlerWeb + verdictHandlerResult verdict idp mlabel samlConfig mbHost >>= verdictHandlerWeb Just (VerdictFormatMobile granted denied mlabel) -> - verdictHandlerResult verdict idp mlabel >>= verdictHandlerMobile granted denied + verdictHandlerResult verdict idp mlabel samlConfig mbHost >>= verdictHandlerMobile granted denied Nothing -> -- (this shouldn't happen too often, see 'storeVerdictFormat') throwSparSem SparNoSuchRequest - Logger.log Logger.Debug $ "leaving verdictHandler: " <> show resp + Logger.debug $ Log.msg ("leaving verdictHandler" :: String) . Log.field "resp" (show resp) pure resp data VerdictHandlerResult @@ -323,10 +329,11 @@ data VerdictHandlerResult verdictHandlerResult :: (HasCallStack) => ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member (Error SparError) r, Member Reporter r, @@ -335,11 +342,13 @@ verdictHandlerResult :: SAML.AccessVerdict -> IdP -> Maybe CookieLabel -> + SAML.Config -> + Maybe Text -> Sem r VerdictHandlerResult -verdictHandlerResult verdict idp mlabel = do - Logger.log Logger.Debug $ "entering verdictHandlerResult" - result <- catchVerdictErrors $ verdictHandlerResultCore idp verdict mlabel - Logger.log Logger.Debug $ "leaving verdictHandlerResult" <> show result +verdictHandlerResult verdict idp mlabel samlConfig mbHost = do + Logger.debug $ Log.msg ("entering verdictHandlerResult" :: String) + result <- catchVerdictErrors $ verdictHandlerResultCore idp verdict mlabel samlConfig mbHost + Logger.debug $ Log.msg ("leaving verdictHandlerResult" :: String) . Log.field "result" (show result) pure result catchVerdictErrors :: @@ -399,12 +408,14 @@ moveUserToNewIssuer oldUserRef newUserRef uid = do SAMLUserStore.delete uid oldUserRef verdictHandlerResultCore :: + forall r. (HasCallStack) => ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member (Error SparError) r, Member SAMLUserStore r @@ -412,35 +423,142 @@ verdictHandlerResultCore :: IdP -> SAML.AccessVerdict -> Maybe CookieLabel -> + SAML.Config -> + Maybe Text -> Sem r VerdictHandlerResult -verdictHandlerResultCore idp verdict mlabel = case verdict of +verdictHandlerResultCore idp verdict mlabel samlConfig mbHost = case verdict of SAML.AccessDenied reasons -> do pure $ VerifyHandlerDenied reasons SAML.AccessGranted uref -> do uid :: UserId <- do let team' = idp ^. idpExtraInfo . team - err = SparUserRefInNoOrMultipleTeams . LText.pack . show $ uref - getUserByUrefUnsafe uref >>= \case - Just usr -> do - if userTeam usr == Just team' - then pure (userId usr) - else throwSparSem err - Nothing -> do - getUserByUrefViaOldIssuerUnsafe idp uref >>= \case - Just (olduref, usr) -> do - let uid = userId usr - if userTeam usr == Just team' - then moveUserToNewIssuer olduref uref uid >> pure uid - else throwSparSem err - Nothing -> do - buid <- Id <$> Random.uuid - autoprovisionSamlUser idp buid uref - validateSamlEmailIfExists buid uref - pure buid - - Logger.log Logger.Debug ("granting sso login for " <> show uid) + findUserWithUref idp team' uref >>= \case + Just uid -> pure uid + Nothing + | SAML.isMultiIngressConfig samlConfig -> + multiIngressFlow team' + Nothing -> provisionNewUser + Logger.debug $ Log.msg ("granting sso login" :: String) . Log.field "user" (idToText uid) cky <- BrigAPIAccess.ssoLogin uid mlabel pure $ VerifyHandlerGranted cky uid + where + provisionNewUser :: Sem r UserId + provisionNewUser = do + buid <- Id <$> Random.uuid + autoprovisionSamlUser idp buid uref + validateSamlEmailIfExists buid uref + pure buid + + -- Try to find a user by UserRef, with fallback to old issuers. + -- Returns the UserId if found and in the correct team, Nothing if not found. + -- Throws SparUserRefInNoOrMultipleTeams if user is found but in the wrong team. + -- Side effect: Old-style users (found via old issuers) are migrated to the new issuer. + findUserWithUref :: IdP -> TeamId -> SAML.UserRef -> Sem r (Maybe UserId) + findUserWithUref idp' team'' uref' = do + let err = SparUserRefInNoOrMultipleTeams . LText.pack . show $ uref' + getUserByUrefUnsafe uref' >>= \case + Just usr -> do + if userTeam usr == Just team'' + then pure (Just (userId usr)) + else throwSparSem err + Nothing -> do + getUserByUrefViaOldIssuerUnsafe idp' uref' >>= \case + Just (olduref, usr) -> do + let uid = userId usr + if userTeam usr == Just team'' + then moveUserToNewIssuer olduref uref' uid >> pure (Just uid) + else throwSparSem err + Nothing -> pure Nothing + + -- In multi-ingress scenarios users can be already assigned to one IdP, + -- but try to authenticate with another. We allow this, when the new IdP + -- is configured for the user's team and the used domain. Additionally, + -- the provided NameId must be an email address (no username) to prevent + -- ambiguities (though, we know this won't be guarding against all + -- ambiguity cases). + -- When we've found the matching IdP and the user's old one, we migrate + -- the user to the new one to not have to run this search again when the + -- user logs in with this IdP. + multiIngressFlow :: TeamId -> Sem r UserId + multiIngressFlow team' = + case uref of + -- Cross-IdP SSO migration only for email-based NameIDs in + -- multi-ingress mode. We may consider to lower the email-only + -- constraint in future. For now, Emil and Sven decided that emails + -- might be a bit more consistent across IdPs then usernames. + SAML.UserRef _ (view SAML.nameID -> UNameIDEmail _) -> do + teamIdPs <- IdPConfigStore.getConfigsByTeam team' + let urefIssuer = uref ^. SAML.uidTenant + + case selectAuthenticatingIdP teamIdPs urefIssuer mbHost of + Nothing -> do + let issuerText = urefIssuer ^. SAML.fromIssuer . to URI.serializeURIRef' + domainText = fromMaybe "default" mbHost + errorMsg = + LText.pack $ + "IdP with issuer '" + <> show issuerText + <> "' for domain '" + <> Text.unpack domainText + <> "' is not configured for this team" + throwSparSem $ SparIdPNotFound errorMsg + Just multiIngressIdp -> do + let subject = uref ^. SAML.uidSubject + findUserInTeamIdPs team' subject teamIdPs >>= \case + Nothing -> do + Logger.info $ + Log.msg ("Multi-ingress SSO: IdP found but user does not exist, provisioning new user" :: String) + . Log.field "team" (idToText (idp ^. idpExtraInfo . team)) + . Log.field "issuer" (uref ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef') + . Log.field "multi_ingress_idp" (multiIngressIdp ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "authenticating_idp" (idp ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "domain" (mbHost & fromMaybe "None") + provisionNewUser + Just (uid, oldUref) -> + do + Logger.info $ + Log.msg ("Multi-ingress SSO: user found via different IdP, migrating issuer" :: String) + . Log.field "team" (idToText (idp ^. idpExtraInfo . team)) + . Log.field "user" (idToText uid) + . Log.field "old_issuer" (oldUref ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef') + . Log.field "new_issuer" (uref ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef') + . Log.field "authenticating_idp" (idp ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "multi_ingress_idp" (multiIngressIdp ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "domain" (mbHost & fromMaybe "None") + moveUserToNewIssuer oldUref uref uid + pure uid + _ -> + throwSparSem . SparMultiIngressIdPConfiguration $ + "Multi-ingress SSO only supports email-based NameIDs for cross-IdP migration. " + <> "Username-based NameIDs are not allowed." + + -- Try to authenticate against all IdPs. In case, return the UserId and the old UserRef. + findUserInTeamIdPs :: TeamId -> SAML.NameID -> [IdP] -> Sem r (Maybe (UserId, SAML.UserRef)) + findUserInTeamIdPs team'' subject idps = runMaybeT $ asum $ map tryIdP idps + where + tryIdP :: IdP -> MaybeT (Sem r) (UserId, SAML.UserRef) + tryIdP idp' = do + let oldIssuer = idp' ^. SAML.idpMetadata . SAML.edIssuer + oldUref = SAML.UserRef oldIssuer subject + uid <- MaybeT $ findUserWithUref idp' team'' oldUref + pure (uid, oldUref) + + -- Select the authenticating IdP for multi-ingress SSO. + -- + -- Rules: + -- 1. If an IdP matches both issuer AND domain, use it (exact match) + -- 2. If no exact match and there's only ONE IdP for the team, use it (singleton) + -- 3. If no exact match and multiple IdPs exist, return Nothing (error case) + selectAuthenticatingIdP :: [IdP] -> Issuer -> Maybe Text -> Maybe IdP + selectAuthenticatingIdP teamIdPs issuer mbDomain = + find matchesIssuerAndDomain teamIdPs + <|> case teamIdPs of + [singleIdP] -> Just singleIdP + _ -> Nothing + where + matchesIssuerAndDomain idp' = + idp' ^. SAML.idpMetadata . SAML.edIssuer == issuer + && idp' ^. idpExtraInfo . domain == mbDomain -- | If the client is web, it will be served with an HTML page that it can process to decide whether -- to log the user in or show an error. diff --git a/services/spar/src/Spar/Error.hs b/services/spar/src/Spar/Error.hs index 2dbc8675461..c39bc592ae8 100644 --- a/services/spar/src/Spar/Error.hs +++ b/services/spar/src/Spar/Error.hs @@ -117,6 +117,7 @@ data SparCustomError | -- | All errors returned from SCIM handlers are wrapped into 'SparScimError' SparScimError Scim.ScimError | SparIdPDomainInUse + | SparMultiIngressIdPConfiguration LText deriving (Eq, Show) data SparProvisioningMoreThanOneIdP @@ -223,6 +224,7 @@ renderSparError (SAML.CustomError (IdpDbError IdpNonUnique)) = StdError $ Wai.mk renderSparError (SAML.CustomError (IdpDbError IdpWrongTeam)) = StdError $ Wai.mkError status409 "idp-wrong-team" "The IdP is not part of this team." renderSparError (SAML.CustomError (IdpDbError IdpNotFound)) = renderSparError (SAML.CustomError (SparIdPNotFound "")) renderSparError (SAML.CustomError SparIdPDomainInUse) = StdError $ Wai.mkError status409 "idp-duplicate-domain-for-team" "This team already has an IdP configured for this domain." +renderSparError (SAML.CustomError (SparMultiIngressIdPConfiguration msg)) = StdError $ Wai.mkError status400 "multi-ingress-config-error" ("Multi-ingress SSO requires email-based NameIDs: " <> msg) -- Errors related to provisioning renderSparError (SAML.CustomError (SparProvisioningMoreThanOneIdP msg)) = StdError $ Wai.mkError status400 "more-than-one-idp" do diff --git a/services/spar/test-integration/Test/Spar/AppSpec.hs b/services/spar/test-integration/Test/Spar/AppSpec.hs index 07263bbb355..89c26f28446 100644 --- a/services/spar/test-integration/Test/Spar/AppSpec.hs +++ b/services/spar/test-integration/Test/Spar/AppSpec.hs @@ -34,6 +34,7 @@ import SAML2.WebSSO as SAML import qualified SAML2.WebSSO.Test.MockResponse as SAML import qualified Servant import qualified Spar.App as Spar +import Spar.Options (saml) import Spar.Orphans () import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import qualified Text.XML as XML @@ -174,8 +175,10 @@ requestAccessVerdict idp isGranted mkAuthnReq = do if isGranted then SAML.AccessGranted uref else SAML.AccessDenied [DeniedNoBearerConfSubj, DeniedNoAuthnStatement] + env <- ask + let samlConfig = saml (env ^. teOpts) outcome <- do - runSpar $ Spar.verdictHandler (authnresp ^. rspPayload) verdict idp + runSpar $ Spar.verdictHandler (authnresp ^. rspPayload) verdict idp samlConfig Nothing let loc :: URI.URI loc = maybe (error "no location") (either error id . SAML.parseURI' . cs)