Fail closed on hosted handoff identity

This commit is contained in:
rcourtman
2026-05-04 09:47:54 +01:00
parent 1a9fa936ee
commit 9cddb49dd5
13 changed files with 387 additions and 53 deletions
@@ -74,7 +74,9 @@ principal once a stable user ID exists.
## Runtime Requirements
1. Handoff sessions bind to the signed stable subject or `UserID`, then verify
that subject against existing server-side organization membership.
that subject against existing server-side organization membership. Blank or
email-shaped handoff subjects are invalid; email may only help find legacy
email-keyed tenant records after a stable subject is already present.
2. Magic-link sessions bind to the stored organization principal resolved from
contact email at verification time, not to the email embedded in the token.
3. Hosted checkout and tenant provisioning seed organization membership from
@@ -189,6 +189,10 @@ profile and assignment columns, but embedded table framing must route through
lifecycle surfaces may display contact email when supplied by the shared
auth boundary, but they must not reinterpret SSO or Stripe email as the
canonical user identifier for setup, install, or fleet-management actions.
Hosted handoff subjects consumed through the shared API auth boundary must
already be stable, non-email principals; lifecycle-adjacent routes must not
recover authority from a blank handoff subject by falling back to contact
email.
API-token owner metadata follows the same rule: lifecycle-adjacent setup or
mobile-pairing token flows may consume the shared token helper, but they
must not pass extension metadata that authors or overwrites `owner_user_id`.
@@ -388,7 +388,9 @@ the canonical monitored-system blocked payload.
(`sub`/`UserID`) rather than the contact email. Email may participate only
as legacy membership lookup and delivery metadata, and any canonicalization
of email-keyed tenant membership must preserve the stored role instead of
creating or elevating membership from the token.
creating or elevating membership from the token. Blank or email-shaped
handoff subjects are invalid even when the tenant still contains legacy
email-keyed owner/member rows.
Hosted magic-link verification follows the same API/session identity rule:
the token may carry contact email for delivery, but `/api/public/magic-link/verify`
must resolve that email against current server-side organization metadata
@@ -239,6 +239,10 @@ runtime gating as separate unlinked claims.
verification must re-read the tenant organization and session as the stored
stable owner/member principal, so an old token cannot keep email as the
runtime identity or survive removal from tenant membership.
Tenant-targeted hosted magic-link handoff must fail closed when the
control-plane registry cannot resolve a stable account `User.ID`; it must
not sign a tenant handoff with a blank subject and let the tenant runtime
promote contact email into identity.
That same portal boundary also owns the signed-in shell shape: hosted
arrivals default to `Workspaces`, self-hosted-only arrivals default to
`Billing`, and the shell destinations are limited to `Workspaces`,
@@ -74,6 +74,10 @@ Storage/recovery may also consume org-scoped session identity from the shared
API boundary, but durable user IDs remain the authorization principal. Contact
email may support display or legacy lookup only; storage and recovery surfaces
must not create their own email-keyed membership or entitlement interpretation.
Hosted direct handoff subjects that reach recovery-adjacent protected routes
must therefore already be stable non-email principals; a blank handoff `UserID`
must fail at the shared API boundary instead of being repaired from contact
email.
The canonical actor vocabulary for those shared sessions is
`docs/release-control/v6/internal/IDENTITY_INVARIANTS.md`; recovery and storage
work may consume accepted org access, but must not mint or widen access from a
@@ -2595,7 +2599,8 @@ opens that still redirect through `/auth/cloud-handoff` must carry enough
canonical account/role identity for the tenant runtime to validate existing
membership and derive the stored effective role before protected routes load,
not just the newer portal exchange path. The direct path must not repair org
membership, claim a blank owner, or honor role upgrades from handoff claims.
membership, claim a blank owner, promote email into a missing handoff subject,
or honor role upgrades from handoff claims.
That same adjacent onboarding boundary must also keep the dedicated
relay-mobile bootstrap credential sufficient for QR, deep-link, and
connection-validation reads, so hosted recovery/support flows that hand a
+6 -5
View File
@@ -53,11 +53,12 @@ func HandleCloudHandoff(dataPath string) http.HandlerFunc {
email := normalizeHandoffEmail(claims.Email)
userID := strings.TrimSpace(claims.UserID)
tenantID := strings.TrimSpace(claims.TenantID)
if userID == "" {
userID = email
}
if email == "" || userID == "" || !isValidOrganizationID(tenantID) {
log.Warn().Str("tenant_id", tenantID).Msg("Cloud handoff token rejected due to invalid tenant ID")
if email == "" || userID == "" || isEmailShapedHandoffUserID(userID) || !isValidOrganizationID(tenantID) {
log.Warn().
Str("tenant_id", tenantID).
Bool("missing_user_id", userID == "").
Bool("email_shaped_user_id", isEmailShapedHandoffUserID(userID)).
Msg("Cloud handoff token rejected due to invalid identity claims")
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
return
}
+8 -7
View File
@@ -286,6 +286,10 @@ func normalizeHandoffEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func isEmailShapedHandoffUserID(userID string) bool {
return strings.Contains(strings.TrimSpace(userID), "@")
}
// HandleHandoffExchange verifies a control-plane-minted handoff JWT, records its
// jti to prevent replay, then creates a tenant session and redirects to the app.
//
@@ -351,7 +355,7 @@ func HandleHandoffExchange(configDir string) http.HandlerFunc {
}
claims.Email = normalizeHandoffEmail(claims.Email)
subject := strings.TrimSpace(claims.Subject)
if strings.TrimSpace(claims.ID) == "" || subject == "" || claims.Email == "" {
if strings.TrimSpace(claims.ID) == "" || subject == "" || isEmailShapedHandoffUserID(subject) || claims.Email == "" {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
@@ -454,15 +458,12 @@ func authorizeHandoffOrganizationMembership(configDir, tenantID, userID, email,
userID = strings.TrimSpace(userID)
email = normalizeHandoffEmail(email)
if userID == "" {
userID = email
}
if email == "" && strings.Contains(userID, "@") {
email = normalizeHandoffEmail(userID)
}
if userID == "" {
return nil, fmt.Errorf("%w: handoff user id is empty", errHandoffAuthorizationDenied)
}
if isEmailShapedHandoffUserID(userID) {
return nil, fmt.Errorf("%w: handoff user id must be a stable subject", errHandoffAuthorizationDenied)
}
effectiveRole := org.GetMemberRoleForPrincipal(userID, email)
if effectiveRole == "" {
@@ -715,6 +715,78 @@ func TestHandleHandoffExchangeRejectsOwnerHandoffWhenOwnerUserIDBlank(t *testing
}
}
func TestAuthorizeHandoffOrganizationMembershipRejectsEmailShapedUserID(t *testing.T) {
configDir := t.TempDir()
tenantID := "tenant-email-user-id"
saveHandoffTestOrganization(t, configDir, &models.Organization{
ID: tenantID,
DisplayName: "Email User ID Test",
Status: models.OrgStatusActive,
CreatedAt: time.Now().UTC(),
OwnerUserID: "owner@example.com",
Members: []models.OrganizationMember{
{UserID: "owner@example.com", Role: models.OrgRoleOwner, AddedAt: time.Now().UTC()},
},
})
authz, err := authorizeHandoffOrganizationMembership(configDir, tenantID, "owner@example.com", "owner@example.com", "owner")
if authz != nil {
t.Fatalf("authz = %+v, want nil", authz)
}
if !errors.Is(err, errHandoffAuthorizationDenied) {
t.Fatalf("err = %v, want %v", err, errHandoffAuthorizationDenied)
}
}
func TestHandleHandoffExchangeRejectsEmailShapedSubject(t *testing.T) {
key := []byte("test-handoff-key")
configDir := t.TempDir()
resetSessionStoreForTests()
t.Cleanup(resetSessionStoreForTests)
resetCSRFStoreForTests()
t.Cleanup(resetCSRFStoreForTests)
InitSessionStore(configDir)
InitCSRFStore(configDir)
secretsDir := filepath.Join(configDir, "secrets")
if err := os.MkdirAll(secretsDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "handoff.key"), key, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
tenantID := "tenant-email-subject"
saveHandoffTestOrganization(t, configDir, &models.Organization{
ID: tenantID,
DisplayName: "Email Subject Test",
Status: models.OrgStatusActive,
CreatedAt: time.Now().UTC(),
OwnerUserID: "owner@example.com",
Members: []models.OrganizationMember{
{UserID: "owner@example.com", Role: models.OrgRoleOwner, AddedAt: time.Now().UTC()},
},
})
token := signHandoffToken(t, key, cloudHandoffClaims{
AccountID: "acct-email-subject",
Email: "owner@example.com",
Role: "owner",
RegisteredClaims: jwt.RegisteredClaims{
ID: "jti-email-subject",
Subject: "owner@example.com",
Issuer: cloudHandoffIssuer,
Audience: jwt.ClaimStrings{tenantID},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
})
rec := makeExchangeRequest(t, HandleHandoffExchange(configDir), tenantID+".example.com", token)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
}
func TestHandleHandoffExchangeKeyMissing(t *testing.T) {
handler := HandleHandoffExchange(t.TempDir())
t.Setenv("PULSE_TENANT_ID", "")
+53 -3
View File
@@ -23,7 +23,12 @@ func TestHandleCloudHandoffRejectsReplay(t *testing.T) {
t.Fatalf("write handoff key: %v", err)
}
token, err := cloudauth.Sign(key, "alice@example.com", "tenant-1", 5*time.Minute)
token, err := cloudauth.SignWithClaims(key, cloudauth.Claims{
Email: "alice@example.com",
TenantID: "tenant-1",
UserID: "user-alice",
Role: "owner",
}, 5*time.Minute)
if err != nil {
t.Fatalf("sign handoff token: %v", err)
}
@@ -72,7 +77,12 @@ func TestHandleCloudHandoffSetsTenantOrgCookie(t *testing.T) {
t.Fatalf("write handoff key: %v", err)
}
token, err := cloudauth.Sign(key, "alice@example.com", "tenant-1", 5*time.Minute)
token, err := cloudauth.SignWithClaims(key, cloudauth.Claims{
Email: "alice@example.com",
TenantID: "tenant-1",
UserID: "user-alice",
Role: "owner",
}, 5*time.Minute)
if err != nil {
t.Fatalf("sign handoff token: %v", err)
}
@@ -118,7 +128,12 @@ func TestHandleCloudHandoffRejectsInvalidTenantID(t *testing.T) {
t.Fatalf("write handoff key: %v", err)
}
token, err := cloudauth.Sign(key, "alice@example.com", "../tenant-1", 5*time.Minute)
token, err := cloudauth.SignWithClaims(key, cloudauth.Claims{
Email: "alice@example.com",
TenantID: "../tenant-1",
UserID: "user-alice",
Role: "owner",
}, 5*time.Minute)
if err != nil {
t.Fatalf("sign handoff token: %v", err)
}
@@ -137,6 +152,41 @@ func TestHandleCloudHandoffRejectsInvalidTenantID(t *testing.T) {
}
}
func TestHandleCloudHandoffRejectsBlankUserID(t *testing.T) {
resetPersistentAuthStoresForTests()
t.Cleanup(resetPersistentAuthStoresForTests)
dataPath := t.TempDir()
key := []byte("0123456789abcdef0123456789abcdef")
if err := os.WriteFile(filepath.Join(dataPath, cloudauth.HandoffKeyFile), key, 0o600); err != nil {
t.Fatalf("write handoff key: %v", err)
}
token, err := cloudauth.Sign(key, "alice@example.com", "tenant-1", 5*time.Minute)
if err != nil {
t.Fatalf("sign handoff token: %v", err)
}
saveHandoffTestOrganization(t, dataPath, &models.Organization{
ID: "tenant-1",
DisplayName: "Tenant One",
Status: models.OrgStatusActive,
CreatedAt: time.Now().UTC(),
OwnerUserID: "alice@example.com",
})
handler := HandleCloudHandoff(dataPath)
req := httptest.NewRequest(http.MethodGet, "/auth/cloud-handoff?token="+url.QueryEscape(token), nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusTemporaryRedirect)
}
if got := rec.Header().Get("Location"); got != "/login?error=handoff_invalid" {
t.Fatalf("redirect = %q, want %q", got, "/login?error=handoff_invalid")
}
}
func TestHandleCloudHandoffLowercasesSessionEmailIdentity(t *testing.T) {
resetPersistentAuthStoresForTests()
t.Cleanup(resetPersistentAuthStoresForTests)
+36
View File
@@ -114,6 +114,42 @@ func TestContract_HostedMagicLinkStablePrincipalProof(t *testing.T) {
}
}
func TestContract_HostedHandoffRequiresStableSubjectProof(t *testing.T) {
directSource, err := os.ReadFile(filepath.Clean("cloud_handoff.go"))
if err != nil {
t.Fatalf("read cloud_handoff.go: %v", err)
}
exchangeSource, err := os.ReadFile(filepath.Clean("cloud_handoff_handlers.go"))
if err != nil {
t.Fatalf("read cloud_handoff_handlers.go: %v", err)
}
for file, source := range map[string]string{
"cloud_handoff.go": string(directSource),
"cloud_handoff_handlers.go": string(exchangeSource),
} {
for _, required := range []string{
"isEmailShapedHandoffUserID(userID)",
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, authz.UserID)",
"TrackUserSession(authz.UserID, sessionToken)",
} {
if !strings.Contains(source, required) {
t.Fatalf("%s must contain %q", file, required)
}
}
for _, forbidden := range []string{
"userID = email",
"email = normalizeHandoffEmail(userID)",
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, claims.Email)",
"TrackUserSession(claims.Email, sessionToken)",
} {
if strings.Contains(source, forbidden) {
t.Fatalf("%s must not contain legacy email-principal pattern %q", file, forbidden)
}
}
}
}
func TestContract_SSOStablePrincipalProof(t *testing.T) {
ssoIdentity, err := os.ReadFile(filepath.Clean("auth_principal_identity.go"))
if err != nil {
@@ -46,10 +46,13 @@ func TestContract_HostedIdentityUsesStablePrincipals(t *testing.T) {
"cloud_handoff.go": {
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, authz.UserID)",
"TrackUserSession(authz.UserID, sessionToken)",
"isEmailShapedHandoffUserID(userID)",
},
"cloud_handoff_handlers.go": {
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, authz.UserID)",
"TrackUserSession(authz.UserID, sessionToken)",
"isEmailShapedHandoffUserID(userID)",
"handoff user id must be a stable subject",
},
"oidc_handlers.go": {
"stableSSOPrincipal(config.SSOProviderTypeOIDC, providerID, idToken.Subject)",
@@ -135,10 +138,13 @@ func TestContract_HostedIdentityUsesStablePrincipals(t *testing.T) {
"cloud_handoff.go": {
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, email)",
"TrackUserSession(email, sessionToken)",
"userID = email",
},
"cloud_handoff_handlers.go": {
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, claims.Email)",
"TrackUserSession(claims.Email, sessionToken)",
"userID = email",
"email = normalizeHandoffEmail(userID)",
},
"oidc_handlers.go": {
"establishOIDCSession(w, req, username, oidcTokens)",
+46 -35
View File
@@ -151,20 +151,26 @@ func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDi
return
}
userID, identityErr := ensureAccountUserAndMembership(reg, tenant, token.Email)
if identityErr != nil {
log.Warn().
Err(identityErr).
userID, err := ensureAccountUserAndMembership(reg, tenant, token.Email)
trimmedUserID := strings.TrimSpace(userID)
if err != nil || trimmedUserID == "" || strings.Contains(trimmedUserID, "@") {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to establish control-plane session identity")
Bool("email_shaped_user_id", strings.Contains(trimmedUserID, "@")).
Str("reason", "handoff_identity_failed").
Msg("Magic link verification failed")
writeError(w, http.StatusInternalServerError, "handoff_error", "Unable to establish hosted handoff identity")
return
}
userID = trimmedUserID
claims := cloudauth.Claims{
Email: token.Email,
TenantID: tenant.ID,
AccountID: strings.TrimSpace(tenant.AccountID),
UserID: strings.TrimSpace(userID),
UserID: userID,
Role: string(registry.MemberRoleOwner),
}
@@ -184,31 +190,29 @@ func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDi
redirectURL := fmt.Sprintf("https://%s.%s/auth/cloud-handoff?token=%s",
tenant.ID, baseDomain, handoffToken)
if identityErr == nil {
if sessionVersion, err := reg.GetUserSessionVersion(userID); err != nil {
log.Warn().
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Str("user_id", userID).
Msg("Failed to read user session version")
} else if sessionToken, err := svc.GenerateSessionTokenWithVersion(userID, token.Email, sessionVersion, SessionTTL); err != nil {
log.Warn().
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to issue control-plane session")
} else {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
}
if sessionVersion, err := reg.GetUserSessionVersion(userID); err != nil {
log.Warn().
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Str("user_id", userID).
Msg("Failed to read user session version")
} else if sessionToken, err := svc.GenerateSessionTokenWithVersion(userID, token.Email, sessionVersion, SessionTTL); err != nil {
log.Warn().
Err(err).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to issue control-plane session")
} else {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
}
auditEvent(r, "cp_magic_link_verify", "success").
@@ -445,14 +449,21 @@ func ensurePortalUser(reg *registry.TenantRegistry, email string) (string, error
user = candidate
}
}
if user == nil || strings.TrimSpace(user.ID) == "" {
userID := ""
if user != nil {
userID = strings.TrimSpace(user.ID)
}
if userID == "" {
return "", fmt.Errorf("user resolution failed")
}
if err := reg.AcceptInvitationsForUser(email, user.ID); err != nil {
if strings.Contains(userID, "@") {
return "", fmt.Errorf("resolved user id must be stable")
}
if err := reg.AcceptInvitationsForUser(email, userID); err != nil {
return "", fmt.Errorf("accept account invitations: %w", err)
}
_ = reg.UpdateUserLastLogin(user.ID)
return user.ID, nil
_ = reg.UpdateUserLastLogin(userID)
return userID, nil
}
func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) {
@@ -242,3 +242,143 @@ func TestHandleMagicLinkVerifyTenantTargetStillRedirectsToTenantHandoff(t *testi
t.Fatal("expected claims.UserID to be populated")
}
}
func TestHandleMagicLinkVerifyTenantTargetRejectsMissingStableIdentity(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
if err := reg.Create(&registry.Tenant{
ID: "t-tenant-no-account",
Email: "tenant@example.com",
DisplayName: "Hosted Workspace",
State: registry.TenantStateActive,
}); err != nil {
t.Fatalf("Create tenant: %v", err)
}
tenantsDir := filepath.Join(dir, "tenants")
tenantDir := filepath.Join(tenantsDir, "t-tenant-no-account")
if err := os.MkdirAll(tenantDir, 0o700); err != nil {
t.Fatalf("MkdirAll tenant dir: %v", err)
}
handoffKey, err := cloudauth.GenerateHandoffKey()
if err != nil {
t.Fatalf("GenerateHandoffKey: %v", err)
}
if err := os.WriteFile(filepath.Join(tenantDir, cloudauth.HandoffKeyFile), handoffKey, 0o600); err != nil {
t.Fatalf("WriteFile handoff key: %v", err)
}
svc, err := NewService(dir)
if err != nil {
t.Fatalf("NewService: %v", err)
}
t.Cleanup(svc.Close)
token, err := svc.GenerateToken("tenant@example.com", "t-tenant-no-account")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/auth/magic-link/verify?token="+token, nil)
rec := httptest.NewRecorder()
HandleMagicLinkVerify(svc, reg, tenantsDir, "cloud.example.com", "/portal")(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Location") != "" {
t.Fatalf("location=%q, want no tenant handoff redirect", rec.Header().Get("Location"))
}
user, err := reg.GetUserByEmail("tenant@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user != nil {
t.Fatalf("user = %+v, want nil", user)
}
}
func TestHandleMagicLinkVerifyTenantTargetRejectsEmailShapedRegistryUserID(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatalf("GenerateAccountID: %v", err)
}
if err := reg.CreateAccount(&registry.Account{
ID: accountID,
Kind: registry.AccountKindIndividual,
DisplayName: "Hosted Account",
}); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := reg.Create(&registry.Tenant{
ID: "t-tenant-email-user",
AccountID: accountID,
Email: "tenant@example.com",
DisplayName: "Hosted Workspace",
State: registry.TenantStateActive,
}); err != nil {
t.Fatalf("Create tenant: %v", err)
}
if err := reg.CreateUser(&registry.User{
ID: "tenant@example.com",
Email: "tenant@example.com",
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
tenantsDir := filepath.Join(dir, "tenants")
tenantDir := filepath.Join(tenantsDir, "t-tenant-email-user")
if err := os.MkdirAll(tenantDir, 0o700); err != nil {
t.Fatalf("MkdirAll tenant dir: %v", err)
}
handoffKey, err := cloudauth.GenerateHandoffKey()
if err != nil {
t.Fatalf("GenerateHandoffKey: %v", err)
}
if err := os.WriteFile(filepath.Join(tenantDir, cloudauth.HandoffKeyFile), handoffKey, 0o600); err != nil {
t.Fatalf("WriteFile handoff key: %v", err)
}
svc, err := NewService(dir)
if err != nil {
t.Fatalf("NewService: %v", err)
}
t.Cleanup(svc.Close)
token, err := svc.GenerateToken("tenant@example.com", "t-tenant-email-user")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/auth/magic-link/verify?token="+token, nil)
rec := httptest.NewRecorder()
HandleMagicLinkVerify(svc, reg, tenantsDir, "cloud.example.com", "/portal")(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Location") != "" {
t.Fatalf("location=%q, want no tenant handoff redirect", rec.Header().Get("Location"))
}
membership, err := reg.GetMembership(accountID, "tenant@example.com")
if err != nil {
t.Fatalf("GetMembership: %v", err)
}
if membership != nil {
t.Fatalf("membership = %+v, want nil", membership)
}
}