Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/e2a/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
77 changes: 76 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
222 changes: 222 additions & 0 deletions internal/auth/signup_hook_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading