package migration import ( "strings" "github.com/ultisuite/ulti-backend/internal/auth" ) // ClaimIdentity holds SSO identity fields checked against a migration invite. type ClaimIdentity struct { Email string PreferredUsername string UPN string } func ClaimIdentityFromAuth(c *auth.Claims) ClaimIdentity { if c == nil { return ClaimIdentity{} } return ClaimIdentity{ Email: c.Email, PreferredUsername: c.PreferredUsername, UPN: c.UPN, } } func normalizeInviteEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } func isEmailAddress(s string) bool { s = strings.TrimSpace(s) at := strings.LastIndex(s, "@") return at > 0 && at < len(s)-1 } func identityCandidateEmails(id ClaimIdentity) []string { seen := make(map[string]struct{}) var out []string for _, raw := range []string{id.Email, id.PreferredUsername, id.UPN} { raw = strings.TrimSpace(raw) if raw == "" || !isEmailAddress(raw) { continue } norm := normalizeInviteEmail(raw) if _, ok := seen[norm]; ok { continue } seen[norm] = struct{}{} out = append(out, norm) } return out } func normalizeEmailLocalPart(local string) string { local = strings.ToLower(strings.TrimSpace(local)) if plus := strings.Index(local, "+"); plus >= 0 { local = local[:plus] } return strings.ReplaceAll(local, ".", "") } func emailLocalAndDomain(email string) (local, domain string, ok bool) { email = normalizeInviteEmail(email) at := strings.LastIndex(email, "@") if at <= 0 || at == len(email)-1 { return "", "", false } return email[:at], email[at+1:], true } func inviteMatchTargets(inviteEmail string, alternateEmails []string) []string { seen := make(map[string]struct{}) var out []string add := func(e string) { e = normalizeInviteEmail(e) if e == "" || !isEmailAddress(e) { return } if _, ok := seen[e]; ok { return } seen[e] = struct{}{} out = append(out, e) } add(inviteEmail) for _, alt := range alternateEmails { add(alt) } return out } func localPartAliasMatch(a, b string) bool { aLocal, aDomain, okA := emailLocalAndDomain(a) bLocal, bDomain, okB := emailLocalAndDomain(b) if !okA || !okB { return false } if !strings.EqualFold(aDomain, bDomain) { return false } return normalizeEmailLocalPart(aLocal) == normalizeEmailLocalPart(bLocal) } // projectDomainUPNMatch accepts claims when the invite is on the hosted project domain // and a UPN-style identity (preferred_username / upn) shares the same mailbox local-part. // Typical Microsoft case: invite alice@acme.com, SSO preferred_username alice@tenant.onmicrosoft.com. func projectDomainUPNMatch(inviteEmail, projectDomain string, identity ClaimIdentity) bool { if projectDomain == "" { return false } projectDomain = strings.ToLower(strings.TrimSpace(projectDomain)) invLocal, invDomain, ok := emailLocalAndDomain(inviteEmail) if !ok || !strings.EqualFold(invDomain, projectDomain) { return false } for _, raw := range []string{identity.PreferredUsername, identity.UPN} { candLocal, _, ok := emailLocalAndDomain(raw) if ok && strings.EqualFold(candLocal, invLocal) { return true } } return false } // InviteEmailMatchesIdentity reports whether SSO identity may claim the invite. // projectDomain is the hosted mail domain when the migration project is domain-bound. func InviteEmailMatchesIdentity(inviteEmail string, alternateEmails []string, projectDomain string, identity ClaimIdentity) bool { targets := inviteMatchTargets(inviteEmail, alternateEmails) if len(targets) == 0 { return false } candidates := identityCandidateEmails(identity) if len(candidates) == 0 { return false } for _, target := range targets { for _, candidate := range candidates { if candidate == target { return true } if localPartAliasMatch(target, candidate) { return true } } } return projectDomainUPNMatch(inviteEmail, projectDomain, identity) }