package api import ( "crypto/sha256" "encoding/hex" "errors" "net/http" "os" "path/filepath" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth" "github.com/rs/zerolog/log" ) // HandleCloudHandoff returns an HTTP handler that completes the control-plane → tenant // auth handoff. It reads a per-tenant HMAC key, verifies the handoff token, creates a // session, and redirects to the dashboard. // // Self-guards: returns 404 if the handoff key file does not exist in dataPath, // meaning this is not a cloud-managed tenant. func HandleCloudHandoff(dataPath string) http.HandlerFunc { InitPersistentAuthStores(dataPath) replay := &jtiReplayStore{configDir: dataPath} return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Self-guard: only respond if a handoff key exists. keyPath := filepath.Join(dataPath, cloudauth.HandoffKeyFile) handoffKey, err := os.ReadFile(keyPath) if err != nil { http.NotFound(w, r) return } tokenStr := strings.TrimSpace(r.URL.Query().Get("token")) if tokenStr == "" { http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect) return } claims, err := cloudauth.VerifyClaimsWithExpiry(handoffKey, tokenStr) if err != nil { log.Warn().Err(err).Msg("Cloud handoff token verification failed") http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect) return } email := normalizeHandoffEmail(claims.Email) userID := strings.TrimSpace(claims.UserID) tenantID := strings.TrimSpace(claims.TenantID) 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 } authz, err := authorizeHandoffOrganizationMembership(dataPath, tenantID, userID, email, claims.Role) if err != nil { if errors.Is(err, errHandoffAuthorizationDenied) { log.Warn(). Err(err). Str("tenant_id", tenantID). Str("email", email). Str("user_id", userID). Msg("Cloud handoff authorization denied") http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect) return } log.Error(). Err(err). Str("tenant_id", tenantID). Str("email", email). Str("user_id", userID). Msg("Cloud handoff authorization lookup failed") http.Error(w, "Internal server error", http.StatusInternalServerError) return } tokenHash := sha256.Sum256([]byte(tokenStr)) replayID := "handoff:" + hex.EncodeToString(tokenHash[:]) stored, storeErr := replay.checkAndStore(replayID, claims.ExpiresAt) if storeErr != nil { log.Error().Err(storeErr).Msg("Cloud handoff replay-store failure") http.Error(w, "Internal server error", http.StatusInternalServerError) return } if !stored { log.Warn().Str("replay_id_prefix", replayID[:24]).Msg("Cloud handoff token replay blocked") http.Redirect(w, r, "/login?error=handoff_replayed", http.StatusTemporaryRedirect) return } // Invalidate any pre-existing session to prevent session fixation attacks. InvalidateOldSessionFromRequest(r) // Create session using existing machinery (same pattern as HandlePublicMagicLinkVerify). sessionToken := generateSessionToken() if sessionToken == "" { http.Error(w, "Failed to create session", http.StatusInternalServerError) return } userAgent := r.Header.Get("User-Agent") clientIP := GetClientIP(r) sessionDuration := 24 * time.Hour GetSessionStore().CreateSession(sessionToken, sessionDuration, userAgent, clientIP, authz.UserID) TrackUserSession(authz.UserID, sessionToken) csrfToken := generateCSRFToken(sessionToken) cookiePolicy := getBrowserCookiePolicy(r) cookieMaxAge := int(sessionDuration.Seconds()) cookiePolicy.setHTTPOnly(w, &http.Cookie{ Name: sessionCookieName(cookiePolicy.secure), Value: sessionToken, Path: "/", HttpOnly: true, MaxAge: cookieMaxAge, }) cookiePolicy.setClientReadable(w, &http.Cookie{ Name: CookieNameCSRF, Value: csrfToken, Path: "/", MaxAge: cookieMaxAge, }) cookiePolicy.setClientReadable(w, &http.Cookie{ Name: CookieNameOrgID, Value: tenantID, Path: "/", MaxAge: cookieMaxAge, }) log.Info(). Str("email", email). Str("user_id", authz.UserID). Msg("Cloud handoff completed, session created") http.Redirect(w, r, "/", http.StatusTemporaryRedirect) } }