diff --git a/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md b/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md index f7f1e7050..8ce9ccf80 100644 --- a/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md +++ b/docs/release-control/v6/internal/IDENTITY_INVARIANTS.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a739d39df..f031400ce 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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`. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1a49693cc..cc1ff3af8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index c41638559..e6ea7398c 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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`, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index dbf26c5e0..58093ce12 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/internal/api/cloud_handoff.go b/internal/api/cloud_handoff.go index 311d77b79..a7435a13d 100644 --- a/internal/api/cloud_handoff.go +++ b/internal/api/cloud_handoff.go @@ -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 } diff --git a/internal/api/cloud_handoff_handlers.go b/internal/api/cloud_handoff_handlers.go index d7678be91..649f614b6 100644 --- a/internal/api/cloud_handoff_handlers.go +++ b/internal/api/cloud_handoff_handlers.go @@ -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 == "" { diff --git a/internal/api/cloud_handoff_handlers_test.go b/internal/api/cloud_handoff_handlers_test.go index 5f380c726..7a6b64633 100644 --- a/internal/api/cloud_handoff_handlers_test.go +++ b/internal/api/cloud_handoff_handlers_test.go @@ -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", "") diff --git a/internal/api/cloud_handoff_test.go b/internal/api/cloud_handoff_test.go index bde5ad92a..0931ee75b 100644 --- a/internal/api/cloud_handoff_test.go +++ b/internal/api/cloud_handoff_test.go @@ -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) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index be7dbfe8b..3af48b061 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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 { diff --git a/internal/api/identity_invariant_contract_test.go b/internal/api/identity_invariant_contract_test.go index 867ca3b08..45a6d284a 100644 --- a/internal/api/identity_invariant_contract_test.go +++ b/internal/api/identity_invariant_contract_test.go @@ -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)", diff --git a/internal/cloudcp/auth/handlers.go b/internal/cloudcp/auth/handlers.go index b33ad10c9..04e43cd3d 100644 --- a/internal/cloudcp/auth/handlers.go +++ b/internal/cloudcp/auth/handlers.go @@ -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) { diff --git a/internal/cloudcp/auth/handlers_magiclink_test.go b/internal/cloudcp/auth/handlers_magiclink_test.go index c3a06c17d..02b5fe824 100644 --- a/internal/cloudcp/auth/handlers_magiclink_test.go +++ b/internal/cloudcp/auth/handlers_magiclink_test.go @@ -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(®istry.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(®istry.Account{ + ID: accountID, + Kind: registry.AccountKindIndividual, + DisplayName: "Hosted Account", + }); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if err := reg.Create(®istry.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(®istry.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) + } +}