Pulse/internal/api/cloud_handoff.go

134 lines
4.2 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"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)
tenantID := strings.TrimSpace(claims.TenantID)
if email == "" || !isValidOrganizationID(tenantID) {
log.Warn().Str("tenant_id", tenantID).Msg("Cloud handoff token rejected due to invalid tenant ID")
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
return
}
if err := ensureHandoffOrganizationMembership(dataPath, tenantID, email, claims.Role); err != nil {
log.Error().
Err(err).
Str("tenant_id", tenantID).
Str("email", email).
Msg("Cloud handoff membership repair 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, email)
TrackUserSession(email, sessionToken)
csrfToken := generateCSRFToken(sessionToken)
isSecure, sameSitePolicy := getCookieSettings(r)
cookieMaxAge := int(sessionDuration.Seconds())
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName(isSecure),
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: isSecure,
SameSite: sameSitePolicy,
MaxAge: cookieMaxAge,
})
http.SetCookie(w, &http.Cookie{
Name: CookieNameCSRF,
Value: csrfToken,
Path: "/",
Secure: isSecure,
SameSite: sameSitePolicy,
MaxAge: cookieMaxAge,
})
http.SetCookie(w, &http.Cookie{
Name: CookieNameOrgID,
Value: tenantID,
Path: "/",
Secure: isSecure,
SameSite: sameSitePolicy,
MaxAge: cookieMaxAge,
})
log.Info().
Str("email", email).
Msg("Cloud handoff completed, session created")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
}