diff --git a/config.example.yaml b/config.example.yaml index 24d2827eb..c97731ecb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 —————————————————————————————————————————————————— diff --git a/docs/data-handling.md b/docs/data-handling.md index 88fe69ad3..b5fcb841c 100644 --- a/docs/data-handling.md +++ b/docs/data-handling.md @@ -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. | diff --git a/docs/deployment.md b/docs/deployment.md index 17f6cca6a..42d2b5401 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 | @@ -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:`), 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. diff --git a/docs/design/async-message-pipeline.md b/docs/design/async-message-pipeline.md index 47d9250d9..433b41317 100644 --- a/docs/design/async-message-pipeline.md +++ b/docs/design/async-message-pipeline.md @@ -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`). diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 9021e56fe..0343faf64 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -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}), }) @@ -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) @@ -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) } diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index 0c53611a1..7bda54a7b 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -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" @@ -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 @@ -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 { @@ -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), @@ -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) @@ -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) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0164ab123..d9b2233ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "time" + "unicode" "gopkg.in/yaml.v3" ) @@ -245,6 +246,32 @@ type OIDCConfig struct { // deployments can use this to cascade logout through their OIDC control // plane; it is never taken from a request parameter. LogoutURL string `yaml:"logout_url"` + // Scopes are the OAuth scopes requested at the authorization endpoint. + // Default ["openid"]; it must contain "openid". Add "email" so the ID + // token carries email/email_verified, which lets a verified login record + // owner-mailbox proof. Override with E2A_OIDC_SCOPES (comma-separated). + // Only list 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. + Scopes []string `yaml:"scopes"` +} + +// DefaultOIDCScopes is the scope list used when oidc.scopes is unset. +var DefaultOIDCScopes = []string{"openid"} + +// EffectiveScopes returns Scopes, or the default when none are configured. +// Load already defaults; this exists for configs built without Load (tests). +func (c OIDCConfig) EffectiveScopes() []string { + if len(c.Scopes) == 0 { + return append([]string(nil), DefaultOIDCScopes...) + } + return append([]string(nil), c.Scopes...) } type SigningConfig struct { @@ -922,6 +949,20 @@ func Load(path string) (*Config, error) { if v := os.Getenv("E2A_OIDC_LOGOUT_URL"); v != "" { cfg.OIDC.LogoutURL = v } + if v := os.Getenv("E2A_OIDC_SCOPES"); v != "" { + var scopes []string + for _, part := range strings.Split(v, ",") { + if part = strings.TrimSpace(part); part != "" { + scopes = append(scopes, part) + } + } + if len(scopes) == 0 { + // Only commas/whitespace: keep an empty entry so Validate fails + // at boot instead of silently using the default. + scopes = []string{""} + } + cfg.OIDC.Scopes = scopes + } if v := os.Getenv("E2A_DELEGATED_ENABLED"); v != "" { if b, err := strconv.ParseBool(v); err == nil { cfg.Delegated.Enabled = b @@ -1109,6 +1150,12 @@ func Load(path string) (*Config, error) { if cfg.HTTP.APIURL == "" { cfg.HTTP.APIURL = cfg.HTTP.PublicURL } + for i, scope := range cfg.OIDC.Scopes { + cfg.OIDC.Scopes[i] = strings.TrimSpace(scope) + } + if len(cfg.OIDC.Scopes) == 0 { + cfg.OIDC.Scopes = cfg.OIDC.EffectiveScopes() + } if err := cfg.Validate(); err != nil { return nil, err @@ -1207,6 +1254,24 @@ func (c *Config) Validate() error { return fmt.Errorf("config: oidc.redirect_url must be an absolute http(s) URL without a fragment") } } + if len(c.OIDC.Scopes) > 0 { + seen := make(map[string]struct{}, len(c.OIDC.Scopes)) + for _, scope := range c.OIDC.Scopes { + if strings.TrimSpace(scope) == "" { + return fmt.Errorf("config: oidc.scopes must not contain an empty entry") + } + if strings.IndexFunc(scope, unicode.IsSpace) >= 0 { + return fmt.Errorf("config: oidc.scopes entry %q contains whitespace; list each scope separately", scope) + } + if _, dup := seen[scope]; dup { + return fmt.Errorf("config: oidc.scopes contains duplicate entry %q", scope) + } + seen[scope] = struct{}{} + } + if _, ok := seen["openid"]; !ok { + return fmt.Errorf("config: oidc.scopes must include \"openid\"") + } + } if c.OIDC.LogoutURL != "" { logoutURL, err := absoluteHTTPURL(c.OIDC.LogoutURL) if err != nil || logoutURL.RawQuery != "" || logoutURL.Fragment != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 519a7b4df..f0d207f7c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -1226,3 +1227,52 @@ func TestNotificationsSupportContact(t *testing.T) { } }) } + +func loadOIDCScopesConfig(t *testing.T, yamlBody string) (*Config, error) { + t.Helper() + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(cfgPath, []byte("env: \"development\"\n"+yamlBody), 0644); err != nil { + t.Fatal(err) + } + return Load(cfgPath) +} + +func TestOIDCScopes(t *testing.T) { + cases := []struct { + name string + yaml string + env string + want []string + wantErr string + }{ + {name: "default", want: []string{"openid"}}, + {name: "yaml list", yaml: "oidc:\n scopes: [openid, email]\n", want: []string{"openid", "email"}}, + {name: "env override", yaml: "oidc:\n scopes: [openid]\n", env: "openid, email,", want: []string{"openid", "email"}}, + {name: "missing openid", yaml: "oidc:\n scopes: [email]\n", wantErr: "openid"}, + {name: "empty entry", yaml: "oidc:\n scopes: [openid, \" \"]\n", wantErr: "empty entry"}, + {name: "duplicate", yaml: "oidc:\n scopes: [openid, email, openid]\n", wantErr: "duplicate"}, + {name: "yaml entries trimmed", yaml: "oidc:\n scopes: [\" openid \", email]\n", want: []string{"openid", "email"}}, + {name: "internal whitespace", yaml: "oidc:\n scopes: [openid, \"email profile\"]\n", wantErr: "whitespace"}, + {name: "env only comma", env: ",", wantErr: "empty entry"}, + {name: "env only commas and spaces", env: " , ", wantErr: "empty entry"}, + {name: "env missing openid", env: "email", wantErr: "openid"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("E2A_OIDC_SCOPES", tc.env) + cfg, err := loadOIDCScopesConfig(t, tc.yaml) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), "oidc.scopes") || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want oidc.scopes error containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("Load: %v", err) + } + if !reflect.DeepEqual(cfg.OIDC.Scopes, tc.want) { + t.Errorf("Scopes = %v, want %v", cfg.OIDC.Scopes, tc.want) + } + }) + } +} diff --git a/internal/identity/store.go b/internal/identity/store.go index 1d16a6383..621bb4784 100644 --- a/internal/identity/store.go +++ b/internal/identity/store.go @@ -6192,6 +6192,32 @@ func (s *Store) RecordGoogleOwnerEmailProof(ctx context.Context, userID, googleS return tag.RowsAffected() == 1, nil } +// RecordOIDCOwnerEmailProof records that an OIDC login whose verified ID token +// asserted email_verified for verifiedEmail just proved the owner mailbox. The +// caller has already verified the token and bound it to userID; the write is +// guarded again in SQL: the account must not be trashed and its current email +// must equal the verified address. Returns whether proof was recorded. +func (s *Store) RecordOIDCOwnerEmailProof(ctx context.Context, userID, verifiedEmail string) (bool, error) { + // Same trim+lowercase rule as sendingpolicy.NormalizeOwnerMailbox (which + // identity may not import). + normalized := strings.ToLower(strings.TrimSpace(verifiedEmail)) + if userID == "" || normalized == "" { + return false, nil + } + tag, err := s.pool.Exec(ctx, ` + UPDATE users + SET owner_email_verified_at = now(), + owner_email_verified_address = $2, + owner_email_verified_source = 'oidc' + WHERE id = $1 AND deleted_at IS NULL + AND lower(btrim(email)) = $2`, + userID, normalized) + if err != nil { + return false, err + } + return tag.RowsAffected() == 1, nil +} + // SetAccountClass sets a user's account_class (standard|internal|system|demo). // Used by the prober's seed to mark the synthetic probe account as system so its // traffic is never metered (see usage.PolicyFor). The CHECK constraint in diff --git a/internal/identity/store_owner_proof_oidc_test.go b/internal/identity/store_owner_proof_oidc_test.go new file mode 100644 index 000000000..46e7de311 --- /dev/null +++ b/internal/identity/store_owner_proof_oidc_test.go @@ -0,0 +1,79 @@ +package identity_test + +import ( + "context" + "testing" + + "github.com/tokencanopy/e2a/internal/identity" + "github.com/tokencanopy/e2a/internal/testutil" +) + +func TestRecordOIDCOwnerEmailProof(t *testing.T) { + pool := testutil.TestDB(t) + store := identity.NewStore(pool) + ctx := context.Background() + + newUser := func(email, sub string) string { + u, err := store.CreateOrGetUser(ctx, email, "Owner", sub) + if err != nil { + t.Fatalf("CreateOrGetUser: %v", err) + } + return u.ID + } + read := func(id string) (addr, src *string) { + if err := pool.QueryRow(ctx, `SELECT owner_email_verified_address, owner_email_verified_source FROM users WHERE id = $1`, id).Scan(&addr, &src); err != nil { + t.Fatal(err) + } + return + } + + t.Run("records normalized proof with oidc source", func(t *testing.T) { + id := newUser("oidc-proof-ok@example.com", "sub-oidc-proof-ok") + ok, err := store.RecordOIDCOwnerEmailProof(ctx, id, " OIDC-Proof-OK@Example.com ") + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + addr, src := read(id) + if addr == nil || *addr != "oidc-proof-ok@example.com" || src == nil || *src != "oidc" { + t.Fatalf("proof = %v %v", addr, src) + } + }) + t.Run("deleted account", func(t *testing.T) { + id := newUser("oidc-proof-del@example.com", "sub-oidc-proof-del") + if _, err := pool.Exec(ctx, `UPDATE users SET deleted_at = now() WHERE id = $1`, id); err != nil { + t.Fatal(err) + } + ok, err := store.RecordOIDCOwnerEmailProof(ctx, id, "oidc-proof-del@example.com") + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want false,nil", ok, err) + } + if addr, _ := read(id); addr != nil { + t.Fatalf("proof recorded on deleted account: %v", *addr) + } + }) + t.Run("email mismatch", func(t *testing.T) { + id := newUser("oidc-proof-mm@example.com", "sub-oidc-proof-mm") + ok, err := store.RecordOIDCOwnerEmailProof(ctx, id, "someone-else@example.com") + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want false,nil", ok, err) + } + if addr, _ := read(id); addr != nil { + t.Fatalf("proof recorded on mismatch: %v", *addr) + } + }) + t.Run("empty inputs", func(t *testing.T) { + for _, c := range [][2]string{{"", "a@example.com"}, {"usr_x", " "}} { + ok, err := store.RecordOIDCOwnerEmailProof(ctx, c[0], c[1]) + if err != nil || ok { + t.Fatalf("%v: ok=%v err=%v", c, ok, err) + } + } + }) + t.Run("constraint still rejects other sources", func(t *testing.T) { + id := newUser("oidc-proof-bad@example.com", "sub-oidc-proof-bad") + _, err := pool.Exec(ctx, `UPDATE users SET owner_email_verified_at = now(), owner_email_verified_address = lower(email), owner_email_verified_source = 'bogus' WHERE id = $1`, id) + if err == nil { + t.Fatal("expected check violation for unknown source") + } + }) +} diff --git a/migrations/132_owner_email_proof_oidc_source.sql b/migrations/132_owner_email_proof_oidc_source.sql new file mode 100644 index 000000000..395aa4312 --- /dev/null +++ b/migrations/132_owner_email_proof_oidc_source.sql @@ -0,0 +1,11 @@ +-- 132_owner_email_proof_oidc_source.sql +-- Allow 'oidc' as an owner-mailbox proof source. A generic OIDC login whose +-- verified ID token asserts email_verified for the account's current email can +-- now record the same proof a verified Google login records (migration 121). +-- Only the allowed-source list widens; the completeness and normalization +-- checks are unchanged. Idempotent. +DO $$ BEGIN + ALTER TABLE users DROP CONSTRAINT IF EXISTS users_owner_email_verified_source_check; + ALTER TABLE users ADD CONSTRAINT users_owner_email_verified_source_check + CHECK (owner_email_verified_source IS NULL OR owner_email_verified_source IN ('google_oauth', 'oidc')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$;