Skip to content
Merged
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
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ oidc:
# URL with no query or fragment; do not derive it from browser input.
# Override: E2A_OIDC_LOGOUT_URL
logout_url: ""
# OAuth scopes requested at login. Default: [openid]. Must include openid.
# Add "email" so the ID token carries email/email_verified: a login whose
# token asserts email_verified for the account's current email then records
# owner-mailbox proof (external sending access). Only add scopes the
# provider's client registration allows, or logins fail with invalid_scope.
# TRUST: enable "email" ONLY against an IdP that sets email_verified: true
# solely after a mailbox confirmation and resets it when the address
# changes. Some providers let a tenant admin set a mutable unverified email
# or report admin-set addresses as verified; a false proof unlocks
# owner-mailbox sending for a restricted account and also exempts sends to
# that address from the erase-defer check.
# Override: E2A_OIDC_SCOPES (comma-separated, e.g. "openid,email")
# scopes: [openid]
# Enable with: E2A_OIDC_ENABLED=true

# — Delegated access tokens ——————————————————————————————————————————————————
Expand Down
2 changes: 1 addition & 1 deletion docs/data-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ For vulnerability reporting and the security model, see [SECURITY.md](../SECURIT
| OAuth sessions | Postgres `user_sessions` | 7 days; cleanup worker removes expired rows hourly |
| Usage events / summaries (only when `E2A_USAGE_TRACKING=true`) | Postgres `usage_events`, `usage_summaries` | Indefinite by default — operator can purge or override |
| Per-webhook signing secret (`whsec_…`) | Postgres, **plaintext** | Until the webhook is deleted. Returned once at creation; rotate via `POST /v1/webhooks/{id}/rotate-secret` (the previous secret stays valid for a 24h grace window). |
| Owner-mailbox proof (the sign-in address a verified Google login confirmed, its source and timestamp) | Postgres `users.owner_email_verified_*` | Until the account is deleted; stops applying as soon as the account email changes. |
| Owner-mailbox proof (the sign-in address a verified Google or OIDC login confirmed, its source and timestamp) | Postgres `users.owner_email_verified_*` | Until the account is deleted; stops applying as soon as the account email changes. |
| External sending access requests (use case, intended recipients, expected volume, review state) | Postgres `external_sending_access_requests` | Until the account is deleted (cascade). Private support data — never filed to a public tracker. |
| External sending access audit (operator grant/revoke: account id, old/new state, actor, reason; no addresses) | Postgres `external_sending_access_events` | Append-only; no account foreign key, so it outlives account deletion. Each row carries an `expires_at` (the `sending_control_audit_retention_days` policy value, 90 days by default) and the sending-ledger janitor deletes it then. |
| Sending pause/resume audit (account id, old/new state, reason, actor, pause class, optional operator evidence reference; no addresses) | Postgres `account_sending_control_events` | No account foreign key, so it outlives account deletion. Deleted by the sending-ledger janitor at its `expires_at` (`sending_control_audit_retention_days`, 90 days by default) — except an `abuse`-class event, which is kept past its expiry while the account still exists (live or trashed), because the account purge reads it to decide the abuse hold; it is deleted after the purge. |
Expand Down
3 changes: 2 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Copy `config.example.yaml` to `config.yaml` and fill in values, or set the envir
| `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_OIDC_LOGOUT_URL` | no | Optional fixed upstream logout URL to redirect to after local logout (absolute `http(s)` URL, no query/fragment) |
| `E2A_OIDC_SCOPES` | no | Comma-separated scopes requested at login (default `openid`; must include `openid`; no empty or duplicate entries). Add `email` to let a verified OIDC login record owner-mailbox proof |
| `E2A_PROVISIONING_ENABLED` | no (default off) | Internal-only. Turns on `POST /api/internal/users/provision`, which lets an external control plane create users idempotently ahead of their first sign-in (see below) |
| `E2A_PROVISIONING_SECRET` | if provisioning enabled | Internal-only, env-only. Shared HMAC key the control plane signs provisioning request bodies with (`X-E2A-Internal-Signature`); must match on both ends. Production requires ≥32 bytes — generate with `openssl rand -hex 32` |
| `E2A_DELEGATED_ENABLED` | no (default off) | Turns on delegated access-token verification (RFC 9068 `at+jwt`) so an external control plane can call `/v1` on behalf of its signed-in humans (see below). The rest of the policy — `issuer_url`, `audience`, `authorized_party`, `required_scope`, `allowed_algorithms`, `max_token_lifetime_seconds`, `clock_skew_seconds`, `required_claims`, `forbidden_claims` — is non-secret and lives in the `delegated:` block of `config.yaml`; there is no shared key |
Expand Down Expand Up @@ -209,7 +210,7 @@ That leaves two real horizontal-scaling caveats:

**Vertical scaling is fine.** The API, the SMTP relay, and all three background workers run safely on multiple replicas today — the only paths that need attention before you do are the two above.

**Dashboard auth is Google OAuth plus an optional generic OIDC login.** [`internal/auth/auth.go`](../internal/auth/auth.go) imports `golang.org/x/oauth2/google` directly and the config exposes `google_client_id` / `google_client_secret`. [`internal/auth/oidc.go`](../internal/auth/oidc.go) additionally implements an off-by-default OpenID Connect relying party (`E2A_OIDC_ENABLED` + the `E2A_OIDC_*` vars above) for teams on Microsoft Entra, Okta, or another standards-compliant OIDC provider. Start the flow at `GET /api/auth/oidc/login`, which accepts the same optional `return_to` and `cli_callback`/`cli_state` query parameters as the Google login (same validation rules, same post-login behavior). It is an additional login path, not a Google OAuth replacement: it maps a configured ID-token claim to an existing e2a user and never provisions new ones, so accounts must already exist (e.g. via Google OAuth or `-bootstrap-email`) before OIDC login works for them. The CLI and SDKs authenticate with API keys, which are provider-agnostic.
**Dashboard auth is Google OAuth plus an optional generic OIDC login.** [`internal/auth/auth.go`](../internal/auth/auth.go) imports `golang.org/x/oauth2/google` directly and the config exposes `google_client_id` / `google_client_secret`. [`internal/auth/oidc.go`](../internal/auth/oidc.go) additionally implements an off-by-default OpenID Connect relying party (`E2A_OIDC_ENABLED` + the `E2A_OIDC_*` vars above) for teams on Microsoft Entra, Okta, or another standards-compliant OIDC provider. Start the flow at `GET /api/auth/oidc/login`, which accepts the same optional `return_to` and `cli_callback`/`cli_state` query parameters as the Google login (same validation rules, same post-login behavior). It is an additional login path, not a Google OAuth replacement: it maps a configured ID-token claim to an existing e2a user and never provisions new ones, so accounts must already exist (e.g. via Google OAuth or `-bootstrap-email`) before OIDC login works for them. Adding `email` to `oidc.scopes` (`E2A_OIDC_SCOPES`) makes an OIDC login whose ID token asserts `email_verified` for the account's current email record owner-mailbox proof; enable it only against an IdP that sets `email_verified: true` solely after a mailbox confirmation and resets it when the address changes, because a false proof unlocks owner-mailbox sending for a restricted account and exempts sends to that address from the erase-defer check. The CLI and SDKs authenticate with API keys, which are provider-agnostic.

**User provisioning for an external control plane is opt-in and internal-only.** When `E2A_PROVISIONING_ENABLED=true`, the server exposes `POST /api/internal/users/provision` (not part of the public `/v1` API or the OpenAPI spec) so an operator's external control plane can create e2a users ahead of their first sign-in. The caller sends `{"external_ref", "email", "name"?}` and authenticates with a hex HMAC-SHA256 of the raw request body in `X-E2A-Internal-Signature`, keyed by `E2A_PROVISIONING_SECRET` (env-only, deliberately separate from the limits internal API secret so each can be rotated independently). `external_ref` is the idempotency key — it becomes the row's `google_subject` (`bootstrap:<ref>`), so a replay returns `200` with the same `user_id`; a fresh create returns `201`; a different `external_ref` carrying an email another account already holds returns `409 {"error":"email_conflict"}` and never attaches or merges. A ref that resolves to an account in the trash returns `409 {"error":"account_trashed"}` and writes nothing — restore is interactive only (the user signs in and chooses in the dashboard), so a control plane must treat it as terminal for that principal rather than retry. When identity tombstones are enabled, a subject or email held by a live tombstone (a recently deleted or abuse-closed account) returns `403 {"error":"registration_refused"}`, and `503 {"error":"registration_unavailable"}` means the tombstone key is not configured. Provisioning creates only the user row — no session, API key, or limits row. Disabled (the self-host default), the endpoint 503s.

Expand Down
4 changes: 3 additions & 1 deletion docs/design/async-message-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,4 +546,6 @@ local server commands (`-approve-external-sending` / `-revoke-external-sending`
with a revision CAS and append-only `external_sending_access_events`); no API
credential can grant access. Owner-mailbox proof
(`users.owner_email_verified_at` + the bound address) is written only by a
verified Google login.
verified Google login, or by an OIDC login whose ID token asserts
`email_verified` for the account's current email (requires the `email` scope
in `oidc.scopes`).
30 changes: 29 additions & 1 deletion internal/auth/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ func (oa *OIDCAuth) discoverWithRetry(ctx context.Context) {
ClientSecret: oa.cfg.ClientSecret,
RedirectURL: oa.cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID},
Scopes: oa.cfg.EffectiveScopes(),
},
verifier: provider.Verifier(&oidc.Config{ClientID: oa.cfg.ClientID}),
})
Expand Down Expand Up @@ -602,6 +602,7 @@ func (oa *OIDCAuth) HandleCallback(w http.ResponseWriter, r *http.Request) {
}
return
}
oa.recordOwnerEmailProof(r.Context(), user, claims)
sessionToken, err := oa.store.CreateUserSession(r.Context(), user.ID)
if err != nil {
oa.recordCallback("session_failed", "trusted", http.StatusInternalServerError, true)
Expand Down Expand Up @@ -658,6 +659,33 @@ func (oa *OIDCAuth) HandleCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, oa.baseURL+"/dashboard", http.StatusFound)
}

// recordOwnerEmailProof records owner-mailbox proof (external sending access)
// when the ID token asserts a verified email equal to the account's email.
//
// Trust argument: the ID token was signature-verified against the issuer's
// keys with audience and nonce checks, and the user_id_claim binds the login
// to this exact account. email_verified is the IdP's own assertion, accepted
// only as a JSON boolean true (a string "true" is not verified). Equality with
// the account's CURRENT email is re-checked in SQL, so an email change between
// lookup and write cannot record proof for a different address. Call only for
// non-trashed accounts. Best effort: failure never fails the login, and the
// email and raw claims are never logged. A missing, unverified or mismatching
// claim silently records nothing.
func (oa *OIDCAuth) recordOwnerEmailProof(ctx context.Context, user *identity.User, claims map[string]any) {
email, _ := claims["email"].(string)
verified, _ := claims["email_verified"].(bool)
if !verified || strings.TrimSpace(email) == "" {
return
}
// Same trim+lowercase rule as sendingpolicy.NormalizeOwnerMailbox.
if strings.ToLower(strings.TrimSpace(email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
return
}
if _, err := oa.store.RecordOIDCOwnerEmailProof(ctx, user.ID, email); err != nil {
log.Printf("[auth] record owner email proof failed: user=%s err=%v", user.ID, err)
}
}

func isOIDCRequestCancellation(ctx context.Context, err error) bool {
return errors.Is(ctx.Err(), context.Canceled) && errors.Is(err, context.Canceled)
}
Expand Down
113 changes: 113 additions & 0 deletions internal/auth/oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

jose "github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/jackc/pgx/v5/pgxpool"

"github.com/tokencanopy/e2a/internal/auth"
"github.com/tokencanopy/e2a/internal/config"
Expand Down Expand Up @@ -63,6 +64,8 @@ type oidcFixture struct {
includeIDToken bool
includeUserID bool
userIDClaimValue any
extraClaims map[string]any
scopes []string
tokenStatus int
tokenFailureBody string
tokenFailureType string
Expand Down Expand Up @@ -148,6 +151,11 @@ func setupOIDCWithOptions(t *testing.T, opts ...auth.OIDCOption) *oidcFixture {
}

func setupOIDCForAppWithOptions(t *testing.T, redirectURL, baseURL string, opts ...auth.OIDCOption) *oidcFixture {
t.Helper()
return setupOIDCFull(t, redirectURL, baseURL, nil, opts...)
}

func setupOIDCFull(t *testing.T, redirectURL, baseURL string, scopesForFixture []string, opts ...auth.OIDCOption) *oidcFixture {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
Expand Down Expand Up @@ -184,6 +192,7 @@ func setupOIDCForAppWithOptions(t *testing.T, redirectURL, baseURL string, opts
ClientSecret: testOIDCClientSecret,
RedirectURL: redirectURL,
UserIDClaim: testOIDCUserIDClaim,
Scopes: scopesForFixture,
}
oidcOpts := []auth.OIDCOption{
auth.WithOIDCDiscoveryBackoff(testOIDCDiscoveryInitialBackoff, testOIDCDiscoveryMaxBackoff),
Expand Down Expand Up @@ -343,6 +352,9 @@ func (fx *oidcFixture) signIDToken(ctx context.Context) string {
}
private[testOIDCUserIDClaim] = value
}
for k, v := range fx.extraClaims {
private[k] = v
}
token, err := jwt.Signed(signer).Claims(claims).Claims(private).CompactSerialize()
if err != nil {
panic(err)
Expand Down Expand Up @@ -1632,3 +1644,104 @@ func TestOIDCCallbackIgnoresResumeCookieWithIncompleteCLIPair(t *testing.T) {
t.Errorf("Location = %q, want %q", got, want)
}
}

func TestOIDCLoginRequestsConfiguredScopes(t *testing.T) {
fx := setupOIDCFull(t, testOIDCRedirectURL, "http://app.example.com", []string{"openid", "email"})
w := httptest.NewRecorder()
fx.oidc.HandleLogin(w, httptest.NewRequest(http.MethodGet, "/api/auth/oidc/login", nil))
location, err := url.Parse(w.Header().Get("Location"))
if err != nil {
t.Fatalf("parse redirect: %v", err)
}
if got := location.Query().Get("scope"); got != "openid email" {
t.Errorf("scope = %q, want %q", got, "openid email")
}
}

type ownerProofRow struct {
address, source *string
}

func readOwnerProof(t *testing.T, fx *oidcFixture, userID string) ownerProofRow {
t.Helper()
var row ownerProofRow
pool, err := pgxpool.New(context.Background(), testutil.TestDBURL())
if err != nil {
t.Fatal(err)
}
defer pool.Close()
err = pool.QueryRow(context.Background(),
`SELECT owner_email_verified_address, owner_email_verified_source FROM users WHERE id = $1`, userID).
Scan(&row.address, &row.source)
if err != nil {
t.Fatalf("read owner proof: %v", err)
}
return row
}

func TestOIDCCallbackOwnerEmailProof(t *testing.T) {
cases := []struct {
name string
claims map[string]any
trash bool
wantProof bool
wantAddr string
}{
{"verified match", map[string]any{"email": "owner@example.com", "email_verified": true}, false, true, "owner@example.com"},
{"unverified", map[string]any{"email": "owner@example.com", "email_verified": false}, false, false, ""},
{"string true is not verified", map[string]any{"email": "owner@example.com", "email_verified": "true"}, false, false, ""},
{"different email", map[string]any{"email": "other@example.com", "email_verified": true}, false, false, ""},
{"case and whitespace", map[string]any{"email": " Owner@Example.COM ", "email_verified": true}, false, true, "owner@example.com"},
{"claims absent", nil, false, false, ""},
{"numeric 1 is not verified", map[string]any{"email": "owner@example.com", "email_verified": 1}, false, false, ""},
{"numeric 1.0 is not verified", map[string]any{"email": "owner@example.com", "email_verified": 1.0}, false, false, ""},
{"email without verified flag", map[string]any{"email": "owner@example.com"}, false, false, ""},
{"trashed account", map[string]any{"email": "owner@example.com", "email_verified": true}, true, false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fx := setupOIDC(t)
ctx := context.Background()
user, err := fx.store.CreateOrGetUser(ctx, "owner@example.com", "Owner", "google-sub-"+strings.ReplaceAll(tc.name, " ", "-"))
if err != nil {
t.Fatalf("CreateOrGetUser: %v", err)
}
if tc.trash {
if _, err := fx.store.TrashAccount(ctx, user.ID, nil); err != nil {
t.Fatalf("TrashAccount: %v", err)
}
}
fx.userID = user.ID
fx.extraClaims = tc.claims
tx := beginOIDCLogin(t, fx)

w := httptest.NewRecorder()
fx.oidc.HandleCallback(w, callbackRequest(tx, "code=valid-code&state="+url.QueryEscape(tx.state)))
if w.Code != http.StatusFound {
t.Fatalf("callback status = %d, want 302; body=%s", w.Code, w.Body.String())
}
session := findCookie(w.Result().Cookies(), auth.SessionCookieName)
if tc.trash {
assertCallbackMetric(t, fx, oidcMetricEvent{outcome: "account_trashed", trust: "trusted", statusClass: "3xx"})
} else {
if got := w.Header().Get("Location"); got != "http://app.example.com/dashboard" {
t.Errorf("Location = %q", got)
}
if session == nil || session.Value == "" {
t.Fatal("expected session cookie")
}
assertCallbackMetric(t, fx, oidcMetricEvent{outcome: "success", trust: "trusted", statusClass: "3xx"})
}
row := readOwnerProof(t, fx, user.ID)
if !tc.wantProof {
if row.address != nil || row.source != nil {
t.Fatalf("unexpected proof recorded: %+v", row)
}
return
}
if row.address == nil || *row.address != tc.wantAddr || row.source == nil || *row.source != "oidc" {
t.Fatalf("proof = %+v, want address %q source oidc", row, tc.wantAddr)
}
})
}
}
Loading
Loading