mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 12:52:25 +00:00
fix(cloudcp): unify portal magic link identity
This commit is contained in:
@@ -40,7 +40,7 @@ type errorResponse struct {
|
||||
// HandleMagicLinkVerify returns an http.HandlerFunc that validates a control-plane
|
||||
// magic link token, generates a short-lived handoff token, and redirects the user
|
||||
// to the tenant container.
|
||||
func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDir, baseDomain string) http.HandlerFunc {
|
||||
func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDir, baseDomain, portalPath string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
@@ -64,13 +64,67 @@ func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDi
|
||||
Msg("Magic link verification failed")
|
||||
// Browser redirect on failure.
|
||||
if !strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
http.Redirect(w, r, "/login?error=magic_link_invalid", http.StatusTemporaryRedirect)
|
||||
http.Redirect(w, r, strings.TrimSpace(portalPath), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "Invalid or expired magic link")
|
||||
return
|
||||
}
|
||||
|
||||
if token.Target == MagicLinkTargetPortal {
|
||||
redirectPath := strings.TrimSpace(portalPath)
|
||||
if redirectPath == "" {
|
||||
redirectPath = "/portal"
|
||||
}
|
||||
userID, err := ensurePortalUserAndMembership(reg, token.TenantID, token.Email)
|
||||
if err != nil {
|
||||
auditEvent(r, "cp_magic_link_verify", "failure").
|
||||
Err(err).
|
||||
Str("tenant_id", token.TenantID).
|
||||
Str("reason", "portal_session_identity_failed").
|
||||
Msg("Magic link verification failed")
|
||||
writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session")
|
||||
return
|
||||
}
|
||||
sessionVersion, err := reg.GetUserSessionVersion(userID)
|
||||
if err != nil {
|
||||
auditEvent(r, "cp_magic_link_verify", "failure").
|
||||
Err(err).
|
||||
Str("reason", "session_version_lookup_failed").
|
||||
Msg("Magic link verification failed")
|
||||
writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session")
|
||||
return
|
||||
}
|
||||
sessionToken, err := svc.GenerateSessionTokenWithVersion(userID, token.Email, sessionVersion, SessionTTL)
|
||||
if err != nil {
|
||||
auditEvent(r, "cp_magic_link_verify", "failure").
|
||||
Err(err).
|
||||
Str("reason", "session_issue_failed").
|
||||
Msg("Magic link verification failed")
|
||||
writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session")
|
||||
return
|
||||
}
|
||||
|
||||
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").
|
||||
Str("tenant_id", token.TenantID).
|
||||
Str("email", token.Email).
|
||||
Str("target", string(token.Target)).
|
||||
Msg("Magic link verified, redirecting to portal")
|
||||
|
||||
http.Redirect(w, r, redirectPath, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Look up tenant to confirm it exists and is active.
|
||||
tenant, err := reg.Get(token.TenantID)
|
||||
if err != nil || tenant == nil {
|
||||
@@ -328,16 +382,27 @@ func clearSessionCookie(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) {
|
||||
func ensurePortalUserAndMembership(reg *registry.TenantRegistry, tenantID, email string) (string, error) {
|
||||
if reg == nil {
|
||||
return "", fmt.Errorf("registry unavailable")
|
||||
}
|
||||
if tenant == nil {
|
||||
return "", fmt.Errorf("tenant is required")
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID != "" {
|
||||
tenant, err := reg.Get(tenantID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lookup tenant: %w", err)
|
||||
}
|
||||
if tenant != nil {
|
||||
return ensureAccountUserAndMembership(reg, tenant, email)
|
||||
}
|
||||
}
|
||||
accountID := strings.TrimSpace(tenant.AccountID)
|
||||
if accountID == "" {
|
||||
return "", fmt.Errorf("tenant has no account id")
|
||||
return ensurePortalUser(reg, email)
|
||||
}
|
||||
|
||||
func ensurePortalUser(reg *registry.TenantRegistry, email string) (string, error) {
|
||||
if reg == nil {
|
||||
return "", fmt.Errorf("registry unavailable")
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
@@ -370,6 +435,31 @@ func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *regist
|
||||
if user == nil || strings.TrimSpace(user.ID) == "" {
|
||||
return "", fmt.Errorf("user resolution failed")
|
||||
}
|
||||
_ = reg.UpdateUserLastLogin(user.ID)
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) {
|
||||
if reg == nil {
|
||||
return "", fmt.Errorf("registry unavailable")
|
||||
}
|
||||
if tenant == nil {
|
||||
return "", fmt.Errorf("tenant is required")
|
||||
}
|
||||
accountID := strings.TrimSpace(tenant.AccountID)
|
||||
if accountID == "" {
|
||||
return "", fmt.Errorf("tenant has no account id")
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return "", fmt.Errorf("email is required")
|
||||
}
|
||||
|
||||
userID, err := ensurePortalUser(reg, email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user := ®istry.User{ID: userID}
|
||||
|
||||
m, err := reg.GetMembership(accountID, user.ID)
|
||||
if err != nil {
|
||||
@@ -389,6 +479,5 @@ func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *regist
|
||||
}
|
||||
}
|
||||
|
||||
_ = reg.UpdateUserLastLogin(user.ID)
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
||||
)
|
||||
|
||||
func TestHandleMagicLinkVerifyPortalTargetCreatesSessionAndRedirectsToPortal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg, err := registry.NewTenantRegistry(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTenantRegistry: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reg.Close() })
|
||||
|
||||
svc, err := NewService(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
t.Cleanup(svc.Close)
|
||||
|
||||
token, err := svc.GeneratePortalToken("buyer@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePortalToken: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/magic-link/verify?token="+token, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
HandleMagicLinkVerify(svc, reg, dir, "cloud.example.com", "/portal")(rec, req)
|
||||
|
||||
if rec.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusTemporaryRedirect, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/portal" {
|
||||
t.Fatalf("location=%q, want /portal", got)
|
||||
}
|
||||
foundSessionCookie := false
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.Name == SessionCookieName && cookie.Value != "" {
|
||||
foundSessionCookie = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSessionCookie {
|
||||
t.Fatalf("expected %s cookie to be set", SessionCookieName)
|
||||
}
|
||||
|
||||
user, err := reg.GetUserByEmail("buyer@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatal("expected control-plane user to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMagicLinkVerifyInvalidBrowserRedirectsToPortal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg, err := registry.NewTenantRegistry(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTenantRegistry: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reg.Close() })
|
||||
|
||||
svc, err := NewService(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
t.Cleanup(svc.Close)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/magic-link/verify?token=ml1_invalid", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
HandleMagicLinkVerify(svc, reg, dir, "cloud.example.com", "/portal")(rec, req)
|
||||
|
||||
if rec.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusTemporaryRedirect, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/portal" {
|
||||
t.Fatalf("location=%q, want /portal", got)
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,18 @@ const (
|
||||
hmacKeySize = 32
|
||||
)
|
||||
|
||||
type MagicLinkTarget string
|
||||
|
||||
const (
|
||||
MagicLinkTargetTenant MagicLinkTarget = "tenant"
|
||||
MagicLinkTargetPortal MagicLinkTarget = "portal"
|
||||
)
|
||||
|
||||
// Token holds the validated data from a consumed magic link token.
|
||||
type Token struct {
|
||||
Email string
|
||||
TenantID string
|
||||
Target MagicLinkTarget
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -67,13 +75,34 @@ func NewService(cpDataDir string) (*Service, error) {
|
||||
// GenerateToken creates a new magic link token for the given email and tenant.
|
||||
// Returns a string in the format "ml1_<random>" that can be included in a URL.
|
||||
func (s *Service) GenerateToken(email, tenantID string) (string, error) {
|
||||
return s.generateTokenForTarget(email, tenantID, MagicLinkTargetTenant)
|
||||
}
|
||||
|
||||
// GeneratePortalToken creates a new portal-targeted magic link token. tenantID
|
||||
// is optional and, when present, is used during verification to ensure the
|
||||
// control-plane membership exists before the session is established.
|
||||
func (s *Service) GeneratePortalToken(email, tenantID string) (string, error) {
|
||||
return s.generateTokenForTarget(email, tenantID, MagicLinkTargetPortal)
|
||||
}
|
||||
|
||||
func (s *Service) generateTokenForTarget(email, tenantID string, target MagicLinkTarget) (string, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("magic link service not configured")
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if email == "" || tenantID == "" {
|
||||
return "", fmt.Errorf("email and tenantID are required")
|
||||
if email == "" {
|
||||
return "", fmt.Errorf("email is required")
|
||||
}
|
||||
switch target {
|
||||
case MagicLinkTargetTenant:
|
||||
if tenantID == "" {
|
||||
return "", fmt.Errorf("tenantID is required")
|
||||
}
|
||||
case MagicLinkTargetPortal:
|
||||
// tenantID is optional for portal-targeted sign-in.
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported magic link target %q", target)
|
||||
}
|
||||
|
||||
expiresAt := s.now().UTC().Add(s.ttl)
|
||||
@@ -88,6 +117,7 @@ func (s *Service) GenerateToken(email, tenantID string) (string, error) {
|
||||
if err := s.store.Put(tokenHash, &TokenRecord{
|
||||
Email: email,
|
||||
TenantID: tenantID,
|
||||
Target: string(target),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
@@ -115,6 +145,7 @@ func (s *Service) ValidateToken(token string) (*Token, error) {
|
||||
return &Token{
|
||||
Email: rec.Email,
|
||||
TenantID: rec.TenantID,
|
||||
Target: MagicLinkTarget(rec.Target),
|
||||
ExpiresAt: rec.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func ensureOwnerOnlyDir(dir string) error {
|
||||
type TokenRecord struct {
|
||||
Email string
|
||||
TenantID string
|
||||
Target string
|
||||
ExpiresAt time.Time
|
||||
Used bool
|
||||
}
|
||||
@@ -95,7 +96,8 @@ func (s *Store) initSchema() error {
|
||||
CREATE TABLE IF NOT EXISTS magic_link_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT 'tenant',
|
||||
expires_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
@@ -107,6 +109,41 @@ func (s *Store) initSchema() error {
|
||||
if _, err := s.db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("init magic link schema: %w", err)
|
||||
}
|
||||
if err := ensureMagicLinkColumn(s.db, "target", "ALTER TABLE magic_link_tokens ADD COLUMN target TEXT NOT NULL DEFAULT 'tenant'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureMagicLinkColumn(s.db, "tenant_id", "ALTER TABLE magic_link_tokens ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureMagicLinkColumn(db *sql.DB, columnName, alterStmt string) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(magic_link_tokens)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect magic link schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull int
|
||||
var defaultValue sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return fmt.Errorf("scan magic link schema: %w", err)
|
||||
}
|
||||
if strings.EqualFold(name, columnName) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterate magic link schema: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(alterStmt); err != nil {
|
||||
return fmt.Errorf("add magic link column %s: %w", columnName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,9 +170,12 @@ func (s *Store) Put(tokenHash []byte, rec *TokenRecord) error {
|
||||
if len(tokenHash) == 0 {
|
||||
return fmt.Errorf("tokenHash is required")
|
||||
}
|
||||
if rec == nil || rec.Email == "" || rec.TenantID == "" || rec.ExpiresAt.IsZero() {
|
||||
if rec == nil || rec.Email == "" || rec.ExpiresAt.IsZero() {
|
||||
return fmt.Errorf("token record is required")
|
||||
}
|
||||
if strings.TrimSpace(rec.Target) == "" {
|
||||
rec.Target = string(MagicLinkTargetTenant)
|
||||
}
|
||||
|
||||
key := hex.EncodeToString(tokenHash)
|
||||
now := time.Now().UTC().Unix()
|
||||
@@ -148,9 +188,9 @@ func (s *Store) Put(tokenHash []byte, rec *TokenRecord) error {
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
_, err := db.Exec(
|
||||
`INSERT OR REPLACE INTO magic_link_tokens (token_hash, email, tenant_id, expires_at, used, created_at, used_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, NULL)`,
|
||||
key, rec.Email, rec.TenantID, rec.ExpiresAt.UTC().Unix(), now,
|
||||
`INSERT OR REPLACE INTO magic_link_tokens (token_hash, email, tenant_id, target, expires_at, used, created_at, used_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, NULL)`,
|
||||
key, rec.Email, rec.TenantID, rec.Target, rec.ExpiresAt.UTC().Unix(), now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("put magic link token: %w", err)
|
||||
@@ -188,12 +228,12 @@ func (s *Store) Consume(tokenHash []byte, now time.Time) (*TokenRecord, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
var email, tenantID string
|
||||
var email, tenantID, target string
|
||||
var expiresAtUnix int64
|
||||
var usedInt int
|
||||
|
||||
row := tx.QueryRow(`SELECT email, tenant_id, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`, key)
|
||||
if scanErr := row.Scan(&email, &tenantID, &expiresAtUnix, &usedInt); scanErr != nil {
|
||||
row := tx.QueryRow(`SELECT email, tenant_id, target, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`, key)
|
||||
if scanErr := row.Scan(&email, &tenantID, &target, &expiresAtUnix, &usedInt); scanErr != nil {
|
||||
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
@@ -227,6 +267,7 @@ func (s *Store) Consume(tokenHash []byte, now time.Time) (*TokenRecord, error) {
|
||||
return &TokenRecord{
|
||||
Email: email,
|
||||
TenantID: tenantID,
|
||||
Target: target,
|
||||
ExpiresAt: expiresAt,
|
||||
Used: true,
|
||||
}, nil
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestNewStoreMigratesLegacySchemaWithNoTargetColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "cp_magic_links.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE magic_link_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
used_at INTEGER
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("create legacy schema: %v", err)
|
||||
}
|
||||
|
||||
store, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
t.Cleanup(store.Close)
|
||||
|
||||
tokenHash := signHMAC([]byte("legacy-key"), "portal")
|
||||
if err := store.Put(tokenHash, &TokenRecord{
|
||||
Email: "buyer@example.com",
|
||||
TenantID: "",
|
||||
Target: string(MagicLinkTargetPortal),
|
||||
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
rec, err := store.Consume(tokenHash, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("Consume: %v", err)
|
||||
}
|
||||
if rec.Target != string(MagicLinkTargetPortal) {
|
||||
t.Fatalf("target=%q, want %q", rec.Target, MagicLinkTargetPortal)
|
||||
}
|
||||
if rec.TenantID != "" {
|
||||
t.Fatalf("tenantID=%q, want empty", rec.TenantID)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestMagicLinkStoreQueryPlansUseIndexes(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "consume lookup uses token primary key",
|
||||
query: `SELECT email, tenant_id, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`,
|
||||
query: `SELECT email, tenant_id, target, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`,
|
||||
args: []any{hex.EncodeToString(tokenHash)},
|
||||
wantIndex: "sqlite_autoindex_magic_link_tokens_1",
|
||||
},
|
||||
|
||||
@@ -67,6 +67,37 @@ func TestValidateToken_Valid(t *testing.T) {
|
||||
if result.TenantID != "t-xyz" {
|
||||
t.Errorf("tenantID = %q, want t-xyz", result.TenantID)
|
||||
}
|
||||
if result.Target != MagicLinkTargetTenant {
|
||||
t.Errorf("target = %q, want %q", result.Target, MagicLinkTargetTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_PortalTargetAllowsEmptyTenant(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc, err := NewService(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
defer svc.Close()
|
||||
|
||||
token, err := svc.GeneratePortalToken("buyer@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePortalToken: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken: %v", err)
|
||||
}
|
||||
if result.Email != "buyer@example.com" {
|
||||
t.Errorf("email = %q, want buyer@example.com", result.Email)
|
||||
}
|
||||
if result.TenantID != "" {
|
||||
t.Errorf("tenantID = %q, want empty", result.TenantID)
|
||||
}
|
||||
if result.Target != MagicLinkTargetPortal {
|
||||
t.Errorf("target = %q, want %q", result.Target, MagicLinkTargetPortal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_AlreadyUsed(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package cloudcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const commercialIdentityLookupTimeout = 10 * time.Second
|
||||
|
||||
type commercialIdentity struct {
|
||||
Email string `json:"email"`
|
||||
HasCommercialIdentity bool `json:"has_commercial_identity"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
V5LicenseCount int `json:"v5_license_count"`
|
||||
V6LicenseCount int `json:"v6_license_count"`
|
||||
StripeCustomerID string `json:"stripe_customer_id,omitempty"`
|
||||
}
|
||||
|
||||
type commercialIdentityLookupFunc func(ctx context.Context, email string) (*commercialIdentity, error)
|
||||
|
||||
func newCommercialIdentityLookup(cfg *CPConfig) commercialIdentityLookupFunc {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
baseURL := strings.TrimSpace(cfg.LicenseServerURL)
|
||||
adminToken := strings.TrimSpace(cfg.LicenseAdminToken)
|
||||
if baseURL == "" || adminToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: commercialIdentityLookupTimeout}
|
||||
return func(ctx context.Context, email string) (*commercialIdentity, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("email is required")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse license server url: %w", err)
|
||||
}
|
||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/v1/admin/commercial/lookup"
|
||||
query := endpoint.Query()
|
||||
query.Set("email", email)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build commercial lookup request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Admin-Key", adminToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("perform commercial lookup request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("commercial lookup returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result commercialIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode commercial lookup response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ type CPConfig struct {
|
||||
TrialSignupPriceID string // Cloud Starter (default tier) price ID
|
||||
CloudPowerPriceID string // Cloud Power tier price ID (optional)
|
||||
CloudMaxPriceID string // Cloud Max tier price ID (optional)
|
||||
LicenseServerURL string
|
||||
LicenseAdminToken string
|
||||
TrialActivationPrivateKey string
|
||||
TrialActivationPublicKey string
|
||||
RequireEmailProvider bool
|
||||
@@ -127,6 +129,8 @@ func LoadConfig() (*CPConfig, error) {
|
||||
TrialSignupPriceID: strings.TrimSpace(os.Getenv("CP_TRIAL_SIGNUP_PRICE_ID")),
|
||||
CloudPowerPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_POWER_PRICE_ID")),
|
||||
CloudMaxPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_MAX_PRICE_ID")),
|
||||
LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"),
|
||||
LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")),
|
||||
TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")),
|
||||
RequireEmailProvider: envOrDefaultBool("CP_REQUIRE_EMAIL_PROVIDER", true),
|
||||
ResendAPIKey: strings.TrimSpace(os.Getenv("RESEND_API_KEY")),
|
||||
@@ -220,6 +224,9 @@ func (c *CPConfig) validate() error {
|
||||
if strings.TrimSpace(c.StripeAPIKey) != "" && strings.TrimSpace(c.TrialActivationPrivateKey) == "" {
|
||||
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when STRIPE_API_KEY is configured")
|
||||
}
|
||||
if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" {
|
||||
return fmt.Errorf("PULSE_LICENSE_SERVER_URL is required when PULSE_LICENSE_ADMIN_TOKEN is configured")
|
||||
}
|
||||
if strings.TrimSpace(c.StripeAPIKey) != "" {
|
||||
stripeMode := stripeSecretKeyMode(c.StripeAPIKey)
|
||||
switch c.Environment {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "32f6b26c75224ffa7ad3ca4e9e444cb647655d9e0a9c9fd8b0a03ff38168cd63",
|
||||
"source_hash": "d3efcc19901a11a535626510e9104c2428ad66d786604e8e59372e47af1a0e92",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+10
-4
@@ -388,7 +388,7 @@
|
||||
return request(bootstrap().magic_link_request_path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email })
|
||||
body: JSON.stringify({ email, target: "portal" })
|
||||
}, "Failed to send magic link.");
|
||||
},
|
||||
logout: function() {
|
||||
@@ -477,7 +477,8 @@
|
||||
return {
|
||||
emailValue: "",
|
||||
request: createMutationState(),
|
||||
success: false
|
||||
success: false,
|
||||
successMessage: ""
|
||||
};
|
||||
}
|
||||
function createPortalAccountState() {
|
||||
@@ -869,12 +870,14 @@
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
beginMutationState(nextState.request);
|
||||
nextState.success = false;
|
||||
nextState.successMessage = "";
|
||||
});
|
||||
try {
|
||||
await deps.api.requestMagicLink(email);
|
||||
var response = await deps.api.requestMagicLink(email);
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
succeedMutationState(nextState.request);
|
||||
nextState.success = true;
|
||||
nextState.successMessage = String(response?.message || "").trim();
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -882,6 +885,7 @@
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
succeedMutationState(nextState.request);
|
||||
nextState.success = true;
|
||||
nextState.successMessage = "";
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -906,6 +910,7 @@
|
||||
event.preventDefault();
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
nextState.success = false;
|
||||
nextState.successMessage = "";
|
||||
resetMutationState(nextState.request);
|
||||
});
|
||||
void sendMagicLink();
|
||||
@@ -1652,7 +1657,8 @@
|
||||
if (context.loginState.request.error) {
|
||||
statusHTML = '<div class="service-status visible error">' + escapeHTML(context.loginState.request.error) + "</div>";
|
||||
} else if (context.loginState.success) {
|
||||
statusHTML = `<div class="service-status visible success">Magic link sent. Check your inbox and click the link to sign in.<br><br><strong>Don't see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.</div>`;
|
||||
var successMessage = context.loginState.successMessage || "If that email is registered, a magic link is on the way.";
|
||||
statusHTML = '<div class="service-status visible success">' + escapeHTML(successMessage) + `<br><br><strong>Don't see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.</div>`;
|
||||
}
|
||||
return '<section class="intro-card"><h1>Pulse Account</h1><p>Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.</p></section><section class="service-section"><div class="service-panel visible"><h3>Sign in</h3><p>Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.</p><div class="form-group"><label for="portal-login-email">Email address</label><input id="portal-login-email" type="email" autocomplete="email" placeholder="you@example.com" value="' + escapeAttr(context.loginState.emailValue || "") + '" data-portal-input="login-email"></div><div class="form-actions"><button class="btn-primary" id="portal-login-send" type="button" data-portal-action="send-magic-link">' + (context.loginState.request.pending ? "Sending\u2026" : "Send magic link") + '</button><a class="btn-secondary link-button" href="' + escapeAttr(context.signupPath) + '">Create an account</a></div>' + statusHTML + "</div></section>";
|
||||
}
|
||||
|
||||
@@ -69,4 +69,32 @@ describe('portal api', function() {
|
||||
message: 'Member already exists.',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends portal-targeted magic-link requests and returns the server message', async function() {
|
||||
var fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async function() {
|
||||
return { message: 'If that email is registered, you will receive a magic link shortly.' };
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
var api = createPortalAPI({
|
||||
getBootstrap: function() {
|
||||
return bootstrap;
|
||||
},
|
||||
});
|
||||
|
||||
var result = await api.requestMagicLink('buyer@example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/public/magic-link/request',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'buyer@example.com', target: 'portal' }),
|
||||
})
|
||||
);
|
||||
expect(result.message).toContain('If that email is registered');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface PortalBillingResponse {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface PortalMagicLinkResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PortalWorkspaceCreateRequest {
|
||||
display_name: string;
|
||||
}
|
||||
@@ -35,7 +39,7 @@ export interface PortalMemberRoleRequest {
|
||||
|
||||
export interface PortalAPI {
|
||||
fetchBootstrap(): Promise<PortalBootstrapData>;
|
||||
requestMagicLink(email: string): Promise<void>;
|
||||
requestMagicLink(email: string): Promise<PortalMagicLinkResponse>;
|
||||
logout(): Promise<void>;
|
||||
postCommercialJSON<T>(path: string, body: Record<string, unknown>): Promise<T>;
|
||||
createWorkspace(accountID: string, body: PortalWorkspaceCreateRequest): Promise<void>;
|
||||
@@ -125,10 +129,10 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
|
||||
}, 'Failed to refresh account state.');
|
||||
},
|
||||
requestMagicLink: function(email: string) {
|
||||
return request<void>(bootstrap().magic_link_request_path, {
|
||||
return request<PortalMagicLinkResponse>(bootstrap().magic_link_request_path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email }),
|
||||
body: JSON.stringify({ email: email, target: 'portal' }),
|
||||
}, 'Failed to send magic link.');
|
||||
},
|
||||
logout: function() {
|
||||
|
||||
@@ -206,10 +206,10 @@ describe('portal app', function() {
|
||||
'/api/public/magic-link/request',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'buyer@example.com' }),
|
||||
body: JSON.stringify({ email: 'buyer@example.com', target: 'portal' }),
|
||||
})
|
||||
);
|
||||
expect(document.getElementById('portal-app-root')?.textContent).toContain('Magic link sent. Check your inbox and click the link to sign in.');
|
||||
expect(document.getElementById('portal-app-root')?.textContent).toContain('If that email is registered, a magic link is on the way.');
|
||||
});
|
||||
|
||||
it('completes the retrieve-license flow through the real authenticated app shell', async function() {
|
||||
|
||||
@@ -58,6 +58,13 @@ describe('auth controller', function() {
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async function() {
|
||||
return { message: 'If that email is registered, you will receive a magic link shortly.' };
|
||||
},
|
||||
text: async function() {
|
||||
return '';
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -81,10 +88,12 @@ describe('auth controller', function() {
|
||||
expect(controller.getLoginState().request.pending).toBe(false);
|
||||
expect(controller.getLoginState().request.error).toBe('');
|
||||
expect(controller.getLoginState().success).toBe(true);
|
||||
expect(controller.getLoginState().successMessage).toContain('If that email is registered');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/magic-link',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'buyer@example.com', target: 'portal' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,12 +41,14 @@ export function installAuthController(deps: AuthControllerDeps): AuthController
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
beginMutationState(nextState.request);
|
||||
nextState.success = false;
|
||||
nextState.successMessage = '';
|
||||
});
|
||||
try {
|
||||
await deps.api.requestMagicLink(email);
|
||||
var response = await deps.api.requestMagicLink(email);
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
succeedMutationState(nextState.request);
|
||||
nextState.success = true;
|
||||
nextState.successMessage = String(response?.message || '').trim();
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -54,6 +56,7 @@ export function installAuthController(deps: AuthControllerDeps): AuthController
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
succeedMutationState(nextState.request);
|
||||
nextState.success = true;
|
||||
nextState.successMessage = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -81,6 +84,7 @@ export function installAuthController(deps: AuthControllerDeps): AuthController
|
||||
event.preventDefault();
|
||||
deps.store.updateLoginState(function(nextState) {
|
||||
nextState.success = false;
|
||||
nextState.successMessage = '';
|
||||
resetMutationState(nextState.request);
|
||||
});
|
||||
void sendMagicLink();
|
||||
|
||||
@@ -36,6 +36,7 @@ function createLoginState(overrides: Partial<PortalLoginState> = {}): PortalLogi
|
||||
error: '',
|
||||
},
|
||||
success: false,
|
||||
successMessage: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -211,7 +212,7 @@ describe('shell view', function() {
|
||||
|
||||
expect(errorHTML).toContain('value="buyer@example.com"');
|
||||
expect(errorHTML).toContain('Invalid email');
|
||||
expect(successHTML).toContain('Magic link sent.');
|
||||
expect(successHTML).toContain('If that email is registered, a magic link is on the way.');
|
||||
expect(successHTML).toContain('data-portal-action="resend-magic-link"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -524,9 +524,10 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string {
|
||||
if (context.loginState.request.error) {
|
||||
statusHTML = '<div class="service-status visible error">' + escapeHTML(context.loginState.request.error) + '</div>';
|
||||
} else if (context.loginState.success) {
|
||||
var successMessage = context.loginState.successMessage || 'If that email is registered, a magic link is on the way.';
|
||||
statusHTML =
|
||||
'<div class="service-status visible success">' +
|
||||
'Magic link sent. Check your inbox and click the link to sign in.' +
|
||||
escapeHTML(successMessage) +
|
||||
'<br><br><strong>Don\'t see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createPortalLoginState(): PortalLoginState {
|
||||
emailValue: '',
|
||||
request: createMutationState(),
|
||||
success: false,
|
||||
successMessage: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface PortalLoginState {
|
||||
emailValue: string;
|
||||
request: PortalMutationState;
|
||||
success: boolean;
|
||||
successMessage: string;
|
||||
}
|
||||
|
||||
export interface PortalTeamMember {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/cpsec"
|
||||
cpemail "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/email"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
||||
@@ -154,7 +155,9 @@ type PublicCloudSignupHandlers struct {
|
||||
registry *registry.TenantRegistry
|
||||
magicLinks interface {
|
||||
GenerateToken(email, tenantID string) (string, error)
|
||||
GeneratePortalToken(email, tenantID string) (string, error)
|
||||
}
|
||||
commercialLookup commercialIdentityLookupFunc
|
||||
emailSender cpemail.Sender
|
||||
createCheckoutSession func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error)
|
||||
}
|
||||
@@ -182,16 +185,19 @@ type publicCloudSignupRequest struct {
|
||||
}
|
||||
|
||||
type publicMagicLinkRequest struct {
|
||||
Email string `json:"email"`
|
||||
Email string `json:"email"`
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
func NewPublicCloudSignupHandlers(cfg *CPConfig, reg *registry.TenantRegistry, magicLinks interface {
|
||||
GenerateToken(email, tenantID string) (string, error)
|
||||
GeneratePortalToken(email, tenantID string) (string, error)
|
||||
}, emailSender cpemail.Sender) *PublicCloudSignupHandlers {
|
||||
return &PublicCloudSignupHandlers{
|
||||
cfg: cfg,
|
||||
registry: reg,
|
||||
magicLinks: magicLinks,
|
||||
commercialLookup: newCommercialIdentityLookup(cfg),
|
||||
emailSender: emailSender,
|
||||
createCheckoutSession: stripesession.New,
|
||||
}
|
||||
@@ -389,6 +395,7 @@ func (h *PublicCloudSignupHandlers) HandlePublicMagicLinkRequest(w http.Response
|
||||
writePublicSignupError(w, http.StatusBadRequest, "invalid_email", "Invalid email format")
|
||||
return
|
||||
}
|
||||
target := parsePublicMagicLinkTarget(req.Target)
|
||||
|
||||
const msg = "If that email is registered, you'll receive a magic link shortly."
|
||||
if h.registry == nil || h.magicLinks == nil {
|
||||
@@ -399,9 +406,9 @@ func (h *PublicCloudSignupHandlers) HandlePublicMagicLinkRequest(w http.Response
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, ok, err := h.findTenantForEmail(email)
|
||||
tenantID, ok, err := h.resolveMagicLinkTenantID(r.Context(), email, target)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("email", email).Msg("public magic link request: tenant lookup failed")
|
||||
log.Warn().Err(err).Str("email", email).Str("target", string(target)).Msg("public magic link request: identity lookup failed")
|
||||
writePublicSignupJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
@@ -416,9 +423,9 @@ func (h *PublicCloudSignupHandlers) HandlePublicMagicLinkRequest(w http.Response
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.magicLinks.GenerateToken(email, tenantID)
|
||||
token, err := h.generateMagicLink(email, tenantID, target)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("email", email).Str("tenant_id", tenantID).Msg("public magic link request: token generation failed")
|
||||
log.Warn().Err(err).Str("email", email).Str("tenant_id", tenantID).Str("target", string(target)).Msg("public magic link request: token generation failed")
|
||||
writePublicSignupJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
@@ -538,6 +545,52 @@ func (h *PublicCloudSignupHandlers) findTenantForEmail(email string) (string, bo
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func parsePublicMagicLinkTarget(raw string) cpauth.MagicLinkTarget {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case string(cpauth.MagicLinkTargetPortal):
|
||||
return cpauth.MagicLinkTargetPortal
|
||||
default:
|
||||
return cpauth.MagicLinkTargetTenant
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) generateMagicLink(email, tenantID string, target cpauth.MagicLinkTarget) (string, error) {
|
||||
switch target {
|
||||
case cpauth.MagicLinkTargetPortal:
|
||||
return h.magicLinks.GeneratePortalToken(email, tenantID)
|
||||
default:
|
||||
return h.magicLinks.GenerateToken(email, tenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) resolveMagicLinkTenantID(ctx context.Context, email string, target cpauth.MagicLinkTarget) (string, bool, error) {
|
||||
if target == cpauth.MagicLinkTargetPortal {
|
||||
if user, err := h.registry.GetUserByEmail(strings.ToLower(strings.TrimSpace(email))); err != nil {
|
||||
return "", false, err
|
||||
} else if user != nil {
|
||||
return "", true, nil
|
||||
}
|
||||
}
|
||||
|
||||
tenantID, ok, err := h.findTenantForEmail(email)
|
||||
if err != nil || ok {
|
||||
return tenantID, ok, err
|
||||
}
|
||||
|
||||
if target != cpauth.MagicLinkTargetPortal || h.commercialLookup == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
identity, err := h.commercialLookup(ctx, email)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if identity != nil && identity.HasCommercialIdentity {
|
||||
return "", true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (h *PublicCloudSignupHandlers) sendMagicLinkEmail(email, verifyURL string) error {
|
||||
if h.emailSender == nil || h.cfg == nil || strings.TrimSpace(h.cfg.EmailFrom) == "" {
|
||||
log.Info().
|
||||
|
||||
@@ -15,11 +15,16 @@ import (
|
||||
)
|
||||
|
||||
type captureMagicLinkGenerator struct {
|
||||
calls int
|
||||
email string
|
||||
tenantID string
|
||||
token string
|
||||
err error
|
||||
calls int
|
||||
email string
|
||||
tenantID string
|
||||
token string
|
||||
err error
|
||||
portalCalls int
|
||||
portalEmail string
|
||||
portalTenant string
|
||||
portalToken string
|
||||
portalErr error
|
||||
}
|
||||
|
||||
func (c *captureMagicLinkGenerator) GenerateToken(email, tenantID string) (string, error) {
|
||||
@@ -32,6 +37,16 @@ func (c *captureMagicLinkGenerator) GenerateToken(email, tenantID string) (strin
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
func (c *captureMagicLinkGenerator) GeneratePortalToken(email, tenantID string) (string, error) {
|
||||
c.portalCalls++
|
||||
c.portalEmail = email
|
||||
c.portalTenant = tenantID
|
||||
if c.portalErr != nil {
|
||||
return "", c.portalErr
|
||||
}
|
||||
return c.portalToken, nil
|
||||
}
|
||||
|
||||
type captureEmailSender struct {
|
||||
calls int
|
||||
msg cpemail.Message
|
||||
@@ -201,7 +216,7 @@ func TestPublicCloudSignupHandlePublicMagicLinkRequestKnownTenantSendsEmail(t *t
|
||||
t.Fatalf("Create tenant: %v", err)
|
||||
}
|
||||
|
||||
magic := &captureMagicLinkGenerator{token: "ml_test_123"}
|
||||
magic := &captureMagicLinkGenerator{token: "ml_test_123", portalToken: "ml_portal_123"}
|
||||
emailSender := &captureEmailSender{}
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
@@ -234,6 +249,94 @@ func TestPublicCloudSignupHandlePublicMagicLinkRequestKnownTenantSendsEmail(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicCloudSignupHandlePublicMagicLinkRequestPortalKnownUserSendsEmail(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.CreateUser(®istry.User{
|
||||
ID: "u_test_1",
|
||||
Email: "owner@example.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
magic := &captureMagicLinkGenerator{token: "ml_test_123", portalToken: "ml_portal_123"}
|
||||
emailSender := &captureEmailSender{}
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
EmailFrom: "noreply@pulserelay.pro",
|
||||
}, reg, magic, emailSender)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/public/magic-link/request", strings.NewReader(`{"email":"OWNER@EXAMPLE.COM","target":"portal"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandlePublicMagicLinkRequest(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if magic.calls != 0 {
|
||||
t.Fatalf("tenant GenerateToken calls=%d, want 0", magic.calls)
|
||||
}
|
||||
if magic.portalCalls != 1 {
|
||||
t.Fatalf("portal GeneratePortalToken calls=%d, want 1", magic.portalCalls)
|
||||
}
|
||||
if magic.portalTenant != "" {
|
||||
t.Fatalf("portal tenantID=%q, want empty", magic.portalTenant)
|
||||
}
|
||||
if emailSender.calls != 1 {
|
||||
t.Fatalf("email sender calls=%d, want 1", emailSender.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicCloudSignupHandlePublicMagicLinkRequestPortalCommercialIdentitySendsEmail(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg, err := registry.NewTenantRegistry(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTenantRegistry: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reg.Close() })
|
||||
|
||||
magic := &captureMagicLinkGenerator{portalToken: "ml_portal_123"}
|
||||
emailSender := &captureEmailSender{}
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
BaseURL: "https://cloud.example.com",
|
||||
EmailFrom: "noreply@pulserelay.pro",
|
||||
}, reg, magic, emailSender)
|
||||
h.commercialLookup = func(_ context.Context, email string) (*commercialIdentity, error) {
|
||||
return &commercialIdentity{
|
||||
Email: email,
|
||||
HasCommercialIdentity: true,
|
||||
Sources: []string{"v6_license"},
|
||||
V6LicenseCount: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/public/magic-link/request", strings.NewReader(`{"email":"buyer@example.com","target":"portal"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandlePublicMagicLinkRequest(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if magic.portalCalls != 1 {
|
||||
t.Fatalf("portal GeneratePortalToken calls=%d, want 1", magic.portalCalls)
|
||||
}
|
||||
if magic.portalTenant != "" {
|
||||
t.Fatalf("portal tenantID=%q, want empty", magic.portalTenant)
|
||||
}
|
||||
if emailSender.calls != 1 {
|
||||
t.Fatalf("email sender calls=%d, want 1", emailSender.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicCloudSignupCheckoutMetadataIncludesPlanVersion(t *testing.T) {
|
||||
// Use a real Stripe price ID from PriceIDToPlanVersion so plan_version is set.
|
||||
h := NewPublicCloudSignupHandlers(&CPConfig{
|
||||
|
||||
@@ -112,7 +112,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
|
||||
|
||||
// Magic link verification (public, token-authenticated)
|
||||
baseDomain := baseDomainFromURL(deps.Config.BaseURL)
|
||||
mux.Handle("/auth/magic-link/verify", magicLinkVerifyLimiter.Middleware(http.HandlerFunc(cpauth.HandleMagicLinkVerify(deps.MagicLinks, deps.Registry, deps.Config.TenantsDir(), baseDomain))))
|
||||
mux.Handle("/auth/magic-link/verify", magicLinkVerifyLimiter.Middleware(http.HandlerFunc(cpauth.HandleMagicLinkVerify(deps.MagicLinks, deps.Registry, deps.Config.TenantsDir(), baseDomain, portal.PortalPagePath))))
|
||||
if deps.MagicLinks != nil {
|
||||
mux.Handle(portal.PortalLogoutPath, sessionAuthLimiter.Middleware(sessionAuth(cpauth.HandleLogout(deps.Registry))))
|
||||
}
|
||||
|
||||
@@ -433,8 +433,9 @@ func TestRegisterRoutes_PortalPageSessionModes(t *testing.T) {
|
||||
`id="pulse-account-bootstrap"`,
|
||||
`id="portal-app-root"`,
|
||||
`"authenticated":true`,
|
||||
"Other account services",
|
||||
"self-hosted billing, license recovery, refund, and privacy tools below now share the same Pulse Account shell",
|
||||
"Hosted operations",
|
||||
"Account services",
|
||||
"Self-hosted licenses and billing",
|
||||
} {
|
||||
if !strings.Contains(authRec.Body.String(), needle) {
|
||||
t.Fatalf("expected authenticated portal page to contain %q, body=%q", needle, authRec.Body.String())
|
||||
|
||||
Reference in New Issue
Block a user