fix(cloudcp): unify portal magic link identity

This commit is contained in:
rcourtman
2026-03-27 14:43:01 +00:00
parent 0fce84ad96
commit e78cde4389
24 changed files with 681 additions and 47 deletions
+98 -9
View File
@@ -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 := &registry.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
}
+86
View File
@@ -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)
}
}
+33 -2
View File
@@ -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
}
+49 -8
View File
@@ -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, &notNull, &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",
},
+31
View File
@@ -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) {
+75
View File
@@ -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
}
}
+7
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{
"source_hash": "32f6b26c75224ffa7ad3ca4e9e444cb647655d9e0a9c9fd8b0a03ff38168cd63",
"source_hash": "d3efcc19901a11a535626510e9104c2428ad66d786604e8e59372e47af1a0e92",
"build_inputs": [
"package.json",
"tsconfig.json",
+10 -4
View File
@@ -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');
});
});
+7 -3
View File
@@ -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(&registry.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{
+1 -1
View File
@@ -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))))
}
+3 -2
View File
@@ -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())