mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fail closed on hosted handoff identity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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(®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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user