diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index c19608a8e..66d57c708 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -483,6 +483,10 @@ func main() { // User auth (Google OAuth for agent developers) userAuth := auth.NewUserAuth(&cfg.OAuth, store, cfg.IsProduction()) + // Optional new-user signup hook (hosted welcome email etc.), signed + // with the same internal secret the billing hook uses. Empty URL = + // disabled, the self-host default. + userAuth.SetSignupHook(cfg.Limits.SignupHookURL, cfg.Limits.InternalAPISecret) // Generic OIDC Authorization Code login. Disabled configurations perform // no discovery and leave both OIDC routes unregistered. Enabled // configurations construct synchronously (no network call) and discover diff --git a/config.example.yaml b/config.example.yaml index 5be7623be..324b78b95 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -185,6 +185,15 @@ limits: # docker compose deployment alongside the hosted billing sidecar: # billing_hook_url: "http://billing:9000/api/internal/billing/cancel" billing_hook_url: "" + # URL the OSS server POSTs (HMAC-signed with the same + # internal_api_secret) when a brand-new user account is created via + # OAuth signup, so an external service can run first-touch + # provisioning such as sending a welcome email. Returning logins + # never fire it. Empty disables the call (self-host without a + # provisioning service). Override with E2A_SIGNUP_HOOK_URL. Example + # for a docker compose deployment alongside a hosted sidecar: + # signup_hook_url: "http://billing:9000/api/internal/billing/signup" + signup_hook_url: "" # Per-user budget shared by authenticated message, conversation, and webhook # reads across every agent client and dashboard session on the account. The diff --git a/docs/deployment.md b/docs/deployment.md index dc4de1b83..df10dfea8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -27,6 +27,7 @@ Copy `config.example.yaml` to `config.yaml` and fill in values, or set the envir | `E2A_OIDC_CLIENT_SECRET` | if OIDC enabled | Confidential client secret | | `E2A_OIDC_REDIRECT_URL` | if OIDC enabled | Registered absolute callback URL | | `E2A_OIDC_USER_ID_CLAIM` | if OIDC enabled | ID-token claim naming an existing `users.id` — OIDC login never provisions new users | +| `E2A_SIGNUP_HOOK_URL` | no (default empty) | URL the server POSTs (HMAC-signed with `E2A_INTERNAL_API_SECRET`, `X-E2A-Internal-Signature` header) when a brand-new user account is created via OAuth signup, so an external service can run first-touch provisioning such as a welcome email. Fired asynchronously and best-effort — login never blocks on it. Returning logins and accounts created outside the OAuth flow never fire it. Empty disables the hook. Mirrors `limits.signup_hook_url`. | | `E2A_OUTBOUND_SMTP_HOST` | for outbound | Upstream SMTP host (e.g. `email-smtp.us-east-1.amazonaws.com`) | | `E2A_OUTBOUND_SMTP_PORT` | for outbound | Upstream SMTP port (typically `587`) | | `E2A_OUTBOUND_SMTP_USERNAME` | for outbound | Upstream SMTP username | diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c8627c169..0f883d4df 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,14 +1,18 @@ package auth import ( + "bytes" "context" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" + "io" "log" "net" "net/http" @@ -48,6 +52,9 @@ type UserAuth struct { secure bool // true in production (Secure cookie flag) baseURL string // frontend origin for post-login redirect userInfoURL string // Google userinfo endpoint (overridable for testing) + + signupHookURL string // optional; when set, HandleCallback POSTs an HMAC-signed new-user notice here (see SetSignupHook) + signupHookSecret string // shared HMAC secret signing the signup hook body (limits.internal_api_secret) } type cliLoginHandoff struct { @@ -154,6 +161,64 @@ func NewUserAuthWithOAuthConfig(cfg *config.OAuthConfig, oauthCfg *oauth2.Config } } +// SetSignupHook wires in the URL of an external service's new-user +// endpoint plus the shared HMAC secret (limits.internal_api_secret) +// that signs the payload. When a Google OAuth callback creates a +// brand-new user, HandleCallback HMAC-signs a JSON notice and POSTs it +// there asynchronously so the external service can run first-touch +// provisioning (e.g. the hosted deployment's welcome email). When the +// URL is empty (the self-host default), no hook fires. Returning users +// never fire the hook, and neither do accounts created outside the +// OAuth flow (prober seeds, -bootstrap-email, test harnesses). +func (ua *UserAuth) SetSignupHook(url, secret string) { + ua.signupHookURL = url + ua.signupHookSecret = secret +} + +// notifySignupHook HMAC-POSTs the new user's identity to the configured +// signup hook. Runs in its own goroutine with its own timeout-bounded +// context — never the request's, which is canceled the moment the login +// response is written. Best-effort by design: any failure is logged and +// dropped, because a welcome-email outage must never break or delay a +// user's first login. Caller already checked ua.signupHookURL is +// non-empty. +func (ua *UserAuth) notifySignupHook(user *identity.User) { + body, err := json.Marshal(map[string]string{ + "user_id": user.ID, + "email": user.Email, + "name": user.Name, + }) + if err != nil { + log.Printf("[auth] signup hook marshal failed (continuing): user=%s err=%v", user.ID, err) + return + } + h := hmac.New(sha256.New, []byte(ua.signupHookSecret)) + h.Write(body) + sig := hex.EncodeToString(h.Sum(nil)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ua.signupHookURL, bytes.NewReader(body)) + if err != nil { + log.Printf("[auth] signup hook request failed (continuing): user=%s err=%v", user.ID, err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-E2A-Internal-Signature", sig) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("[auth] signup hook failed (continuing): user=%s err=%v", user.ID, err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + log.Printf("[auth] signup hook returned %d (continuing): user=%s body=%q", resp.StatusCode, user.ID, string(b)) + } +} + func generateNonce() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { @@ -385,12 +450,22 @@ func (ua *UserAuth) HandleCallback(w http.ResponseWriter, r *http.Request) { return } - user, err := ua.store.CreateOrGetUser(ctx, userInfo.Email, userInfo.Name, userInfo.Sub) + user, created, err := ua.store.CreateOrGetUserWithCreated(ctx, userInfo.Email, userInfo.Name, userInfo.Sub) if err != nil { http.Error(w, "failed to create user", http.StatusInternalServerError) return } + // First-time signup: fire the external provisioning hook (welcome + // email etc.) asynchronously. Best-effort — the goroutine logs and + // drops any failure, so login latency and success never depend on + // the hook endpoint. Deliberately placed here (not in the store + // upsert) so synthetic accounts — prober seeds, -bootstrap-email, + // test harnesses — never trigger it. + if created && ua.signupHookURL != "" { + go ua.notifySignupHook(user) + } + sessionToken, err := ua.store.CreateUserSession(ctx, user.ID) if err != nil { http.Error(w, "failed to create session", http.StatusInternalServerError) diff --git a/internal/auth/signup_hook_test.go b/internal/auth/signup_hook_test.go new file mode 100644 index 000000000..2d1ce189d --- /dev/null +++ b/internal/auth/signup_hook_test.go @@ -0,0 +1,222 @@ +package auth_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/tokencanopy/e2a/internal/auth" + "github.com/tokencanopy/e2a/internal/identity" +) + +// The signup hook fires from HandleCallback — asynchronously, best-effort, +// and only when the OAuth upsert actually INSERTed a new user row. These +// tests drive the same fake-Google harness as cli_login_test.go and point +// the hook at a recording httptest server. The fake userinfo endpoint +// always returns sub "google-sub-cli-test" / email "cliuser@test.com" / +// name "CLI User"; the per-test TestDB truncation means each test decides +// whether that identity is new or returning. + +type recordedSignupHook struct { + mu sync.Mutex + called bool + body []byte + signature string +} + +func (r *recordedSignupHook) snapshot() (bool, []byte, string) { + r.mu.Lock() + defer r.mu.Unlock() + return r.called, r.body, r.signature +} + +// waitForSignupHook polls until the async hook goroutine has landed or the +// deadline passes. Returns whether it was called. +func (r *recordedSignupHook) waitForSignupHook(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if called, _, _ := r.snapshot(); called { + return true + } + time.Sleep(10 * time.Millisecond) + } + called, _, _ := r.snapshot() + return called +} + +// newRecordingSignupHookServer starts an httptest server that records the +// hook body + signature header and responds 204. +func newRecordingSignupHookServer(t *testing.T) (*httptest.Server, *recordedSignupHook) { + t.Helper() + rec := &recordedSignupHook{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rec.mu.Lock() + rec.body = body + rec.signature = r.Header.Get("X-E2A-Internal-Signature") + rec.called = true + rec.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + return srv, rec +} + +// runWebCallback drives HandleCallback through a plain web login (no CLI +// handoff, no return_to) with a matching nonce cookie and returns the +// recorder. +func runWebCallback(t *testing.T, ua *auth.UserAuth) *httptest.ResponseRecorder { + t.Helper() + nonce := "signup-hook-nonce" + state := auth.EncodeOAuthState(&auth.OAuthState{Nonce: nonce}) + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf("/api/auth/callback?code=fake-code&state=%s", url.QueryEscape(state)), + nil, + ) + req.AddCookie(&http.Cookie{Name: "e2a_oauth_state", Value: nonce}) + w := httptest.NewRecorder() + ua.HandleCallback(w, req) + return w +} + +// signupHMAC re-derives the expected X-E2A-Internal-Signature for the +// recorded body — computed independently so a bug on either side shows up. +func signupHMAC(secret string, body []byte) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write(body) + return hex.EncodeToString(h.Sum(nil)) +} + +// TestHandleCallback_NewUser_FiresSignupHook: a first-time OAuth signup +// POSTs {user_id, email, name} to the hook with a matching HMAC signature, +// and the login itself still lands on /dashboard. +func TestHandleCallback_NewUser_FiresSignupHook(t *testing.T) { + secret := "test-signup-secret" + ua, store, _ := setupUserAuthWithFakeOAuth(t) + hookSrv, rec := newRecordingSignupHookServer(t) + ua.SetSignupHook(hookSrv.URL, secret) + + w := runWebCallback(t, ua) + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusFound, w.Body.String()) + } + if loc := w.Header().Get("Location"); !strings.Contains(loc, "/dashboard") { + t.Fatalf("expected redirect to dashboard, got %q", loc) + } + + if !rec.waitForSignupHook(3 * time.Second) { + t.Fatalf("signup hook was not called for a new user") + } + _, body, signature := rec.snapshot() + + var payload struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("hook body not JSON: %v (body=%q)", err, string(body)) + } + // Resolve the user the callback created (idempotent upsert on the + // fake userinfo's fixed subject) and compare against the payload. + user, created, err := store.CreateOrGetUserWithCreated(context.Background(), "cliuser@test.com", "CLI User", "google-sub-cli-test") + if err != nil { + t.Fatalf("CreateOrGetUserWithCreated: %v", err) + } + if created { + t.Fatalf("callback should already have created the user") + } + if payload.UserID != user.ID { + t.Errorf("hook user_id = %q, want %q", payload.UserID, user.ID) + } + if payload.Email != "cliuser@test.com" { + t.Errorf("hook email = %q, want %q", payload.Email, "cliuser@test.com") + } + if payload.Name != "CLI User" { + t.Errorf("hook name = %q, want %q", payload.Name, "CLI User") + } + if want := signupHMAC(secret, body); signature != want { + t.Errorf("signature mismatch:\n got %s\n expected %s", signature, want) + } +} + +// TestHandleCallback_ReturningUser_DoesNotFireSignupHook: a login by an +// existing user (same google_subject) takes the upsert's UPDATE path and +// must not re-fire the provisioning hook. +func TestHandleCallback_ReturningUser_DoesNotFireSignupHook(t *testing.T) { + ua, store, _ := setupUserAuthWithFakeOAuth(t) + hookSrv, rec := newRecordingSignupHookServer(t) + ua.SetSignupHook(hookSrv.URL, "test-signup-secret") + + // Pre-create the user the fake userinfo endpoint will report. + if _, err := store.CreateOrGetUser(context.Background(), "cliuser@test.com", "CLI User", "google-sub-cli-test"); err != nil { + t.Fatalf("CreateOrGetUser: %v", err) + } + + w := runWebCallback(t, ua) + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusFound, w.Body.String()) + } + + // The hook fires (if at all) from a goroutine; give a wrongly-fired + // one a moment to land before asserting silence. + if rec.waitForSignupHook(300 * time.Millisecond) { + t.Errorf("signup hook fired for a returning user") + } +} + +// TestHandleCallback_NoSignupHookConfigured: with an empty hook URL (the +// self-host default) a new-user signup completes with no outbound call. +func TestHandleCallback_NoSignupHookConfigured(t *testing.T) { + ua, _, _ := setupUserAuthWithFakeOAuth(t) + _, rec := newRecordingSignupHookServer(t) + // Recording server exists but is deliberately not wired in: empty + // URL disables the hook even with a secret configured. + ua.SetSignupHook("", "test-signup-secret") + + w := runWebCallback(t, ua) + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusFound, w.Body.String()) + } + if rec.waitForSignupHook(300 * time.Millisecond) { + t.Errorf("signup hook fired despite empty URL") + } +} + +// TestHandleCallback_SignupHookDown_LoginStillSucceeds: an unreachable hook +// endpoint must never break or block a first-time login — best-effort means +// the failure is logged and dropped. +func TestHandleCallback_SignupHookDown_LoginStillSucceeds(t *testing.T) { + ua, store, _ := setupUserAuthWithFakeOAuth(t) + deadSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + deadURL := deadSrv.URL + deadSrv.Close() // connection refused from here on + ua.SetSignupHook(deadURL, "test-signup-secret") + + w := runWebCallback(t, ua) + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusFound, w.Body.String()) + } + + // The user was still created despite the hook outage. + var user *identity.User + user, created, err := store.CreateOrGetUserWithCreated(context.Background(), "cliuser@test.com", "CLI User", "google-sub-cli-test") + if err != nil { + t.Fatalf("CreateOrGetUserWithCreated: %v", err) + } + if created || user == nil { + t.Fatalf("callback should have created the user even with the hook down") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 066cc2412..462089f11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -284,6 +284,14 @@ type LimitsConfig struct { // — appropriate for self-host without billing. The same // InternalAPISecret signs the POST body. BillingHookURL string `yaml:"billing_hook_url"` + // SignupHookURL is the URL the OSS server POSTs to when a brand-new + // user account is created via OAuth signup, so an external service + // (e.g. the hosted deployment's billing sidecar) can run first-touch + // provisioning such as sending a welcome email. Returning users never + // fire the hook. Empty disables the call — appropriate for self-host. + // The same InternalAPISecret signs the POST body. Override with + // E2A_SIGNUP_HOOK_URL. + SignupHookURL string `yaml:"signup_hook_url"` } // RateLimitsConfig tunes server-side request rate limits. A zero value @@ -440,6 +448,9 @@ func Load(path string) (*Config, error) { if v := os.Getenv("E2A_WEBHOOK_INTERNAL_SINK_URL"); v != "" { cfg.Webhook.InternalSinkURL = v } + if v := os.Getenv("E2A_SIGNUP_HOOK_URL"); v != "" { + cfg.Limits.SignupHookURL = v + } if v := os.Getenv("E2A_OUTBOUND_SMTP_REQUIRE_TLS"); v != "" { if b, err := strconv.ParseBool(v); err == nil { cfg.OutboundSMTP.RequireTLS = &b diff --git a/internal/identity/store.go b/internal/identity/store.go index bb9b48596..abd151d72 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -4471,18 +4471,32 @@ func (s *Store) GetConversationByID(ctx context.Context, agentID, conversationID // --- User management --- func (s *Store) CreateOrGetUser(ctx context.Context, email, name, googleSub string) (*User, error) { + u, _, err := s.CreateOrGetUserWithCreated(ctx, email, name, googleSub) + return u, err +} + +// CreateOrGetUserWithCreated is CreateOrGetUser plus a created signal: +// true when the upsert INSERTed a brand-new user row, false when the +// ON CONFLICT branch matched an existing google_subject. Postgres gives +// no direct created-vs-updated flag for upserts, so we read the system +// column xmax: a freshly inserted (never-updated) row has xmax = 0, +// while a row the DO UPDATE touched carries a non-zero xmax. Callers +// that need to react to first-time signups (e.g. the OAuth callback's +// signup hook) use this; everyone else keeps the plain wrapper. +func (s *Store) CreateOrGetUserWithCreated(ctx context.Context, email, name, googleSub string) (*User, bool, error) { u := &User{} + var created bool err := s.pool.QueryRow(ctx, `INSERT INTO users (id, email, name, google_subject) VALUES ($1, $2, $3, $4) ON CONFLICT (google_subject) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name - RETURNING id, email, name, google_subject, created_at`, + RETURNING id, email, name, google_subject, created_at, (xmax = 0) AS inserted`, generateID(), email, name, googleSub, - ).Scan(&u.ID, &u.Email, &u.Name, &u.GoogleSubject, &u.CreatedAt) + ).Scan(&u.ID, &u.Email, &u.Name, &u.GoogleSubject, &u.CreatedAt, &created) if err != nil { - return nil, err + return nil, false, err } - return u, nil + return u, created, nil } // SetAccountClass sets a user's account_class (standard|internal|system|demo). diff --git a/internal/identity/user_upsert_test.go b/internal/identity/user_upsert_test.go new file mode 100644 index 000000000..88da52906 --- /dev/null +++ b/internal/identity/user_upsert_test.go @@ -0,0 +1,66 @@ +package identity_test + +import ( + "context" + "testing" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +// TestCreateOrGetUserWithCreated_Discriminator: the upsert reports +// created=true exactly once — on the INSERT that makes the row — and +// created=false on every subsequent login with the same google_subject, +// even when the profile fields change (the ON CONFLICT UPDATE path). +// The xmax=0 trick is subtle enough to deserve its own regression test. +func TestCreateOrGetUserWithCreated_Discriminator(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + + first, created, err := store.CreateOrGetUserWithCreated(ctx, "upsert@test.com", "First Name", "google-upsert-disc") + if err != nil { + t.Fatalf("CreateOrGetUserWithCreated (insert): %v", err) + } + if !created { + t.Errorf("first upsert: created = false, want true") + } + + // Same subject, updated profile → the DO UPDATE path: same user row, + // refreshed fields, created must be false. + second, created, err := store.CreateOrGetUserWithCreated(ctx, "upsert-renamed@test.com", "New Name", "google-upsert-disc") + if err != nil { + t.Fatalf("CreateOrGetUserWithCreated (update): %v", err) + } + if created { + t.Errorf("second upsert: created = true, want false") + } + if second.ID != first.ID { + t.Errorf("second upsert returned a different user: %s vs %s", second.ID, first.ID) + } + if second.Email != "upsert-renamed@test.com" || second.Name != "New Name" { + t.Errorf("second upsert did not refresh profile fields: email=%q name=%q", second.Email, second.Name) + } + + // A different subject is a fresh signup again. + third, created, err := store.CreateOrGetUserWithCreated(ctx, "other@test.com", "Other", "google-upsert-other") + if err != nil { + t.Fatalf("CreateOrGetUserWithCreated (second insert): %v", err) + } + if !created { + t.Errorf("distinct-subject upsert: created = false, want true") + } + if third.ID == first.ID { + t.Errorf("distinct subjects share a user ID: %s", third.ID) + } + + // The plain wrapper keeps its contract for the many callers that + // don't care about the signal. + fourth, err := store.CreateOrGetUser(ctx, "upsert-renamed@test.com", "New Name", "google-upsert-disc") + if err != nil { + t.Fatalf("CreateOrGetUser: %v", err) + } + if fourth.ID != first.ID { + t.Errorf("wrapper returned a different user: %s vs %s", fourth.ID, first.ID) + } +}