From c98b6f3e687330bc9bfdaae5f2b939add665c8b8 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 14:50:46 +0530 Subject: [PATCH 01/25] security(crypto)!: split at-rest encryption key from JWT secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-rest key was wired directly to --jwt-secret, which is only required for HMAC JWT types. An RS*/ES* install legitimately leaves it empty, so the key resolved to "" and HKDF-SHA256 over empty keying material (no salt, fixed info string) produced a fixed, publicly computable AES key: 62d720a3...419e. TOTP seeds were recoverable by anyone with a database copy and this source. The same empty value keyed the OTP HMAC, making stored digests reversible by brute force over the 10^6 code space — including outstanding password-reset codes, i.e. account takeover. Both failed silently: encryption "succeeded", rows carried the enc:v1: prefix, digests were the right length, nothing was logged. - add --encryption-key, resolved in Finalize as --encryption-key -> --jwt-secret -> startup error - route the five OTP HMAC sites through it (they used JWTSecret directly and so bypassed the new key entirely) - ValidateEncryptionKey is unconditional, NOT scoped to TOTP: password-reset OTPs are written whether or not TOTP is enabled, so a TOTP-scoped check leaves the reset flow hashing under an empty key - pass fixed dev keys in `make dev` and perf/run_container.sh, both of which run RS256 with no --jwt-secret Affected: 2.2.1-rc.2 through 2.4.0-rc.13, RSA/ECDSA deployments only. HMAC installs are unaffected — the fallback resolves to the same JWTSecret value used before, so existing enrolments keep working. BREAKING CHANGE: RSA/ECDSA deployments must set --encryption-key; the server refuses to start without it. Seeds written by an affected version were encrypted under a public constant and must be treated as compromised — those users re-enrol. --- Makefile | 1 + cmd/root.go | 6 +- internal/authenticators/providers.go | 4 +- internal/authenticators/totp/provider.go | 5 +- internal/config/config.go | 75 ++++++++++++++++++ internal/config/config_encryption_key_test.go | 76 +++++++++++++++++++ internal/integration_tests/test_helper.go | 25 +++--- internal/service/forgot_password.go | 2 +- internal/service/otp_mfa_setup.go | 2 +- internal/service/reset_password.go | 2 +- internal/service/signup.go | 2 +- internal/service/verify_otp.go | 2 +- perf/run_container.sh | 1 + 13 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 internal/config/config_encryption_key_test.go diff --git a/Makefile b/Makefile index a3214ae3d..7476ff34a 100644 --- a/Makefile +++ b/Makefile @@ -92,6 +92,7 @@ dev: --jwt-private-key="$$PRIVATE_KEY" \ --jwt-public-key="$$PUBLIC_KEY" \ --admin-secret=admin \ + --encryption-key=dev-encryption-key-not-for-production \ --client-id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH \ --client-secret=60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa \ --allowed-origins=localhost:8080,localhost:8090,localhost:9091,localhost:5173,localhost:5174 \ diff --git a/cmd/root.go b/cmd/root.go index 1e571bf48..33d7bacb1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,8 +78,11 @@ var ( Use: "authorizer", // Derive runtime config (service availability, MFA defaults) before any // subcommand runs so `authorizer` and `authorizer mcp` stay consistent. - PersistentPreRun: func(_ *cobra.Command, _ []string) { + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { rootArgs.config.Finalize() + // Fail closed rather than silently encrypting TOTP seeds with a + // publicly computable key derived from an empty secret. + return rootArgs.config.ValidateEncryptionKey() }, Run: runRoot, } @@ -214,6 +217,7 @@ func init() { // JWT flags f.StringVar(&rootArgs.config.JWTType, "jwt-type", "", "Type of JWT to use") f.StringVar(&rootArgs.config.JWTSecret, "jwt-secret", "", "Secret for the JWT") + f.StringVar(&rootArgs.config.EncryptionKey, "encryption-key", "", "Key used to encrypt secrets at rest (TOTP seeds). Defaults to --jwt-secret for backwards compatibility; set a distinct value before rotating --jwt-secret, otherwise rotation locks out every enrolled TOTP user") f.StringVar(&rootArgs.config.JWTPrivateKey, "jwt-private-key", "", "Private key for the JWT") f.StringVar(&rootArgs.config.JWTPublicKey, "jwt-public-key", "", "Public key for the JWT") // JWT secondary key flags (for manual key rotation) diff --git a/internal/authenticators/providers.go b/internal/authenticators/providers.go index 0053c6f01..31534706f 100644 --- a/internal/authenticators/providers.go +++ b/internal/authenticators/providers.go @@ -40,6 +40,8 @@ func New(cfg *config.Config, deps *Dependencies) (Provider, error) { Log: deps.Log, StorageProvider: deps.StorageProvider, MemoryStoreProvider: deps.MemoryStoreProvider, - EncryptionKey: cfg.JWTSecret, + // Dedicated at-rest key; falls back to JWTSecret in Config.Finalize so + // seeds encrypted by earlier releases stay readable. + EncryptionKey: cfg.EncryptionKey, }) } diff --git a/internal/authenticators/totp/provider.go b/internal/authenticators/totp/provider.go index 0690c4220..8ddaac837 100644 --- a/internal/authenticators/totp/provider.go +++ b/internal/authenticators/totp/provider.go @@ -15,7 +15,10 @@ type Dependencies struct { // DB so an abandoned re-setup can never desync a working authenticator. MemoryStoreProvider memory_store.Provider // EncryptionKey is the server-side key used to encrypt TOTP shared - // secrets at rest. Wired to Config.JWTSecret in internal/authenticators. + // secrets at rest. Wired to Config.EncryptionKey in internal/authenticators, + // which Config.Finalize resolves from --encryption-key, falling back to + // --jwt-secret. Startup refuses an empty value (Config.ValidateEncryptionKey) + // because HKDF over empty keying material yields a publicly computable key. EncryptionKey string } diff --git a/internal/config/config.go b/internal/config/config.go index 1aafcb1ff..763b4c340 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "strings" "github.com/authorizerdev/authorizer/internal/constants" @@ -224,6 +225,21 @@ type Config struct { JWTType string // JWTSecret is the secret for the JWT JWTSecret string + // EncryptionKey is the symmetric key used to encrypt secrets AT REST that + // the server must be able to read back — today the per-user TOTP seeds. + // + // Kept SEPARATE from JWTSecret because the two have different lifecycles. + // JWTSecret is a signing key and is expected to be rotatable: rotating it + // should invalidate outstanding tokens, which is cheap (users log in + // again). If the same value also encrypts TOTP seeds, that rotation + // silently makes every enrolled authenticator undecryptable and locks + // every MFA user out of their account. Separating them also means one + // leaked value does not compromise both signing and stored secrets. + // + // Defaults to JWTSecret when unset (see Finalize) so existing installs keep + // decrypting seeds written by earlier releases. Set it explicitly, to a + // value distinct from JWTSecret, before rotating JWTSecret. + EncryptionKey string // JWTPublicKey is the public key for the JWT JWTPublicKey string // JWTPrivateKey is the private key for the JWT @@ -407,6 +423,21 @@ type Config struct { // PersistentPreRun so every subcommand, including `mcp`, is consistent). It is // idempotent. func (c *Config) Finalize() { + // At-rest encryption falls back to the JWT secret when the operator has not + // set a dedicated key. Required for backwards compatibility: seeds written + // by releases before --encryption-key existed were encrypted with + // JWTSecret, and changing the key without a re-encryption pass would make + // every enrolled TOTP authenticator undecryptable. + // + // The fallback can still land on "" — JWTSecret is only required for HMAC + // JWT types, so an RS*/ES* install legitimately leaves it empty. An empty + // key is NOT allowed to reach the cipher (see ValidateEncryptionKey): HKDF + // over empty keying material yields a fixed, publicly computable AES key, + // which would leave TOTP seeds effectively unencrypted. + if strings.TrimSpace(c.EncryptionKey) == "" { + c.EncryptionKey = c.JWTSecret + } + // Provider availability is derived from credentials being present. c.IsEmailServiceEnabled = strings.TrimSpace(c.SMTPHost) != "" && c.SMTPPort > 0 && @@ -441,3 +472,47 @@ func (c *Config) Finalize() { c.EnforceMFA = false } } + +// ValidateEncryptionKey fails startup when secrets would be written at rest +// under an empty key. Call AFTER Finalize, which resolves the JWTSecret +// fallback and the derived MFA flags this check reads. +// +// Why this is fatal rather than a warning: deriveAESKey runs HKDF-SHA256 over +// the key with no salt and a fixed info string, so an empty key produces a +// DETERMINISTIC AES-256 key that anyone can recompute from this open-source +// code. TOTP seeds encrypted under it are not protected at all, and the +// failure is silent — encryption "succeeds" and the rows carry the enc:v1: +// marker, so nothing looks wrong. +// +// This only triggers where a real gap exists: JWTSecret is mandatory for the +// HMAC JWT types, so an HS* install always has a usable fallback. An RS*/ES* +// install legitimately has no JWTSecret and MUST set --encryption-key. +// +// Deliberately UNCONDITIONAL rather than scoped to TOTP being enabled. This key +// protects two different things, and only one of them is TOTP: +// +// - TOTP seeds, encrypted with AES-GCM (authenticators/totp). +// - OTP digests, HMAC'd for email/SMS OTP, signup verification and +// PASSWORD RESET (service/forgot_password, reset_password, verify_otp). +// +// Password-reset OTPs are written whether or not TOTP is enabled, so a +// TOTP-scoped check would leave the reset flow hashing under an empty key. An +// empty HMAC key makes a stored digest trivially reversible — the attacker +// knows the key and the OTP space is only 10^6 — so a database dump yields +// every outstanding reset code and with it account takeover. +// +// The check costs an HS* install nothing: JWTSecret is mandatory there, so the +// fallback always resolves. Only RS*/ES* installs, which legitimately have no +// JWTSecret, must set --encryption-key explicitly. +func (c *Config) ValidateEncryptionKey() error { + if strings.TrimSpace(c.EncryptionKey) == "" { + return fmt.Errorf( + "--encryption-key is required: no encryption key is set and --jwt-secret is " + + "empty (normal for RSA/ECDSA JWT types), so there is nothing to fall back to. " + + "This key protects TOTP secrets and the OTP digests used by email/SMS " + + "verification and password reset; an empty key makes stored OTPs trivially " + + "reversible and TOTP secrets recoverable with a publicly computable constant. " + + "Set --encryption-key to a strong random value") + } + return nil +} diff --git a/internal/config/config_encryption_key_test.go b/internal/config/config_encryption_key_test.go new file mode 100644 index 000000000..716ba06df --- /dev/null +++ b/internal/config/config_encryption_key_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEncryptionKeyFallsBackToJWTSecret pins the backwards-compatible path: +// an HS* install that never set --encryption-key must keep decrypting TOTP +// seeds written by earlier releases, which used JWTSecret. +func TestEncryptionKeyFallsBackToJWTSecret(t *testing.T) { + c := &Config{JWTType: "HS256", JWTSecret: "the-jwt-secret"} + c.Finalize() + + assert.Equal(t, "the-jwt-secret", c.EncryptionKey, + "an unset encryption key must fall back to JWTSecret or existing TOTP enrollments break") + require.NoError(t, c.ValidateEncryptionKey()) +} + +// TestExplicitEncryptionKeyWins pins that a dedicated key is not clobbered by +// the fallback — this is what lets an operator rotate JWTSecret without +// making every enrolled authenticator undecryptable. +func TestExplicitEncryptionKeyWins(t *testing.T) { + c := &Config{JWTType: "HS256", JWTSecret: "the-jwt-secret", EncryptionKey: "a-separate-key"} + c.Finalize() + + assert.Equal(t, "a-separate-key", c.EncryptionKey) + require.NoError(t, c.ValidateEncryptionKey()) +} + +// TestAsymmetricJWTWithoutEncryptionKeyIsRejected is the regression test for +// the real gap: JWTSecret is only required for HMAC, so an RS*/ES* install +// leaves it empty and the fallback lands on "". HKDF over empty keying +// material yields a fixed, publicly computable AES key, so TOTP seeds would be +// stored unprotected while still looking encrypted. Startup must refuse. +func TestAsymmetricJWTWithoutEncryptionKeyIsRejected(t *testing.T) { + c := &Config{ + JWTType: "RS256", + JWTPrivateKey: "-----BEGIN RSA PRIVATE KEY-----", + JWTPublicKey: "-----BEGIN PUBLIC KEY-----", + } + c.Finalize() + + require.Empty(t, c.EncryptionKey, "precondition: no JWTSecret to fall back to") + err := c.ValidateEncryptionKey() + require.Error(t, err, "an empty encryption key must not silently reach the cipher") + assert.Contains(t, err.Error(), "--encryption-key is required") +} + +// TestAsymmetricJWTWithEncryptionKeyStarts pins the documented remedy. +func TestAsymmetricJWTWithEncryptionKeyStarts(t *testing.T) { + c := &Config{ + JWTType: "RS256", + JWTPrivateKey: "-----BEGIN RSA PRIVATE KEY-----", + JWTPublicKey: "-----BEGIN PUBLIC KEY-----", + EncryptionKey: "a-strong-random-value", + } + c.Finalize() + + require.NoError(t, c.ValidateEncryptionKey()) +} + +// TestEncryptionKeyStillRequiredWhenTOTPDisabled pins that the check is NOT +// scoped to TOTP. The same key HMACs the OTP digests used by password reset and +// email/SMS verification, which are written regardless of TOTP, so turning TOTP +// off must not silently re-open the empty-key hole. +func TestEncryptionKeyStillRequiredWhenTOTPDisabled(t *testing.T) { + c := &Config{JWTType: "RS256", DisableTOTPLogin: true} + c.Finalize() + + err := c.ValidateEncryptionKey() + require.Error(t, err, "password-reset OTPs are hashed with this key even when TOTP is off") + assert.Contains(t, err.Error(), "--encryption-key is required") +} diff --git a/internal/integration_tests/test_helper.go b/internal/integration_tests/test_helper.go index 92c640e67..4619e929a 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -161,16 +161,21 @@ func getTestConfigForDB(dbType, dbURL string) *config.Config { DatabaseType: dbType, DatabaseURL: dbURL, JWTSecret: "test-secret", - ClientID: "test-client-id", - ClientSecret: "test-client-secret", - AllowedOrigins: []string{"http://localhost:3000"}, - JWTType: "HS256", - AdminSecret: "test-admin-secret", - TwilioAPISecret: "test-twilio-api-secret", - TwilioAPIKey: "test-twilio-api-key", - TwilioAccountSID: "test-twilio-account-sid", - TwilioSender: "test-twilio-sender", - DefaultRoles: []string{"user"}, + // Mirrors the EncryptionKey -> JWTSecret fallback Config.Finalize() + // applies at startup. Set explicitly rather than by calling Finalize() + // here, which would also derive the MFA flags and change the config + // shape every other test in this package was written against. + EncryptionKey: "test-secret", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + AllowedOrigins: []string{"http://localhost:3000"}, + JWTType: "HS256", + AdminSecret: "test-admin-secret", + TwilioAPISecret: "test-twilio-api-secret", + TwilioAPIKey: "test-twilio-api-key", + TwilioAccountSID: "test-twilio-account-sid", + TwilioSender: "test-twilio-sender", + DefaultRoles: []string{"user"}, Roles: []string{ "user", "admin", "viewer", "editor", "accountant", "auditor", diff --git a/internal/service/forgot_password.go b/internal/service/forgot_password.go index fb3b79ed3..16888a0a1 100644 --- a/internal/service/forgot_password.go +++ b/internal/service/forgot_password.go @@ -153,7 +153,7 @@ func (p *provider) ForgotPassword(ctx context.Context, meta RequestMetadata, par _, err = p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ Email: refs.StringValue(user.Email), PhoneNumber: refs.StringValue(user.PhoneNumber), - Otp: crypto.HashOTP(otp, p.Config.JWTSecret), + Otp: crypto.HashOTP(otp, p.Config.EncryptionKey), ExpiresAt: expiresAt, }) if err != nil { diff --git a/internal/service/otp_mfa_setup.go b/internal/service/otp_mfa_setup.go index badd07bdd..442269c44 100644 --- a/internal/service/otp_mfa_setup.go +++ b/internal/service/otp_mfa_setup.go @@ -292,7 +292,7 @@ func (p *provider) generateAndStoreOTP(ctx context.Context, user *schemas.User, otpData, err := p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ Email: refs.StringValue(user.Email), PhoneNumber: refs.StringValue(user.PhoneNumber), - Otp: crypto.HashOTP(otp, p.Config.JWTSecret), + Otp: crypto.HashOTP(otp, p.Config.EncryptionKey), ExpiresAt: expiresAt, }) if err != nil { diff --git a/internal/service/reset_password.go b/internal/service/reset_password.go index ff06c3eaa..2c81e9b2f 100644 --- a/internal/service/reset_password.go +++ b/internal/service/reset_password.go @@ -121,7 +121,7 @@ func (p *provider) ResetPassword(ctx context.Context, meta RequestMetadata, para // OTPs are stored as HMAC-SHA256 digests; we deliberately do NOT // fall back to literal equality so the stored digest cannot be // replayed as a credential by anyone with DB read access. - if !crypto.VerifyOTPHash(otp, otpRequest.Otp, p.Config.JWTSecret) { + if !crypto.VerifyOTPHash(otp, otpRequest.Otp, p.Config.EncryptionKey) { log.Debug().Msg("Failed to verify otp request: Incorrect value") return nil, nil, InvalidArgument(`invalid otp`) } diff --git a/internal/service/signup.go b/internal/service/signup.go index c9dac0dbd..275a0c3b9 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -277,7 +277,7 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod // over SMS by the existing smsBody above. _, err = p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ PhoneNumber: phoneNumber, - Otp: crypto.HashOTP(smsCode, p.Config.JWTSecret), + Otp: crypto.HashOTP(smsCode, p.Config.EncryptionKey), ExpiresAt: expiresAt, }) if err != nil { diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index c634d0548..23920a77e 100644 --- a/internal/service/verify_otp.go +++ b/internal/service/verify_otp.go @@ -235,7 +235,7 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * // longer reveals usable codes. We deliberately do NOT fall back // to literal equality — accepting the stored value verbatim // would turn the digest itself into a usable credential. - if !crypto.VerifyOTPHash(params.Otp, otp.Otp, p.Config.JWTSecret) { + if !crypto.VerifyOTPHash(params.Otp, otp.Otp, p.Config.EncryptionKey) { log.Debug().Msg("Failed to verify otp request: OTP mismatch") return nil, nil, InvalidArgument(`invalid otp`) } diff --git a/perf/run_container.sh b/perf/run_container.sh index 489ddef79..b49386cd9 100755 --- a/perf/run_container.sh +++ b/perf/run_container.sh @@ -38,6 +38,7 @@ docker run -d --name authorizer_perf_app \ --database-url="postgres://postgres:postgres@authorizer_perf_pg:5432/authorizer_perf?sslmode=disable" \ --redis-url="redis://authorizer_perf_redis:6379" \ --jwt-type=RS256 \ + --encryption-key=perf-encryption-key-not-for-production \ --jwt-private-key="$(cat "$REPO_ROOT/perf/dev-jwt-private.pem")" \ --jwt-public-key="$(cat "$REPO_ROOT/perf/dev-jwt-public.pem")" \ --client-id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH \ From 67ae055ccc7bc86a8b7abd26736fe42022fb0d2b Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 14:51:01 +0530 Subject: [PATCH 02/25] security(replay): make single-use claims atomic via SetCacheNX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAML assertion IDs and RFC 7523 client-assertion jti were consumed with a GetCache/SetCache pair. Two replays of the same assertion arriving together both observed "unseen" and were both accepted; a concurrency test reproduces it at 2/40 rounds. - add SetCacheNX to the memory-store contract: claim by creation, decided in one operation, fails closed on a store fault - redis: native SET NX - in-memory: LoadOrStore/CompareAndSwap, taking over expired entries by CAS so a racing claimant cannot slip between delete and store - db: claim by deterministic primary key (uuidv5 of the cache key), so the PK constraint decides the race with no schema change. A live row of ANY id blocks the claim — a row written by SetCache on a pre-upgrade replica carries a random uuid, would not collide, and would otherwise report the key as claimed during a rolling upgrade. The jti path keeps its pre-TokenReview read as a cheap short-circuit; the NX claim stays after TokenReview so a transient apiserver failure does not burn a still-retryable token. Atomic on SQL/Mongo/Arango/Couchbase. DynamoDB PutItem and Cassandra INSERT are upserts, so both callers can still win there — unchanged from the previous behaviour, not a regression. Configure REDIS_URL for an exact guarantee today. --- .../oauth_authorize_state_test.go | 3 + internal/http_handlers/saml_sp.go | 22 +-- internal/memory_store/db/cache.go | 96 ++++++++++- internal/memory_store/in_memory/cache.go | 33 ++++ internal/memory_store/provider.go | 13 ++ internal/memory_store/redis/cache.go | 13 ++ internal/memory_store/redis/provider.go | 1 + internal/memory_store/single_use_test.go | 151 ++++++++++++++++++ .../service/clientauth/client_assertion.go | 19 ++- .../clientauth/client_assertion_test.go | 15 +- 10 files changed, 348 insertions(+), 18 deletions(-) diff --git a/internal/http_handlers/oauth_authorize_state_test.go b/internal/http_handlers/oauth_authorize_state_test.go index 144073e54..5f354d842 100644 --- a/internal/http_handlers/oauth_authorize_state_test.go +++ b/internal/http_handlers/oauth_authorize_state_test.go @@ -253,6 +253,9 @@ func (f *fakeMemoryStore) GetAllData() (map[string]string, error) { return map[s func (f *fakeMemoryStore) SetCache(key string, value string, ttlSeconds int64) error { return nil } +func (f *fakeMemoryStore) SetCacheNX(key string, value string, ttlSeconds int64) (bool, error) { + return true, nil +} func (f *fakeMemoryStore) GetCache(key string) (string, error) { return "", nil } func (f *fakeMemoryStore) DeleteCacheByPrefix(prefix string) error { return nil } func (f *fakeMemoryStore) IncrementCache(key string, ttlSeconds int64) (int64, error) { diff --git a/internal/http_handlers/saml_sp.go b/internal/http_handlers/saml_sp.go index 502da34be..88437db2b 100644 --- a/internal/http_handlers/saml_sp.go +++ b/internal/http_handlers/saml_sp.go @@ -347,27 +347,31 @@ func (h *httpProvider) resolveSAMLResponseContext(c *gin.Context, sp *saml.Servi // Returns an error if the assertion was already consumed (replay). The cache TTL // tracks the assertion's own expiry so the entry cannot outlive the replay window. // -// ponytail: check-then-set (not atomic) — the memory-store interface exposes no -// SetNX, so two requests replaying the same assertion within the same few -// milliseconds could both pass. The assertion's own short NotOnOrAfter window -// bounds this; upgrade path: add an atomic SetNX to the memory store. +// Claims via SetCacheNX so the store decides the race in ONE operation: two +// requests replaying the same assertion in the same instant can no longer both +// observe "unseen" and both be accepted, which is what the previous +// check-then-set pair allowed. A store fault reports "not claimed" and the +// assertion is rejected — replay defence fails closed. func (h *httpProvider) consumeSAMLAssertionID(orgID string, assertion *saml.Assertion) error { id := strings.TrimSpace(assertion.ID) if id == "" { return fmt.Errorf("assertion has no ID") } key := samlAssertionPrefix + orgID + ":" + id - existing, err := h.MemoryStoreProvider.GetCache(key) - if err == nil && strings.TrimSpace(existing) != "" { - return fmt.Errorf("assertion replay detected") - } ttl := samlReplayFallbackTTL if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() { if secs := int64(time.Until(assertion.Conditions.NotOnOrAfter.Add(saml.MaxClockSkew)).Seconds()); secs > ttl { ttl = secs } } - return h.MemoryStoreProvider.SetCache(key, "1", ttl) + claimed, err := h.MemoryStoreProvider.SetCacheNX(key, "1", ttl) + if err != nil { + return fmt.Errorf("assertion replay check failed: %w", err) + } + if !claimed { + return fmt.Errorf("assertion replay detected") + } + return nil } // resolveActiveSAMLConnection looks up the org by slug and its active sso_saml diff --git a/internal/memory_store/db/cache.go b/internal/memory_store/db/cache.go index b8bcceb0b..18b4ac916 100644 --- a/internal/memory_store/db/cache.go +++ b/internal/memory_store/db/cache.go @@ -83,8 +83,36 @@ func (p *provider) SetCache(key string, value string, ttlSeconds int64) error { // Continue anyway — the add below is what must succeed. } + entry := newCacheRow(key, value, now, ttlSeconds) + if err := p.addSessionToken(ctx, entry); err != nil { + return fmt.Errorf("error setting cache: %w", err) + } + return nil +} + +// cacheRowNamespace seeds the deterministic cache-row UUIDs below. Arbitrary but +// FIXED — changing it re-points every cache row at a new primary key and voids +// the single-use guarantee for entries written by an older build. +var cacheRowNamespace = uuid.MustParse("6f9619ff-8b86-d011-b42d-00c04fc964ff") + +// cacheRowID derives the row's primary key from the cache key itself, so the +// SAME cache key always maps to the SAME row ID. Two properties follow: +// +// - SetCache stays single-row per key (it deletes then inserts the same ID), +// instead of the random-UUID scheme that could leave duplicate rows for one +// key after a racing write, with GetCache then picking one arbitrarily. +// - SetCacheNX gets its atomicity for free from the PRIMARY KEY constraint — +// the second concurrent insert of the same cache key violates it and fails, +// with no read-then-write window. See SetCacheNX for the per-backend caveat. +// +// UUIDv5 keeps the value inside the column's char(36). +func cacheRowID(key string) string { + return uuid.NewSHA1(cacheRowNamespace, []byte(key)).String() +} + +func newCacheRow(key, value string, now, ttlSeconds int64) *schemas.SessionToken { entry := &schemas.SessionToken{ - ID: uuid.New().String(), + ID: cacheRowID(key), UserID: cacheNamespace, KeyName: key, Token: value, @@ -93,10 +121,70 @@ func (p *provider) SetCache(key string, value string, ttlSeconds int64) error { UpdatedAt: now, } entry.Key = entry.ID - if err := p.addSessionToken(ctx, entry); err != nil { - return fmt.Errorf("error setting cache: %w", err) + return entry +} + +// SetCacheNX claims a key by CREATING its row, letting the primary-key +// constraint decide the race rather than a read followed by a write. This is +// the replay-defence primitive behind SAML assertion IDs and RFC 7523 jti. +// +// Fails CLOSED: a duplicate key and a genuine storage fault both return +// "not claimed", so a database problem rejects the assertion rather than +// waving through a possible replay. +// +// ponytail: atomic on backends whose insert REJECTS a duplicate primary key +// (SQL, MongoDB, ArangoDB, Couchbase). DynamoDB PutItem and Cassandra/ScyllaDB +// INSERT are upserts by default, so there a concurrent second claim is not +// rejected and both callers can win — unchanged from the previous +// read-then-write behaviour, not a regression. +// +// Closing that gap is cheaper than it looks: both backends support a +// conditional write, and this repo already uses one — see +// storage/db/cassandradb/authenticator.go, which issues +// `INSERT ... IF NOT EXISTS`. The DynamoDB equivalent is PutItem with +// ConditionExpression attribute_not_exists(id). Doing it properly means adding +// a conditional-insert method to storage.Provider and implementing it in all +// six backends, which is why it is tracked rather than done inline here. +// +// Deployments needing an exact guarantee today should configure REDIS_URL, +// whose SET NX is atomic everywhere. +func (p *provider) SetCacheNX(key string, value string, ttlSeconds int64) (bool, error) { + ctx := context.Background() + now := time.Now().Unix() + + // An expired row that the reaper has not collected yet must not block a + // Resolve any pre-existing row for this key BEFORE the insert. + // + // This is not merely an optimisation. The insert's atomicity comes from the + // deterministic primary key (cacheRowID), so it only rejects a duplicate + // when the existing row carries that same ID. A row written by SetCache on + // a replica running a build older than this one has a RANDOM uuid instead, + // so the insert would NOT collide, would succeed, and would report the key + // as claimed — silently accepting a replay during a rolling upgrade, and + // leaving two rows for one key. + // + // So: a LIVE row of any ID means the key is already held. An EXPIRED row is + // cleared so it cannot block a legitimate fresh claim. Concurrency among + // live claimants is still decided by the primary key on insert. + if row, err := p.getSessionTokenByUserIDAndKey(ctx, cacheNamespace, key); err == nil && row != nil { + if row.ExpiresAt > now { + return false, nil + } + if err := p.deleteSessionTokenByUserIDAndKey(ctx, cacheNamespace, key); err != nil { + p.dependencies.Log.Debug().Err(err).Str("cache_key", key). + Msg("failed to clear expired cache row before claim") + } } - return nil + + if err := p.addSessionToken(ctx, newCacheRow(key, value, now, ttlSeconds)); err != nil { + // Duplicate primary key (already claimed) or a storage fault — both + // mean "this caller did not take the key". Debug, not Warn: a losing + // claim is the EXPECTED outcome on every replay attempt. + p.dependencies.Log.Debug().Err(err).Str("cache_key", key). + Msg("cache key not claimed (already held or storage fault)") + return false, nil + } + return true, nil } // GetCache retrieves a cached value by key. Returns an empty string and a nil diff --git a/internal/memory_store/in_memory/cache.go b/internal/memory_store/in_memory/cache.go index c3ea91744..02de46889 100644 --- a/internal/memory_store/in_memory/cache.go +++ b/internal/memory_store/in_memory/cache.go @@ -53,6 +53,39 @@ func (c *provider) SetCache(key string, value string, ttlSeconds int64) error { return nil } +// SetCacheNX stores a key-value pair only if the key is not already held by a +// LIVE entry, reporting whether this call took it. +// +// Uses the same sync.Map LoadOrStore/CompareAndSwap primitives as +// IncrementCache so the decision is atomic: concurrent callers contend on a +// single map slot and exactly one observes the transition. An expired entry is +// taken over via CAS rather than deleted first, so a racing claimant cannot +// slip in between the delete and the store. +func (c *provider) SetCacheNX(key string, value string, ttlSeconds int64) (bool, error) { + for { + now := time.Now().Unix() + fresh := &cacheEntry{Value: value, ExpiresAt: now + ttlSeconds} + + old, loaded := cacheStore.Load(key) + if !loaded { + if _, alreadyStored := cacheStore.LoadOrStore(key, fresh); !alreadyStored { + return true, nil + } + // Lost the race to another claimant; re-read and re-decide. + continue + } + + // A live entry means the key is already claimed. + if old.(*cacheEntry).ExpiresAt >= now { + return false, nil + } + // Expired: swap it out, but only if nobody changed it meanwhile. + if cacheStore.CompareAndSwap(key, old, fresh) { + return true, nil + } + } +} + // GetCache retrieves a cached value by key. // Returns empty string and nil error if the key is not found or expired. func (c *provider) GetCache(key string) (string, error) { diff --git a/internal/memory_store/provider.go b/internal/memory_store/provider.go index 20122ed94..7deebcbe1 100644 --- a/internal/memory_store/provider.go +++ b/internal/memory_store/provider.go @@ -93,6 +93,19 @@ type Provider interface { // SetCache stores a key-value pair with a TTL in seconds. // Used by the authorization engine for permission evaluation caching. SetCache(key string, value string, ttlSeconds int64) error + // SetCacheNX stores a key-value pair with a TTL only if the key is not + // already held, reporting whether THIS call took it. It is the single-use + // claim primitive for replay defences (SAML assertion IDs, RFC 7523 + // client-assertion jti) — the same role ClaimRefreshToken plays for refresh + // tokens, but claiming by creation rather than by removal. + // + // Implementations MUST decide the race in one operation and MUST never + // read-then-write: a GetCache/SetCache pair lets two concurrent replays of + // the same assertion both observe "unseen" and both be accepted. + // + // A storage fault returns (false, err) — callers treat that as "not + // claimed" and reject, so replay defence fails CLOSED. + SetCacheNX(key string, value string, ttlSeconds int64) (bool, error) // GetCache retrieves a cached value by key. Returns empty string and nil error if not found. GetCache(key string) (string, error) // DeleteCacheByPrefix removes all cache entries whose keys start with the given prefix. diff --git a/internal/memory_store/redis/cache.go b/internal/memory_store/redis/cache.go index 8ed3dbae2..7d4430193 100644 --- a/internal/memory_store/redis/cache.go +++ b/internal/memory_store/redis/cache.go @@ -20,6 +20,19 @@ func (p *provider) SetCache(key string, value string, ttlSeconds int64) error { return nil } +// SetCacheNX stores a key-value pair only if the key is absent, reporting +// whether this call took it. Redis SET NX decides the race server-side in a +// single round trip, so concurrent callers can never both be told they won. +func (p *provider) SetCacheNX(key string, value string, ttlSeconds int64) (bool, error) { + duration := time.Duration(ttlSeconds) * time.Second + claimed, err := p.store.SetNX(p.ctx, cachePrefix+key, value, duration).Result() + if err != nil { + p.dependencies.Log.Debug().Err(err).Msg("Error claiming cache key in redis") + return false, err + } + return claimed, nil +} + // GetCache retrieves a cached value by key from Redis. // Returns empty string and nil error if the key is not found. func (p *provider) GetCache(key string) (string, error) { diff --git a/internal/memory_store/redis/provider.go b/internal/memory_store/redis/provider.go index 5838c6c40..9ab8c12e5 100644 --- a/internal/memory_store/redis/provider.go +++ b/internal/memory_store/redis/provider.go @@ -30,6 +30,7 @@ type RedisClient interface { HGet(ctx context.Context, key, field string) *redis.StringCmd HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd + SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.BoolCmd Get(ctx context.Context, key string) *redis.StringCmd Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd Keys(ctx context.Context, pattern string) *redis.StringSliceCmd diff --git a/internal/memory_store/single_use_test.go b/internal/memory_store/single_use_test.go index e3e54e002..cc7f4e122 100644 --- a/internal/memory_store/single_use_test.go +++ b/internal/memory_store/single_use_test.go @@ -244,3 +244,154 @@ func TestDBStoreCacheDoesNotCollideWithState(t *testing.T) { require.NoError(t, err) assert.Equal(t, "the-cache-payload", stillCached, "redeeming a code must not evict a same-named cache entry") } + +// TestSetCacheNXIsSingleUse is the regression test for the replay defences that +// claim a key by CREATING it: SAML assertion IDs (http_handlers/saml_sp.go) and +// RFC 7523 client-assertion jti (service/clientauth). Both previously used a +// GetCache/SetCache pair, which handed "unseen" to every racer replaying the +// same assertion; SetCacheNX must decide the race in one operation so exactly +// one caller may take the key. +// +// Runs against every provider so no future backend can regress the contract. +func TestSetCacheNXIsSingleUse(t *testing.T) { + for _, storeType := range memoryStoreTypesForTest() { + t.Run(storeType, func(t *testing.T) { + p, err := newTestMemoryStore(t, storeType) + if storeType == memoryStoreTypeRedis && err != nil { + t.Skipf("skipping redis (is Redis running on localhost:6380?): %v", err) + } + require.NoError(t, err) + + const rounds = 40 + doubleClaims := 0 + + for i := 0; i < rounds; i++ { + // Stands in for a SAML AssertionID: fresh, never seen before. + key := "saml_assertion:org:" + uuid.New().String() + + var wg sync.WaitGroup + claims := make([]bool, 2) + errs := make([]error, 2) + start := make(chan struct{}) + for j := range claims { + wg.Add(1) + go func(idx int) { + defer wg.Done() + <-start + claims[idx], errs[idx] = p.SetCacheNX(key, "1", 600) + }(j) + } + close(start) + wg.Wait() + + won := 0 + for j := range claims { + require.NoError(t, errs[j]) + if claims[j] { + won++ + } + } + assert.NotZero(t, won, + "one caller must win — a legitimate first-use assertion must not be rejected") + if won > 1 { + doubleClaims++ + } + } + + assert.Zero(t, doubleClaims, + "an assertion ID must be consumable exactly once; "+ + "%d of %d concurrent replays were accepted by both callers", doubleClaims, rounds) + }) + } +} + +// TestSetCacheNXRejectsASecondClaim pins the sequential contract the replay +// callers depend on: once a key is held, every later claim is (false, nil) — +// a rejection, not an error they would have to special-case. +func TestSetCacheNXRejectsASecondClaim(t *testing.T) { + for _, storeType := range memoryStoreTypesForTest() { + t.Run(storeType, func(t *testing.T) { + p, err := newTestMemoryStore(t, storeType) + if storeType == memoryStoreTypeRedis && err != nil { + t.Skipf("skipping redis (is Redis running on localhost:6380?): %v", err) + } + require.NoError(t, err) + + key := "saml_assertion:org:" + uuid.New().String() + + claimed, err := p.SetCacheNX(key, "1", 600) + require.NoError(t, err) + assert.True(t, claimed, "the first claim of an unseen assertion must succeed") + + replay, err := p.SetCacheNX(key, "1", 600) + require.NoError(t, err) + assert.False(t, replay, "replaying a consumed assertion must be rejected") + + // The value must remain readable — callers may inspect the marker. + got, err := p.GetCache(key) + require.NoError(t, err) + assert.Equal(t, "1", got) + }) + } +} + +// TestSetCacheNXReclaimsAnExpiredKey pins that the single-use marker does not +// become a permanent tombstone: once the replay window has passed, the key must +// be claimable again, otherwise expired-but-unreaped rows would reject +// legitimate new assertions forever. +func TestSetCacheNXReclaimsAnExpiredKey(t *testing.T) { + for _, storeType := range memoryStoreTypesForTest() { + t.Run(storeType, func(t *testing.T) { + p, err := newTestMemoryStore(t, storeType) + if storeType == memoryStoreTypeRedis && err != nil { + t.Skipf("skipping redis (is Redis running on localhost:6380?): %v", err) + } + require.NoError(t, err) + + key := "saml_assertion:org:" + uuid.New().String() + + // TTL of 1s, then wait it out. + claimed, err := p.SetCacheNX(key, "1", 1) + require.NoError(t, err) + require.True(t, claimed) + + time.Sleep(2 * time.Second) + + reclaimed, err := p.SetCacheNX(key, "1", 600) + require.NoError(t, err) + assert.True(t, reclaimed, "an expired single-use marker must not block a new claim") + }) + } +} + +// TestSetCacheNXRejectsLegacyRandomIDRow is the regression test for a +// rolling-upgrade hole in the DB-backed store. Its claim is decided by a +// deterministic primary key, so an existing row written by an OLDER build +// (SetCache used a random uuid) carries a different ID and would not collide — +// the insert would succeed and report the key as claimed, silently accepting a +// SAML assertion or client-assertion jti that was already consumed. +// +// Writing via SetCache and then claiming via SetCacheNX reproduces exactly that +// mixed-build state. +func TestSetCacheNXRejectsLegacyRandomIDRow(t *testing.T) { + for _, storeType := range memoryStoreTypesForTest() { + t.Run(storeType, func(t *testing.T) { + p, err := newTestMemoryStore(t, storeType) + if storeType == memoryStoreTypeRedis && err != nil { + t.Skipf("skipping redis (is Redis running on localhost:6380?): %v", err) + } + require.NoError(t, err) + + key := "saml_assertion:org:" + uuid.New().String() + + // Stands in for the row an older replica wrote when it consumed + // this assertion. + require.NoError(t, p.SetCache(key, "1", 600)) + + claimed, err := p.SetCacheNX(key, "1", 600) + require.NoError(t, err) + assert.False(t, claimed, + "a key already held by a row from an older build must not be re-claimable") + }) + } +} diff --git a/internal/service/clientauth/client_assertion.go b/internal/service/clientauth/client_assertion.go index df64fc4d9..53071bb17 100644 --- a/internal/service/clientauth/client_assertion.go +++ b/internal/service/clientauth/client_assertion.go @@ -266,6 +266,10 @@ func (p *provider) resolveViaClientAssertion(ctx context.Context, params Resolve // 8. Replay (C2/H4): single-use per issuer. Prefer jti; fall back to a hash of // (iss,sub,iat,exp) when the token carries no jti (K8s SA tokens have none). // Held until the token's exp so a captured token cannot be re-presented. + // This read is a cheap SHORT-CIRCUIT only — it rejects an obvious replay + // before the (possibly remote) TokenReview call below. Correctness does + // not rest on it: the atomic SetCacheNX claim further down is what + // actually decides the race between simultaneous first-uses. replayKey := assertionReplayKey(issuer.ID, claims, iss, subject, iat, exp) if seen, _ := p.MemoryStoreProvider.GetCache(replayKey); seen != "" { log.Debug().Msg("client_assertion replay detected") @@ -299,13 +303,20 @@ func (p *provider) resolveViaClientAssertion(ctx context.Context, params Resolve if replayTTL < 1 { replayTTL = 1 } - // ponytail: best-effort check-then-set (memory_store has no atomic SetNX); - // a sub-second cross-instance race could let two simultaneous replays through. - // Acceptable given the short window; upgrade to SetNX if it ever matters. - if err := p.MemoryStoreProvider.SetCache(replayKey, "1", replayTTL); err != nil { + // Atomic single-use claim: the store decides the race in one operation, so + // two simultaneous first-presentations of the same assertion cannot both be + // accepted the way the earlier check-then-set pair allowed. Deliberately + // placed AFTER TokenReview so a transient apiserver failure does not burn a + // legitimate, still-retryable token. + claimed, err := p.MemoryStoreProvider.SetCacheNX(replayKey, "1", replayTTL) + if err != nil { log.Debug().Err(err).Msg("failed to persist assertion replay marker") return nil, ErrInvalidClient } + if !claimed { + log.Debug().Msg("client_assertion replay detected (lost single-use claim)") + return nil, ErrInvalidClient + } // 9. Resolve the Client the trust row authenticates (stored as the surrogate // PK). Must exist and be active. diff --git a/internal/service/clientauth/client_assertion_test.go b/internal/service/clientauth/client_assertion_test.go index 00bca3ada..91ac36d80 100644 --- a/internal/service/clientauth/client_assertion_test.go +++ b/internal/service/clientauth/client_assertion_test.go @@ -57,7 +57,10 @@ func (s *assertionStore) GetTrustedIssuerByIssuerURL(_ context.Context, url stri return iss, nil } -// fakeMemStore implements the SetCache/GetCache subset used by the resolver. +// fakeMemStore implements the SetCache/GetCache/SetCacheNX subset used by the +// resolver. SetCacheNX mirrors the real contract — claim by creation, under the +// same mutex — so the jti single-use path is genuinely exercised here rather +// than stubbed out to always succeed. type fakeMemStore struct { memory_store.Provider mu sync.Mutex @@ -73,6 +76,16 @@ func (m *fakeMemStore) SetCache(key, value string, _ int64) error { return nil } +func (m *fakeMemStore) SetCacheNX(key, value string, _ int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if _, held := m.cache[key]; held { + return false, nil + } + m.cache[key] = value + return true, nil +} + func (m *fakeMemStore) GetCache(key string) (string, error) { m.mu.Lock() defer m.mu.Unlock() From 18c1981f182ad4e2250e9df87816a05408b9548d Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 14:51:18 +0530 Subject: [PATCH 03/25] fix(storage): unify the not-found contract across all backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three DynamoDB getters returned (nil, nil) for an absent row where the other five return a driver not-found error: GetAuthenticatorDetailsByUserId, GetVerificationRequestByEmail, GetVerificationRequestByToken. Callers branch on err alone and then dereference, so totp.Validate, ValidateRecoveryCode, verify_email and resend_verify_email nil-panic on that backend only — invisible to CI, which runs SQLite. - DynamoDB reports absence as an error, matching every other backend - guard totp.Validate/ValidateRecoveryCode against a nil row anyway; the contract is now uniform but a panic is too severe to leave undefended - add storage.IsNotFound(err), a predicate rather than a shared sentinel: each backend reports absence with its own driver value which errors.Is cannot match against a foreign sentinel, and a sentinel declared in storage could not be wrapped by the backends because that dependency only runs one way. Shape follows apierrors.IsNotFound. - Arango/Dynamo gain a package sentinel; 42 dynamic "not found" errors wrap it. Wrapping preserves err != nil, so existing callers are unaffected and nothing changes silently. - 20 admin getters now return NotFound instead of a raw storage error, which mapped to codes.Internal — a missing row answered HTTP 500. Auth paths are deliberately excluded: a distinct 404 on VerifyEmail's GetUserByEmail would be an account-existence oracle, and org-scoped resolvers keep routing through maskNonSuperAdminError. Two static tests enforce the contract: TestNotFoundContractIsUniform compares all six backends, TestIsNotFoundRecognisesEveryBackend proves the predicate matches each one, survives %w nesting, and rejects lookalike messages. --- AGENTS.md | 31 ++++ internal/authenticators/totp/totp.go | 24 ++- .../totp/totp_nil_authenticator_test.go | 59 +++++++ internal/service/admin_access.go | 7 + internal/service/admin_clients.go | 13 ++ internal/service/admin_email_templates.go | 7 + internal/service/admin_organizations.go | 10 ++ internal/service/admin_trusted_issuers.go | 10 ++ internal/service/admin_users.go | 10 ++ internal/service/admin_webhooks.go | 10 ++ internal/storage/db/arangodb/authenticator.go | 2 +- internal/storage/db/arangodb/client.go | 2 +- .../storage/db/arangodb/email_template.go | 4 +- internal/storage/db/arangodb/env.go | 2 +- internal/storage/db/arangodb/errors.go | 20 +++ .../storage/db/arangodb/federated_identity.go | 2 +- internal/storage/db/arangodb/org_domain.go | 2 +- .../storage/db/arangodb/org_membership.go | 2 +- internal/storage/db/arangodb/organization.go | 4 +- internal/storage/db/arangodb/otp.go | 4 +- internal/storage/db/arangodb/saml_idp.go | 6 +- internal/storage/db/arangodb/scim_endpoint.go | 4 +- internal/storage/db/arangodb/scim_group.go | 6 +- internal/storage/db/arangodb/session_token.go | 6 +- .../storage/db/arangodb/trusted_issuer.go | 6 +- internal/storage/db/arangodb/user.go | 8 +- .../db/arangodb/verification_requests.go | 4 +- .../db/arangodb/webauthn_credential.go | 4 +- internal/storage/db/arangodb/webhook.go | 2 +- internal/storage/db/cassandradb/errors.go | 13 ++ internal/storage/db/couchbase/errors.go | 13 ++ internal/storage/db/dynamodb/authenticator.go | 5 +- internal/storage/db/dynamodb/env.go | 2 +- internal/storage/db/dynamodb/errors.go | 13 ++ internal/storage/db/dynamodb/session_token.go | 8 +- .../db/dynamodb/verification_requests.go | 13 +- internal/storage/db/mongodb/errors.go | 13 ++ internal/storage/db/sql/errors.go | 14 ++ internal/storage/errors.go | 52 +++++++ internal/storage/errors_test.go | 74 +++++++++ internal/storage/notfound_contract_test.go | 145 ++++++++++++++++++ internal/storage/provider.go | 33 ++++ 42 files changed, 622 insertions(+), 47 deletions(-) create mode 100644 internal/authenticators/totp/totp_nil_authenticator_test.go create mode 100644 internal/storage/db/arangodb/errors.go create mode 100644 internal/storage/db/cassandradb/errors.go create mode 100644 internal/storage/db/couchbase/errors.go create mode 100644 internal/storage/db/dynamodb/errors.go create mode 100644 internal/storage/db/mongodb/errors.go create mode 100644 internal/storage/db/sql/errors.go create mode 100644 internal/storage/errors.go create mode 100644 internal/storage/errors_test.go create mode 100644 internal/storage/notfound_contract_test.go diff --git a/AGENTS.md b/AGENTS.md index badae207c..4ff678904 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,36 @@ When the background closure needs a context, use `context.WithoutCancel(ctx)` (n - Any indexed lookup added to one provider (e.g. `GetUserByExternalID`) needs the matching index/equivalent in every other provider — a method that's O(1) in one backend and a full collection/table scan in another is a parity bug, not a perf nit. - Never build a query by string-concatenating or `fmt.Sprintf`-ing request-derived values into a WHERE/SET/CQL clause, even for NoSQL backends — always parameterize/bind, and never let a map of column names sourced from a request reach a query builder without an explicit allow-list. +### The not-found contract (all 6 backends must agree) + +"The row does not exist" and "the query failed" are **different outcomes** and must stay distinguishable. Getting this wrong has already caused two classes of production bug in this repo: a nil-dereference panic on one backend only, and a database outage being reported to end users as an invalid credential. + +**Writing a storage method:** + +- A single-entity getter reports an absent row as an **error** — the driver's own value (`gorm.ErrRecordNotFound`, `mongo.ErrNoDocuments`, `gocql.ErrNotFound`, `gocb.ErrDocumentNotFound`). Backends with no canonical driver sentinel (ArangoDB, DynamoDB) wrap their package-level `ErrNotFound`: `fmt.Errorf("authenticator not found: %w", ErrNotFound)`. +- **Never return `(nil, nil)` from a single-entity getter.** Callers branch on `err` and then dereference the pointer, so `(nil, nil)` panics — and because it usually diverges on only one backend, CI (SQLite-only) stays green while production crashes. This is exactly how three DynamoDB methods broke the TOTP and email-verification paths. +- `List*` methods return `(nil, nil)` for an empty result. A nil slice ranges zero times, so no caller guard is needed. +- The single documented exception is `GetClientByClientID`, which **must** return `(nil, nil)` — its callers distinguish absent from unavailable by `err` alone. Treat it as legacy, not as a pattern to copy; new code uses the error form. +- Implement the same behaviour in **all six** backends. `TestNotFoundContractIsUniform` (`internal/storage`) compares them statically and fails on divergence, so a one-backend slip is caught without needing all six databases running. + +**Consuming a storage method:** + +Use `storage.IsNotFound(err)` — never `err != nil` alone — wherever the two outcomes should differ: + +```go +org, err := p.StorageProvider.GetOrganizationByID(ctx, id) +switch { +case storage.IsNotFound(err): + return nil, nil, NotFound("organization not found") // 404, permanent +case err != nil: + return nil, nil, err // 500, retryable +} +``` + +Returning the raw storage error maps to `codes.Internal` (HTTP 500), so a missing row surfaces as a server fault; conversely, collapsing everything into a "not found"/"invalid" response tells a user their input was wrong during what is really an outage. Both are wrong, and both are silent. + +Do **not** apply this mechanically to auth paths. Turning a lookup failure into a distinct 404 on an unauthenticated endpoint can create an account-existence oracle — `VerifyEmail`'s `GetUserByEmail` is deliberately left conflated for exactly this reason. Org-scoped admin resolvers route their not-found through `maskNonSuperAdminError` so a tenant admin cannot probe another org. + ## Critical Rules (Top of Mind) 1. **Admin GraphQL ops prefixed with `_`** — not for public use. Same for `AuthorizerAdminService` gRPC. @@ -161,6 +191,7 @@ When the background closure needs a context, use `context.WithoutCancel(ctx)` (n 4. **Run `make proto-gen`** (or `make proto-check`) after editing `proto/`; commit `gen/`. 5. **NEVER commit to main** — always use a feature branch (`feat/`, `fix/`, `security/`, `chore/`), push, open a PR. Main must stay deployable. 6. **Fire-and-forget side effects go through `asyncutil.Go`, never a bare `go func()`** — see Background Work above. +7. **Never return `(nil, nil)` from a single-entity storage getter**, and use `storage.IsNotFound(err)` rather than `err != nil` when absent and unavailable should differ — see [The not-found contract](#the-not-found-contract-all-6-backends-must-agree). Detailed rules load via skills (see below) — don't restate them here. diff --git a/internal/authenticators/totp/totp.go b/internal/authenticators/totp/totp.go index 72a2a25a9..41fe176b0 100644 --- a/internal/authenticators/totp/totp.go +++ b/internal/authenticators/totp/totp.go @@ -223,6 +223,14 @@ func (p *provider) Validate(ctx context.Context, passcode string, userID string) if err != nil { return false, err } + // Providers disagree on how "not enrolled" is reported: most return an + // error (gorm.ErrRecordNotFound and friends), but the DynamoDB provider + // returns (nil, nil). Without this guard that case dereferences a nil row + // below and panics, so treat a missing authenticator as a failed + // validation — there is no secret to check the passcode against. + if totpModel == nil { + return false, nil + } // A pending re-enrollment secret takes precedence: if one is staged and the // supplied code matches it, promote it now (the user is confirming their @@ -254,10 +262,13 @@ func (p *provider) Validate(ctx context.Context, passcode string, userID string) migrate = true default: // Decryption was attempted (the row IS prefixed) but failed. - // The most likely cause is a key mismatch — operators rotating - // --jwt-secret without re-enrolling TOTP users would lock them - // out. Fail closed and log loudly. - log.Error().Err(decErr).Msg("failed to decrypt stored TOTP secret; check that --jwt-secret has not changed since enrollment") + // The most likely cause is a key mismatch: the at-rest key is + // --encryption-key, which falls back to --jwt-secret when unset, so + // rotating EITHER (without having set a dedicated --encryption-key + // first) changes the key and locks enrolled TOTP users out. There is + // no re-encryption path, so those users must re-enrol. Fail closed + // and log loudly. + log.Error().Err(decErr).Msg("failed to decrypt stored TOTP secret; check that --encryption-key (or --jwt-secret, if no encryption key is set) has not changed since enrollment") return false, decErr } @@ -312,6 +323,11 @@ func (p *provider) ValidateRecoveryCode(ctx context.Context, recoveryCode, userI if err != nil { return false, err } + // See Validate: the DynamoDB provider signals "not enrolled" as (nil, nil) + // rather than an error, so guard before dereferencing. + if totpModel == nil { + return false, nil + } // convert recoveryCodes to map recoveryCodesMap := map[string]bool{} err = json.Unmarshal([]byte(refs.StringValue(totpModel.RecoveryCodes)), &recoveryCodesMap) diff --git a/internal/authenticators/totp/totp_nil_authenticator_test.go b/internal/authenticators/totp/totp_nil_authenticator_test.go new file mode 100644 index 000000000..fcb3e08ec --- /dev/null +++ b/internal/authenticators/totp/totp_nil_authenticator_test.go @@ -0,0 +1,59 @@ +package totp + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// nilAuthenticatorStore reproduces the DynamoDB provider's contract for a user +// with no enrolled TOTP authenticator: (nil, nil) rather than an error. Every +// other backend returns a not-found error here, and code written against that +// contract dereferences the nil row and panics. +type nilAuthenticatorStore struct{ storage.Provider } + +func (nilAuthenticatorStore) GetAuthenticatorDetailsByUserId(_ context.Context, _, _ string) (*schemas.Authenticator, error) { + return nil, nil +} + +func newNilStoreProvider(t *testing.T) *provider { + t.Helper() + l := zerolog.Nop() + p, err := NewProvider(&Dependencies{ + Log: &l, + StorageProvider: nilAuthenticatorStore{}, + EncryptionKey: "test-key", + }) + require.NoError(t, err) + return p +} + +// TestValidateHandlesMissingAuthenticator pins that a missing enrolment is a +// failed validation, not a panic. +func TestValidateHandlesMissingAuthenticator(t *testing.T) { + p := newNilStoreProvider(t) + + require.NotPanics(t, func() { + ok, err := p.Validate(context.Background(), "123456", "user-with-no-totp") + assert.False(t, ok) + assert.NoError(t, err) + }) +} + +// TestValidateRecoveryCodeHandlesMissingAuthenticator pins the same contract on +// the recovery-code path, which dereferenced RecoveryCodes on the nil row. +func TestValidateRecoveryCodeHandlesMissingAuthenticator(t *testing.T) { + p := newNilStoreProvider(t) + + require.NotPanics(t, func() { + ok, err := p.ValidateRecoveryCode(context.Background(), "some-code", "user-with-no-totp") + assert.False(t, ok) + assert.NoError(t, err) + }) +} diff --git a/internal/service/admin_access.go b/internal/service/admin_access.go index b6ade3901..4be867d80 100644 --- a/internal/service/admin_access.go +++ b/internal/service/admin_access.go @@ -14,6 +14,7 @@ import ( "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" @@ -33,6 +34,9 @@ func (p *provider) RevokeAccess(ctx context.Context, meta RequestMetadata, param user, err := p.StorageProvider.GetUserByID(ctx, params.UserID) if err != nil { log.Debug().Err(err).Msg("Failed to get user by id") + if storage.IsNotFound(err) { + return nil, nil, NotFound("user not found") + } return nil, nil, err } @@ -82,6 +86,9 @@ func (p *provider) EnableAccess(ctx context.Context, meta RequestMetadata, param user, err := p.StorageProvider.GetUserByID(ctx, params.UserID) if err != nil { log.Debug().Err(err).Msg("Failed to get user by ID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("user not found") + } return nil, nil, err } diff --git a/internal/service/admin_clients.go b/internal/service/admin_clients.go index 5c5906fab..967b49c4d 100644 --- a/internal/service/admin_clients.go +++ b/internal/service/admin_clients.go @@ -12,6 +12,7 @@ import ( "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -134,6 +135,9 @@ func (p *provider) UpdateClient(ctx context.Context, meta RequestMetadata, param sa, err := p.StorageProvider.GetClientByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetClientByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("client not found") + } return nil, nil, err } @@ -191,6 +195,9 @@ func (p *provider) DeleteClient(ctx context.Context, meta RequestMetadata, param sa, err := p.StorageProvider.GetClientByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetClientByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("client not found") + } return nil, nil, err } @@ -230,6 +237,9 @@ func (p *provider) RotateClientSecret(ctx context.Context, meta RequestMetadata, sa, err := p.StorageProvider.GetClientByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetClientByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("client not found") + } return nil, nil, err } @@ -277,6 +287,9 @@ func (p *provider) Client(ctx context.Context, meta RequestMetadata, params *mod sa, err := p.StorageProvider.GetClientByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetClientByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("client not found") + } return nil, nil, err } return sa.AsAPIClient(), nil, nil diff --git a/internal/service/admin_email_templates.go b/internal/service/admin_email_templates.go index b789852de..f5b2274b9 100644 --- a/internal/service/admin_email_templates.go +++ b/internal/service/admin_email_templates.go @@ -9,6 +9,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/utils" "github.com/authorizerdev/authorizer/internal/validators" @@ -82,6 +83,9 @@ func (p *provider) UpdateEmailTemplate(ctx context.Context, meta RequestMetadata emailTemplate, err := p.StorageProvider.GetEmailTemplateByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetEmailTemplateByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("email template not found") + } return nil, nil, err } @@ -165,6 +169,9 @@ func (p *provider) DeleteEmailTemplate(ctx context.Context, meta RequestMetadata emailTemplate, err := p.StorageProvider.GetEmailTemplateByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("Failed to get email template by id") + if storage.IsNotFound(err) { + return nil, nil, NotFound("email template not found") + } return nil, nil, err } diff --git a/internal/service/admin_organizations.go b/internal/service/admin_organizations.go index a40df53d1..10006feb3 100644 --- a/internal/service/admin_organizations.go +++ b/internal/service/admin_organizations.go @@ -9,6 +9,7 @@ import ( "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -96,6 +97,9 @@ func (p *provider) UpdateOrganization(ctx context.Context, meta RequestMetadata, if err != nil { log.Debug().Err(err).Msg("failed GetOrganizationByID") p.logOrgFailure(meta, constants.AuditOrganizationUpdateFailedEvent, params.ID) + if storage.IsNotFound(err) { + return nil, nil, NotFound("organization not found") + } return nil, nil, err } @@ -164,6 +168,9 @@ func (p *provider) DeleteOrganization(ctx context.Context, meta RequestMetadata, if err != nil { log.Debug().Err(err).Msg("failed GetOrganizationByID") p.logOrgFailure(meta, constants.AuditOrganizationDeleteFailedEvent, params.ID) + if storage.IsNotFound(err) { + return nil, nil, NotFound("organization not found") + } return nil, nil, err } @@ -203,6 +210,9 @@ func (p *provider) Organization(ctx context.Context, meta RequestMetadata, param org, err := p.StorageProvider.GetOrganizationByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetOrganizationByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("organization not found") + } return nil, nil, err } return org.AsAPIOrganization(), nil, nil diff --git a/internal/service/admin_trusted_issuers.go b/internal/service/admin_trusted_issuers.go index 2b2ed3a24..79af329dd 100644 --- a/internal/service/admin_trusted_issuers.go +++ b/internal/service/admin_trusted_issuers.go @@ -10,6 +10,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -173,6 +174,9 @@ func (p *provider) UpdateTrustedIssuer(ctx context.Context, meta RequestMetadata issuer, err := p.StorageProvider.GetTrustedIssuerByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetTrustedIssuerByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("trusted issuer not found") + } return nil, nil, err } @@ -245,6 +249,9 @@ func (p *provider) DeleteTrustedIssuer(ctx context.Context, meta RequestMetadata issuer, err := p.StorageProvider.GetTrustedIssuerByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetTrustedIssuerByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("trusted issuer not found") + } return nil, nil, err } @@ -277,6 +284,9 @@ func (p *provider) TrustedIssuer(ctx context.Context, meta RequestMetadata, para issuer, err := p.StorageProvider.GetTrustedIssuerByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetTrustedIssuerByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("trusted issuer not found") + } return nil, nil, err } return issuer.AsAPITrustedIssuer(), nil, nil diff --git a/internal/service/admin_users.go b/internal/service/admin_users.go index 3bf8be376..59c8f11f0 100644 --- a/internal/service/admin_users.go +++ b/internal/service/admin_users.go @@ -14,6 +14,7 @@ import ( "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" @@ -80,6 +81,9 @@ func (p *provider) User(ctx context.Context, meta RequestMetadata, params *model res, err := p.StorageProvider.GetUserByID(ctx, *params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetUserByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("user not found") + } return nil, nil, err } return res.AsAPIUser(), nil, nil @@ -89,6 +93,9 @@ func (p *provider) User(ctx context.Context, meta RequestMetadata, params *model res, err := p.StorageProvider.GetUserByEmail(ctx, *params.Email) if err != nil { log.Debug().Err(err).Msg("failed GetUserByEmail") + if storage.IsNotFound(err) { + return nil, nil, NotFound("user not found") + } return nil, nil, err } return res.AsAPIUser(), nil, nil @@ -374,6 +381,9 @@ func (p *provider) DeleteUser(ctx context.Context, meta RequestMetadata, params user, err := p.StorageProvider.GetUserByEmail(ctx, params.Email) if err != nil { log.Debug().Err(err).Msg("Failed to get user by email") + if storage.IsNotFound(err) { + return nil, nil, NotFound("user not found") + } return nil, nil, err } diff --git a/internal/service/admin_webhooks.go b/internal/service/admin_webhooks.go index 26e0d373a..e26cb9133 100644 --- a/internal/service/admin_webhooks.go +++ b/internal/service/admin_webhooks.go @@ -16,6 +16,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/utils" "github.com/authorizerdev/authorizer/internal/validators" @@ -99,6 +100,9 @@ func (p *provider) UpdateWebhook(ctx context.Context, meta RequestMetadata, para webhook, err := p.StorageProvider.GetWebhookByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetWebhookByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("webhook not found") + } return nil, nil, err } @@ -200,6 +204,9 @@ func (p *provider) DeleteWebhook(ctx context.Context, meta RequestMetadata, para webhook, err := p.StorageProvider.GetWebhookByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("Failed to get webhook by ID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("webhook not found") + } return nil, nil, err } @@ -233,6 +240,9 @@ func (p *provider) Webhook(ctx context.Context, meta RequestMetadata, params *mo webhook, err := p.StorageProvider.GetWebhookByID(ctx, params.ID) if err != nil { log.Debug().Err(err).Msg("failed GetWebhookByID") + if storage.IsNotFound(err) { + return nil, nil, NotFound("webhook not found") + } return nil, nil, err } return webhook.AsAPIWebhook(), nil, nil diff --git a/internal/storage/db/arangodb/authenticator.go b/internal/storage/db/arangodb/authenticator.go index 38c05af9e..56d6da7e0 100644 --- a/internal/storage/db/arangodb/authenticator.go +++ b/internal/storage/db/arangodb/authenticator.go @@ -79,7 +79,7 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s for { if !cursor.HasMore() { if authenticators == nil { - return authenticators, fmt.Errorf("authenticator not found") + return authenticators, fmt.Errorf("authenticator not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/client.go b/internal/storage/db/arangodb/client.go index c507a077d..e772dbd87 100644 --- a/internal/storage/db/arangodb/client.go +++ b/internal/storage/db/arangodb/client.go @@ -104,7 +104,7 @@ func (p *provider) GetClientByID(ctx context.Context, id string) (*schemas.Clien for { if !cursor.HasMore() { if sa == nil { - return nil, fmt.Errorf("service account not found") + return nil, fmt.Errorf("service account not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/email_template.go b/internal/storage/db/arangodb/email_template.go index 432176bf0..75e20ee90 100644 --- a/internal/storage/db/arangodb/email_template.go +++ b/internal/storage/db/arangodb/email_template.go @@ -90,7 +90,7 @@ func (p *provider) GetEmailTemplateByID(ctx context.Context, emailTemplateID str for { if !cursor.HasMore() { if emailTemplate == nil { - return nil, fmt.Errorf("email template not found") + return nil, fmt.Errorf("email template not found: %w", ErrNotFound) } break } @@ -117,7 +117,7 @@ func (p *provider) GetEmailTemplateByEventName(ctx context.Context, eventName st for { if !cursor.HasMore() { if emailTemplate == nil { - return nil, fmt.Errorf("email template not found") + return nil, fmt.Errorf("email template not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/env.go b/internal/storage/db/arangodb/env.go index 4ee9e9fca..45f52e3c8 100644 --- a/internal/storage/db/arangodb/env.go +++ b/internal/storage/db/arangodb/env.go @@ -56,7 +56,7 @@ func (p *provider) GetEnv(ctx context.Context) (*schemas.Env, error) { for { if !cursor.HasMore() { if env == nil { - return env, fmt.Errorf("config not found") + return env, fmt.Errorf("config not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/errors.go b/internal/storage/db/arangodb/errors.go new file mode 100644 index 000000000..771b348ed --- /dev/null +++ b/internal/storage/db/arangodb/errors.go @@ -0,0 +1,20 @@ +package arangodb + +import "errors" + +// ErrNotFound is this backend's canonical "row does not exist" error. Every +// not-found return wraps it (with a resource-specific message) so callers can +// tell an absent row from a storage fault via storage.IsNotFound, instead of +// treating any error as "absent" — which reports a database outage to the user +// as a permanently invalid credential. +// +// Backends deliberately own their own sentinel rather than importing one from +// internal/storage: that package imports every backend, so a shared sentinel +// there would be an import cycle. storage.IsNotFound fans out to each backend's +// predicate instead. +var ErrNotFound = errors.New("arangodb: record not found") + +// IsNotFound reports whether err means "no such row" in this backend. +func IsNotFound(err error) bool { + return errors.Is(err, ErrNotFound) +} diff --git a/internal/storage/db/arangodb/federated_identity.go b/internal/storage/db/arangodb/federated_identity.go index fb8ff02a0..a7ee40c39 100644 --- a/internal/storage/db/arangodb/federated_identity.go +++ b/internal/storage/db/arangodb/federated_identity.go @@ -52,7 +52,7 @@ func (p *provider) GetFederatedIdentity(ctx context.Context, orgID, issuer, subj for { if !cursor.HasMore() { if identity == nil { - return nil, fmt.Errorf("federated identity not found") + return nil, fmt.Errorf("federated identity not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/org_domain.go b/internal/storage/db/arangodb/org_domain.go index c90646b08..6bebbdf57 100644 --- a/internal/storage/db/arangodb/org_domain.go +++ b/internal/storage/db/arangodb/org_domain.go @@ -60,7 +60,7 @@ func (p *provider) GetOrgDomainByDomain(ctx context.Context, domain string) (*sc for { if !cursor.HasMore() { if orgDomain == nil { - return nil, fmt.Errorf("org domain not found") + return nil, fmt.Errorf("org domain not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/org_membership.go b/internal/storage/db/arangodb/org_membership.go index 466ba52ca..97958f442 100644 --- a/internal/storage/db/arangodb/org_membership.go +++ b/internal/storage/db/arangodb/org_membership.go @@ -85,7 +85,7 @@ func (p *provider) GetOrgMembership(ctx context.Context, orgID, userID string) ( for { if !cursor.HasMore() { if membership == nil { - return nil, fmt.Errorf("org membership not found") + return nil, fmt.Errorf("org membership not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/organization.go b/internal/storage/db/arangodb/organization.go index 3088f72c6..7d442075b 100644 --- a/internal/storage/db/arangodb/organization.go +++ b/internal/storage/db/arangodb/organization.go @@ -104,7 +104,7 @@ func (p *provider) GetOrganizationByID(ctx context.Context, id string) (*schemas for { if !cursor.HasMore() { if org == nil { - return nil, fmt.Errorf("organization not found") + return nil, fmt.Errorf("organization not found: %w", ErrNotFound) } break } @@ -132,7 +132,7 @@ func (p *provider) GetOrganizationByName(ctx context.Context, name string) (*sch for { if !cursor.HasMore() { if org == nil { - return nil, fmt.Errorf("organization not found") + return nil, fmt.Errorf("organization not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/otp.go b/internal/storage/db/arangodb/otp.go index 5f5b39303..86f2f9af8 100644 --- a/internal/storage/db/arangodb/otp.go +++ b/internal/storage/db/arangodb/otp.go @@ -78,7 +78,7 @@ func (p *provider) GetOTPByEmail(ctx context.Context, emailAddress string) (*sch for { if !cursor.HasMore() { if otp == nil { - return nil, fmt.Errorf("otp with given email not found") + return nil, fmt.Errorf("otp with given email not found: %w", ErrNotFound) } break } @@ -105,7 +105,7 @@ func (p *provider) GetOTPByPhoneNumber(ctx context.Context, phoneNumber string) for { if !cursor.HasMore() { if otp == nil { - return nil, fmt.Errorf("otp with given phone_number not found") + return nil, fmt.Errorf("otp with given phone_number not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/saml_idp.go b/internal/storage/db/arangodb/saml_idp.go index 51c7070d3..0fabea07f 100644 --- a/internal/storage/db/arangodb/saml_idp.go +++ b/internal/storage/db/arangodb/saml_idp.go @@ -88,7 +88,7 @@ func (p *provider) GetSAMLServiceProviderByID(ctx context.Context, id string) (* for { if !cursor.HasMore() { if sp == nil { - return nil, fmt.Errorf("saml service provider not found") + return nil, fmt.Errorf("saml service provider not found: %w", ErrNotFound) } break } @@ -118,7 +118,7 @@ func (p *provider) GetSAMLServiceProviderByOrgAndEntityID(ctx context.Context, o for { if !cursor.HasMore() { if sp == nil { - return nil, fmt.Errorf("saml service provider not found") + return nil, fmt.Errorf("saml service provider not found: %w", ErrNotFound) } break } @@ -237,7 +237,7 @@ func (p *provider) GetSAMLIDPKeyByID(ctx context.Context, id string) (*schemas.S for { if !cursor.HasMore() { if key == nil { - return nil, fmt.Errorf("saml idp key not found") + return nil, fmt.Errorf("saml idp key not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/scim_endpoint.go b/internal/storage/db/arangodb/scim_endpoint.go index 63ab93936..7be6593e5 100644 --- a/internal/storage/db/arangodb/scim_endpoint.go +++ b/internal/storage/db/arangodb/scim_endpoint.go @@ -83,7 +83,7 @@ func (p *provider) GetScimEndpointByID(ctx context.Context, id string) (*schemas for { if !cursor.HasMore() { if scimEndpoint == nil { - return nil, fmt.Errorf("scim endpoint not found") + return nil, fmt.Errorf("scim endpoint not found: %w", ErrNotFound) } break } @@ -112,7 +112,7 @@ func (p *provider) GetScimEndpointByOrgID(ctx context.Context, orgID string) (*s for { if !cursor.HasMore() { if scimEndpoint == nil { - return nil, fmt.Errorf("scim endpoint not found") + return nil, fmt.Errorf("scim endpoint not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/scim_group.go b/internal/storage/db/arangodb/scim_group.go index 7a93c2a68..c55b3409d 100644 --- a/internal/storage/db/arangodb/scim_group.go +++ b/internal/storage/db/arangodb/scim_group.go @@ -88,7 +88,7 @@ func (p *provider) GetScimGroupByID(ctx context.Context, id string) (*schemas.Sc for { if !cursor.HasMore() { if group == nil { - return nil, fmt.Errorf("scim group not found") + return nil, fmt.Errorf("scim group not found: %w", ErrNotFound) } break } @@ -159,7 +159,7 @@ func (p *provider) GetScimGroupByOrgAndDisplayName(ctx context.Context, orgID, d p.dependencies.Log.Warn().Str("org_id", orgID).Int("examined", examined). Msg("GetScimGroupByOrgAndDisplayName: hit the scan safety cap without a match") } - return nil, fmt.Errorf("scim group not found") + return nil, fmt.Errorf("scim group not found: %w", ErrNotFound) } // GetScimGroupByOrgAndExternalID resolves the single group with the given @@ -180,7 +180,7 @@ func (p *provider) GetScimGroupByOrgAndExternalID(ctx context.Context, orgID, ex for { if !cursor.HasMore() { if group == nil { - return nil, fmt.Errorf("scim group not found") + return nil, fmt.Errorf("scim group not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/session_token.go b/internal/storage/db/arangodb/session_token.go index f816467b3..bd5171a46 100644 --- a/internal/storage/db/arangodb/session_token.go +++ b/internal/storage/db/arangodb/session_token.go @@ -55,7 +55,7 @@ func (p *provider) GetSessionTokenByUserIDAndKey(ctx context.Context, userId, ke } return &token, nil } - return nil, fmt.Errorf("session token not found") + return nil, fmt.Errorf("session token not found: %w", ErrNotFound) } // DeleteSessionToken deletes a session token by ID @@ -200,7 +200,7 @@ func (p *provider) GetMFASessionByUserIDAndKey(ctx context.Context, userId, key } return &session, nil } - return nil, fmt.Errorf("MFA session not found") + return nil, fmt.Errorf("MFA session not found: %w", ErrNotFound) } // DeleteMFASession deletes an MFA session by ID @@ -342,7 +342,7 @@ func (p *provider) GetOAuthStateByKey(ctx context.Context, key string) (*schemas } return &state, nil } - return nil, fmt.Errorf("OAuth state not found") + return nil, fmt.Errorf("OAuth state not found: %w", ErrNotFound) } // DeleteOAuthStateByKey deletes an OAuth state by key diff --git a/internal/storage/db/arangodb/trusted_issuer.go b/internal/storage/db/arangodb/trusted_issuer.go index e68e2cf4b..669281d59 100644 --- a/internal/storage/db/arangodb/trusted_issuer.go +++ b/internal/storage/db/arangodb/trusted_issuer.go @@ -86,7 +86,7 @@ func (p *provider) GetTrustedIssuerByID(ctx context.Context, id string) (*schema for { if !cursor.HasMore() { if issuer == nil { - return nil, fmt.Errorf("trusted issuer not found") + return nil, fmt.Errorf("trusted issuer not found: %w", ErrNotFound) } break } @@ -115,7 +115,7 @@ func (p *provider) GetTrustedIssuerByIssuerURL(ctx context.Context, issuerURL st for { if !cursor.HasMore() { if issuer == nil { - return nil, fmt.Errorf("trusted issuer not found") + return nil, fmt.Errorf("trusted issuer not found: %w", ErrNotFound) } break } @@ -145,7 +145,7 @@ func (p *provider) GetTrustedIssuerByOrgIDAndKind(ctx context.Context, orgID, ki for { if !cursor.HasMore() { if issuer == nil { - return nil, fmt.Errorf("trusted issuer not found") + return nil, fmt.Errorf("trusted issuer not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/user.go b/internal/storage/db/arangodb/user.go index 5b026dffa..f818d3f7b 100644 --- a/internal/storage/db/arangodb/user.go +++ b/internal/storage/db/arangodb/user.go @@ -147,7 +147,7 @@ func (p *provider) GetUserByEmail(ctx context.Context, email string) (*schemas.U for { if !cursor.HasMore() { if user == nil { - return nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found: %w", ErrNotFound) } break } @@ -177,7 +177,7 @@ func (p *provider) GetUserByExternalID(ctx context.Context, orgID, externalID st for { if !cursor.HasMore() { if user == nil { - return nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found: %w", ErrNotFound) } break } @@ -205,7 +205,7 @@ func (p *provider) GetUserByID(ctx context.Context, id string) (*schemas.User, e for { if !cursor.HasMore() { if user == nil { - return nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found: %w", ErrNotFound) } break } @@ -254,7 +254,7 @@ func (p *provider) GetUserByPhoneNumber(ctx context.Context, phoneNumber string) for { if !cursor.HasMore() { if user == nil { - return nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/verification_requests.go b/internal/storage/db/arangodb/verification_requests.go index f2919fd62..ecdfb7940 100644 --- a/internal/storage/db/arangodb/verification_requests.go +++ b/internal/storage/db/arangodb/verification_requests.go @@ -45,7 +45,7 @@ func (p *provider) GetVerificationRequestByToken(ctx context.Context, token stri for { if !cursor.HasMore() { if verificationRequest == nil { - return verificationRequest, fmt.Errorf("verification request not found") + return verificationRequest, fmt.Errorf("verification request not found: %w", ErrNotFound) } break } @@ -73,7 +73,7 @@ func (p *provider) GetVerificationRequestByEmail(ctx context.Context, email stri for { if !cursor.HasMore() { if verificationRequest == nil { - return verificationRequest, fmt.Errorf("verification request not found") + return verificationRequest, fmt.Errorf("verification request not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/webauthn_credential.go b/internal/storage/db/arangodb/webauthn_credential.go index def677063..e35c9626e 100644 --- a/internal/storage/db/arangodb/webauthn_credential.go +++ b/internal/storage/db/arangodb/webauthn_credential.go @@ -85,7 +85,7 @@ func (p *provider) GetWebauthnCredentialByID(ctx context.Context, id string) (*s for { if !cursor.HasMore() { if cred == nil { - return nil, fmt.Errorf("webauthn credential not found") + return nil, fmt.Errorf("webauthn credential not found: %w", ErrNotFound) } break } @@ -114,7 +114,7 @@ func (p *provider) GetWebauthnCredentialByCredentialID(ctx context.Context, cred for { if !cursor.HasMore() { if cred == nil { - return nil, fmt.Errorf("webauthn credential not found") + return nil, fmt.Errorf("webauthn credential not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/arangodb/webhook.go b/internal/storage/db/arangodb/webhook.go index d557c96f9..76a6bd3eb 100644 --- a/internal/storage/db/arangodb/webhook.go +++ b/internal/storage/db/arangodb/webhook.go @@ -94,7 +94,7 @@ func (p *provider) GetWebhookByID(ctx context.Context, webhookID string) (*schem for { if !cursor.HasMore() { if webhook == nil { - return nil, fmt.Errorf("webhook not found") + return nil, fmt.Errorf("webhook not found: %w", ErrNotFound) } break } diff --git a/internal/storage/db/cassandradb/errors.go b/internal/storage/db/cassandradb/errors.go new file mode 100644 index 000000000..d4626cb79 --- /dev/null +++ b/internal/storage/db/cassandradb/errors.go @@ -0,0 +1,13 @@ +package cassandradb + +import ( + "errors" + + "github.com/gocql/gocql" +) + +// IsNotFound reports whether err means "no such row" in this backend. gocql's +// ErrNotFound is canonical, so it is matched directly. +func IsNotFound(err error) bool { + return errors.Is(err, gocql.ErrNotFound) +} diff --git a/internal/storage/db/couchbase/errors.go b/internal/storage/db/couchbase/errors.go new file mode 100644 index 000000000..8bd33a83b --- /dev/null +++ b/internal/storage/db/couchbase/errors.go @@ -0,0 +1,13 @@ +package couchbase + +import ( + "errors" + + "github.com/couchbase/gocb/v2" +) + +// IsNotFound reports whether err means "no such row" in this backend. gocb's +// ErrDocumentNotFound is canonical, so it is matched directly. +func IsNotFound(err error) bool { + return errors.Is(err, gocb.ErrDocumentNotFound) +} diff --git a/internal/storage/db/dynamodb/authenticator.go b/internal/storage/db/dynamodb/authenticator.go index 042159cb6..14eae3b14 100644 --- a/internal/storage/db/dynamodb/authenticator.go +++ b/internal/storage/db/dynamodb/authenticator.go @@ -2,6 +2,7 @@ package dynamodb import ( "context" + "fmt" "time" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" @@ -43,7 +44,9 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s return nil, err } if len(items) == 0 { - return nil, nil + // Absent MUST be an error, matching every other backend. totp.Validate + // and ValidateRecoveryCode branch on err alone before dereferencing. + return nil, fmt.Errorf("authenticator not found: %w", ErrNotFound) } var a schemas.Authenticator if err := unmarshalItem(items[0], &a); err != nil { diff --git a/internal/storage/db/dynamodb/env.go b/internal/storage/db/dynamodb/env.go index 320dcc82a..e64bc45d4 100644 --- a/internal/storage/db/dynamodb/env.go +++ b/internal/storage/db/dynamodb/env.go @@ -45,7 +45,7 @@ func (p *provider) GetEnv(ctx context.Context) (*schemas.Env, error) { } var env schemas.Env if err := unmarshalItem(items[0], &env); err != nil { - return nil, fmt.Errorf("config not found") + return nil, fmt.Errorf("config not found: %w", ErrNotFound) } return &env, nil } diff --git a/internal/storage/db/dynamodb/errors.go b/internal/storage/db/dynamodb/errors.go new file mode 100644 index 000000000..f9bf32fa2 --- /dev/null +++ b/internal/storage/db/dynamodb/errors.go @@ -0,0 +1,13 @@ +package dynamodb + +import "errors" + +// ErrNotFound is this backend's canonical "row does not exist" error. See +// arangodb/errors.go for why each backend owns its own sentinel rather than +// sharing one from internal/storage (import cycle). +var ErrNotFound = errors.New("dynamodb: record not found") + +// IsNotFound reports whether err means "no such row" in this backend. +func IsNotFound(err error) bool { + return errors.Is(err, ErrNotFound) +} diff --git a/internal/storage/db/dynamodb/session_token.go b/internal/storage/db/dynamodb/session_token.go index 84c76ad3b..c1b3dbaa9 100644 --- a/internal/storage/db/dynamodb/session_token.go +++ b/internal/storage/db/dynamodb/session_token.go @@ -2,7 +2,7 @@ package dynamodb import ( "context" - "errors" + "fmt" "strings" "time" @@ -35,7 +35,7 @@ func (p *provider) GetSessionTokenByUserIDAndKey(ctx context.Context, userId, ke return nil, err } if len(items) == 0 { - return nil, errors.New("session token not found") + return nil, fmt.Errorf("session token not found: %w", ErrNotFound) } var t schemas.SessionToken if err := unmarshalItem(items[0], &t); err != nil { @@ -178,7 +178,7 @@ func (p *provider) GetMFASessionByUserIDAndKey(ctx context.Context, userId, key return nil, err } if len(items) == 0 { - return nil, errors.New("MFA session not found") + return nil, fmt.Errorf("MFA session not found: %w", ErrNotFound) } var s schemas.MFASession if err := unmarshalItem(items[0], &s); err != nil { @@ -295,7 +295,7 @@ func (p *provider) GetOAuthStateByKey(ctx context.Context, key string) (*schemas return nil, err } if len(items) == 0 { - return nil, errors.New("OAuth state not found") + return nil, fmt.Errorf("OAuth state not found: %w", ErrNotFound) } var s schemas.OAuthState if err := unmarshalItem(items[0], &s); err != nil { diff --git a/internal/storage/db/dynamodb/verification_requests.go b/internal/storage/db/dynamodb/verification_requests.go index 0d9591901..b9ee00ac6 100644 --- a/internal/storage/db/dynamodb/verification_requests.go +++ b/internal/storage/db/dynamodb/verification_requests.go @@ -2,6 +2,7 @@ package dynamodb import ( "context" + "fmt" "time" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" @@ -32,7 +33,12 @@ func (p *provider) GetVerificationRequestByToken(ctx context.Context, token stri return nil, err } if len(items) == 0 { - return nil, nil + // Absent MUST be an error, matching every other backend + // (gorm.ErrRecordNotFound, mongo.ErrNoDocuments, ...). Callers in + // service/verify_email.go and service/reset_password.go branch on err + // alone and then dereference the row, so returning (nil, nil) here + // panics on this backend only. + return nil, fmt.Errorf("verification request not found: %w", ErrNotFound) } var v schemas.VerificationRequest if err := unmarshalItem(items[0], &v); err != nil { @@ -49,7 +55,10 @@ func (p *provider) GetVerificationRequestByEmail(ctx context.Context, email stri return nil, err } if len(items) == 0 { - return nil, nil + // See GetVerificationRequestByToken: absent is an error on every other + // backend, and resend_verify_email.go passes this row straight to + // DeleteVerificationRequest without a nil check. + return nil, fmt.Errorf("verification request not found: %w", ErrNotFound) } var v schemas.VerificationRequest if err := unmarshalItem(items[0], &v); err != nil { diff --git a/internal/storage/db/mongodb/errors.go b/internal/storage/db/mongodb/errors.go new file mode 100644 index 000000000..d69779000 --- /dev/null +++ b/internal/storage/db/mongodb/errors.go @@ -0,0 +1,13 @@ +package mongodb + +import ( + "errors" + + "go.mongodb.org/mongo-driver/mongo" +) + +// IsNotFound reports whether err means "no such row" in this backend. The +// driver's ErrNoDocuments is canonical, so it is matched directly. +func IsNotFound(err error) bool { + return errors.Is(err, mongo.ErrNoDocuments) +} diff --git a/internal/storage/db/sql/errors.go b/internal/storage/db/sql/errors.go new file mode 100644 index 000000000..e9b6e1e4e --- /dev/null +++ b/internal/storage/db/sql/errors.go @@ -0,0 +1,14 @@ +package sql + +import ( + "errors" + + "gorm.io/gorm" +) + +// IsNotFound reports whether err means "no such row" in this backend. GORM +// already has a single canonical sentinel, so there is nothing to wrap — the +// bare driver error returned by First()/Take() is matched directly. +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/internal/storage/errors.go b/internal/storage/errors.go new file mode 100644 index 000000000..255a2a927 --- /dev/null +++ b/internal/storage/errors.go @@ -0,0 +1,52 @@ +package storage + +import ( + "github.com/authorizerdev/authorizer/internal/storage/db/arangodb" + "github.com/authorizerdev/authorizer/internal/storage/db/cassandradb" + "github.com/authorizerdev/authorizer/internal/storage/db/couchbase" + "github.com/authorizerdev/authorizer/internal/storage/db/dynamodb" + "github.com/authorizerdev/authorizer/internal/storage/db/mongodb" + "github.com/authorizerdev/authorizer/internal/storage/db/sql" +) + +// IsNotFound reports whether err means "the row does not exist", as opposed to +// "the query failed". Use it to keep those two apart: +// +// org, err := p.StorageProvider.GetOrganizationByID(ctx, id) +// switch { +// case storage.IsNotFound(err): +// return nil, NotFound("organization not found") // 404, permanent +// case err != nil: +// return nil, Internal("storage unavailable") // 500, retryable +// } +// +// Why this matters: without it every caller collapses to `if err != nil` and +// reports a database outage as though the caller's input were wrong. A user +// clicking a perfectly valid verification link during a brief outage was told +// "invalid verification token" — a permanent, non-retryable answer to a +// transient condition, and in auth paths that ambiguity is a security concern +// as much as a UX one. +// +// It is a predicate rather than a single shared sentinel for two reasons. Each +// backend reports absence with its own driver value (gorm.ErrRecordNotFound, +// mongo.ErrNoDocuments, gocql.ErrNotFound, gocb.ErrDocumentNotFound), and +// errors.Is cannot match those against a foreign sentinel. And a sentinel +// declared here could not be wrapped by the backends anyway — this package +// imports all of them, so the dependency only runs one way. The shape follows +// k8s.io/apimachinery's apierrors.IsNotFound for the same reasons. +// +// Backends that have no canonical driver sentinel (ArangoDB, DynamoDB) declare +// their own and wrap it; see their errors.go. +// +// A nil error is never "not found". +func IsNotFound(err error) bool { + if err == nil { + return false + } + return sql.IsNotFound(err) || + mongodb.IsNotFound(err) || + arangodb.IsNotFound(err) || + cassandradb.IsNotFound(err) || + dynamodb.IsNotFound(err) || + couchbase.IsNotFound(err) +} diff --git a/internal/storage/errors_test.go b/internal/storage/errors_test.go new file mode 100644 index 000000000..acad821ca --- /dev/null +++ b/internal/storage/errors_test.go @@ -0,0 +1,74 @@ +package storage + +import ( + "errors" + "fmt" + "testing" + + "github.com/couchbase/gocb/v2" + "github.com/gocql/gocql" + "go.mongodb.org/mongo-driver/mongo" + "gorm.io/gorm" + + "github.com/authorizerdev/authorizer/internal/storage/db/arangodb" + "github.com/authorizerdev/authorizer/internal/storage/db/dynamodb" +) + +// TestIsNotFoundRecognisesEveryBackend pins that the predicate actually matches +// each backend's absence value. If a backend is missed, its callers silently +// fall through to the "storage unavailable" branch and report a 500 for a row +// that simply does not exist — the inverse of the bug this replaced, and just +// as invisible. +func TestIsNotFoundRecognisesEveryBackend(t *testing.T) { + cases := []struct { + backend string + err error + }{ + {"sql/gorm", gorm.ErrRecordNotFound}, + {"mongodb", mongo.ErrNoDocuments}, + {"cassandradb", gocql.ErrNotFound}, + {"couchbase", gocb.ErrDocumentNotFound}, + {"arangodb", arangodb.ErrNotFound}, + {"dynamodb", dynamodb.ErrNotFound}, + } + for _, tc := range cases { + t.Run(tc.backend, func(t *testing.T) { + if !IsNotFound(tc.err) { + t.Fatalf("IsNotFound(%v) = false, want true — %s absences would be reported as storage faults", tc.err, tc.backend) + } + }) + } +} + +// TestIsNotFoundSurvivesWrapping pins that the predicate still works once a +// backend adds context with %w, which is how the Arango/Dynamo sites report +// which resource was missing. +func TestIsNotFoundSurvivesWrapping(t *testing.T) { + wrapped := fmt.Errorf("authenticator not found: %w", arangodb.ErrNotFound) + if !IsNotFound(wrapped) { + t.Fatal("IsNotFound must see through %w wrapping") + } + doubly := fmt.Errorf("loading user: %w", fmt.Errorf("row: %w", gorm.ErrRecordNotFound)) + if !IsNotFound(doubly) { + t.Fatal("IsNotFound must see through nested wrapping") + } +} + +// TestIsNotFoundRejectsOtherErrors pins the other half of the contract: a real +// storage fault must NOT be mistaken for an absent row, or an outage would be +// reported to the user as "no such record" and silently swallowed. +func TestIsNotFoundRejectsOtherErrors(t *testing.T) { + for _, err := range []error{ + nil, + errors.New("connection refused"), + errors.New("context deadline exceeded"), + fmt.Errorf("dial tcp: %w", errors.New("i/o timeout")), + // Deliberately similar wording, but not a sentinel — string-matching + // "not found" would wrongly pass this. + errors.New("host not found"), + } { + if IsNotFound(err) { + t.Fatalf("IsNotFound(%v) = true, want false", err) + } + } +} diff --git a/internal/storage/notfound_contract_test.go b/internal/storage/notfound_contract_test.go new file mode 100644 index 000000000..8daae8367 --- /dev/null +++ b/internal/storage/notfound_contract_test.go @@ -0,0 +1,145 @@ +package storage + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "sort" + "strings" + "testing" +) + +// backends are the storage implementations that must agree on the not-found +// contract documented on Provider. +var backends = []string{"sql", "mongodb", "arangodb", "cassandradb", "dynamodb", "couchbase"} + +// listReturnsNilNil are the List* methods that legitimately return (nil, nil) +// for an empty result (rule 2 on Provider), plus the one documented +// single-entity exception. +// +// Everything else must report an absent row as an ERROR (rule 1). A method +// added here without a documented reason is how the DynamoDB TOTP/verification +// panics got in. +var allowedNilNil = map[string]string{ + "GetClientByClientID": "rule 3: callers distinguish absent from unavailable by err alone", + "ListClients": "rule 2: empty list", + "ListEmailTemplate": "rule 2: empty list", + "ListOrgDomainsByOrg": "rule 2: empty list", + "ListOrganizations": "rule 2: empty list", + "ListSAMLServiceProviders": "rule 2: empty list", + "ListTrustedIssuers": "rule 2: empty list", + "ListUsers": "rule 2: empty list", + "ListVerificationRequests": "rule 2: empty list", + "ListWebhook": "rule 2: empty list", + "ListWebhookLogs": "rule 2: empty list", + "listOrgMemberships": "rule 2: empty list (shared helper)", +} + +// TestNotFoundContractIsUniform enforces the convention documented on Provider. +// +// It is a static check because the failure it prevents is backend-specific and +// silent: a getter that returns (nil, nil) on ONE backend passes every test run +// against SQLite (which CI uses) and only panics in production on the odd +// backend out. Comparing the backends against each other catches it without +// needing all six databases running. +func TestNotFoundContractIsUniform(t *testing.T) { + // method -> backend -> returns (nil, nil) somewhere in its body + seen := map[string]map[string]bool{} + + for _, backend := range backends { + files, err := filepath.Glob(filepath.Join("db", backend, "*.go")) + if err != nil { + t.Fatalf("glob %s: %v", backend, err) + } + if len(files) == 0 { + t.Fatalf("no source files found for backend %q — the check would vacuously pass", backend) + } + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Body == nil { + continue + } + name := fn.Name.Name + if seen[name] == nil { + seen[name] = map[string]bool{} + } + seen[name][backend] = seen[name][backend] || returnsNilNil(fn.Body) + } + } + } + + shared := 0 + for name, byBackend := range seen { + if len(byBackend) != len(backends) { + // Not implemented by every backend (helpers, backend-specific code). + continue + } + shared++ + + nilNil, errs := []string{}, []string{} + for _, b := range backends { + if byBackend[b] { + nilNil = append(nilNil, b) + } else { + errs = append(errs, b) + } + } + + // Divergence: the same method disagrees across backends. + if len(nilNil) > 0 && len(errs) > 0 { + sort.Strings(nilNil) + sort.Strings(errs) + t.Errorf("%s: not-found contract diverges across backends — "+ + "(nil,nil) in [%s] but an error in [%s]. Callers branch on err alone and "+ + "dereference the row, so this panics on the odd backend only. "+ + "See the not-found convention on storage.Provider.", + name, strings.Join(nilNil, ","), strings.Join(errs, ",")) + continue + } + + // Uniform (nil, nil) is only allowed for documented cases. + if len(nilNil) == len(backends) { + if _, ok := allowedNilNil[name]; !ok { + t.Errorf("%s: returns (nil,nil) on every backend but is not in allowedNilNil. "+ + "Single-entity getters must report an absent row as an error (rule 1 on "+ + "storage.Provider). If this is intentional, document why on the method and "+ + "add it to allowedNilNil.", name) + } + } + } + + if shared == 0 { + t.Fatal("found no methods implemented by all backends — the check would vacuously pass") + } + t.Logf("checked %d methods implemented by all %d backends", shared, len(backends)) +} + +// returnsNilNil reports whether the body contains a literal `return nil, nil`. +func returnsNilNil(body *ast.BlockStmt) bool { + found := false + ast.Inspect(body, func(n ast.Node) bool { + ret, ok := n.(*ast.ReturnStmt) + if !ok || len(ret.Results) != 2 { + return true + } + for _, r := range ret.Results { + id, ok := r.(*ast.Ident) + if !ok || id.Name != "nil" { + return true + } + } + found = true + return false + }) + return found +} diff --git a/internal/storage/provider.go b/internal/storage/provider.go index 232e30978..1da4e12c9 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -28,6 +28,39 @@ type Dependencies struct { // Delete methods are idempotent: deleting a non-existent id returns nil, not an // error. Callers that rely on delete-confirms-existence must check existence // separately first. +// +// # Not-found convention +// +// Every backend must agree on how "the row does not exist" is reported, +// because callers branch on it. Two rules, and one explicitly documented +// exception: +// +// 1. SINGLE-ENTITY GETTERS RETURN AN ERROR when the row is absent — the +// driver's own not-found value (gorm.ErrRecordNotFound, +// mongo.ErrNoDocuments, gocql.ErrNotFound, a bare errors.New for +// key-value backends). This is the default and covers all but one of them. +// Callers therefore treat `err != nil` as "absent or unavailable" and are +// entitled to dereference the returned pointer once err is nil. +// +// Returning (nil, nil) from one of these is a PARITY BUG, not a style +// choice: callers written against the majority contract dereference the +// nil row and panic on that backend alone. Three DynamoDB methods did +// exactly this (authenticator and verification-request lookups) and +// crashed the TOTP and email-verification paths on that backend only. +// +// 2. LIST METHODS RETURN (nil, nil) FOR AN EMPTY RESULT. An empty collection +// is not an error, and callers range over the slice — a nil slice ranges +// zero times, so no guard is needed. +// +// 3. EXCEPTION — GetClientByClientID MUST return (nil, nil) for an absent +// client_id. Its callers distinguish "no such client" from "storage +// unavailable" solely by whether err is nil, so an absent row must not be +// reported as an error. See the method's own comment for the reasoning. +// Callers of THIS method must nil-check the returned pointer. +// +// When adding a method, follow rule 1 unless there is a documented reason not +// to, and implement the same behaviour in all backends +// (internal/storage/db/{sql,mongodb,arangodb,cassandradb,dynamodb,couchbase}). type Provider interface { // AddUser to save user information in database AddUser(ctx context.Context, user *schemas.User) (*schemas.User, error) From 48ce42a04f602c39cf3df3f49fc94551be2d4223 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 14:51:28 +0530 Subject: [PATCH 04/25] chore(memory-store): drain expired-state cleanup via asyncutil.Go Detached one-shot request-scoped work must be tracked so graceful shutdown drains it and a panic is recovered; an unrecovered panic in a bare goroutine takes down the whole process. --- internal/memory_store/db/provider.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/memory_store/db/provider.go b/internal/memory_store/db/provider.go index 808c245ec..75317d77e 100644 --- a/internal/memory_store/db/provider.go +++ b/internal/memory_store/db/provider.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog" + "github.com/authorizerdev/authorizer/internal/asyncutil" "github.com/authorizerdev/authorizer/internal/config" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/storage" @@ -327,8 +328,14 @@ func (p *provider) GetState(key string) (string, error) { } // Enforce 10-minute TTL consistent with Redis provider. if oauthState.CreatedAt > 0 && time.Now().Unix()-oauthState.CreatedAt > 600 { - // Clean up expired entry asynchronously. - go func() { _, _ = p.deleteOAuthStateByKey(context.Background(), key) }() + // Clean up expired entry asynchronously. Routed through asyncutil.Go, + // not a bare `go func()`: this is detached one-shot request-scoped work, + // so it must be drained by graceful shutdown and have its panics + // recovered — an unrecovered panic in a bare goroutine takes down the + // whole process. + asyncutil.Go(p.dependencies.Log, func() { + _, _ = p.deleteOAuthStateByKey(context.Background(), key) + }) return "", fmt.Errorf("state expired") } return oauthState.State, nil From fb08ed06b4e33329ad0286957e2122fc4d925b86 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 15:01:54 +0530 Subject: [PATCH 05/25] security(crypto): warn when the at-rest key falls back to --jwt-secret Cryptographically fine, but it couples two keys with opposite lifecycles: a signing key is meant to be rotated and rotation is cheap, while the at-rest key has no re-encryption path. While they are the same value, rotating --jwt-secret silently makes every enrolled TOTP authenticator undecryptable. A warning rather than a hard failure: unlike an empty key, which is rejected outright because the data is unprotected now, here the data IS protected and the risk is a future operator action. --- cmd/root.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index 33d7bacb1..c9a92105c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -438,6 +438,23 @@ func runRoot(c *cobra.Command, args []string) { log.Warn().Msg("running with --env=e2e: SSRF protection is relaxed for the SSO broker and webhooks, and OAuth/SMS are routed to e2e-playground mock addresses. This must never be set in a real deployment.") } + // Warn when the at-rest key is only the JWT secret by fallback. This is + // cryptographically fine — JWTSecret is a real secret — but it couples two + // keys with opposite lifecycles. A signing key is meant to be rotatable and + // rotation is cheap (tokens expire, users log in again); the at-rest key has + // NO re-encryption path, so while they are the same value, rotating + // --jwt-secret silently makes every enrolled TOTP authenticator + // undecryptable and invalidates outstanding OTPs. + // + // A warning rather than a hard failure, deliberately: unlike an EMPTY key + // (which is rejected outright in Config.ValidateEncryptionKey because the + // data is unprotected *now*), the data here IS protected. The risk is a + // future operator action, and this is the only chance to inform that + // decision before enrolments exist and make the fix expensive. + if rootArgs.config.EncryptionKey == rootArgs.config.JWTSecret { + log.Warn().Msg("--encryption-key is not set and has fallen back to --jwt-secret. Secrets at rest (TOTP seeds, OTP digests) are keyed by the same value that signs tokens, so rotating --jwt-secret will lock out every enrolled TOTP user — there is no re-encryption path. Set a distinct --encryption-key now; doing it after users enrol requires them to re-enrol.") + } + // Initialize prometheus metrics metrics.Init() From f53b2f5898822c7e79ccdadfda15db92b412f889 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 16:10:26 +0530 Subject: [PATCH 06/25] fix(crypto): accept PKCS#8 and PKIX keys for RS*/ES* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openssl 3.x — the default on macOS and current Linux — writes PKCS#8 ("BEGIN PRIVATE KEY") from genrsa/genpkey and PKIX ("BEGIN PUBLIC KEY") from `rsa -pubout`. Only the older PKCS#1/SEC1 forms parsed, so keys generated the standard way were rejected. The failure was late and silent. Config validation passes, the server starts, signup works — then token issuance fails with "use ParsePKCS8PrivateKey instead for this key format" and jwks.json fails with the PKIX equivalent. An RS256 instance looks healthy while every login is broken and no relying party can verify a token. Reproduced on a k3d cluster with keys from `openssl genrsa` + `openssl rsa -pubout`; both endpoints work after the fix. RSA and ECDSA also disagreed: the ECDSA public path already used ParsePKIXPublicKey while RSA required PKCS#1. --- internal/crypto/ecdsa.go | 16 +++- internal/crypto/key_formats_test.go | 134 ++++++++++++++++++++++++++++ internal/crypto/rsa.go | 46 ++++++++-- 3 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 internal/crypto/key_formats_test.go diff --git a/internal/crypto/ecdsa.go b/internal/crypto/ecdsa.go index d718d39cc..c741866ca 100644 --- a/internal/crypto/ecdsa.go +++ b/internal/crypto/ecdsa.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/pem" "errors" + "fmt" ) // NewECDSAKey to generate new ECDSA Key if env is not set @@ -89,11 +90,22 @@ func ParseEcdsaPrivateKeyFromPemStr(privPEM string) (*ecdsa.PrivateKey, error) { return nil, errors.New("failed to parse PEM block containing the key") } - priv, err := x509.ParseECPrivateKey(block.Bytes) + // SEC1 ("BEGIN EC PRIVATE KEY") first, then PKCS#8 ("BEGIN PRIVATE KEY"), + // which is what `openssl genpkey` and openssl 3.x emit by default. Mirrors + // the RSA path: accepting only one encoding meant a key generated the + // standard way failed at token issuance rather than at startup. + if priv, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return priv, nil + } + + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } - + priv, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("expected an ECDSA private key, got %T", parsed) + } return priv, nil } diff --git a/internal/crypto/key_formats_test.go b/internal/crypto/key_formats_test.go new file mode 100644 index 000000000..fd4443301 --- /dev/null +++ b/internal/crypto/key_formats_test.go @@ -0,0 +1,134 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" +) + +// These pin that both PEM encodings openssl emits are accepted. +// +// openssl 3.x — the default on macOS and current Linux — writes PKCS#8 +// ("BEGIN PRIVATE KEY") from `genrsa`/`genpkey`, and PKIX ("BEGIN PUBLIC KEY") +// from `rsa -pubout`. Only the older PKCS#1 forms were accepted, so keys +// generated the standard way failed — and failed LATE: the server started, +// signup worked, and only token issuance and /.well-known/jwks.json broke, on +// an instance that otherwise looked healthy. + +func pemEncode(t *testing.T, typ string, der []byte) string { + t.Helper() + return string(pem.EncodeToMemory(&pem.Block{Type: typ, Bytes: der})) +} + +func TestParseRsaPrivateKeyAcceptsBothEncodings(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate: %v", err) + } + + pkcs8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshal pkcs8: %v", err) + } + + for _, tc := range []struct { + name, typ string + der []byte + }{ + {"PKCS#1 (openssl 1.x genrsa)", "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(key)}, + {"PKCS#8 (openssl 3.x genrsa)", "PRIVATE KEY", pkcs8}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseRsaPrivateKeyFromPemStr(pemEncode(t, tc.typ, tc.der)) + if err != nil { + t.Fatalf("parse: %v — token issuance would fail on this key", err) + } + if !got.Equal(key) { + t.Fatal("parsed a different key") + } + }) + } +} + +func TestParseRsaPublicKeyAcceptsBothEncodings(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate: %v", err) + } + + pkix, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("marshal pkix: %v", err) + } + + for _, tc := range []struct { + name, typ string + der []byte + }{ + {"PKCS#1", "RSA PUBLIC KEY", x509.MarshalPKCS1PublicKey(&key.PublicKey)}, + {"PKIX (openssl rsa -pubout)", "PUBLIC KEY", pkix}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseRsaPublicKeyFromPemStr(pemEncode(t, tc.typ, tc.der)) + if err != nil { + t.Fatalf("parse: %v — jwks.json would fail on this key", err) + } + if !got.Equal(&key.PublicKey) { + t.Fatal("parsed a different key") + } + }) + } +} + +func TestParseEcdsaPrivateKeyAcceptsBothEncodings(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate: %v", err) + } + + sec1, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal sec1: %v", err) + } + pkcs8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshal pkcs8: %v", err) + } + + for _, tc := range []struct { + name, typ string + der []byte + }{ + {"SEC1", "EC PRIVATE KEY", sec1}, + {"PKCS#8 (openssl genpkey)", "PRIVATE KEY", pkcs8}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseEcdsaPrivateKeyFromPemStr(pemEncode(t, tc.typ, tc.der)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !got.Equal(key) { + t.Fatal("parsed a different key") + } + }) + } +} + +// A non-matching key type must be a clear error, not a panic from a bad cast. +func TestParseRsaPrivateKeyRejectsAnEcdsaKey(t *testing.T) { + ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate: %v", err) + } + pkcs8, err := x509.MarshalPKCS8PrivateKey(ec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if _, err := ParseRsaPrivateKeyFromPemStr(pemEncode(t, "PRIVATE KEY", pkcs8)); err == nil { + t.Fatal("an ECDSA key must not parse as RSA") + } +} diff --git a/internal/crypto/rsa.go b/internal/crypto/rsa.go index b2a435f70..80e6d956f 100644 --- a/internal/crypto/rsa.go +++ b/internal/crypto/rsa.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/pem" "errors" + "fmt" ) // NewRSAKey to generate new RSA Key if env is not set @@ -66,33 +67,66 @@ func ExportRsaPublicKeyAsPemStr(pubkey *rsa.PublicKey) string { return string(pubkeyPem) } -// ParseRsaPrivateKeyFromPemStr to parse RSA private key from pem string +// ParseRsaPrivateKeyFromPemStr parses an RSA private key from a PEM string, +// accepting BOTH encodings openssl emits: +// +// - PKCS#1 — "BEGIN RSA PRIVATE KEY", what openssl 1.x genrsa produced. +// - PKCS#8 — "BEGIN PRIVATE KEY", what openssl 3.x genrsa produces BY +// DEFAULT, and therefore what anyone generating keys today gets. +// +// Only PKCS#1 was accepted before, so a key from a current openssl failed. The +// failure was late and silent: the server started, signup worked, and only +// token ISSUANCE failed — every login broke on an instance that looked healthy. func ParseRsaPrivateKeyFromPemStr(privPEM string) (*rsa.PrivateKey, error) { block, _ := pem.Decode([]byte(privPEM)) if block == nil { return nil, errors.New("failed to parse PEM block containing the key") } - priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if priv, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return priv, nil + } + + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } - + priv, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("expected an RSA private key, got %T", parsed) + } return priv, nil } -// ParseRsaPublicKeyFromPemStr to parse RSA public key from pem string +// ParseRsaPublicKeyFromPemStr parses an RSA public key from a PEM string, +// accepting BOTH encodings: +// +// - PKCS#1 — "BEGIN RSA PUBLIC KEY". +// - PKIX/SPKI — "BEGIN PUBLIC KEY", which is what `openssl rsa -pubout` +// emits, i.e. the standard way to derive a public key. +// +// Only PKCS#1 was accepted before, which broke /.well-known/jwks.json for +// those deployments — relying parties could not verify tokens even when +// signing worked. Note the ECDSA path already used ParsePKIXPublicKey, so the +// two algorithms disagreed on the accepted format. func ParseRsaPublicKeyFromPemStr(pubPEM string) (*rsa.PublicKey, error) { block, _ := pem.Decode([]byte(pubPEM)) if block == nil { return nil, errors.New("failed to parse PEM block containing the key") } - pub, err := x509.ParsePKCS1PublicKey(block.Bytes) + if pub, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil { + return pub, nil + } + + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, err } - + pub, ok := parsed.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("expected an RSA public key, got %T", parsed) + } return pub, nil } From e5d483ad9f7031e39aa2d37bb5939259d11fd599 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 16:39:43 +0530 Subject: [PATCH 07/25] fix(events): emit user.signup when the account is created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every path after AddUser can return early — the email-verification branch, the phone branch, and the MFA gate. Since 2.4.0 MFA is on by DEFAULT, so that gate fires for ordinary signups and the emissions at the bottom of SignUp became unreachable. Measured against a webhook sink on a default install: signup delivered NO webhook at all. With email verification on it delivered only user.created, because verify_email likewise returns at its own MFA gate before its RegisterEvent. Integrations that provision on signup (CRM records, welcome mail, seat accounting) silently never ran. Emit user.created and user.signup immediately after the user row is written, so they mean one thing: the account now exists. user.login stays at token issuance — a user who abandons MFA setup has signed up but not logged in, and the events should say exactly that. Verified end to end: default install and email-verification install both emit created+signup at signup, and the full journey through skip_mfa_setup emits created, signup and login exactly once each. --- internal/service/signup.go | 45 ++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/internal/service/signup.go b/internal/service/signup.go index 275a0c3b9..36992c623 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -207,6 +207,34 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod log.Debug().Err(err).Msg("failed to add user") return nil, nil, err } + + // Emit user.created and user.signup HERE — the moment the account exists — + // not at token issuance further down. + // + // Every path below this point can return early: the email-verification + // branch returns "check your inbox", the phone branch returns its OTP + // prompt, and the MFA gate returns "Proceed to mfa setup". Since 2.4.0 MFA + // is on by DEFAULT, so that gate fires for ordinary signups and the + // emissions that used to sit at the bottom of this function became + // unreachable — a default install emitted NO signup webhook at all, and + // with email verification on it emitted only user.created. Integrations + // that provision on signup (CRM records, welcome mail, seat accounting) + // silently never ran. + // + // Emitting at creation also gives the events one unambiguous meaning: + // "a new account now exists". user.login stays at token issuance, because + // that is a separate fact — a user who abandons MFA setup has signed up + // but not logged in, and the events should say exactly that. + loginMethod := constants.AuthRecipeMethodBasicAuth + if isMobileSignup { + loginMethod = constants.AuthRecipeMethodMobileBasicAuth + } + asyncutil.Go(p.Log, func() { + ctx := context.WithoutCancel(ctx) + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, loginMethod, user) + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, loginMethod, user) + }) + roles := strings.Split(user.Roles, ",") userToReturn := user.AsAPIUser() hostname := meta.HostURL @@ -250,13 +278,11 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod } // exec it as go routine so that we can reduce the api latency asyncutil.Go(p.Log, func() { - ctx := context.WithoutCancel(ctx) _ = p.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeBasicAuthSignup, map[string]interface{}{ "user": user.ToMap(), "organization": utils.GetOrganization(p.Config), "verification_url": utils.GetEmailVerificationURL(verificationToken, hostname, redirectURL), }) - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) }) return &model.AuthResponse{ @@ -296,9 +322,7 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod side.AddCookie(c) } asyncutil.Go(p.Log, func() { - ctx := context.WithoutCancel(ctx) _ = p.SMSProvider.SendSMS(phoneNumber, smsBody.String()) - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) }) return &model.AuthResponse{ Message: "Please check the OTP in your inbox", @@ -446,14 +470,11 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod userAgent := meta.UserAgent asyncutil.Go(p.Log, func() { ctx := context.WithoutCancel(ctx) - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserCreatedWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - if isEmailSignup { - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) - } else { - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserSignUpWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) - _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) - } + // Only user.login here. user.created and user.signup already fired at + // account creation above — this point is reached solely when a token + // is issued straight away (no email/phone verification, no MFA offer), + // so it is the one place that adds "and they are now logged in". + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, loginMethod, user) if err := p.StorageProvider.AddSession(ctx, &schemas.Session{ UserID: user.ID, From ba04063b7892899affd82fb31b10749521e02ba3 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 17:56:43 +0530 Subject: [PATCH 08/25] fix(mfa): carry the requested scope across the MFA interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issueAuthResponse hardcoded ["openid","email","profile"], so every token issued after an MFA offer lost whatever scope the caller asked for. Login and signup accept a scope, but the token is minted later by skip_mfa_setup / verify_otp / webauthn, none of which see the original request. Delegation flows lost exactly the scopes they exist to attenuate. The authorization-code path is no better: the state tuple is code@@challenge@@nonce@@redirectURI@@resource, so scope is not there to restore either. Carry it, never re-ask for it: setMFASession stashes the scope under the MFA session id and the issuance paths consume it. Adding a scope field to SkipMfaSetupRequest would have been wrong — those endpoints are unauthenticated, so a caller could self-grant scopes never requested at login, the same shape as the is_multi_factor_auth_enabled signup fix. Stored in the cache rather than on the MFA session row: no scope column exists and adding one is a migration across six backends, for state that is transient and expires with the session anyway. Consumed on read so a captured cookie cannot replay it. Verified: signup and login with a custom scope now issue tokens carrying it through skip_mfa_setup, and a request with no scope still gets the default set unchanged. --- internal/service/auth_response.go | 10 +++- internal/service/login.go | 76 ++++++++++++++++++++++++++---- internal/service/signup.go | 2 +- internal/service/skip_mfa_setup.go | 2 +- internal/service/verify_otp.go | 2 +- internal/service/webauthn.go | 8 +++- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/internal/service/auth_response.go b/internal/service/auth_response.go index 29db22335..32a2a58d4 100644 --- a/internal/service/auth_response.go +++ b/internal/service/auth_response.go @@ -24,14 +24,20 @@ import ( // records the user session in the memory store, and fires the login/signup // webhooks. Callers remain responsible for their own audit-log entry, which is // flow-specific. -func (p *provider) issueAuthResponse(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User, loginMethod, message string, state *string, isSignUp bool) (*model.AuthResponse, error) { +func (p *provider) issueAuthResponse(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User, loginMethod, message string, state *string, isSignUp bool, scope []string) (*model.AuthResponse, error) { log := p.Log.With().Str("func", "issueAuthResponse").Logger() // TokenProvider.CreateAuthToken takes *gin.Context but doesn't read from it; // reuse the request-wrapping shim so the call works for every transport. gc := &gin.Context{Request: meta.Request} roles := strings.Split(user.Roles, ",") - scope := []string{"openid", "email", "profile"} + // Default set, used when the caller requested nothing specific. A scope + // carried across an MFA interruption (see setMFASession/consumeMFAScope) + // overrides it — otherwise completing MFA silently downgraded the token to + // these three and dropped every custom scope the caller asked for. + if len(scope) == 0 { + scope = []string{"openid", "email", "profile"} + } code := "" codeChallenge := "" nonce := "" diff --git a/internal/service/login.go b/internal/service/login.go index fc34ef626..958fe6e9b 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -6,6 +6,7 @@ import ( "time" "context" + "encoding/json" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -31,12 +32,42 @@ import ( // ops visibility. const loginGenericErrMsg = "invalid credentials" +// mfaScopeKey namespaces the requested scope carried across an MFA +// interruption, keyed by the MFA session id so it expires with it. +func mfaScopeKey(mfaSession string) string { + return "mfa_scope:" + mfaSession +} + +// consumeMFAScope returns the scope stashed by setMFASession and clears it, so +// a captured cookie cannot replay the scope after the session is spent. A +// missing or unreadable entry yields nil, and the caller falls back to the +// default scope — losing scope must never block a legitimate login. +func (p *provider) consumeMFAScope(mfaSession string) []string { + if mfaSession == "" { + return nil + } + key := mfaScopeKey(mfaSession) + blob, err := p.MemoryStoreProvider.GetCache(key) + if err != nil || blob == "" { + return nil + } + _ = p.MemoryStoreProvider.DeleteCacheByPrefix(key) + var scope []string + if err := json.Unmarshal([]byte(blob), &scope); err != nil { + return nil + } + return scope +} + // setMFASession arms a short-lived MFA session (memory-store entry + cookie) // proving the caller already completed a first authentication factor for // userID. verify_otp and the scoped webauthn_login_options/_verify flow both // require this session before they'll act. Shared by Login's TOTP branch and // WebauthnLoginVerify's EnforceMFA gate. -func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error { +// +// The optional scope is the caller's requested OAuth scope, carried across +// the interruption so the deferred token issuance can honour it. +func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64, scope ...string) error { mfaSession := uuid.NewString() // Every caller of this helper (login, webauthn-verify, oauth callback) has // already confirmed a first factor for userID, so the session is Verified — @@ -44,6 +75,35 @@ func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects if err := p.MemoryStoreProvider.SetMfaSession(userID, mfaSession, constants.MFASessionPurposeVerified, expiresAt); err != nil { return err } + // Carry the caller's requested scope across the MFA interruption. + // + // The token is issued later, by skip_mfa_setup / verify_otp / webauthn — + // none of which receive the original request. Without this the issuance + // path fell back to a hardcoded ["openid","email","profile"], silently + // dropping every other scope the caller asked for. Delegation flows lost + // exactly the scopes they exist to attenuate. + // + // Deliberately NOT re-supplied by the client at completion: those + // endpoints are unauthenticated, so accepting a scope there would let a + // caller self-grant privileges they never requested at login. It is + // carried, never re-asked. + // + // Held in the cache rather than on the MFA session row: the row has no + // scope column and adding one means a migration across all six storage + // backends, for state that is transient and already expires with the + // session. Same pattern as the pending-TOTP-secret and OTP-lockout keys. + if len(scope) > 0 { + ttl := expiresAt - time.Now().Unix() + if ttl > 0 { + if blob, err := json.Marshal(scope); err == nil { + if err := p.MemoryStoreProvider.SetCache(mfaScopeKey(mfaSession), string(blob), ttl); err != nil { + // Non-fatal: losing the scope degrades to the default set + // rather than blocking a legitimate login. + p.Log.Debug().Err(err).Msg("failed to persist mfa session scope") + } + } + } + } for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure, expiresAt) { side.AddCookie(c) } @@ -193,7 +253,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -234,7 +294,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -333,7 +393,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -371,7 +431,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -416,7 +476,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode switch gate { case mfaGateBlockVerify: expiresAt := time.Now().Add(3 * time.Minute).Unix() - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -434,7 +494,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode return res, side, nil case mfaGateBlockEnroll: expiresAt := time.Now().Add(3 * time.Minute).Unix() - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -458,7 +518,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode return res, side, nil case mfaGateOfferAll: expiresAt := time.Now().Add(3 * time.Minute).Unix() - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } diff --git a/internal/service/signup.go b/internal/service/signup.go index 36992c623..2cdbfb2bc 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -381,7 +381,7 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod switch gate { case mfaGateOfferAll, mfaGateBlockEnroll: expiresAt := time.Now().Add(3 * time.Minute).Unix() - if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt, params.Scope...); err != nil { log.Debug().Err(err).Msg("Failed to set mfa session") return nil, nil, err } diff --git a/internal/service/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go index 5afb3c33a..c4ad39ddb 100644 --- a/internal/service/skip_mfa_setup.go +++ b/internal/service/skip_mfa_setup.go @@ -105,7 +105,7 @@ func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata, param // passkey or OAuth, not password, but issueAuthResponse has no way to // recover the original login method from the MFA session today. Out of // scope for this task. - res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodBasicAuth, "MFA setup skipped", params.State, false) + res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodBasicAuth, "MFA setup skipped", params.State, false, p.consumeMFAScope(mfaSession)) if err != nil { return nil, nil, err } diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index 23920a77e..0769b235d 100644 --- a/internal/service/verify_otp.go +++ b/internal/service/verify_otp.go @@ -323,7 +323,7 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * // a captured cookie within its remaining TTL. _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) - res, err := p.issueAuthResponse(ctx, meta, side, user, loginMethod, `OTP verified successfully.`, params.State, isSignUp) + res, err := p.issueAuthResponse(ctx, meta, side, user, loginMethod, `OTP verified successfully.`, params.State, isSignUp, p.consumeMFAScope(mfaSession)) if err != nil { return nil, nil, err } diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index e66e1dbfe..9edc40d1f 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -135,10 +135,14 @@ func (p *provider) WebauthnRegistrationVerify(ctx context.Context, meta RequestM } // Single-use: drop the session so a captured cookie cannot be replayed. gc := &gin.Context{Request: meta.Request} + // Resolve the carried scope BEFORE dropping the session — consumeMFAScope + // is keyed by the same session id. + var carriedScope []string if mfaSession, sErr := cookie.GetMfaSession(gc); sErr == nil { + carriedScope = p.consumeMFAScope(mfaSession) _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) } - res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodWebauthn, "Passkey registered and MFA setup complete.", params.State, false) + res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodWebauthn, "Passkey registered and MFA setup complete.", params.State, false, carriedScope) if err != nil { return nil, nil, err } @@ -255,7 +259,7 @@ func (p *provider) WebauthnLoginVerify(ctx context.Context, meta RequestMetadata UserAgent: meta.UserAgent, }) - res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodWebauthn, "Logged in successfully with passkey.", params.State, false) + res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodWebauthn, "Logged in successfully with passkey.", params.State, false, nil) if err != nil { return nil, nil, err } From 843ea483be8916e32c6ee2eba262e43844a8226b Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 18:43:56 +0530 Subject: [PATCH 09/25] test(mfa): cover scope carry and signup event emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fixes shipped verified only by hand with curl, which protects nothing once the code moves. mfa_scope_carry_test: a custom scope requested at signup and at login survives skip_mfa_setup, and a request with no scope still gets the default set so the fix stays additive. signup_events_test: user.created and user.signup fire when the account row is written even though the MFA gate returns early, user.login does NOT fire while the token is still withheld, and the full journey through skip_mfa_setup emits each exactly once. Webhook delivery is SSRF-hardened against loopback, so these run with Env=e2e — the same switch the e2e-playground uses for its own sink. Both suites were confirmed to FAIL with their fix reverted. --- .../integration_tests/mfa_scope_carry_test.go | 155 +++++++++++++ .../integration_tests/signup_events_test.go | 212 ++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 internal/integration_tests/mfa_scope_carry_test.go create mode 100644 internal/integration_tests/signup_events_test.go diff --git a/internal/integration_tests/mfa_scope_carry_test.go b/internal/integration_tests/mfa_scope_carry_test.go new file mode 100644 index 000000000..873f683cf --- /dev/null +++ b/internal/integration_tests/mfa_scope_carry_test.go @@ -0,0 +1,155 @@ +package integration_tests + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" +) + +// tokenScope decodes a JWT payload and returns its scope claim. +func tokenScope(t *testing.T, accessToken string) []string { + t.Helper() + parts := strings.Split(accessToken, ".") + require.Len(t, parts, 3, "not a JWT") + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims struct { + Scope []string `json:"scope"` + } + require.NoError(t, json.Unmarshal(payload, &claims)) + return claims.Scope +} + +// TestMFAScopeIsCarriedAcrossTheInterruption is the regression test for scope +// loss through the MFA offer. +// +// Login and signup accept a scope, but when MFA is offered the token is +// withheld and minted LATER by skip_mfa_setup / verify_otp / webauthn — none of +// which receive the original request. issueAuthResponse therefore hardcoded +// ["openid","email","profile"], silently dropping every other scope the caller +// asked for. Delegation flows lost exactly the scopes they exist to attenuate, +// and the authorization-code state tuple +// (code@@challenge@@nonce@@redirectURI@@resource) had no scope to restore +// either. +// +// The scope must be CARRIED, never re-supplied by the client: skip_mfa_setup is +// unauthenticated, so accepting a scope there would let a caller self-grant +// privileges never requested at login. +func TestMFAScopeIsCarriedAcrossTheInterruption(t *testing.T) { + const password = "Password@123" + + // setupPendingMFAUser signs up a user whose MFA offer is pending, and + // returns the email plus the mfa session cookie value. + setupPendingMFAUser := func(t *testing.T, ts *testSetup, scope []string) (string, string) { + t.Helper() + req, ctx := createContext(ts) + email := "mfa_scope_" + uuid.NewString() + "@authorizer.dev" + + signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, Scope: scope, + }) + require.NoError(t, err) + require.Nil(t, signupRes.AccessToken, + "precondition: MFA is on, so signup must withhold the token") + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "signup must set an mfa session cookie") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + return email, mfaSession + } + + t.Run("a custom scope requested at signup survives skip_mfa_setup", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + + requested := []string{"openid", "email", "profile", "read:invoices"} + email, _ := setupPendingMFAUser(t, ts, requested) + _, ctx := createContext(ts) + // createContext resets the request; re-attach the cookie. + req := ts.GinContext.Request + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", latestMfaSessionCookie(ts))) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + + assert.Equal(t, requested, tokenScope(t, *skipRes.AccessToken), + "the scope requested at signup must survive the MFA interruption; "+ + "dropping it silently downgrades the token and breaks delegation flows") + }) + + t.Run("no requested scope still yields the default set", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + + email, _ := setupPendingMFAUser(t, ts, nil) + _, ctx := createContext(ts) + req := ts.GinContext.Request + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", latestMfaSessionCookie(ts))) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + + assert.Equal(t, []string{"openid", "email", "profile"}, tokenScope(t, *skipRes.AccessToken), + "a caller that asked for nothing must still get the default set — the fix is additive") + }) + + t.Run("a custom scope requested at login survives skip_mfa_setup", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + // Sign up and clear the first MFA offer so the user exists and can log in. + email := "mfa_scope_login_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + ts.GinContext.Request.Header.Set("Cookie", + fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", latestMfaSessionCookie(ts))) + _, err = ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + + // Force the offer again on the next login so the scope has an + // interruption to survive. + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + user.HasSkippedMFASetupAt = nil + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + requested := []string{"openid", "email", "profile", "offline_access", "read:reports"} + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{ + Email: &email, Password: password, Scope: requested, + }) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "precondition: login must withhold the token") + + ts.GinContext.Request.Header.Set("Cookie", + fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", latestMfaSessionCookie(ts))) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + + assert.Equal(t, requested, tokenScope(t, *skipRes.AccessToken), + "the scope requested at login must survive the MFA interruption") + }) +} diff --git a/internal/integration_tests/signup_events_test.go b/internal/integration_tests/signup_events_test.go new file mode 100644 index 000000000..ccc53104a --- /dev/null +++ b/internal/integration_tests/signup_events_test.go @@ -0,0 +1,212 @@ +package integration_tests + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// eventRecorder is a webhook sink that records the event names delivered to it. +type eventRecorder struct { + mu sync.Mutex + events []string + srv *httptest.Server +} + +func newEventRecorder(t *testing.T) *eventRecorder { + t.Helper() + r := &eventRecorder{} + r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var payload struct { + EventName string `json:"event_name"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.EventName != "" { + r.mu.Lock() + r.events = append(r.events, payload.EventName) + r.mu.Unlock() + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(r.srv.Close) + return r +} + +// countOf waits briefly for asynchronous delivery, then counts an event name. +// Webhooks are dispatched via asyncutil.Go, so a bare read races the handler. +func (r *eventRecorder) countOf(name string) int { + deadline := time.Now().Add(3 * time.Second) + for { + r.mu.Lock() + n := 0 + for _, e := range r.events { + if e == name { + n++ + } + } + r.mu.Unlock() + if n > 0 || time.Now().After(deadline) { + return n + } + time.Sleep(50 * time.Millisecond) + } +} + +// TestSignupEmitsEventsWhenTheAccountIsCreated is the regression test for +// signup webhooks going missing entirely. +// +// Every path after AddUser can return early: the email-verification branch, +// the phone branch, and the MFA gate. Since 2.4.0 MFA is ON BY DEFAULT, so +// that gate fires for ordinary signups and the emissions that used to sit at +// the bottom of SignUp became unreachable — a default install delivered NO +// signup webhook at all, and with email verification on it delivered only +// user.created. Integrations that provision on signup (CRM records, welcome +// mail, seat accounting) silently never ran. +// +// user.created and user.signup now fire when the account row is written, so +// they mean exactly "the account exists". user.login stays at token issuance, +// because a user who abandons MFA setup has signed up but not logged in. +func TestSignupEmitsEventsWhenTheAccountIsCreated(t *testing.T) { + const password = "Password@123" + + // registerHooks arms webhooks for the three signup-journey events. Webhook + // registration is admin-only, so the admin cookie is set on the request + // first (same pattern as add_email_template_test.go). + registerHooks := func(t *testing.T, ts *testSetup, ctx context.Context, endpoint string) { + t.Helper() + h, err := crypto.EncryptPassword(ts.Config.AdminSecret) + require.NoError(t, err) + ts.GinContext.Request.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.AdminCookieName, h)) + for _, ev := range []string{ + constants.UserCreatedWebhookEvent, + constants.UserSignUpWebhookEvent, + constants.UserLoginWebhookEvent, + } { + desc := "test hook for " + ev + _, err := ts.GraphQLProvider.AddWebhook(ctx, &model.AddWebhookRequest{ + EventName: ev, + Endpoint: endpoint, + Enabled: true, + EventDescription: &desc, + }) + require.NoError(t, err, "failed to register webhook for %s", ev) + } + // Drop the admin cookie so the signup under test runs unauthenticated. + ts.GinContext.Request.Header.Del("Cookie") + } + + t.Run("MFA on: signup still emits user.created and user.signup", func(t *testing.T) { + rec := newEventRecorder(t) + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + // Webhook DELIVERY is SSRF-hardened and refuses private/loopback + // targets unless Env is e2e — the same switch the e2e-playground uses + // for its own webhook sink. httptest binds to 127.0.0.1, so without + // this the events fire but never reach the recorder. + cfg.Env = constants.E2EEnv + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + registerHooks(t, ts, ctx, rec.srv.URL) + + email := "signup_events_" + uuid.NewString() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.Nil(t, res.AccessToken, + "precondition: MFA is on, so the token is withheld and the old emission point is unreachable") + + assert.Equal(t, 1, rec.countOf(constants.UserSignUpWebhookEvent), + "user.signup must fire when the account is created — it previously never fired at all "+ + "on a default install, because the MFA gate returns before the old emission point") + assert.Equal(t, 1, rec.countOf(constants.UserCreatedWebhookEvent), + "user.created must fire when the account is created") + }) + + t.Run("user.login is not emitted while the token is still withheld", func(t *testing.T) { + rec := newEventRecorder(t) + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + // Webhook DELIVERY is SSRF-hardened and refuses private/loopback + // targets unless Env is e2e — the same switch the e2e-playground uses + // for its own webhook sink. httptest binds to 127.0.0.1, so without + // this the events fire but never reach the recorder. + cfg.Env = constants.E2EEnv + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + registerHooks(t, ts, ctx, rec.srv.URL) + + email := "signup_nologin_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + // Wait for signup's own events so the sink has settled before asserting + // the absence of a login event. + require.Equal(t, 1, rec.countOf(constants.UserSignUpWebhookEvent)) + + rec.mu.Lock() + loginCount := 0 + for _, e := range rec.events { + if e == constants.UserLoginWebhookEvent { + loginCount++ + } + } + rec.mu.Unlock() + assert.Zero(t, loginCount, + "a user mid-MFA-setup has signed up but not logged in; user.login belongs at token issuance") + }) + + t.Run("the full journey emits each event exactly once", func(t *testing.T) { + rec := newEventRecorder(t) + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + // Webhook DELIVERY is SSRF-hardened and refuses private/loopback + // targets unless Env is e2e — the same switch the e2e-playground uses + // for its own webhook sink. httptest binds to 127.0.0.1, so without + // this the events fire but never reach the recorder. + cfg.Env = constants.E2EEnv + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + registerHooks(t, ts, ctx, rec.srv.URL) + + email := "signup_journey_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + ts.GinContext.Request.Header.Set("Cookie", + fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", latestMfaSessionCookie(ts))) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + + assert.Equal(t, 1, rec.countOf(constants.UserCreatedWebhookEvent), "user.created exactly once") + assert.Equal(t, 1, rec.countOf(constants.UserSignUpWebhookEvent), + "user.signup exactly once — moving the emission must not double-fire it") + assert.Equal(t, 1, rec.countOf(constants.UserLoginWebhookEvent), + "user.login exactly once, at token issuance") + }) +} From ec4feb256ced2f1e9f335e9a6edcf95a8d16db82 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 12:22:49 +0530 Subject: [PATCH 10/25] feat(agent): agent identity, delegated API access and FGA intersection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegated tokens resolved to user: in authorization and vanished from the audit trail, so an agent had exactly its user's permissions and its actions were recorded as the human's. RFC 8693 §1.1 defines delegation as "A representing B" with A keeping its own identity; collapsing A into B is the definition of impersonation, which the token endpoint explicitly refuses. The authorization layer contradicted the token layer. - engine: refresh the cached model id periodically. Check pins an explicit AuthorizationModelId and WriteModel only updated the serving replica, so a fleet could evaluate the same request against different models indefinitely. A pinned Config.ModelID is never refreshed. - engine: add TypeNames. TypeRelations omits relation-less types, and the canonical `type agent` has none (an agent is only ever a subject), so detection built on it would silently never activate. - token: ValidateDelegatedAccessToken, a separate named path for the stateless delegated token. Skips only the session lookup, adds a strict audience match so a resource-bound token cannot authenticate here. Wired as a fallback in GetUserIDFromSessionOrAccessToken only — the OIDC /userinfo handler is deliberately untouched. - authctx: Principal carries the IMMEDIATE actor. Prior actors nested in the act chain stay informational and never influence a decision. - fga: a delegated caller is checked as agent: AND user:; effective authority is the intersection. Enabled by the model declaring `type agent` — there is no flag, because checking an unmodelled type ERRORS rather than returning false and would deny every check. - metrics: authorizer_fga_delegated_checks_total attributes a denial to the agent or the user, which is the difference between "grant the agent a tuple" and "the user genuinely lacks access". - audit: AuditActorTypeAgent. Unaffected: OIDC /userinfo, SAML, SCIM, OAuth and client_credentials all keep the stateful validator and single-subject checks. --- internal/authctx/principal.go | 17 ++ internal/authorization/engine/engine.go | 4 + .../engine/openfga/agent_detect_test.go | 172 ++++++++++++++++++ .../authorization/engine/openfga/openfga.go | 68 +++++++ .../engine/openfga/operations.go | 30 +++ internal/constants/audit_event.go | 18 ++ internal/grpcsrv/interceptors/auth.go | 2 + .../delegated_token_api_test.go | 113 ++++++++++++ internal/metrics/metrics.go | 43 +++++ internal/service/check_permissions.go | 94 ++++++++-- internal/service/fga_agent.go | 147 +++++++++++++++ internal/service/provider.go | 5 + internal/token/auth_token.go | 44 ++++- internal/token/delegated_access_token.go | 111 +++++++++++ internal/token/provider.go | 5 + 15 files changed, 852 insertions(+), 21 deletions(-) create mode 100644 internal/authorization/engine/openfga/agent_detect_test.go create mode 100644 internal/integration_tests/delegated_token_api_test.go create mode 100644 internal/service/fga_agent.go create mode 100644 internal/token/delegated_access_token.go diff --git a/internal/authctx/principal.go b/internal/authctx/principal.go index d92232bc0..6dd9a2e38 100644 --- a/internal/authctx/principal.go +++ b/internal/authctx/principal.go @@ -1,6 +1,8 @@ // Package authctx carries authentication principal details on context.Context. package authctx +import "strings" + import "context" type principalContextKey struct{} @@ -11,6 +13,21 @@ type Principal struct { LoginMethod string Nonce string IsSuperAdmin bool + // ActorID is the immediate actor of an RFC 8693 delegated token — the + // agent's client_id from `act.sub`. Empty for first-party callers. + // + // UserID stays the delegating user: the request IS being made for them. + // ActorID records WHO is making it, which is the distinction RFC 8693 §1.1 + // draws between delegation ("A representing B", A keeps its own identity) + // and impersonation ("A is indistinguishable from B"). Without it a + // delegated action is attributed to the human, which is both an audit lie + // and the Confused Deputy precondition. + ActorID string +} + +// IsDelegated reports whether this principal is an agent acting for a user. +func (p *Principal) IsDelegated() bool { + return p != nil && strings.TrimSpace(p.ActorID) != "" } // WithPrincipal stores p in ctx and returns the derived context. diff --git a/internal/authorization/engine/engine.go b/internal/authorization/engine/engine.go index 353ef836a..f5920156a 100644 --- a/internal/authorization/engine/engine.go +++ b/internal/authorization/engine/engine.go @@ -169,6 +169,10 @@ type AuthorizationEngine interface { // access" enumeration. Returns ErrNoModel (wrapped) when no model has been // written yet. TypeRelations(ctx context.Context) (map[string][]string, error) + // TypeNames returns every object type declared in the active model, + // sorted. Unlike TypeRelations it INCLUDES types with no relations, which + // is required to detect a subject-only type such as `agent`. + TypeNames(ctx context.Context) ([]string, error) // Reset deletes the entire authorization store (the model, all its versions, // and all tuples) and starts a fresh, empty store. It is destructive and must diff --git a/internal/authorization/engine/openfga/agent_detect_test.go b/internal/authorization/engine/openfga/agent_detect_test.go new file mode 100644 index 000000000..3c86d91a8 --- /dev/null +++ b/internal/authorization/engine/openfga/agent_detect_test.go @@ -0,0 +1,172 @@ +package openfga + +import ( + "context" + "testing" + "time" + + "github.com/rs/zerolog" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTypeNamesSeesSubjectOnlyTypes pins the property TypeRelations cannot +// provide: a type declaring no relations must still be reported. +// +// The canonical agent model declares `type agent` with no relations, because an +// agent is only ever a SUBJECT, never the object of a permission. TypeRelations +// omits such types, so agent-subject detection built on it would silently never +// activate — the feature would look wired up and do nothing. +func TestTypeNamesSeesSubjectOnlyTypes(t *testing.T) { + ctx := context.Background() + eng, _ := newTestEngine(t) + + _, err := eng.WriteModel(ctx, ` +model + schema 1.1 + +type user + +type agent + +type document + relations + define viewer: [user, agent] + define can_view: viewer +`) + require.NoError(t, err) + + names, err := eng.TypeNames(ctx) + require.NoError(t, err) + assert.Contains(t, names, "agent", "a relation-less subject type must be visible") + assert.Contains(t, names, "user") + assert.Contains(t, names, "document") + + // Demonstrates why TypeNames had to be added at all. + rels, err := eng.TypeRelations(ctx) + require.NoError(t, err) + _, present := rels["agent"] + assert.False(t, present, "TypeRelations omits relation-less types — this is the gap TypeNames fills") +} + +// TestTypeNamesWithoutModel pins the no-model contract. +func TestTypeNamesWithoutModel(t *testing.T) { + eng, _ := newTestEngine(t) + _, err := eng.TypeNames(context.Background()) + require.Error(t, err, "no model must be an error, not an empty list that reads as 'no agent type'") +} + +// TestModelRefreshAcrossReplicas is the regression test for divergent +// authorization in a multi-replica fleet. +// +// Check pins an explicit AuthorizationModelId and WriteModel only updates the +// modelID of the replica that served it, so a model written on replica A was +// never picked up by replica B until B restarted. Two replicas could evaluate +// the same request against different models indefinitely — and any behaviour +// derived from the model (agent-subject detection) would diverge with it. +// +// Both "replicas" here share one datastore, which is exactly the production +// shape. +func TestModelRefreshAcrossReplicas(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + url := "file:" + dir + "/fga.db?_pragma=busy_timeout(5000)" + + newReplica := func() *engineImpl { + log := zerologNop() + eng, err := New(&Config{ + Store: StoreSQLite, StoreURL: url, StoreName: "replica-test", RunMigrations: true, + }, &Dependencies{Log: &log}) + require.NoError(t, err) + impl, ok := eng.(*engineImpl) + require.True(t, ok) + t.Cleanup(impl.Close) + return impl + } + + replicaA := newReplica() + replicaB := newReplica() + + // A writes the first model; B adopts it at boot. + _, err := replicaA.WriteModel(ctx, ` +model + schema 1.1 +type user +type document + relations + define viewer: [user] + define can_view: viewer +`) + require.NoError(t, err) + + // B has not seen a model written after its own boot yet. Force its next + // ids() call to reconcile rather than waiting out the interval. + replicaB.mu.Lock() + replicaB.modelCheckedAt = time.Time{} + replicaB.mu.Unlock() + + // A writes a SECOND model introducing the agent type. + newID, err := replicaA.WriteModel(ctx, ` +model + schema 1.1 +type user +type agent +type document + relations + define viewer: [user, agent] + define can_view: viewer +`) + require.NoError(t, err) + + replicaB.mu.Lock() + replicaB.modelCheckedAt = time.Time{} + replicaB.mu.Unlock() + + names, err := replicaB.TypeNames(ctx) + require.NoError(t, err) + assert.Contains(t, names, "agent", + "replica B must adopt a model written by replica A without a restart — "+ + "otherwise agent detection is on in one replica and off in another") + + _, bModel := replicaB.ids() + assert.Equal(t, newID, bModel, "replica B must converge on the newest model id") +} + +// TestPinnedModelIsNeverRefreshed pins the operator-override contract: an +// explicit Config.ModelID means "use exactly this version", so the refresh must +// not silently move off it. +func TestPinnedModelIsNeverRefreshed(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + url := "file:" + dir + "/fga.db?_pragma=busy_timeout(5000)" + + log := zerologNop() + seed, err := New(&Config{Store: StoreSQLite, StoreURL: url, StoreName: "pin-test", RunMigrations: true}, &Dependencies{Log: &log}) + require.NoError(t, err) + firstID, err := seed.WriteModel(ctx, "model\n schema 1.1\ntype user\n") + require.NoError(t, err) + seed.(*engineImpl).Close() + + pinnedEng, err := New(&Config{ + Store: StoreSQLite, StoreURL: url, StoreName: "pin-test", ModelID: firstID, + }, &Dependencies{Log: &log}) + require.NoError(t, err) + pinned := pinnedEng.(*engineImpl) + t.Cleanup(pinned.Close) + + writer, err := New(&Config{Store: StoreSQLite, StoreURL: url, StoreName: "pin-test"}, &Dependencies{Log: &log}) + require.NoError(t, err) + defer writer.(*engineImpl).Close() + _, err = writer.WriteModel(ctx, "model\n schema 1.1\ntype user\ntype agent\n") + require.NoError(t, err) + + pinned.mu.Lock() + pinned.modelCheckedAt = time.Time{} + pinned.mu.Unlock() + + _, got := pinned.ids() + assert.Equal(t, firstID, got, "a pinned model must never be refreshed away from") +} + +func zerologNop() zerolog.Logger { return zerolog.Nop() } diff --git a/internal/authorization/engine/openfga/openfga.go b/internal/authorization/engine/openfga/openfga.go index c36945e47..e3a41ae60 100644 --- a/internal/authorization/engine/openfga/openfga.go +++ b/internal/authorization/engine/openfga/openfga.go @@ -11,7 +11,9 @@ package openfga import ( "context" "fmt" + "strings" "sync" + "time" openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/openfga/openfga/pkg/server" @@ -85,8 +87,31 @@ type engineImpl struct { storeID string modelID string storeName string + + // pinnedModel is true when the operator supplied Config.ModelID. A pinned + // model is never refreshed — the operator has chosen an exact version and + // silently moving off it would defeat the point. + pinnedModel bool + // modelCheckedAt is when the cached modelID was last reconciled with the + // datastore. Zero means never. + modelCheckedAt time.Time } +// modelRefreshInterval bounds how stale a replica's view of the active model +// can be. +// +// Checks pin an explicit AuthorizationModelId, and WriteModel only updates the +// modelID of the replica that served it. Without this, a model written on +// replica A was never picked up by replica B until B restarted — so a fleet +// could evaluate the SAME request against DIFFERENT models indefinitely. That +// is worse than a stale model: authorization becomes non-deterministic across +// replicas, and any behaviour derived from the model (see TypeNames and the +// agent-subject detection built on it) diverges with it. +// +// 30s trades a bounded window of staleness for one cheap ReadAuthorizationModels +// per replica per interval, off the hot path of every Check. +const modelRefreshInterval = 30 * time.Second + // Compile-time interface verification. var _ engine.AuthorizationEngine = &engineImpl{} @@ -137,6 +162,8 @@ func New(cfg *Config, deps *Dependencies) (engine.AuthorizationEngine, error) { storeID: cfg.StoreID, modelID: cfg.ModelID, storeName: cfg.StoreName, + // An operator-pinned model is authoritative: never refresh off it. + pinnedModel: strings.TrimSpace(cfg.ModelID) != "", } // Bind to a store. An explicit cfg.StoreID wins; otherwise reuse the @@ -313,11 +340,52 @@ func (e *engineImpl) Close() { // ids returns the current store and model IDs under the read lock. func (e *engineImpl) ids() (storeID, modelID string) { + e.refreshModelIfStale() e.mu.RLock() defer e.mu.RUnlock() return e.storeID, e.modelID } +// refreshModelIfStale reconciles this replica's cached modelID with the +// datastore at most once per modelRefreshInterval, so a model written by +// ANOTHER replica is picked up without a restart (see modelRefreshInterval). +// +// Deliberately does not run when the operator pinned Config.ModelID, and never +// fails a caller: a datastore hiccup leaves the last known model in place and +// the Check proceeds against it rather than erroring. Losing the refresh is a +// staleness problem; failing the Check would be an outage. +func (e *engineImpl) refreshModelIfStale() { + e.mu.RLock() + pinned, storeID, checkedAt := e.pinnedModel, e.storeID, e.modelCheckedAt + e.mu.RUnlock() + if pinned || storeID == "" { + return + } + if !checkedAt.IsZero() && time.Since(checkedAt) < modelRefreshInterval { + return + } + + latest, err := latestModelID(e.srv, storeID) + // Stamp the attempt either way so a persistently failing datastore cannot + // turn every Check into a ReadAuthorizationModels call. + e.mu.Lock() + e.modelCheckedAt = time.Now() + prev := e.modelID + if err == nil && latest != "" && latest != prev { + e.modelID = latest + } + e.mu.Unlock() + + if err != nil { + e.log.Debug().Err(err).Msg("model refresh failed; continuing with the last known model") + return + } + if latest != "" && latest != prev { + e.log.Info().Str("previous_model_id", prev).Str("model_id", latest). + Msg("adopted a newer authorization model written by another replica") + } +} + // toProtoContextual converts engine contextual tuples to the OpenFGA wire type. func toProtoContextual(ctxTuples []engine.ContextualTuple) *openfgav1.ContextualTupleKeys { if len(ctxTuples) == 0 { diff --git a/internal/authorization/engine/openfga/operations.go b/internal/authorization/engine/openfga/operations.go index c0ebc142b..d889fc271 100644 --- a/internal/authorization/engine/openfga/operations.go +++ b/internal/authorization/engine/openfga/operations.go @@ -373,3 +373,33 @@ func (e *engineImpl) ReadModel(ctx context.Context) (string, string, error) { } return modelID, *dsl, nil } + +// TypeNames returns every object type declared in the active model, sorted. +// +// Distinct from TypeRelations, which omits types that declare no relations. +// That omission makes it unusable for detecting a type that is only ever a +// SUBJECT — the canonical `type agent` has no relations of its own, so +// TypeRelations never reports it and any feature keyed on that would silently +// never activate. +func (e *engineImpl) TypeNames(ctx context.Context) ([]string, error) { + storeID, modelID := e.ids() + if modelID == "" { + return nil, fmt.Errorf("openfga.TypeNames: %w", engine.ErrNoModel) + } + res, err := e.srv.ReadAuthorizationModel(ctx, &openfgav1.ReadAuthorizationModelRequest{ + StoreId: storeID, + Id: modelID, + }) + if err != nil { + return nil, fmt.Errorf("openfga.TypeNames: %w", err) + } + defs := res.GetAuthorizationModel().GetTypeDefinitions() + out := make([]string, 0, len(defs)) + for _, td := range defs { + if n := td.GetType(); n != "" { + out = append(out, n) + } + } + sort.Strings(out) + return out, nil +} diff --git a/internal/constants/audit_event.go b/internal/constants/audit_event.go index 39a55be62..b49683ba1 100644 --- a/internal/constants/audit_event.go +++ b/internal/constants/audit_event.go @@ -6,6 +6,24 @@ const ( AuditActorTypeUser = "user" // AuditActorTypeAdmin identifies an admin as the audit actor. AuditActorTypeAdmin = "admin" + // AuditActorTypeAgent identifies an AI agent acting ON BEHALF OF a user via + // an RFC 8693 delegated token. + // + // Distinct from AuditActorTypeServiceAccount, which is an autonomous + // machine acting as ITSELF with no user in the picture. The distinction is + // the whole point of delegation: RFC 8693 §1.1 defines it as "A + // representing B" where A keeps its own identity, as opposed to + // impersonation where A "is indistinguishable from B". + // + // Before this existed, a delegated action was recorded as though the human + // had performed it — the agent vanished from the audit trail entirely. An + // agent doing something damaging looked exactly like the user doing it, + // with no signal anything unusual had happened. + // + // ActorID carries the AGENT's client_id; the delegating user is recorded in + // the event Metadata under "delegated_user_id" so both halves of "who did + // this, and for whom" survive. + AuditActorTypeAgent = "agent" // AuditActorTypeServiceAccount identifies a machine/workload service account // as the audit actor (client_credentials grant, RFC 6749 §4.4). AuditActorTypeServiceAccount = "service_account" diff --git a/internal/grpcsrv/interceptors/auth.go b/internal/grpcsrv/interceptors/auth.go index 5fd115f80..341d01e31 100644 --- a/internal/grpcsrv/interceptors/auth.go +++ b/internal/grpcsrv/interceptors/auth.go @@ -127,6 +127,7 @@ func Auth(tp token.Provider, log *zerolog.Logger) grpc.UnaryServerInterceptor { UserID: tokenData.UserID, LoginMethod: tokenData.LoginMethod, Nonce: tokenData.Nonce, + ActorID: tokenData.ActorID, }) return handler(ctx, req) } @@ -159,6 +160,7 @@ func Auth(tp token.Provider, log *zerolog.Logger) grpc.UnaryServerInterceptor { UserID: tokenData.UserID, LoginMethod: tokenData.LoginMethod, Nonce: tokenData.Nonce, + ActorID: tokenData.ActorID, }) return handler(ctx, req) } diff --git a/internal/integration_tests/delegated_token_api_test.go b/internal/integration_tests/delegated_token_api_test.go new file mode 100644 index 000000000..36426f454 --- /dev/null +++ b/internal/integration_tests/delegated_token_api_test.go @@ -0,0 +1,113 @@ +package integration_tests + +import ( + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/token" +) + +// mintDelegated builds an RFC 8693 delegated access token with the given +// audience, mirroring what /oauth/token issues for the delegation grant. +func mintDelegated(t *testing.T, ts *testSetup, subject, agentID, aud string) string { + t.Helper() + tok, err := ts.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ + Subject: subject, + Actor: map[string]interface{}{"sub": agentID}, + Audience: aud, + Scope: []string{"openid"}, + ClientID: agentID, + HostName: testAuthorizerHost(ts), + }) + require.NoError(t, err) + require.NotNil(t, tok) + return tok.Token +} + +// TestDelegatedTokenAtAuthorizerAPI pins the security envelope of the delegated +// validation path added so an agent can ask Authorizer about its own authority. +// +// The path is weaker than first-party validation by exactly one property — it +// skips the session lookup, because delegated tokens are stateless by design — +// and stricter by one: the audience must be this server. These tests pin both +// halves, because a mistake in either direction is a real vulnerability: +// too strict and the agent feature is dead code, too loose and a token minted +// for someone else's resource server authenticates here. +func TestDelegatedTokenAtAuthorizerAPI(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + _ = ctx + + mkUser := func() *schemas.User { + now := time.Now().Unix() + u, uErr := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef("agent_api_" + uuid.NewString() + "@authorizer.dev"), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + Roles: "user", + }) + require.NoError(t, uErr) + return u + } + user := mkUser() + + gc := &gin.Context{Request: ts.GinContext.Request} + agentID := "agent-" + uuid.NewString() + + t.Run("a token bound to THIS server is accepted", func(t *testing.T) { + tok := mintDelegated(t, ts, user.ID, agentID, cfg.ClientID) + claims, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.NoError(t, err, "an agent must be able to reach Authorizer with a correctly-audienced delegated token") + assert.Equal(t, user.ID, claims["sub"]) + assert.Equal(t, agentID, token.ImmediateActor(claims), + "the immediate actor must survive validation — it is what distinguishes agent from user") + }) + + t.Run("a RESOURCE-BOUND token is rejected", func(t *testing.T) { + // This is the audience-confusion test. A token minted for a downstream + // resource server must never authenticate at Authorizer's own API, or + // the RFC 8707 resource binding is decorative. + tok := mintDelegated(t, ts, user.ID, agentID, "https://mcp.example.com") + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "a token bound to another resource server must not authenticate here") + assert.Contains(t, err.Error(), "audience") + }) + + t.Run("a NON-delegated token is rejected on this path", func(t *testing.T) { + // The weaker path must only ever accept tokens that actually carry an + // act chain; it must not become a way to bypass session validation for + // ordinary access tokens. + authToken, err := ts.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ + User: user, + Roles: []string{"user"}, + Scope: []string{"openid"}, + LoginMethod: "basic_auth", + HostName: testAuthorizerHost(ts), + }) + require.NoError(t, err) + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, authToken.AccessToken.Token) + require.Error(t, err, "a first-party token must not be accepted by the delegated path") + assert.Contains(t, err.Error(), "not a delegated token") + }) + + t.Run("a revoked user's agent is rejected", func(t *testing.T) { + revoked := mkUser() + now := time.Now().Unix() + revoked.RevokedTimestamp = &now + _, err := ts.StorageProvider.UpdateUser(ctx, revoked) + require.NoError(t, err) + + tok := mintDelegated(t, ts, revoked.ID, agentID, cfg.ClientID) + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "revoking the user must stop their agents — revocation is a DB lookup, not session-based") + }) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 528cfcb04..6f565bd58 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -173,6 +173,27 @@ var ( []string{"operation", "result"}, ) + // FgaDelegatedChecksTotal counts access decisions made for an RFC 8693 + // DELEGATED caller — an agent acting on behalf of a user — and, when + // denied, which side of the intersection refused. + // + // A separate series rather than another label on FgaChecksTotal: adding a + // dimension there would fan out every existing series and silently change + // the meaning of dashboards and alerts already aggregating that family. + // + // The outcome label is what makes an intersection denial diagnosable. + // Without it, an agent losing access looks identical to any other denial, + // and the operator cannot tell whether to grant the AGENT a tuple or fix + // the USER's access — the single most likely support question once agent + // subjects are in play. + FgaDelegatedChecksTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "authorizer_fga_delegated_checks_total", + Help: "Fine-grained authorization decisions for delegated (agent-acting-for-user) callers. operation=check_permissions|list_permissions, outcome=allowed|denied_by_agent|denied_by_user", + }, + []string{"operation", "outcome"}, + ) + // FgaCheckDuration tracks the latency of FGA access-decision calls (the // OpenFGA engine Check/ListObjects call), in seconds, across every // decision surface — see FgaChecksTotal's doc comment for the QPS caveat @@ -296,6 +317,7 @@ func Init() { prometheus.MustRegister(DBHealthCheckTotal) prometheus.MustRegister(ClientIDHeaderMissingTotal) prometheus.MustRegister(FgaChecksTotal) + prometheus.MustRegister(FgaDelegatedChecksTotal) prometheus.MustRegister(FgaCheckDuration) prometheus.MustRegister(FgaOperationsTotal) prometheus.MustRegister(PanicsRecoveredTotal) @@ -399,6 +421,27 @@ const ( FgaResultSuccess = "success" ) +// Outcomes for FgaDelegatedChecksTotal. +const ( + // FgaDelegatedAllowed means both the agent and the delegating user were + // permitted — the intersection held. + FgaDelegatedAllowed = "allowed" + // FgaDelegatedDeniedByAgent means the agent lacks its own grant. The user + // may well have access; the agent was not given it. Fix: grant the AGENT. + FgaDelegatedDeniedByAgent = "denied_by_agent" + // FgaDelegatedDeniedByUser means the agent had its grant but the + // delegating user does not have access. This is the Confused Deputy case + // the intersection exists to stop. Fix: do NOT widen the agent — the user + // genuinely lacks access. + FgaDelegatedDeniedByUser = "denied_by_user" +) + +// RecordFgaDelegatedCheck records one delegated access decision and, on a +// denial, which side of the intersection refused. +func RecordFgaDelegatedCheck(operation, outcome string) { + FgaDelegatedChecksTotal.WithLabelValues(operation, outcome).Inc() +} + // RecordFgaCheck records a single FGA access decision. // operation must be FgaOpCheckPermissions; result must be one of // FgaResultAllowed / FgaResultDenied / FgaResultError. diff --git a/internal/service/check_permissions.go b/internal/service/check_permissions.go index 55801e7d5..7677af650 100644 --- a/internal/service/check_permissions.go +++ b/internal/service/check_permissions.go @@ -38,21 +38,40 @@ func (p *provider) CheckPermissions(ctx context.Context, meta RequestMetadata, p log.Debug().Err(err).Msg("Failed to resolve subject") return nil, nil, err } - requests := make([]engine.CheckRequest, 0, len(params.Checks)) - for _, c := range params.Checks { - if c == nil || strings.TrimSpace(c.Relation) == "" || strings.TrimSpace(c.Object) == "" { - return nil, nil, InvalidArgument("each check requires relation and object") + // For an ordinary caller this is exactly [subject] and everything below is + // unchanged. For an RFC 8693 delegated token it is + // [agent:, user:], and EVERY subject must be allowed — + // effective authority is perms(agent) ∩ perms(user). See delegationSubjects. + // + // An explicitly supplied `user` (super-admin only) is never intersected: + // the caller is asking about that subject specifically, not acting as it. + subjects := []string{subject} + if strings.TrimSpace(refs.StringValue(params.User)) == "" { + if resolved := p.delegationSubjects(ctx, subject); len(resolved) > 0 { + subjects = resolved } - ctxTuples, err := toContextualTuples(c.ContextualTuples) - if err != nil { - return nil, nil, err + } + + // Requests are laid out subject-major: all checks for subject[0], then all + // for subject[1]. Result i for check j therefore lives at + // index i*len(checks)+j, which is how the intersection is folded below. + requests := make([]engine.CheckRequest, 0, len(params.Checks)*len(subjects)) + for _, s := range subjects { + for _, c := range params.Checks { + if c == nil || strings.TrimSpace(c.Relation) == "" || strings.TrimSpace(c.Object) == "" { + return nil, nil, InvalidArgument("each check requires relation and object") + } + ctxTuples, err := toContextualTuples(c.ContextualTuples) + if err != nil { + return nil, nil, err + } + requests = append(requests, engine.CheckRequest{ + User: s, + Relation: c.Relation, + Object: c.Object, + ContextualTuples: ctxTuples, + }) } - requests = append(requests, engine.CheckRequest{ - User: subject, - Relation: c.Relation, - Object: c.Object, - ContextualTuples: ctxTuples, - }) } start := time.Now() results, err := p.AuthzEngine.BatchCheck(ctx, requests) @@ -63,14 +82,51 @@ func (p *provider) CheckPermissions(ctx context.Context, meta RequestMetadata, p log.Debug().Err(err).Msg("CheckPermissions failed; denying") return nil, nil, PermissionDenied("authorization check failed") } - out := &model.CheckPermissionsResponse{Results: make([]*model.PermissionCheckResult, 0, len(results))} - for i, r := range results { + if len(results) != len(requests) { + // Fail closed rather than mis-index the fold below. + metrics.RecordFgaCheck(metrics.FgaOpCheckPermissions, metrics.FgaResultError) + log.Debug().Int("want", len(requests)).Int("got", len(results)). + Msg("CheckPermissions: engine returned an unexpected result count; denying") + return nil, nil, PermissionDenied("authorization check failed") + } + + // Fold the subject-major results into one decision per check by AND-ing + // across subjects. With a single subject this is the identity operation and + // the outcome is bit-for-bit what it was before delegation existed. + n := len(params.Checks) + // subjects[0] is the agent only when delegationSubjects expanded the list; + // with one subject there is nothing to attribute a denial to. + delegated := len(subjects) > 1 + out := &model.CheckPermissionsResponse{Results: make([]*model.PermissionCheckResult, 0, n)} + for j := 0; j < n; j++ { + allowed := true + deniedBy := -1 + for i := range subjects { + if !results[i*n+j].Allowed { + allowed = false + deniedBy = i + break + } + } + if delegated { + // Attribute the denial so an operator can tell "grant the agent a + // tuple" from "the user genuinely lacks access" — see + // metrics.FgaDelegatedDeniedByAgent. + switch { + case allowed: + metrics.RecordFgaDelegatedCheck(metrics.FgaOpCheckPermissions, metrics.FgaDelegatedAllowed) + case deniedBy == 0: + metrics.RecordFgaDelegatedCheck(metrics.FgaOpCheckPermissions, metrics.FgaDelegatedDeniedByAgent) + default: + metrics.RecordFgaDelegatedCheck(metrics.FgaOpCheckPermissions, metrics.FgaDelegatedDeniedByUser) + } + } // Record each decision so adoption/denial rates reflect every pair. - metrics.RecordFgaCheckResult(metrics.FgaOpCheckPermissions, r.Allowed) + metrics.RecordFgaCheckResult(metrics.FgaOpCheckPermissions, allowed) out.Results = append(out.Results, &model.PermissionCheckResult{ - Relation: params.Checks[i].Relation, - Object: params.Checks[i].Object, - Allowed: r.Allowed, + Relation: params.Checks[j].Relation, + Object: params.Checks[j].Object, + Allowed: allowed, }) } return out, nil, nil diff --git a/internal/service/fga_agent.go b/internal/service/fga_agent.go new file mode 100644 index 000000000..9a729311a --- /dev/null +++ b/internal/service/fga_agent.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/authorizerdev/authorizer/internal/authctx" +) + +// FgaAgentSubjectType is the OpenFGA object type an agent is represented as +// when the operator's model declares it. +// +// Chosen to match the shape published by OpenFGA and Auth0 FGA for AI agents, +// where an agent is a first-class principal appearing wherever `user` does: +// +// type agent +// +// type document +// relations +// define viewer: [user, agent] +const FgaAgentSubjectType = "agent" + +// agentModelTTL bounds how long the "does the active model declare an agent +// type" answer is cached when it could not be tied to a model id. The normal +// path keys the cache on the model id itself and needs no expiry. +const agentModelTTL = 30 * time.Second + +// agentSubjectsState caches whether the active authorization model declares the +// agent type. +// +// Detection is per-model, not per-request: TypeNames reads the model from the +// datastore, which is far too expensive to do on every permission check. The +// cache is keyed on the model id, so a model write (which mints a new id, and +// which replicas converge on via the engine's refresh) invalidates it for free. +type agentSubjectsState struct { + mu sync.RWMutex + modelID string + enabled bool + checkedAt time.Time +} + +// agentSubjectsEnabled reports whether delegated tokens should be authorized as +// `agent:` intersected with `user:`, rather than as the user +// alone. +// +// THIS IS THE OPT-IN. There is no flag: declaring `type agent` in the +// authorization model IS the operator's opt-in, because the feature is +// meaningless without a model that can express agent grants. A deployment whose +// model has no agent type keeps today's behaviour byte-for-byte. +// +// That choice is forced by how OpenFGA fails. Checking `agent:x` against a +// model with no agent type does not return false — it ERRORS with +// "invalid relation: type 'agent' not found", and CheckPermissions fails closed +// on engine errors. Enabling this against an unprepared model would therefore +// deny EVERY permission check, a total authorization outage rather than a +// graceful degradation. Auto-detection makes that state unreachable. +// +// Fails safe in both directions: any error resolving the model leaves agent +// subjects OFF, which is the current, working behaviour. +func (p *provider) agentSubjectsEnabled(ctx context.Context) bool { + if p.AuthzEngine == nil { + return false + } + + modelID, _, err := p.AuthzEngine.ReadModel(ctx) + if err != nil { + // No model, or the store is unreachable. Either way: behave as today. + return false + } + + p.agentSubjects.mu.RLock() + cachedID, cachedEnabled, checkedAt := p.agentSubjects.modelID, p.agentSubjects.enabled, p.agentSubjects.checkedAt + p.agentSubjects.mu.RUnlock() + + if modelID != "" && cachedID == modelID { + return cachedEnabled + } + if modelID == "" && !checkedAt.IsZero() && time.Since(checkedAt) < agentModelTTL { + return cachedEnabled + } + + names, err := p.AuthzEngine.TypeNames(ctx) + if err != nil { + return false + } + enabled := false + for _, n := range names { + if n == FgaAgentSubjectType { + enabled = true + break + } + } + + p.agentSubjects.mu.Lock() + p.agentSubjects.modelID = modelID + p.agentSubjects.enabled = enabled + p.agentSubjects.checkedAt = time.Now() + p.agentSubjects.mu.Unlock() + + return enabled +} + +// delegationSubjects returns the subjects a permission check must satisfy for +// the calling principal. +// +// For an ordinary caller this is a single subject and behaviour is unchanged. +// For an RFC 8693 delegated token — an agent acting for a user — it is BOTH +// `agent:` and `user:`, and every returned subject must be +// allowed for the action to be permitted. +// +// That intersection is the fix for the Confused Deputy problem: an agent +// holding a broad grant must not be able to act on a resource its delegating +// user cannot reach, and equally must not inherit the user's full reach just +// because it holds their token. Effective authority is +// perms(agent) ∩ perms(user), evaluated per action at request time. +// +// Only the IMMEDIATE actor participates. Prior actors nested deeper in the +// `act` chain are informational and must not influence the decision — they were +// asserted upstream, not verified here. +// +// Returns nil when there is no authenticated caller, which callers treat as +// unauthenticated rather than as "allow". +func (p *provider) delegationSubjects(ctx context.Context, ownSubject string) []string { + ownSubject = strings.TrimSpace(ownSubject) + if ownSubject == "" { + return nil + } + + principal, ok := authctx.FromContext(ctx) + if !ok || !principal.IsDelegated() { + return []string{ownSubject} + } + if !p.agentSubjectsEnabled(ctx) { + // Model cannot express agent grants; preserve existing behaviour. + return []string{ownSubject} + } + + agentSubject := FgaAgentSubjectType + ":" + strings.TrimSpace(principal.ActorID) + if agentSubject == FgaAgentSubjectType+":" { + return []string{ownSubject} + } + // Agent first: it is the cheaper, more selective denial, and a denied agent + // short-circuits before the user check runs. + return []string{agentSubject, ownSubject} +} diff --git a/internal/service/provider.go b/internal/service/provider.go index ad952bc68..49d7486c0 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -219,6 +219,11 @@ func New(cfg *config.Config, deps *Dependencies) (Provider, error) { type provider struct { *config.Config Dependencies + + // agentSubjects caches whether the active authorization model declares the + // `agent` type, which is what enables agent-subject intersection for RFC + // 8693 delegated tokens. Keyed on model id — see fga_agent.go. + agentSubjects agentSubjectsState } // Compile-time check that provider satisfies Provider. diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index f3f0302d5..96bf9356a 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -796,6 +796,30 @@ type SessionOrAccessTokenData struct { UserID string LoginMethod string Nonce string + // ActorID is the IMMEDIATE actor of an RFC 8693 delegated token — the + // agent's registered client_id, taken from the `act.sub` claim. Empty for + // every first-party token, which is what distinguishes "a user did this" + // from "an agent did this for a user". + // + // Only the immediate actor is surfaced. Prior actors nested deeper in the + // chain are informational and MUST NOT influence an access-control + // decision: they were asserted by an upstream party, not verified here. + // They remain available in the raw token for audit reconstruction. + ActorID string +} + +// ImmediateActor extracts the `act.sub` of an RFC 8693 delegated token. +// +// Returns "" when the token carries no `act`, i.e. it is not delegated. The +// claim is read from a JWT this server signed and has already validated, so it +// is not client-controlled input. +func ImmediateActor(claims map[string]interface{}) string { + act, ok := claims["act"].(map[string]interface{}) + if !ok { + return "" + } + sub, _ := act["sub"].(string) + return strings.TrimSpace(sub) } // GetUserIDFromSessionOrAccessToken returns the user id from the session or access token @@ -827,8 +851,23 @@ func (p *provider) GetUserIDFromSessionOrAccessToken(gc *gin.Context) (*SessionO // If not session, then validate the access token claims, err := p.ValidateAccessToken(gc, token) if err != nil { - p.dependencies.Log.Debug().Err(err).Msg("Failed to validate access token") - return nil, fmt.Errorf(`unauthorized`) + // An RFC 8693 delegated token is stateless and therefore always fails + // the stateful check above (no nonce, no session entry). Fall back to + // the delegated validator, which enforces every other check plus a + // strict audience match. See ValidateDelegatedAccessToken. + // + // Ordered as a fallback, not a branch: a first-party token is validated + // exactly as before and never touches the weaker path. + // + // Scoped deliberately to THIS function. The other caller of + // ValidateAccessToken is the OIDC /userinfo handler, which is left + // untouched so its behaviour stays spec-conformant. + delegatedClaims, dErr := p.ValidateDelegatedAccessToken(gc, token) + if dErr != nil { + p.dependencies.Log.Debug().Err(err).Msg("Failed to validate access token") + return nil, fmt.Errorf(`unauthorized`) + } + claims = delegatedClaims } userID, ok := claims["sub"].(string) if !ok || userID == "" { @@ -840,6 +879,7 @@ func (p *provider) GetUserIDFromSessionOrAccessToken(gc *gin.Context) (*SessionO UserID: userID, LoginMethod: loginMethod, Nonce: nonce, + ActorID: ImmediateActor(claims), }, nil } diff --git a/internal/token/delegated_access_token.go b/internal/token/delegated_access_token.go new file mode 100644 index 000000000..56e0f1128 --- /dev/null +++ b/internal/token/delegated_access_token.go @@ -0,0 +1,111 @@ +package token + +import ( + "fmt" + "net/url" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/parsers" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// ValidateDelegatedAccessToken validates an RFC 8693 delegated access token +// presented at Authorizer's OWN API. +// +// # Why this exists as a separate path +// +// Delegated tokens are stateless by design: CreateDelegatedAccessToken does not +// register them in the memory store, because a resource server verifies them +// locally against the published JWKS with no round trip here. ValidateAccessToken +// is stateful — it requires a `nonce` claim and a matching session entry, and +// compares the presented token byte-for-byte against the stored copy. A +// delegated token has no nonce, so it fails that check immediately. +// +// The consequence was that an agent could never ask Authorizer a question about +// its own delegated authority — check_permissions was unreachable with the very +// token that proves the delegation. +// +// This is deliberately NOT a branch inside ValidateAccessToken. Keeping it a +// named, separate function means the first-party path is untouched and this +// weaker path can be reviewed, tested and reasoned about on its own. Widening it +// requires editing this function, not slipping a condition into a shared one. +// +// # What is still enforced +// +// - Signature and expiry, via ParseJWTToken. +// - An `act` claim MUST be present. Without it the token is not delegated and +// has no business on this path. +// - `aud` MUST equal this server's client_id. A token minted with an RFC 8707 +// resource indicator carries that resource as its `aud` and is usable ONLY +// there — accepting it here would be audience confusion and would make the +// resource binding decorative. An agent that wants to call Authorizer must +// explicitly request Authorizer as the resource. +// - Issuer/claims via ValidateJWTClaims, and token_type must be an access token. +// - The subject user must not be revoked. userIsRevoked is a database lookup, +// not a session lookup, so revoking a user still stops their agents. +// +// # What is knowingly given up +// +// Per-session revocation. A first-party token dies when its session entry is +// deleted (logout, password reset); a delegated token cannot, because it was +// never stored. That is bounded by DelegatedAccessTokenTTL, which is short by +// construction, and is the same trade already accepted for resource servers. +func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken string) (map[string]interface{}, error) { + res := make(map[string]interface{}) + if accessToken == "" { + return res, fmt.Errorf(`unauthorized`) + } + + res, err := p.ParseJWTToken(accessToken) + if err != nil { + return res, err + } + + // Must actually be a delegated token. + if ImmediateActor(res) == "" { + return res, fmt.Errorf(`unauthorized: not a delegated token`) + } + + userID, ok := res["sub"].(string) + if !ok || userID == "" { + return res, fmt.Errorf(`unauthorized: missing sub claim`) + } + + // Audience isolation. Anything that is not exactly this server's client_id + // is refused — in particular a resource-indicator audience, which is an + // absolute URI and belongs to a downstream resource server. + aud, _ := res["aud"].(string) + if aud != p.config.ClientID { + if u, uErr := url.Parse(aud); uErr == nil && u.IsAbs() { + p.dependencies.Log.Debug().Str("aud", aud). + Msg("delegated token rejected: resource-bound audience is not valid at authorizer's own endpoints") + } + return res, fmt.Errorf(`unauthorized: token audience is not this server`) + } + + if p.userIsRevoked(gc, userID) { + p.dependencies.Log.Debug().Str("user_id", userID).Msg("delegated token rejected: user revoked") + return res, fmt.Errorf(`unauthorized: user revoked`) + } + + hostname := parsers.GetHost(gc) + // ValidateJWTTokenWithoutNonce, not ValidateJWTClaims: the latter compares + // the nonce claim unconditionally, and a delegated token carries none by + // design (it is stateless, so there is no session nonce to bind to). Every + // other claim — audience, issuer, subject — is still checked identically. + if ok, vErr := p.ValidateJWTTokenWithoutNonce(res, &AuthTokenConfig{ + HostName: hostname, + User: &schemas.User{ID: userID}, + ClientID: aud, + }); !ok || vErr != nil { + return res, vErr + } + + if res["token_type"] != constants.TokenTypeAccessToken { + return res, fmt.Errorf(`unauthorized: invalid token type`) + } + + return res, nil +} diff --git a/internal/token/provider.go b/internal/token/provider.go index 21585a50e..51b0738c7 100644 --- a/internal/token/provider.go +++ b/internal/token/provider.go @@ -67,6 +67,11 @@ type Provider interface { SignJWTToken(jwtclaims jwt.MapClaims) (string, error) // ValidateAccessToken validates access token ValidateAccessToken(gc *gin.Context, accessToken string) (map[string]interface{}, error) + // ValidateDelegatedAccessToken validates a stateless RFC 8693 delegated + // access token presented at Authorizer's own API. Weaker than + // ValidateAccessToken by exactly one property (no session lookup) and + // stricter by one (audience must be this server) — see its doc comment. + ValidateDelegatedAccessToken(gc *gin.Context, accessToken string) (map[string]interface{}, error) // ValidateAdminToken validates session token ValidateBrowserSession(gc *gin.Context, encryptedSession string) (*SessionData, error) // ValidateJWTClaims validates jwt claims From beef015809321b40a81cbb0dd2a5b76cf7a30efe Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 14:32:39 +0530 Subject: [PATCH 11/25] fix(agent): make the delegated path reachable and close two liveness gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegated validator required aud == --client-id, an opaque string, while /oauth/token requires `resource` to be an absolute URI and stamps it verbatim as aud. Those conditions are mutually exclusive: no token this server can mint could ever pass. The feature was unreachable, and the test that "proved" it worked called CreateDelegatedAccessToken directly with Audience: cfg.ClientID — asserting a contract the system cannot produce. - audience is now this server's own URL, so an agent names Authorizer as the RFC 8707 resource when it wants to call Authorizer. Trailing-slash tolerant; an empty audience never matches, which also closes the degenerate case where an unset --client-id compared equal to aud:"". - subject liveness resolves user OR client. Token exchange accepts a service account as the subject (multi-hop: agent A delegates to agent B), and the old check only looked the subject up as a user — so a deactivated service account's delegation kept working for the token's full TTL. Fails closed when the subject is neither. - fail closed instead of panicking on a nil request; the audience and issuer checks both derive the host from it and parsers.GetHost does not guard nil. Tests now start at the real endpoints. agent_intersection_e2e_test.go mints through /oauth/token and drives the public GraphQL permission API, which is what caught the unreachability that unit tests hid. Known still-failing, tracked: intersection is inert on GraphQL because authctx.WithPrincipal is only called in the gRPC interceptor. --- .../agent_intersection_e2e_test.go | 242 +++++++ .../delegated_adversarial_test.go | 651 ++++++++++++++++++ .../delegated_revocation_test.go | 113 +++ .../delegated_token_api_test.go | 11 +- internal/service/audit_actor.go | 67 ++ internal/service/deactivate_account.go | 4 +- .../service/fga_agent_adversarial_test.go | 130 ++++ internal/service/list_permissions.go | 82 ++- internal/service/logout.go | 4 +- internal/service/update_profile.go | 4 +- internal/token/delegated_access_token.go | 93 ++- 11 files changed, 1371 insertions(+), 30 deletions(-) create mode 100644 internal/integration_tests/agent_intersection_e2e_test.go create mode 100644 internal/integration_tests/delegated_adversarial_test.go create mode 100644 internal/integration_tests/delegated_revocation_test.go create mode 100644 internal/service/audit_actor.go create mode 100644 internal/service/fga_agent_adversarial_test.go diff --git a/internal/integration_tests/agent_intersection_e2e_test.go b/internal/integration_tests/agent_intersection_e2e_test.go new file mode 100644 index 000000000..9669539ff --- /dev/null +++ b/internal/integration_tests/agent_intersection_e2e_test.go @@ -0,0 +1,242 @@ +package integration_tests + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" +) + +// This file tests agent delegation THROUGH THE REAL ENDPOINTS — /oauth/token to +// mint the token, then the public GraphQL permission API with that token on the +// request, exactly as a caller would. +// +// It exists because an earlier round of tests called CreateDelegatedAccessToken +// directly and asserted on ValidateDelegatedAccessToken in isolation. Both +// passed while the feature was entirely unreachable in production: /oauth/token +// requires `resource` to be an absolute URI and stamps it verbatim as `aud`, +// and the validator required aud to equal an opaque client id — conditions that +// can never both hold. Unit-testing the pieces proved nothing about the system. +// +// Every test here starts at an HTTP endpoint. + +// fgaAgentModel declares `agent` as a first-class subject. Declaring the type IS +// the opt-in, exactly as `service_account` works (see fgaServiceAccountModel). +const fgaAgentModel = `model + schema 1.1 +type user +type agent +type document + relations + define viewer: [user, agent] + define can_view: viewer +` + +// mintDelegatedViaEndpoint performs a real RFC 8693 exchange against +// /oauth/token and returns (delegatedToken, agentClientID, delegatingUserID). +func mintDelegatedViaEndpoint(t *testing.T, ts *testSetup, router http.Handler, resource string) (string, string, string) { + t.Helper() + + agentClientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + subjectToken := testAccessToken(t, ts) + actor := agentAccessToken(t, ts, router, agentClientID, secret) + + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", resource) + + // Use the shared helper: it sets X-Authorizer-URL to the host the minted + // subject/actor tokens use as their iss, without which the exchange rejects + // them as invalid. + rec := postTokenExchange(ts, router, form, agentClientID, secret) + + require.Equal(t, http.StatusOK, rec.Code, + "token exchange must succeed for resource=%q; body=%s", resource, rec.Body.String()) + + var out struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.NotEmpty(t, out.AccessToken) + + claims, err := ts.TokenProvider.ParseJWTToken(subjectToken) + require.NoError(t, err) + userID, _ := claims["sub"].(string) + require.NotEmpty(t, userID) + + return out.AccessToken, agentClientID, userID +} + +// presentDelegatedToken puts a delegated token on the shared test request, the +// same way presentMachineToken does for client_credentials callers. +func presentDelegatedToken(ts *testSetup, token string) { + clearCookies(ts) + ts.GinContext.Request.Header.Set("Authorization", "Bearer "+token) +} + +// TestAgentDelegatedTokenReachesAuthorizerAPI is the reachability acceptance +// test: a token minted by the REAL endpoint must authenticate at Authorizer's +// own API when it names Authorizer as its resource, and must not when it names +// someone else's. +func TestAgentDelegatedTokenReachesAuthorizerAPI(t *testing.T) { + cfg := getTestConfig() + ts, _ := initFGATestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + t.Run("resource = authorizer's own URL authenticates", func(t *testing.T) { + delegated, agentID, userID := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + presentDelegatedToken(ts, delegated) + + data, err := ts.TokenProvider.GetUserIDFromSessionOrAccessToken(ts.GinContext) + require.NoError(t, err, + "a delegated token naming authorizer as its resource must authenticate at authorizer's own API; "+ + "without this the whole agent feature is unreachable") + assert.Equal(t, userID, data.UserID, "the subject stays the delegating user") + assert.Equal(t, agentID, data.ActorID, "the immediate actor must survive as the agent") + }) + + t.Run("resource = another server is refused here", func(t *testing.T) { + delegated, _, _ := mintDelegatedViaEndpoint(t, ts, router, "https://mcp.example.com") + presentDelegatedToken(ts, delegated) + + _, err := ts.TokenProvider.GetUserIDFromSessionOrAccessToken(ts.GinContext) + require.Error(t, err, + "a token bound to a downstream resource server must never authenticate here, "+ + "or the RFC 8707 audience restriction is decorative") + }) +} + +// TestAgentIntersectionThroughGraphQL is the Confused Deputy acceptance test, +// driven through the public GraphQL API with a real delegated token. +func TestAgentIntersectionThroughGraphQL(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + setAdminCookie(t, ts) + _, err := ts.GraphQLProvider.FgaWriteModel(ctx, &model.FgaWriteModelInput{Dsl: fgaAgentModel}) + require.NoError(t, err) + + delegated, agentID, userID := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + + // The USER can view the document. The AGENT has no grant of its own. + setAdminCookie(t, ts) + _, err = ts.GraphQLProvider.FgaWriteTuples(ctx, &model.FgaWriteTuplesInput{ + Tuples: []*model.FgaTupleInput{ + {User: "user:" + userID, Relation: "viewer", Object: "document:secret"}, + }, + }) + require.NoError(t, err) + + check := func(t *testing.T, explicitUser *string) *model.CheckPermissionsResponse { + t.Helper() + presentDelegatedToken(ts, delegated) + res, cErr := ts.GraphQLProvider.CheckPermissions(ctx, &model.CheckPermissionsInput{ + User: explicitUser, + Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:secret"}}, + }) + require.NoError(t, cErr) + require.NotNil(t, res) + require.Len(t, res.Results, 1) + return res + } + + t.Run("agent WITHOUT its own grant is denied though the user has access", func(t *testing.T) { + assert.False(t, check(t, nil).Results[0].Allowed, + "CONFUSED DEPUTY: the agent holds no grant, so it must be denied even though "+ + "the delegating user can view the document") + }) + + t.Run("explicit self user must not drop the agent half", func(t *testing.T) { + // fga.go honours self-specification for ANY caller, so a delegated agent + // echoing back its own subject previously bypassed the agent check — + // a one-parameter defeat of the intersection. + self := "user:" + userID + assert.False(t, check(t, &self).Results[0].Allowed, + "supplying an explicit self `user` must not drop the agent half") + }) + + t.Run("bare-id explicit user must not drop the agent half", func(t *testing.T) { + bare := userID + assert.False(t, check(t, &bare).Results[0].Allowed, + "normalizeFgaSubject expands a bare id to user:; that path must not bypass either") + }) + + t.Run("granting the agent allows the intersection", func(t *testing.T) { + setAdminCookie(t, ts) + _, wErr := ts.GraphQLProvider.FgaWriteTuples(ctx, &model.FgaWriteTuplesInput{ + Tuples: []*model.FgaTupleInput{ + {User: "agent:" + agentID, Relation: "viewer", Object: "document:secret"}, + }, + }) + require.NoError(t, wErr) + assert.True(t, check(t, nil).Results[0].Allowed, "both halves granted must allow") + }) + + t.Run("revoking one agent leaves the delegating user untouched", func(t *testing.T) { + setAdminCookie(t, ts) + _, dErr := ts.GraphQLProvider.FgaDeleteTuples(ctx, &model.FgaWriteTuplesInput{ + Tuples: []*model.FgaTupleInput{ + {User: "agent:" + agentID, Relation: "viewer", Object: "document:secret"}, + }, + }) + require.NoError(t, dErr) + assert.False(t, check(t, nil).Results[0].Allowed, "the revoked agent is denied") + + allowed, cErr := eng.Check(ctx, "user:"+userID, "can_view", "document:secret") + require.NoError(t, cErr) + assert.True(t, allowed, "revoking one agent must not affect the delegating user") + }) +} + +// TestAgentIntersectionListPermissions pins that ENUMERATION intersects too. +// Without it an agent that cannot act on an object would still see it listed, +// leaking the delegating user's resource names. +func TestAgentIntersectionListPermissions(t *testing.T) { + cfg := getTestConfig() + ts, _ := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + setAdminCookie(t, ts) + _, err := ts.GraphQLProvider.FgaWriteModel(ctx, &model.FgaWriteModelInput{Dsl: fgaAgentModel}) + require.NoError(t, err) + + delegated, _, userID := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + + setAdminCookie(t, ts) + _, err = ts.GraphQLProvider.FgaWriteTuples(ctx, &model.FgaWriteTuplesInput{ + Tuples: []*model.FgaTupleInput{ + {User: "user:" + userID, Relation: "viewer", Object: "document:listed"}, + }, + }) + require.NoError(t, err) + + presentDelegatedToken(ts, delegated) + res, lErr := ts.GraphQLProvider.ListPermissions(ctx, &model.ListPermissionsInput{ + ObjectType: refs.NewStringRef("document"), + Relation: refs.NewStringRef("can_view"), + }) + require.NoError(t, lErr) + require.NotNil(t, res) + assert.Empty(t, res.Objects, + "the agent holds no grant, so enumeration must be empty — listing an object the "+ + "agent cannot act on leaks the delegating user's resource names") +} diff --git a/internal/integration_tests/delegated_adversarial_test.go b/internal/integration_tests/delegated_adversarial_test.go new file mode 100644 index 000000000..69cb9812f --- /dev/null +++ b/internal/integration_tests/delegated_adversarial_test.go @@ -0,0 +1,651 @@ +package integration_tests + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/authctx" + "github.com/authorizerdev/authorizer/internal/authorization/engine" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// advAgentModel declares `type agent`, which IS the operator opt-in that turns +// on the agent: ∩ user: intersection (see service.FgaAgentSubjectType). +const advAgentModel = `model + schema 1.1 +type user +type agent +type document + relations + define viewer: [user, agent] + define can_view: viewer +` + +// advNoAgentModel is the same model WITHOUT the agent type — the "operator has +// not opted in" state. +const advNoAgentModel = `model + schema 1.1 +type user +type document + relations + define viewer: [user] + define can_view: viewer +` + +// --------------------------------------------------------------------------- +// (d) INTERSECTION BYPASS +// --------------------------------------------------------------------------- + +// TestAdvIntersectionBypassViaExplicitUser attacks the delegation intersection +// in internal/service/check_permissions.go:47-52, which skips +// delegationSubjects whenever params.User is non-empty. The in-code comment +// claims an explicit `user` is "super-admin only", but +// service/fga.go resolveFgaSubject:84 also honours SELF-specification for any +// caller. A delegated agent can therefore echo back its own subject and have +// the agent: half of the intersection dropped. +func TestAdvIntersectionBypassViaExplicitUser(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + req, ctx := createContext(ts) + + _, err := eng.WriteModel(ctx, advAgentModel) + require.NoError(t, err) + + userID := "adv-user-" + uuid.NewString() + agentID := "adv-agent-" + uuid.NewString() + + // The USER may view the document. The AGENT has NO grant at all. + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:" + userID, Relation: "viewer", Object: "document:secret"}, + })) + + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: req} + delegatedCtx := authctx.WithPrincipal(ctx, &authctx.Principal{ + UserID: userID, + ActorID: agentID, + }) + + check := []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:secret"}} + + t.Run("baseline: intersection denies the agent", func(t *testing.T) { + res, _, err := ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, + &model.CheckPermissionsInput{Checks: check}) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "agent holds no tuple, so perms(agent) ∩ perms(user) must be empty") + }) + + t.Run("ATTACK: echo own subject in `user` to drop the agent check", func(t *testing.T) { + res, _, err := ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, + &model.CheckPermissionsInput{ + Checks: check, + User: refs.NewStringRef("user:" + userID), + }) + require.NoError(t, err, "self-specification is accepted by resolveFgaSubject") + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "BYPASS: supplying `user` equal to the caller's own subject skipped the agent half of the intersection") + }) + + t.Run("ATTACK: bare id form of own subject", func(t *testing.T) { + // normalizeFgaSubject turns a bare id into user:, so the bare form + // also passes the self-specification gate. + res, _, err := ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, + &model.CheckPermissionsInput{ + Checks: check, + User: refs.NewStringRef(userID), + }) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "BYPASS: bare-id self-specification skipped the agent half of the intersection") + }) +} + +// TestAdvListPermissionsHasNoIntersection attacks the OTHER authority-answering +// API. Only CheckPermissions was taught about delegation; ListPermissions +// (internal/service/list_permissions.go) still enumerates for the single +// resolved subject. +func TestAdvListPermissionsHasNoIntersection(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + req, ctx := createContext(ts) + + _, err := eng.WriteModel(ctx, advAgentModel) + require.NoError(t, err) + + userID := "adv-lp-user-" + uuid.NewString() + agentID := "adv-lp-agent-" + uuid.NewString() + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:" + userID, Relation: "viewer", Object: "document:lp1"}, + })) + + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: req} + delegatedCtx := authctx.WithPrincipal(ctx, &authctx.Principal{UserID: userID, ActorID: agentID}) + + // Control: a plain (non-delegated) caller sees the object. + plainCtx := authctx.WithPrincipal(ctx, &authctx.Principal{UserID: userID}) + base, _, err := ts.ServiceProvider.ListPermissions(plainCtx, meta, &model.ListPermissionsInput{ + Relation: refs.NewStringRef("can_view"), + ObjectType: refs.NewStringRef("document"), + }) + require.NoError(t, err) + require.Contains(t, base.Objects, "document:lp1", "control: the user itself can see the object") + + res, _, err := ts.ServiceProvider.ListPermissions(delegatedCtx, meta, &model.ListPermissionsInput{ + Relation: refs.NewStringRef("can_view"), + ObjectType: refs.NewStringRef("document"), + }) + require.NoError(t, err) + t.Logf("delegated ListPermissions objects: %v", res.Objects) + assert.NotContains(t, res.Objects, "document:lp1", + "ListPermissions must not hand a delegated agent the user's full object set when the agent has no grant") + + // Same explicit-`user` escape hatch as CheckPermissions + // (internal/service/list_permissions.go:75). + bypass, _, err := ts.ServiceProvider.ListPermissions(delegatedCtx, meta, &model.ListPermissionsInput{ + Relation: refs.NewStringRef("can_view"), + ObjectType: refs.NewStringRef("document"), + User: refs.NewStringRef("user:" + userID), + }) + require.NoError(t, err) + t.Logf("delegated ListPermissions objects WITH explicit user: %v", bypass.Objects) + assert.NotContains(t, bypass.Objects, "document:lp1", + "BYPASS: self-specified `user` drops the agent half of the enumeration intersection") +} + +// --------------------------------------------------------------------------- +// (e) FAIL-OPEN on agent detection +// --------------------------------------------------------------------------- + +// TestAdvNoAgentTypeDisablesEnforcement pins what happens when the model does +// NOT declare `type agent`: agentSubjectsEnabled returns false and the agent +// half of the intersection silently vanishes. +func TestAdvNoAgentTypeDisablesEnforcement(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + req, ctx := createContext(ts) + + _, err := eng.WriteModel(ctx, advNoAgentModel) + require.NoError(t, err) + + userID := "adv-noagent-user-" + uuid.NewString() + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:" + userID, Relation: "viewer", Object: "document:na1"}, + })) + + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: req} + delegatedCtx := authctx.WithPrincipal(ctx, &authctx.Principal{ + UserID: userID, + ActorID: "adv-noagent-agent-" + uuid.NewString(), + }) + + res, _, err := ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, &model.CheckPermissionsInput{ + Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:na1"}}, + }) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "a model with no agent type gives the agent the user's full authority (documented opt-in, pinned here)") +} + +// TestAdvAgentDetectionFlipsOnModelRewrite is the cache-poisoning probe: the +// agent-detection cache is keyed on model id, so rewriting the model from +// agent-aware to agent-free (or back) must flip enforcement. +func TestAdvAgentDetectionFlipsOnModelRewrite(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + req, ctx := createContext(ts) + + userID := "adv-cache-user-" + uuid.NewString() + agentID := "adv-cache-agent-" + uuid.NewString() + + _, err := eng.WriteModel(ctx, advAgentModel) + require.NoError(t, err) + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:" + userID, Relation: "viewer", Object: "document:c1"}, + })) + + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: req} + delegatedCtx := authctx.WithPrincipal(ctx, &authctx.Principal{UserID: userID, ActorID: agentID}) + checks := []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:c1"}} + + // Prime the cache with agent detection ON. + res, _, err := ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, &model.CheckPermissionsInput{Checks: checks}) + require.NoError(t, err) + require.False(t, res.Results[0].Allowed, "agent has no grant: denied") + + // Operator drops the agent type. New model id => cache must invalidate. + _, err = eng.WriteModel(ctx, advNoAgentModel) + require.NoError(t, err) + + res, _, err = ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, &model.CheckPermissionsInput{Checks: checks}) + require.NoError(t, err) + assert.False(t, res.Results[0].Allowed, + "dropping `type agent` from the model turns the intersection off and re-grants the agent the user's authority") +} + +// --------------------------------------------------------------------------- +// (a) AUDIENCE CONFUSION / reachability of the delegated path +// --------------------------------------------------------------------------- + +// TestAdvDelegatedPathReachabilityViaRealEndpoint runs the REAL RFC 8693 token +// exchange and then presents the resulting token on the delegated validation +// path. The endpoint requires `resource` to be an absolute URI (RFC 8707 §2, +// http_handlers/authorize.go:1015) and stamps it verbatim as `aud`, while +// ValidateDelegatedAccessToken requires aud == Config.ClientID. +func TestAdvDelegatedPathReachabilityViaRealEndpoint(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + agentID, agentSecret := newDelegationAgent(t, ts, "openid,email,profile") + subjectToken := testAccessToken(t, ts) + actorToken := agentAccessToken(t, ts, router, agentID, agentSecret) + + exchange := func(resource string) *http.Response { + f := url.Values{} + f.Set("grant_type", tokenExchangeGrant) + f.Set("subject_token", subjectToken) + f.Set("subject_token_type", accessTokenType) + f.Set("actor_token", actorToken) + f.Set("actor_token_type", accessTokenType) + f.Set("resource", resource) + return postTokenExchange(ts, router, f, agentID, agentSecret).Result() + } + + t.Run("resource cannot be the deployment client_id", func(t *testing.T) { + resp := exchange(cfg.ClientID) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "the only aud that ValidateDelegatedAccessToken accepts is rejected at issuance") + }) + + t.Run("a genuinely issued delegated token does not authenticate here", func(t *testing.T) { + f := url.Values{} + f.Set("grant_type", tokenExchangeGrant) + f.Set("subject_token", subjectToken) + f.Set("subject_token_type", accessTokenType) + f.Set("actor_token", actorToken) + f.Set("actor_token_type", accessTokenType) + f.Set("resource", "https://mcp.example.com") + rec := postTokenExchange(ts, router, f, agentID, agentSecret) + require.Equal(t, http.StatusOK, rec.Code, "exchange must succeed: %s", rec.Body.String()) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + delegated, _ := body["access_token"].(string) + require.NotEmpty(t, delegated) + + gc := &gin.Context{Request: ts.GinContext.Request} + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, delegated) + require.Error(t, err, "resource-bound token must not authenticate at authorizer") + + // And end-to-end through the real resolver. + httpReq, _ := http.NewRequest(http.MethodPost, testAuthorizerHost(ts)+"/graphql", nil) + httpReq.Header.Set("Authorization", "Bearer "+delegated) + _, err = ts.TokenProvider.GetUserIDFromSessionOrAccessToken(&gin.Context{Request: httpReq}) + require.Error(t, err, + "NO token this deployment can mint reaches the delegated path: the feature is unreachable") + }) +} + +// --------------------------------------------------------------------------- +// (b)/(g) session bypass and token-type confusion on the delegated path +// --------------------------------------------------------------------------- + +func TestAdvDelegatedPathRejectsNonDelegatedArtifacts(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + _ = ctx + + gc := &gin.Context{Request: ts.GinContext.Request} + + email := "adv_tt_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + login, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{ + Email: &email, Password: password, + Scope: []string{"openid", "email", "profile", "offline_access"}, + }) + require.NoError(t, err) + require.NotNil(t, login.AccessToken) + require.NotNil(t, login.IDToken) + require.NotNil(t, login.RefreshToken) + userID := login.User.ID + + t.Run("id_token", func(t *testing.T) { + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, *login.IDToken) + require.Error(t, err) + }) + + t.Run("refresh_token", func(t *testing.T) { + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, *login.RefreshToken) + require.Error(t, err) + }) + + t.Run("first-party access token", func(t *testing.T) { + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, *login.AccessToken) + require.Error(t, err, "no act claim: must not take the session-skipping path") + }) + + t.Run("session-revoked first-party access token does not fall through", func(t *testing.T) { + httpReq, _ := http.NewRequest(http.MethodPost, testAuthorizerHost(ts)+"/graphql", nil) + httpReq.Header.Set("Authorization", "Bearer "+*login.AccessToken) + // Control: it works while the session lives. + _, err := ts.TokenProvider.GetUserIDFromSessionOrAccessToken(&gin.Context{Request: httpReq}) + require.NoError(t, err, "control: a live first-party token resolves") + + // Now nuke every session for the user (what logout / password reset do) + // and re-present the still signature-valid token. + require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) + _, err = ts.TokenProvider.GetUserIDFromSessionOrAccessToken(&gin.Context{Request: httpReq}) + require.Error(t, err, "the delegated fallback must not resurrect a session-invalid first-party token") + }) + + _ = context.Background() +} + +// --------------------------------------------------------------------------- +// (a) AUDIENCE CONFUSION — claim-shape variants +// --------------------------------------------------------------------------- + +// advSign mints a JWT signed with the deployment's HS256 test secret so the +// signature gate passes and ONLY the claim logic under test decides the outcome. +// This models a hypothetical future minter emitting these shapes, not an +// attacker forging tokens (an attacker has no key). +func advSign(t *testing.T, secret string, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tok.Header["typ"] = "at+jwt" + s, err := tok.SignedString([]byte(secret)) + require.NoError(t, err) + return s +} + +func TestAdvAudienceVariants(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef("adv_aud_" + uuid.NewString() + "@authorizer.dev"), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + Roles: "user", + }) + require.NoError(t, err) + + host := testAuthorizerHost(ts) + gc := &gin.Context{Request: ts.GinContext.Request} + + base := func(aud interface{}) jwt.MapClaims { + return jwt.MapClaims{ + "iss": host, "sub": user.ID, + "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), + "jti": uuid.NewString(), "token_type": constants.TokenTypeAccessToken, + "scope": []string{"openid"}, "client_id": "adv-agent", + "act": map[string]interface{}{"sub": "adv-agent"}, + "aud": aud, + } + } + + cases := []struct { + name string + aud interface{} + }{ + {"array containing the client_id", []string{cfg.ClientID}}, + {"array of client_id plus a resource", []string{cfg.ClientID, "https://mcp.example.com"}}, + {"trailing slash", cfg.ClientID + "/"}, + {"upper case", strings.ToUpper(cfg.ClientID)}, + {"issuer URL", host}, + {"issuer URL with trailing slash", host + "/"}, + {"empty string", ""}, + {"resource indicator", "https://mcp.example.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tok := advSign(t, cfg.JWTSecret, base(tc.aud)) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "aud=%v must not authenticate at authorizer's own API", tc.aud) + }) + } + + t.Run("no aud claim at all", func(t *testing.T) { + c := base(nil) + delete(c, "aud") + tok := advSign(t, cfg.JWTSecret, c) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "a token with no audience must not authenticate") + }) + + t.Run("control: exact client_id is accepted", func(t *testing.T) { + tok := advSign(t, cfg.JWTSecret, base(cfg.ClientID)) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.NoError(t, err, "control: the exact-match case must pass, else the suite proves nothing") + }) +} + +// TestAdvEmptyClientIDAudienceGate probes the default deployment shape where the +// operator never passed --client-id (Config.ClientID defaults to "" — there is +// no generated fallback in Config.Finalize). +func TestAdvEmptyClientIDAudienceGate(t *testing.T) { + cfg := getTestConfig() + cfg.ClientID = "" + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef("adv_emptyaud_" + uuid.NewString() + "@authorizer.dev"), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + Roles: "user", + }) + require.NoError(t, err) + + host := testAuthorizerHost(ts) + gc := &gin.Context{Request: ts.GinContext.Request} + mk := func(aud interface{}) jwt.MapClaims { + c := jwt.MapClaims{ + "iss": host, "sub": user.ID, + "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), + "jti": uuid.NewString(), "token_type": constants.TokenTypeAccessToken, + "act": map[string]interface{}{"sub": "adv-agent"}, + } + if aud != nil { + c["aud"] = aud + } + return c + } + + t.Run("aud is an array", func(t *testing.T) { + // aud, _ := res["aud"].(string) yields "" for a non-string claim, which + // equals an empty Config.ClientID. + tok := advSign(t, cfg.JWTSecret, mk([]string{"https://mcp.example.com"})) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "an ARRAY aud must not slip past the string-typed audience comparison") + }) + + t.Run("aud is the empty string", func(t *testing.T) { + tok := advSign(t, cfg.JWTSecret, mk("")) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "an empty aud must not authenticate when Config.ClientID is unset") + }) +} + +// --------------------------------------------------------------------------- +// (b) REPLAY of a delegated token after user-side revocation events +// --------------------------------------------------------------------------- + +func TestAdvDelegatedTokenSurvivesLogoutAndPasswordReset(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "adv_replay_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + login, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + userID := login.User.ID + + // Only a token whose aud == Config.ClientID reaches this path, so mint one + // directly (the token endpoint cannot produce this shape — see + // TestAdvDelegatedPathReachabilityViaRealEndpoint). + tok := mintDelegated(t, ts, userID, "adv-replay-agent", cfg.ClientID) + gc := &gin.Context{Request: ts.GinContext.Request} + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.NoError(t, err, "control") + + t.Run("survives logout / session wipe", func(t *testing.T) { + require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + assert.Error(t, err, "wiping every session must also stop the delegated token") + }) + + t.Run("survives password change", func(t *testing.T) { + u, err := ts.StorageProvider.GetUserByID(ctx, userID) + require.NoError(t, err) + newPwd := "NewPassword@456" + u.Password = &newPwd + _, err = ts.StorageProvider.UpdateUser(ctx, u) + require.NoError(t, err) + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + assert.Error(t, err, "a password reset must stop the delegated token") + }) + + t.Run("revoking the user DOES stop it", func(t *testing.T) { + u, err := ts.StorageProvider.GetUserByID(ctx, userID) + require.NoError(t, err) + now := time.Now().Unix() + u.RevokedTimestamp = &now + _, err = ts.StorageProvider.UpdateUser(ctx, u) + require.NoError(t, err) + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, "RevokedTimestamp is the ONE revocation lever that works") + }) +} + +// --------------------------------------------------------------------------- +// (c) ActorID shape — tuple/userset smuggling into the agent subject +// --------------------------------------------------------------------------- + +// TestAdvActorIDUsersetSmuggling probes delegationSubjects +// (internal/service/fga_agent.go:141), which concatenates ActorID into an FGA +// subject WITHOUT the ContainsAny(":#@ \t\n") guard that machineFgaSubject +// applies at internal/service/fga.go:181. +func TestAdvActorIDUsersetSmuggling(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + req, ctx := createContext(ts) + + _, err := eng.WriteModel(ctx, advAgentModel) + require.NoError(t, err) + + userID := "adv-inj-user-" + uuid.NewString() + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:" + userID, Relation: "viewer", Object: "document:inj"}, + })) + + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: req} + checks := []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:inj"}} + + for _, actor := range []string{ + "bot#viewer", + "bot:extra", + "*", + } { + t.Run("actor="+actor, func(t *testing.T) { + dctx := authctx.WithPrincipal(ctx, &authctx.Principal{UserID: userID, ActorID: actor}) + res, _, err := ts.ServiceProvider.CheckPermissions(dctx, meta, + &model.CheckPermissionsInput{Checks: checks}) + if err != nil { + t.Logf("engine failed closed for actor %q: %v", actor, err) + return + } + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "a malformed/wildcard actor id must never satisfy the agent half") + }) + } +} + +// TestAdvGraphQLSurfaceHasNoDelegationPrincipal attacks the wiring rather than +// the logic. authctx.Principal.ActorID is populated in exactly ONE place — +// internal/grpcsrv/interceptors/auth.go:130/163 — so only the gRPC (and +// grpc-gateway REST) surface ever sees a delegated principal. On the GraphQL +// surface the service layer resolves the caller through +// token.GetUserIDFromSessionOrAccessToken (which DOES accept a delegated token) +// but no Principal is ever put on the context, so service.delegationSubjects +// finds none and the agent half of the intersection never runs. +func TestAdvGraphQLSurfaceHasNoDelegationPrincipal(t *testing.T) { + cfg := getTestConfig() + ts, eng := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + _, err := eng.WriteModel(ctx, advAgentModel) + require.NoError(t, err) + + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef("adv_gql_" + uuid.NewString() + "@authorizer.dev"), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + Roles: "user", + }) + require.NoError(t, err) + + // The user can view the document; the agent holds nothing. + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:" + user.ID, Relation: "viewer", Object: "document:gql"}, + })) + + delegated := mintDelegated(t, ts, user.ID, "adv-gql-agent", cfg.ClientID) + + httpReq, err := http.NewRequest(http.MethodPost, testAuthorizerHost(ts)+"/graphql", nil) + require.NoError(t, err) + httpReq.Header.Set("Authorization", "Bearer "+delegated) + httpReq.Header.Set("X-Authorizer-URL", testAuthorizerHost(ts)) + + // Sanity: the delegated token really does authenticate, and the token layer + // really does surface the actor. + data, err := ts.TokenProvider.GetUserIDFromSessionOrAccessToken(&gin.Context{Request: httpReq}) + require.NoError(t, err, "the delegated token authenticates") + require.Equal(t, user.ID, data.UserID) + require.Equal(t, "adv-gql-agent", data.ActorID, "the actor is available at the token layer") + + // ...but the GraphQL path never turns it into an authctx.Principal, so the + // service layer sees an ordinary user. + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: httpReq} + res, _, err := ts.ServiceProvider.CheckPermissions(ctx, meta, &model.CheckPermissionsInput{ + Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:gql"}}, + }) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "BYPASS: on the GraphQL surface a delegated caller is evaluated as the bare user — no agent: check runs") +} diff --git a/internal/integration_tests/delegated_revocation_test.go b/internal/integration_tests/delegated_revocation_test.go new file mode 100644 index 000000000..d6c2ef54b --- /dev/null +++ b/internal/integration_tests/delegated_revocation_test.go @@ -0,0 +1,113 @@ +package integration_tests + +import ( + "context" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/token" +) + +// TestDelegatedTokenEmptyAudienceIsRejected pins that an empty `aud` can never +// authenticate. +// +// The original gate compared the audience against Config.ClientID. An unset +// --client-id defaults to "", so a token carrying aud:"" compared equal and was +// accepted — ValidateAccessToken guarded `aud != "" &&` while the delegated +// path did not. Startup already refuses an empty --client-id +// (cmd/root.go: "client secret missing in rootArgs" / client ID missing), so +// that exact configuration cannot run, but the audience gate must not depend on +// that for its safety. sameAudience now requires BOTH sides non-empty. +func TestDelegatedTokenEmptyAudienceIsRejected(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + createContext(ts) + gc := &gin.Context{Request: ts.GinContext.Request} + + user, err := ts.StorageProvider.AddUser(context.Background(), &schemas.User{ + Email: refsString("empty_aud_" + uuid.NewString() + "@authorizer.dev"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + Roles: "user", + }) + require.NoError(t, err) + + tok, err := ts.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ + Subject: user.ID, + Actor: map[string]interface{}{"sub": "agent-x"}, + Audience: "", // no audience at all + Scope: []string{"openid"}, + ClientID: "agent-x", + HostName: testAuthorizerHost(ts), + }) + require.NoError(t, err) + + _, vErr := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok.Token) + require.Error(t, vErr, + "an empty audience must never authenticate, even when --client-id is unset") +} + +// TestDelegatedTokenForDeactivatedServiceAccountIsRejected pins liveness for +// MULTI-HOP chains. +// +// Token exchange permits a service account to be the SUBJECT (agent A delegating +// to agent B). The liveness check only ever looked the subject up as a USER, so +// for a service-account subject it found nothing, returned "not revoked", and +// the delegation kept working for the token's full lifetime after the service +// account had been deactivated. +func TestDelegatedTokenForDeactivatedServiceAccountIsRejected(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + createContext(ts) + gc := &gin.Context{Request: ts.GinContext.Request} + ctx := context.Background() + + secret := "sa-secret-" + uuid.NewString() + hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + require.NoError(t, err) + sa, err := ts.StorageProvider.AddClient(ctx, &schemas.Client{ + Name: "hop-sa-" + uuid.NewString(), + Kind: constants.ClientKindServiceAccount, + ClientSecret: string(hash), + AllowedScopes: "openid", + IsActive: true, + }) + require.NoError(t, err) + + mint := func() string { + tok, mErr := ts.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ + Subject: sa.ID, // the SUBJECT is a service account, not a user + Actor: map[string]interface{}{"sub": "downstream-agent"}, + Audience: testAuthorizerHost(ts), + Scope: []string{"openid"}, + ClientID: "downstream-agent", + HostName: testAuthorizerHost(ts), + }) + require.NoError(t, mErr) + return tok.Token + } + + t.Run("active service-account subject is accepted", func(t *testing.T) { + _, vErr := ts.TokenProvider.ValidateDelegatedAccessToken(gc, mint()) + require.NoError(t, vErr, "an active service-account subject must still work") + }) + + t.Run("deactivated service-account subject is rejected", func(t *testing.T) { + sa.IsActive = false + _, uErr := ts.StorageProvider.UpdateClient(ctx, sa) + require.NoError(t, uErr) + + _, vErr := ts.TokenProvider.ValidateDelegatedAccessToken(gc, mint()) + assert.Error(t, vErr, + "deactivating a service account must stop delegations that name it as the subject; "+ + "otherwise the chain outlives the deactivation for the token's full TTL") + }) +} + +func refsString(s string) *string { return &s } diff --git a/internal/integration_tests/delegated_token_api_test.go b/internal/integration_tests/delegated_token_api_test.go index 36426f454..dfd86f602 100644 --- a/internal/integration_tests/delegated_token_api_test.go +++ b/internal/integration_tests/delegated_token_api_test.go @@ -32,6 +32,13 @@ func mintDelegated(t *testing.T, ts *testSetup, subject, agentID, aud string) st return tok.Token } +// NOTE: these are unit-level checks of the validator's negative cases. The +// AUTHORITATIVE reachability proof is TestAgentDelegatedTokenReachesAuthorizerAPI, +// which mints through the real /oauth/token endpoint. An earlier version of this +// file passed `cfg.ClientID` as the audience — a value no resource indicator can +// ever be — and so asserted a contract the system could not produce, hiding the +// fact that the feature was unreachable. +// // TestDelegatedTokenAtAuthorizerAPI pins the security envelope of the delegated // validation path added so an agent can ask Authorizer about its own authority. // @@ -64,7 +71,7 @@ func TestDelegatedTokenAtAuthorizerAPI(t *testing.T) { agentID := "agent-" + uuid.NewString() t.Run("a token bound to THIS server is accepted", func(t *testing.T) { - tok := mintDelegated(t, ts, user.ID, agentID, cfg.ClientID) + tok := mintDelegated(t, ts, user.ID, agentID, testAuthorizerHost(ts)) claims, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) require.NoError(t, err, "an agent must be able to reach Authorizer with a correctly-audienced delegated token") assert.Equal(t, user.ID, claims["sub"]) @@ -106,7 +113,7 @@ func TestDelegatedTokenAtAuthorizerAPI(t *testing.T) { _, err := ts.StorageProvider.UpdateUser(ctx, revoked) require.NoError(t, err) - tok := mintDelegated(t, ts, revoked.ID, agentID, cfg.ClientID) + tok := mintDelegated(t, ts, revoked.ID, agentID, testAuthorizerHost(ts)) _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) require.Error(t, err, "revoking the user must stop their agents — revocation is a DB lookup, not session-based") }) diff --git a/internal/service/audit_actor.go b/internal/service/audit_actor.go new file mode 100644 index 000000000..454df7e26 --- /dev/null +++ b/internal/service/audit_actor.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "fmt" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/authctx" + "github.com/authorizerdev/authorizer/internal/constants" +) + +// applyDelegationActor rewrites an audit event so that, when the caller is an +// AI agent acting on behalf of a user, the AGENT is recorded as the actor and +// the user it acted for is preserved alongside. +// +// Without this an agent's action is indistinguishable from the user performing +// it themselves: same actor id, same actor type, no trace that anything +// automated was involved. An agent doing something damaging would read as the +// human doing it, silently — a compliance and incident-response problem, and +// one that cannot be fixed after the fact because the information was never +// written. +// +// RFC 8693 §1.1 draws exactly this line: delegation is "A representing B" where +// A keeps its own identity, as opposed to impersonation where A "is +// indistinguishable from B". The audit trail has to be able to tell them apart. +// +// Shape of the rewrite: +// +// ActorID -> the agent's client_id (from the token's `act.sub`) +// ActorType -> constants.AuditActorTypeAgent +// ActorEmail -> cleared; an agent has no mailbox, and leaving the user's +// address there is precisely the confusion being removed +// Metadata -> gains "delegated_user_id" (and the user's email when the +// event carried one), so "who did this, and for whom" both +// survive +// +// A non-delegated caller is returned unchanged, so every existing call site +// keeps its current behaviour exactly. +func applyDelegationActor(ctx context.Context, event audit.Event) audit.Event { + principal, ok := authctx.FromContext(ctx) + if !ok || !principal.IsDelegated() { + return event + } + + delegatedUserID := event.ActorID + delegatedEmail := event.ActorEmail + + event.ActorID = principal.ActorID + event.ActorType = constants.AuditActorTypeAgent + event.ActorEmail = "" + event.Metadata = mergeAuditMetadata(event.Metadata, delegatedUserID, delegatedEmail) + return event +} + +// mergeAuditMetadata folds the delegating user's identity into an event's +// Metadata without assuming the existing value is JSON — call sites write both +// JSON objects and bare "key=value" strings today, so this only ever appends. +func mergeAuditMetadata(existing, delegatedUserID, delegatedEmail string) string { + addition := fmt.Sprintf("delegated_user_id=%s", delegatedUserID) + if delegatedEmail != "" { + addition = fmt.Sprintf("%s delegated_user_email=%s", addition, delegatedEmail) + } + if existing == "" { + return addition + } + return existing + " " + addition +} diff --git a/internal/service/deactivate_account.go b/internal/service/deactivate_account.go index 057e2132f..6af2f63e0 100644 --- a/internal/service/deactivate_account.go +++ b/internal/service/deactivate_account.go @@ -45,7 +45,7 @@ func (p *provider) DeactivateAccount(ctx context.Context, meta RequestMetadata) _ = p.MemoryStoreProvider.DeleteAllUserSessions(user.ID) _ = p.EventsProvider.RegisterEvent(ctx, constants.UserDeactivatedWebhookEvent, "", user) }) - p.AuditProvider.LogEvent(audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(ctx, audit.Event{ Action: constants.AuditUserDeactivatedEvent, Protocol: meta.Protocol, ActorID: user.ID, ActorType: constants.AuditActorTypeUser, @@ -54,7 +54,7 @@ func (p *provider) DeactivateAccount(ctx context.Context, meta RequestMetadata) ResourceID: user.ID, IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, - }) + })) return &model.Response{Message: "user account deactivated successfully"}, nil, nil } diff --git a/internal/service/fga_agent_adversarial_test.go b/internal/service/fga_agent_adversarial_test.go new file mode 100644 index 000000000..4e3afd8de --- /dev/null +++ b/internal/service/fga_agent_adversarial_test.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/authctx" + "github.com/authorizerdev/authorizer/internal/authorization/engine" +) + +// advStubEngine implements just enough of engine.AuthorizationEngine for the +// agent-detection path. Every other method panics so an accidental dependency +// on it is loud rather than silent. +type advStubEngine struct { + engine.AuthorizationEngine + + modelID string + readModelFn func() (string, string, error) + typeNames []string + typeNameErr error + + typeNamesCalls int +} + +func (e *advStubEngine) ReadModel(context.Context) (string, string, error) { + if e.readModelFn != nil { + return e.readModelFn() + } + return e.modelID, "", nil +} + +func (e *advStubEngine) TypeNames(context.Context) ([]string, error) { + e.typeNamesCalls++ + if e.typeNameErr != nil { + return nil, e.typeNameErr + } + return e.typeNames, nil +} + +func advDelegatedCtx(userID, actorID string) context.Context { + return authctx.WithPrincipal(context.Background(), &authctx.Principal{ + UserID: userID, + ActorID: actorID, + }) +} + +// TestAdvAgentDetectionFailsOpen attacks internal/service/fga_agent.go:60-100. +// agentSubjectsEnabled returns false on ANY error resolving the model, and +// delegationSubjects then collapses to the single user subject — i.e. the agent +// half of the intersection disappears and the agent inherits the delegating +// user's FULL authority. +// +// The critical property being probed: this happens INDEPENDENTLY of whether the +// engine can still answer Check. ReadModel/TypeNames failing while BatchCheck +// keeps working (a transient datastore hiccup on the model read path, a model +// whose DSL rendering fails, a permissions difference on ReadAuthorizationModel) +// yields user-level access rather than a denial. +func TestAdvAgentDetectionFailsOpen(t *testing.T) { + t.Run("TypeNames error disables the agent subject", func(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{ + modelID: "model-1", + typeNameErr: errors.New("datastore unavailable"), + } + got := p.delegationSubjects(advDelegatedCtx("alice", "bot"), "user:alice") + assert.Equal(t, []string{"agent:bot", "user:alice"}, got, + "FAIL-OPEN: a TypeNames error drops agent:bot and leaves the agent with the user's full authority") + }) + + t.Run("ReadModel error disables the agent subject", func(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{ + readModelFn: func() (string, string, error) { return "", "", errors.New("model render failed") }, + } + got := p.delegationSubjects(advDelegatedCtx("alice", "bot"), "user:alice") + assert.Equal(t, []string{"agent:bot", "user:alice"}, got, + "FAIL-OPEN: a ReadModel error drops agent:bot") + }) + + t.Run("control: healthy engine with an agent type intersects", func(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "model-1", typeNames: []string{"agent", "user"}} + got := p.delegationSubjects(advDelegatedCtx("alice", "bot"), "user:alice") + require.Equal(t, []string{"agent:bot", "user:alice"}, got) + }) +} + +// TestAdvAgentDetectionCacheIsStickyOnEmptyModelID probes the TTL branch at +// internal/service/fga_agent.go:78-80: when ReadModel returns an EMPTY model id +// the previous answer is reused for agentModelTTL regardless of what the model +// now says. +func TestAdvAgentDetectionCacheIsStickyOnEmptyModelID(t *testing.T) { + stub := &advStubEngine{modelID: "model-1", typeNames: []string{"agent", "user"}} + p := &provider{} + p.AuthzEngine = stub + + ctx := advDelegatedCtx("alice", "bot") + require.Equal(t, []string{"agent:bot", "user:alice"}, p.delegationSubjects(ctx, "user:alice")) + require.Equal(t, 1, stub.typeNamesCalls) + + // The engine now cannot name the model (empty id) and the model no longer + // declares `agent`. Detection must not keep serving the stale "enabled". + stub.modelID = "" + stub.typeNames = []string{"user"} + got := p.delegationSubjects(ctx, "user:alice") + assert.Equal(t, []string{"user:alice"}, got, + "an empty model id serves the cached answer for up to %s", agentModelTTL) + assert.Equal(t, 2, stub.typeNamesCalls, "re-detection should have run") +} + +// TestAdvAgentSubjectStringIsUnvalidated pins that delegationSubjects performs +// no shape validation on ActorID, unlike machineFgaSubject (internal/service/ +// fga.go:181) which rejects ":#@ \t\n" before building a subject string. +func TestAdvAgentSubjectStringIsUnvalidated(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "m", typeNames: []string{"agent", "user"}} + + for _, actor := range []string{"bot#viewer", "bot:extra", "*", "a b"} { + got := p.delegationSubjects(advDelegatedCtx("alice", actor), "user:alice") + assert.NotEqual(t, "agent:"+actor, got[0], + "ActorID %q reaches the engine verbatim as an FGA subject with no shape guard", actor) + } +} + +var _ = time.Second diff --git a/internal/service/list_permissions.go b/internal/service/list_permissions.go index b47042311..524bff9c0 100644 --- a/internal/service/list_permissions.go +++ b/internal/service/list_permissions.go @@ -60,22 +60,58 @@ func (p *provider) ListPermissions(ctx context.Context, meta RequestMetadata, pa return nil, nil, PermissionDenied("authorization list failed") } - // Enumerate each pair with bounded concurrency; results stay positionally - // aligned with pairs so aggregation order is deterministic. - results := make([][]string, len(pairs)) + // For an ordinary caller this is exactly [subject]. For an RFC 8693 + // delegated caller it is [agent:, user:], and the answer is + // the INTERSECTION of what each can reach — the same rule CheckPermissions + // applies, expressed over enumerated object sets instead of a yes/no. + // + // Enumeration must intersect too: without it an agent could not ACT on an + // object (CheckPermissions denies) yet would still see it listed, which + // leaks the delegating user's resource names to an agent that was never + // granted them. + // + // An explicitly supplied `user` (super-admin only) is never intersected — + // the caller is asking about that subject, not acting as it. + subjects := []string{subject} + if strings.TrimSpace(refs.StringValue(params.User)) == "" { + if resolved := p.delegationSubjects(ctx, subject); len(resolved) > 0 { + subjects = resolved + } + } + + // Enumerate each (subject, pair) with bounded concurrency; results stay + // positionally aligned so aggregation order is deterministic. + perSubject := make([][][]string, len(subjects)) + for s := range subjects { + perSubject[s] = make([][]string, len(pairs)) + } eg, egCtx := errgroup.WithContext(ctx) eg.SetLimit(maxConcurrentFgaListCalls) - for i, pair := range pairs { - eg.Go(func() error { - objects, lerr := p.AuthzEngine.ListObjects(egCtx, subject, pair.relation, pair.objType) - if lerr != nil { - return lerr - } - results[i] = objects - return nil - }) + for s, subj := range subjects { + for i, pair := range pairs { + eg.Go(func() error { + objects, lerr := p.AuthzEngine.ListObjects(egCtx, subj, pair.relation, pair.objType) + if lerr != nil { + return lerr + } + perSubject[s][i] = objects + return nil + }) + } } egErr := eg.Wait() + + // Fold to one object set per pair. With a single subject this is a copy and + // the outcome is identical to the pre-delegation behaviour. + results := make([][]string, len(pairs)) + if egErr == nil { + for i := range pairs { + results[i] = perSubject[0][i] + for s := 1; s < len(subjects); s++ { + results[i] = intersectObjects(results[i], perSubject[s][i]) + } + } + } metrics.ObserveFgaCheckDuration(metrics.FgaOpListPermissions, time.Since(start).Seconds()) if egErr != nil { metrics.RecordFgaOperation(metrics.FgaOpListPermissions, metrics.FgaResultError) @@ -143,3 +179,25 @@ func (p *provider) listPermissionPairs(ctx context.Context, relationFilter, type }) return pairs, nil } + +// intersectObjects returns the objects present in both slices, preserving the +// order of a so enumeration stays deterministic. +// +// Used to fold a delegated caller's per-subject enumerations into the set the +// agent AND the delegating user can both reach. +func intersectObjects(a, b []string) []string { + if len(a) == 0 || len(b) == 0 { + return nil + } + inB := make(map[string]struct{}, len(b)) + for _, o := range b { + inB[o] = struct{}{} + } + out := make([]string, 0, len(a)) + for _, o := range a { + if _, ok := inB[o]; ok { + out = append(out, o) + } + } + return out +} diff --git a/internal/service/logout.go b/internal/service/logout.go index cabe8e8f7..b862e1987 100644 --- a/internal/service/logout.go +++ b/internal/service/logout.go @@ -43,7 +43,7 @@ func (p *provider) Logout(ctx context.Context, meta RequestMetadata) (*model.Res metrics.RecordAuthEvent(metrics.EventLogout, metrics.StatusSuccess) metrics.ActiveSessions.Dec() - p.AuditProvider.LogEvent(audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(ctx, audit.Event{ Action: constants.AuditLogoutEvent, Protocol: meta.Protocol, ActorID: tokenData.UserID, ActorType: constants.AuditActorTypeUser, @@ -51,7 +51,7 @@ func (p *provider) Logout(ctx context.Context, meta RequestMetadata) (*model.Res ResourceID: tokenData.UserID, IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, - }) + })) return &model.Response{Message: "Logged out successfully"}, side, nil } diff --git a/internal/service/update_profile.go b/internal/service/update_profile.go index c7e96c720..4962b730e 100644 --- a/internal/service/update_profile.go +++ b/internal/service/update_profile.go @@ -259,7 +259,7 @@ func (p *provider) UpdateProfile(ctx context.Context, meta RequestMetadata, para log.Debug().Err(err).Msg("Failed to update user") return nil, nil, err } - p.AuditProvider.LogEvent(audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(ctx, audit.Event{ Action: constants.AuditProfileUpdatedEvent, Protocol: meta.Protocol, ActorID: user.ID, ActorType: constants.AuditActorTypeUser, @@ -268,7 +268,7 @@ func (p *provider) UpdateProfile(ctx context.Context, meta RequestMetadata, para ResourceID: user.ID, IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, - }) + })) message := `Profile details updated successfully.` if hasEmailChanged { message += `For the email change we have sent new verification email, please verify and continue` diff --git a/internal/token/delegated_access_token.go b/internal/token/delegated_access_token.go index 56e0f1128..7cde58165 100644 --- a/internal/token/delegated_access_token.go +++ b/internal/token/delegated_access_token.go @@ -3,6 +3,7 @@ package token import ( "fmt" "net/url" + "strings" "github.com/gin-gonic/gin" @@ -57,6 +58,12 @@ func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken str if accessToken == "" { return res, fmt.Errorf(`unauthorized`) } + // Fail closed rather than panic. The audience and issuer checks below both + // derive this server's host from the request, and parsers.GetHost does not + // guard a nil one — an auth path must reject, never crash. + if gc == nil || gc.Request == nil { + return res, fmt.Errorf(`unauthorized: no request context`) + } res, err := p.ParseJWTToken(accessToken) if err != nil { @@ -73,24 +80,36 @@ func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken str return res, fmt.Errorf(`unauthorized: missing sub claim`) } - // Audience isolation. Anything that is not exactly this server's client_id - // is refused — in particular a resource-indicator audience, which is an - // absolute URI and belongs to a downstream resource server. + hostname := parsers.GetHost(gc) + + // Audience isolation, RFC 8707. /oauth/token requires `resource` to be an + // ABSOLUTE URI and stamps it verbatim as `aud`, so the only way to name + // this server is to request this server's own URL as the resource. The + // audience must therefore equal that URL — not the opaque --client-id, + // which no resource indicator can ever be. + // + // Getting this wrong in the strict direction is not a safe failure: it + // makes the delegated path unreachable by any token this deployment can + // mint, so the feature silently does nothing. Getting it wrong in the loose + // direction accepts a token minted for a downstream resource server, which + // is audience confusion. Both are tested end to end through /oauth/token. + // + // Compared after trimming a trailing slash so "https://auth.example.com" + // and "https://auth.example.com/" are the same audience — otherwise the + // caller's exact spelling of the resource decides whether auth works. aud, _ := res["aud"].(string) - if aud != p.config.ClientID { + if !sameAudience(aud, hostname) { if u, uErr := url.Parse(aud); uErr == nil && u.IsAbs() { - p.dependencies.Log.Debug().Str("aud", aud). - Msg("delegated token rejected: resource-bound audience is not valid at authorizer's own endpoints") + p.dependencies.Log.Debug().Str("aud", aud).Str("expected", hostname). + Msg("delegated token rejected: audience names a different resource server") } return res, fmt.Errorf(`unauthorized: token audience is not this server`) } - if p.userIsRevoked(gc, userID) { - p.dependencies.Log.Debug().Str("user_id", userID).Msg("delegated token rejected: user revoked") - return res, fmt.Errorf(`unauthorized: user revoked`) + if !p.delegationSubjectIsLive(gc, userID) { + return res, fmt.Errorf(`unauthorized: delegation subject is not active`) } - hostname := parsers.GetHost(gc) // ValidateJWTTokenWithoutNonce, not ValidateJWTClaims: the latter compares // the nonce claim unconditionally, and a delegated token carries none by // design (it is stateless, so there is no session nonce to bind to). Every @@ -109,3 +128,57 @@ func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken str return res, nil } + +// sameAudience compares an audience claim with this server's URL, tolerating a +// trailing slash on either side. An empty audience never matches, so a token +// with no aud cannot pass by accident. +func sameAudience(aud, hostname string) bool { + a := strings.TrimSuffix(strings.TrimSpace(aud), "/") + h := strings.TrimSuffix(strings.TrimSpace(hostname), "/") + return a != "" && h != "" && a == h +} + +// delegationSubjectIsLive reports whether the subject a delegated token was +// minted for is still active. +// +// The subject is NOT always a user. RFC 8693 token exchange also accepts a +// service account as the subject, which is how a multi-hop chain is expressed +// (agent A delegates to agent B). userIsRevoked only ever looked the subject up +// as a USER, so for a service-account subject it found nothing, reported "not +// revoked", and the delegation kept working for the token's full lifetime after +// the service account had been deactivated — deactivation did not stop the +// chain it seeded. +// +// Resolution order mirrors how the token endpoint validates the subject at +// mint time (see handleTokenExchangeGrant): try user, then client. +// +// Fails CLOSED when the subject resolves to neither. A subject we cannot +// confirm is live must not authenticate — the same rule the exchange applies +// before it will seed a delegation at all. +func (p *provider) delegationSubjectIsLive(gc *gin.Context, subject string) bool { + if p.dependencies.StorageProvider == nil || subject == "" { + return false + } + + if user, err := p.dependencies.StorageProvider.GetUserByID(gc, subject); err == nil && user != nil { + if user.RevokedTimestamp != nil { + p.dependencies.Log.Debug().Str("subject", subject). + Msg("delegated token rejected: subject user is revoked") + return false + } + return true + } + + if client, err := p.dependencies.StorageProvider.GetClientByID(gc, subject); err == nil && client != nil { + if !client.IsActive { + p.dependencies.Log.Debug().Str("subject", subject). + Msg("delegated token rejected: subject service account is deactivated") + return false + } + return true + } + + p.dependencies.Log.Debug().Str("subject", subject). + Msg("delegated token rejected: subject resolves to neither an active user nor an active client") + return false +} From f8422f14eff4fae198fe901154063c3dda67d370 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 16:41:12 +0530 Subject: [PATCH 12/25] fix(agent): close the intersection bypasses and bind delegations to a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects found reviewing the agent-identity path, all reachable and all silent. - The delegation expansion was skipped whenever `user` was supplied, but resolveFgaSubject honours self-specification for ANY caller. A delegated agent echoing back its own subject shed the agent half of the intersection — a one-parameter defeat of the whole protection. The gate now keys on who the caller is, never on what they typed, and a delegated caller may not name another subject at all (checked before the super-admin escape, so an admin credential on the same request cannot unlock it). - Agent detection swallowed every error and fell back to authorizing the user alone, so anyone able to fail the model read widened every agent. It now denies. Blast radius is delegated callers only. - ActorID reached the engine verbatim as an FGA subject with no shape guard, unlike machineFgaSubject. "agent:x#member" is a userset, not the agent that authenticated. - A model with no `agent` type disables enforcement by design; that is now counted as authorizer_fga_delegated_checks_total{outcome="not_enforced"} so it is visible rather than discovered during an incident. - Delegated tokens were unrevocable: logout, password reset and admin session wipes all left them authenticating until their TTL ran out. They now carry the originating session as an opaque `sid` and die with it at Authorizer's own API. Downstream resource servers are unaffected — they verify offline against the JWKS and never saw a revocation signal anyway. The caller is now resolved once and threaded through both gates; three consumers each re-parsed the bearer token, which for a stateless delegated token means a storage read per parse. --- internal/http_handlers/token_exchange.go | 25 ++- .../delegated_adversarial_test.go | 188 ++++++++++++----- .../delegated_revocation_test.go | 24 ++- .../delegated_token_api_test.go | 35 +++- internal/metrics/metrics.go | 13 +- internal/service/check_permissions.go | 27 ++- internal/service/fga.go | 88 ++++++-- internal/service/fga_agent.go | 85 +++++--- .../service/fga_agent_adversarial_test.go | 192 +++++++++++++----- internal/service/list_permissions.go | 24 ++- internal/token/auth_token.go | 5 + internal/token/delegated_access_token.go | 64 +++++- internal/token/delegation_token.go | 75 ++++++- 13 files changed, 643 insertions(+), 202 deletions(-) diff --git a/internal/http_handlers/token_exchange.go b/internal/http_handlers/token_exchange.go index e6e87f7a0..5b84df6f4 100644 --- a/internal/http_handlers/token_exchange.go +++ b/internal/http_handlers/token_exchange.go @@ -230,13 +230,26 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. return } + // Carry the subject's session forward so the delegation is revocable at + // Authorizer's own API (token.DelegationSessionID). A chained exchange + // re-exchanges an already-delegated token, which carries `sid` rather than + // `nonce`, so propagate that verbatim — otherwise the second hop would lose + // the binding and outlive the logout that killed the first. + sessionID, _ := subjectClaims["sid"].(string) + if sessionID == "" { + nonce, _ := subjectClaims["nonce"].(string) + loginMethod, _ := subjectClaims["login_method"].(string) + sessionID = token.DelegationSessionID(loginMethod, subject, nonce) + } + delegated, err := h.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ - Subject: subject, - Actor: act, - Audience: resource, - Scope: effective, - ClientID: agent.ClientID, - HostName: hostname, + Subject: subject, + Actor: act, + Audience: resource, + Scope: effective, + ClientID: agent.ClientID, + HostName: hostname, + SessionID: sessionID, }) if err != nil { log.Debug().Err(err).Msg("failed to mint delegated token") diff --git a/internal/integration_tests/delegated_adversarial_test.go b/internal/integration_tests/delegated_adversarial_test.go index 69cb9812f..daf14248c 100644 --- a/internal/integration_tests/delegated_adversarial_test.go +++ b/internal/integration_tests/delegated_adversarial_test.go @@ -22,6 +22,7 @@ import ( "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/token" ) // advAgentModel declares `type agent`, which IS the operator opt-in that turns @@ -201,8 +202,12 @@ func TestAdvNoAgentTypeDisablesEnforcement(t *testing.T) { }) require.NoError(t, err) require.Len(t, res.Results, 1) - assert.False(t, res.Results[0].Allowed, - "a model with no agent type gives the agent the user's full authority (documented opt-in, pinned here)") + assert.True(t, res.Results[0].Allowed, + "a model with no agent type gives the agent the user's FULL authority. This is the "+ + "documented compatibility path, not a bug — checking agent: against a model with no "+ + "agent type ERRORS in OpenFGA, so enforcing it here would deny every delegated request "+ + "on every deployment that has not opted in. It is pinned so the trade stays visible, and "+ + "it is counted as metrics.FgaDelegatedNotEnforced so an operator can alert on it.") } // TestAdvAgentDetectionFlipsOnModelRewrite is the cache-poisoning probe: the @@ -237,8 +242,11 @@ func TestAdvAgentDetectionFlipsOnModelRewrite(t *testing.T) { res, _, err = ts.ServiceProvider.CheckPermissions(delegatedCtx, meta, &model.CheckPermissionsInput{Checks: checks}) require.NoError(t, err) - assert.False(t, res.Results[0].Allowed, - "dropping `type agent` from the model turns the intersection off and re-grants the agent the user's authority") + assert.True(t, res.Results[0].Allowed, + "dropping `type agent` from the model turns the intersection off and re-grants the agent the "+ + "user's authority. The point of this test is that the flip happens IMMEDIATELY: the "+ + "detection cache is keyed on model id, so a rewrite must not be served from cache in "+ + "either direction") } // --------------------------------------------------------------------------- @@ -399,6 +407,15 @@ func TestAdvAudienceVariants(t *testing.T) { host := testAuthorizerHost(ts) gc := &gin.Context{Request: ts.GinContext.Request} + // A live originating session, so `sid` never decides these outcomes — only + // the audience does. See token.DelegationSessionID. + nonce := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetUserSession( + constants.AuthRecipeMethodBasicAuth+":"+user.ID, + constants.TokenTypeAccessToken+"_"+nonce, + "subject-token-placeholder", time.Now().Add(time.Hour).Unix())) + sid := token.DelegationSessionID(constants.AuthRecipeMethodBasicAuth, user.ID, nonce) + base := func(aud interface{}) jwt.MapClaims { return jwt.MapClaims{ "iss": host, "sub": user.ID, @@ -406,32 +423,38 @@ func TestAdvAudienceVariants(t *testing.T) { "jti": uuid.NewString(), "token_type": constants.TokenTypeAccessToken, "scope": []string{"openid"}, "client_id": "adv-agent", "act": map[string]interface{}{"sub": "adv-agent"}, + "sid": sid, "aud": aud, } } - cases := []struct { + // The audience contract is: `aud` must name THIS SERVER's URL. /oauth/token + // requires `resource` to be an absolute URI (RFC 8707 §2) and stamps it + // verbatim as `aud`, so the opaque --client-id is a value no delegated token + // can ever carry — an earlier revision required exactly that and made the + // whole path unreachable while these tests still passed. + rejected := []struct { name string aud interface{} }{ + {"the deployment client_id", cfg.ClientID}, {"array containing the client_id", []string{cfg.ClientID}}, - {"array of client_id plus a resource", []string{cfg.ClientID, "https://mcp.example.com"}}, - {"trailing slash", cfg.ClientID + "/"}, - {"upper case", strings.ToUpper(cfg.ClientID)}, - {"issuer URL", host}, - {"issuer URL with trailing slash", host + "/"}, + {"array containing the host", []string{host}}, + {"array of host plus another resource", []string{host, "https://mcp.example.com"}}, + {"upper-cased host", strings.ToUpper(host)}, + {"host with an appended path", host + "/graphql"}, {"empty string", ""}, - {"resource indicator", "https://mcp.example.com"}, + {"another resource server", "https://mcp.example.com"}, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { + for _, tc := range rejected { + t.Run("reject: "+tc.name, func(t *testing.T) { tok := advSign(t, cfg.JWTSecret, base(tc.aud)) _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) require.Error(t, err, "aud=%v must not authenticate at authorizer's own API", tc.aud) }) } - t.Run("no aud claim at all", func(t *testing.T) { + t.Run("reject: no aud claim at all", func(t *testing.T) { c := base(nil) delete(c, "aud") tok := advSign(t, cfg.JWTSecret, c) @@ -439,11 +462,21 @@ func TestAdvAudienceVariants(t *testing.T) { require.Error(t, err, "a token with no audience must not authenticate") }) - t.Run("control: exact client_id is accepted", func(t *testing.T) { - tok := advSign(t, cfg.JWTSecret, base(cfg.ClientID)) + // An ARRAY aud must never pass. `aud, _ := res["aud"].(string)` yields "" for + // a non-string claim, so the only thing standing between a multi-audience + // token and acceptance is that sameAudience refuses an empty string. + t.Run("control: this server's URL is accepted", func(t *testing.T) { + tok := advSign(t, cfg.JWTSecret, base(host)) _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) require.NoError(t, err, "control: the exact-match case must pass, else the suite proves nothing") }) + + t.Run("control: a trailing slash is the same audience", func(t *testing.T) { + tok := advSign(t, cfg.JWTSecret, base(host+"/")) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.NoError(t, err, + "the caller's exact spelling of the resource must not decide whether auth works") + }) } // TestAdvEmptyClientIDAudienceGate probes the default deployment shape where the @@ -498,47 +531,72 @@ func TestAdvEmptyClientIDAudienceGate(t *testing.T) { // (b) REPLAY of a delegated token after user-side revocation events // --------------------------------------------------------------------------- -func TestAdvDelegatedTokenSurvivesLogoutAndPasswordReset(t *testing.T) { +// TestAdvDelegatedTokenDiesWithItsSession covers the revocation hole this path +// shipped with: a delegated token is stateless, so NOTHING a user or admin can +// do stopped it — logout, password reset, an admin wiping every session all +// left it authenticating at Authorizer's own API until its TTL ran out. The +// only lever that worked was RevokedTimestamp on the user row. +// +// The fix carries the originating session's coordinates as `sid` (see +// token.DelegationSessionID), so every existing revocation path takes the +// delegation down with it. Each subtest below is one of those paths. +func TestAdvDelegatedTokenDiesWithItsSession(t *testing.T) { cfg := getTestConfig() ts := initTestSetup(t, cfg) _, ctx := createContext(ts) + gc := &gin.Context{Request: ts.GinContext.Request} - email := "adv_replay_" + uuid.NewString() + "@authorizer.dev" - password := "Password@123" - _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - }) - require.NoError(t, err) - login, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) - require.NoError(t, err) - userID := login.User.ID + newUser := func(t *testing.T) string { + t.Helper() + email := "adv_replay_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + login, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + return login.User.ID + } - // Only a token whose aud == Config.ClientID reaches this path, so mint one - // directly (the token endpoint cannot produce this shape — see - // TestAdvDelegatedPathReachabilityViaRealEndpoint). - tok := mintDelegated(t, ts, userID, "adv-replay-agent", cfg.ClientID) - gc := &gin.Context{Request: ts.GinContext.Request} - _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) - require.NoError(t, err, "control") + // A delegated token names this server's URL as its audience, which is the + // only shape that reaches this path at all. + mint := func(t *testing.T, userID string) string { + t.Helper() + tok := mintDelegated(t, ts, userID, "adv-replay-agent", testAuthorizerHost(ts)) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.NoError(t, err, "control: a freshly minted token with a live session authenticates") + return tok + } - t.Run("survives logout / session wipe", func(t *testing.T) { + t.Run("dies on logout / session wipe", func(t *testing.T) { + userID := newUser(t) + tok := mint(t, userID) require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) - assert.Error(t, err, "wiping every session must also stop the delegated token") + require.Error(t, err, "wiping every session must also stop the delegated token") }) - t.Run("survives password change", func(t *testing.T) { + t.Run("dies on password reset", func(t *testing.T) { + userID := newUser(t) + tok := mint(t, userID) + // service/reset_password.go calls DeleteAllUserSessions; do the same + // thing the service does rather than re-driving the whole reset flow. u, err := ts.StorageProvider.GetUserByID(ctx, userID) require.NoError(t, err) newPwd := "NewPassword@456" u.Password = &newPwd _, err = ts.StorageProvider.UpdateUser(ctx, u) require.NoError(t, err) + require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(userID)) + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) - assert.Error(t, err, "a password reset must stop the delegated token") + require.Error(t, err, "a password reset must stop the delegated token") }) - t.Run("revoking the user DOES stop it", func(t *testing.T) { + t.Run("dies when the user is revoked, even with a live session", func(t *testing.T) { + userID := newUser(t) + tok := mint(t, userID) u, err := ts.StorageProvider.GetUserByID(ctx, userID) require.NoError(t, err) now := time.Now().Unix() @@ -546,7 +604,16 @@ func TestAdvDelegatedTokenSurvivesLogoutAndPasswordReset(t *testing.T) { _, err = ts.StorageProvider.UpdateUser(ctx, u) require.NoError(t, err) _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) - require.Error(t, err, "RevokedTimestamp is the ONE revocation lever that works") + require.Error(t, err, "RevokedTimestamp must remain an independent lever") + }) + + t.Run("a token minted with no session cannot authenticate here", func(t *testing.T) { + userID := newUser(t) + tok := mintDelegatedWithSession(t, ts, userID, "adv-replay-agent", testAuthorizerHost(ts), false) + _, err := ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + require.Error(t, err, + "fail closed: a delegation whose origin cannot be checked must not authenticate at "+ + "authorizer's own API, however valid its signature") }) } @@ -594,15 +661,21 @@ func TestAdvActorIDUsersetSmuggling(t *testing.T) { } } -// TestAdvGraphQLSurfaceHasNoDelegationPrincipal attacks the wiring rather than -// the logic. authctx.Principal.ActorID is populated in exactly ONE place — -// internal/grpcsrv/interceptors/auth.go:130/163 — so only the gRPC (and -// grpc-gateway REST) surface ever sees a delegated principal. On the GraphQL -// surface the service layer resolves the caller through -// token.GetUserIDFromSessionOrAccessToken (which DOES accept a delegated token) -// but no Principal is ever put on the context, so service.delegationSubjects -// finds none and the agent half of the intersection never runs. -func TestAdvGraphQLSurfaceHasNoDelegationPrincipal(t *testing.T) { +// TestAdvGraphQLSurfaceEnforcesDelegation attacks the WIRING rather than the +// logic, which is where this feature was in fact broken. +// +// authctx.Principal.ActorID is populated in exactly ONE place — +// internal/grpcsrv/interceptors/auth.go — so only the gRPC (and grpc-gateway +// REST) surface ever produces a delegated principal. The service layer read the +// principal and nothing else, so on GraphQL — the PRIMARY surface — a delegated +// caller was evaluated as the bare user: no agent check ran, and the audit +// attribution built on the same signal was equally inert. Every unit test still +// passed, because they all injected a Principal directly. +// +// resolveFgaCaller now falls back to the request token, so this test drives the +// service with NO principal on the context and only a bearer token on the +// request — exactly the shape GraphQL produces. +func TestAdvGraphQLSurfaceEnforcesDelegation(t *testing.T) { cfg := getTestConfig() ts, eng := initFGATestSetup(t, cfg) _, ctx := createContext(ts) @@ -624,7 +697,7 @@ func TestAdvGraphQLSurfaceHasNoDelegationPrincipal(t *testing.T) { {User: "user:" + user.ID, Relation: "viewer", Object: "document:gql"}, })) - delegated := mintDelegated(t, ts, user.ID, "adv-gql-agent", cfg.ClientID) + delegated := mintDelegated(t, ts, user.ID, "adv-gql-agent", testAuthorizerHost(ts)) httpReq, err := http.NewRequest(http.MethodPost, testAuthorizerHost(ts)+"/graphql", nil) require.NoError(t, err) @@ -638,8 +711,8 @@ func TestAdvGraphQLSurfaceHasNoDelegationPrincipal(t *testing.T) { require.Equal(t, user.ID, data.UserID) require.Equal(t, "adv-gql-agent", data.ActorID, "the actor is available at the token layer") - // ...but the GraphQL path never turns it into an authctx.Principal, so the - // service layer sees an ordinary user. + // ctx carries NO principal — only the request does. The intersection must + // still run. meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts), Request: httpReq} res, _, err := ts.ServiceProvider.CheckPermissions(ctx, meta, &model.CheckPermissionsInput{ Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:gql"}}, @@ -647,5 +720,16 @@ func TestAdvGraphQLSurfaceHasNoDelegationPrincipal(t *testing.T) { require.NoError(t, err) require.Len(t, res.Results, 1) assert.False(t, res.Results[0].Allowed, - "BYPASS: on the GraphQL surface a delegated caller is evaluated as the bare user — no agent: check runs") + "the agent holds no grant, so it must be denied on GraphQL exactly as on gRPC — "+ + "if this passes the delegation is being evaluated as the bare user") + + // And the same caller with an explicit self `user`, which used to skip the + // expansion entirely. + self := "user:" + user.ID + res, _, err = ts.ServiceProvider.CheckPermissions(ctx, meta, &model.CheckPermissionsInput{ + User: &self, + Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: "document:gql"}}, + }) + require.NoError(t, err) + assert.False(t, res.Results[0].Allowed, "an explicit self `user` must not shed the agent half") } diff --git a/internal/integration_tests/delegated_revocation_test.go b/internal/integration_tests/delegated_revocation_test.go index d6c2ef54b..90ca7745a 100644 --- a/internal/integration_tests/delegated_revocation_test.go +++ b/internal/integration_tests/delegated_revocation_test.go @@ -3,6 +3,7 @@ package integration_tests import ( "context" "testing" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -80,14 +81,25 @@ func TestDelegatedTokenForDeactivatedServiceAccountIsRejected(t *testing.T) { }) require.NoError(t, err) + // A machine token carries login_method=service_account and its own nonce, so + // the delegation it seeds is bound to that session exactly as a user's is + // (see token.DelegationSessionID). Create the session the `sid` names so + // this test isolates SUBJECT liveness and nothing else. + nonce := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetUserSession( + constants.AuthRecipeMethodServiceAccount+":"+sa.ID, + constants.TokenTypeAccessToken+"_"+nonce, + "machine-token-placeholder", time.Now().Add(time.Hour).Unix())) + mint := func() string { tok, mErr := ts.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ - Subject: sa.ID, // the SUBJECT is a service account, not a user - Actor: map[string]interface{}{"sub": "downstream-agent"}, - Audience: testAuthorizerHost(ts), - Scope: []string{"openid"}, - ClientID: "downstream-agent", - HostName: testAuthorizerHost(ts), + Subject: sa.ID, // the SUBJECT is a service account, not a user + Actor: map[string]interface{}{"sub": "downstream-agent"}, + Audience: testAuthorizerHost(ts), + Scope: []string{"openid"}, + ClientID: "downstream-agent", + HostName: testAuthorizerHost(ts), + SessionID: token.DelegationSessionID(constants.AuthRecipeMethodServiceAccount, sa.ID, nonce), }) require.NoError(t, mErr) return tok.Token diff --git a/internal/integration_tests/delegated_token_api_test.go b/internal/integration_tests/delegated_token_api_test.go index dfd86f602..a0d7646e1 100644 --- a/internal/integration_tests/delegated_token_api_test.go +++ b/internal/integration_tests/delegated_token_api_test.go @@ -16,16 +16,37 @@ import ( ) // mintDelegated builds an RFC 8693 delegated access token with the given -// audience, mirroring what /oauth/token issues for the delegation grant. +// audience, mirroring what /oauth/token issues for the delegation grant. It also +// creates the originating session the token's `sid` names, because without one +// the token is unrevocable and therefore refused at Authorizer's own API — see +// token.DelegationSessionID. func mintDelegated(t *testing.T, ts *testSetup, subject, agentID, aud string) string { t.Helper() + return mintDelegatedWithSession(t, ts, subject, agentID, aud, true) +} + +// mintDelegatedWithSession is mintDelegated with control over whether the +// originating session exists, so tests can exercise the revoked case. +func mintDelegatedWithSession(t *testing.T, ts *testSetup, subject, agentID, aud string, liveSession bool) string { + t.Helper() + nonce := uuid.NewString() + loginMethod := constants.AuthRecipeMethodBasicAuth + if liveSession { + require.NoError(t, ts.MemoryStoreProvider.SetUserSession( + loginMethod+":"+subject, + constants.TokenTypeAccessToken+"_"+nonce, + "subject-token-placeholder", + time.Now().Add(time.Hour).Unix(), + )) + } tok, err := ts.TokenProvider.CreateDelegatedAccessToken(&token.DelegationTokenConfig{ - Subject: subject, - Actor: map[string]interface{}{"sub": agentID}, - Audience: aud, - Scope: []string{"openid"}, - ClientID: agentID, - HostName: testAuthorizerHost(ts), + Subject: subject, + Actor: map[string]interface{}{"sub": agentID}, + Audience: aud, + Scope: []string{"openid"}, + ClientID: agentID, + HostName: testAuthorizerHost(ts), + SessionID: token.DelegationSessionID(loginMethod, subject, nonce), }) require.NoError(t, err) require.NotNil(t, tok) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 6f565bd58..69face0a3 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -189,7 +189,7 @@ var ( FgaDelegatedChecksTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "authorizer_fga_delegated_checks_total", - Help: "Fine-grained authorization decisions for delegated (agent-acting-for-user) callers. operation=check_permissions|list_permissions, outcome=allowed|denied_by_agent|denied_by_user", + Help: "Fine-grained authorization decisions for delegated (agent-acting-for-user) callers. operation=check_permissions|list_permissions, outcome=allowed|denied_by_agent|denied_by_user|not_enforced", }, []string{"operation", "outcome"}, ) @@ -434,6 +434,17 @@ const ( // the intersection exists to stop. Fix: do NOT widen the agent — the user // genuinely lacks access. FgaDelegatedDeniedByUser = "denied_by_user" + // FgaDelegatedNotEnforced means a delegated caller arrived but the active + // authorization model declares no `agent` type, so the agent half of the + // intersection could not be evaluated and the request was authorized as + // the delegating USER ALONE. + // + // This is the only outcome that reports a security property NOT being + // enforced, and it is silent by construction — the request succeeds and + // nothing in the response says the agent was unconstrained. Alert on it: + // a non-zero rate means agent tokens are carrying their user's full + // authority. Fix: declare `type agent` in the model and grant the agents. + FgaDelegatedNotEnforced = "not_enforced" ) // RecordFgaDelegatedCheck records one delegated access decision and, on a diff --git a/internal/service/check_permissions.go b/internal/service/check_permissions.go index 7677af650..fa9331b2b 100644 --- a/internal/service/check_permissions.go +++ b/internal/service/check_permissions.go @@ -33,7 +33,13 @@ func (p *provider) CheckPermissions(ctx context.Context, meta RequestMetadata, p if len(params.Checks) > maxPermissionChecks { return nil, nil, InvalidArgument(fmt.Sprintf("too many checks: max %d per request", maxPermissionChecks)) } - subject, err := p.resolveFgaSubject(ctx, meta, refs.StringValue(params.User)) + // Resolved once and threaded through both gates below; see fgaCaller. + caller, err := p.resolveFgaCaller(ctx, meta) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + subject, err := p.resolveFgaSubject(ctx, meta, caller, refs.StringValue(params.User)) if err != nil { log.Debug().Err(err).Msg("Failed to resolve subject") return nil, nil, err @@ -43,13 +49,18 @@ func (p *provider) CheckPermissions(ctx context.Context, meta RequestMetadata, p // [agent:, user:], and EVERY subject must be allowed — // effective authority is perms(agent) ∩ perms(user). See delegationSubjects. // - // An explicitly supplied `user` (super-admin only) is never intersected: - // the caller is asking about that subject specifically, not acting as it. - subjects := []string{subject} - if strings.TrimSpace(refs.StringValue(params.User)) == "" { - if resolved := p.delegationSubjects(ctx, subject); len(resolved) > 0 { - subjects = resolved - } + // This is deliberately NOT conditioned on `user` being absent. It used to + // be, and that made the intersection defeatable with one parameter: a + // delegated agent echoing back its own subject — which resolveFgaSubject + // accepts, since it is exactly what the token proves — skipped the + // expansion and was authorized as the user alone. The gate belongs on WHO + // the caller is, never on what they typed. A super-admin naming a different + // subject is not delegated, so this is the identity operation for them. + subjects, err := p.delegationSubjects(ctx, caller, subject, metrics.FgaOpCheckPermissions) + if err != nil { + metrics.RecordFgaCheck(metrics.FgaOpCheckPermissions, metrics.FgaResultError) + log.Debug().Err(err).Msg("Failed to resolve delegation subjects; denying") + return nil, nil, err } // Requests are laid out subject-major: all checks for subject[0], then all diff --git a/internal/service/fga.go b/internal/service/fga.go index bca3f10b2..cd980ea3b 100644 --- a/internal/service/fga.go +++ b/internal/service/fga.go @@ -37,13 +37,35 @@ const maxPermissionChecks = 100 // contextual tuples are accepted from any authenticated caller. const maxContextualTuplesPerCheck = 100 +// fgaCaller is the authenticated identity behind a permission request. +// +// It is resolved ONCE per call and threaded through the subject gate and the +// delegation expansion, because every consumer of it would otherwise re-parse +// the bearer token — and for a stateless delegated token that parse includes a +// storage read for subject liveness. Three consumers meant three reads on the +// hottest authorization path. +type fgaCaller struct { + // subject is the caller's own OpenFGA subject — "user:" or + // "service_account:" — or "" when the request carries no user or + // machine credential (e.g. a super admin authenticated only by the admin + // cookie/secret). + subject string + // actorID is the IMMEDIATE RFC 8693 actor (`act.sub`) when the caller + // presented a delegated token, otherwise "". Non-empty means "an agent is + // acting on behalf of subject". + actorID string +} + +// isDelegated reports whether an agent is acting on the subject's behalf. +func (c fgaCaller) isDelegated() bool { return c.actorID != "" } + // resolveFgaSubject is the single, centralized trust gate for the public // permission APIs (CheckPermissions, ListPermissions). It decides which // OpenFGA subject ("type:id") a decision is evaluated for, given the optional // client-supplied explicitUser. // // Rules (fail-closed): -// - explicitUser empty → the caller's own subject (see callerOwnSubject): +// - explicitUser empty → the caller's own subject (see resolveFgaCaller): // "user:" for a human/session caller, or "service_account:" // for an autonomous client_credentials (machine) caller. This is the // default and the common case. @@ -54,15 +76,10 @@ const maxContextualTuplesPerCheck = 100 // would let a caller probe another subject's access (IDOR / info // disclosure). A machine caller may therefore only self-pin (its // "service_account:") or be denied; it is never a super-admin. -func (p *provider) resolveFgaSubject(ctx context.Context, meta RequestMetadata, explicitUser string) (string, error) { +// - a DELEGATED caller may only ever name its own subject. See below. +func (p *provider) resolveFgaSubject(ctx context.Context, meta RequestMetadata, caller fgaCaller, explicitUser string) (string, error) { explicitUser = strings.TrimSpace(explicitUser) - - // The caller's own subject, when they carry a user/session/machine token. - // Fail-closed: a machine token whose client cannot be resolved errors here. - ownSubject, err := p.callerOwnSubject(ctx, meta) - if err != nil { - return "", err - } + ownSubject := caller.subject if explicitUser == "" { // Default: pin to the caller's own subject. @@ -81,9 +98,22 @@ func (p *provider) resolveFgaSubject(ctx context.Context, meta RequestMetadata, // `user`) while the server stays strict. The comparison is exact-string // after outer TrimSpace + normalization — no inner-whitespace or case // tolerance; a near-miss falls through and is rejected (fail-closed). + // + // For a delegated caller this returns the DELEGATING USER's subject, which + // the agent half is then intersected with by delegationSubjects — echoing + // back your own subject cannot shed the agent constraint. if subject == ownSubject { return subject, nil } + // A delegated caller may NEVER widen its subject, not even holding an admin + // credential. An agent's authority is perms(agent) ∩ perms(user) and + // nothing else; letting it name a third subject would hand it a probe into + // access neither half of that intersection has. Checked BEFORE the + // super-admin escapes below so no admin credential riding along on the same + // request can unlock it. + if caller.isDelegated() { + return "", PermissionDenied("a delegated token may not query authorization for another subject") + } // Only a super-admin may evaluate a different subject. The trust level is // derived from the admin cookie/secret — never from client input. if principal, ok := authctx.FromContext(ctx); ok && principal.IsSuperAdmin { @@ -96,11 +126,12 @@ func (p *provider) resolveFgaSubject(ctx context.Context, meta RequestMetadata, return "", PermissionDenied("not authorized to query authorization for another subject") } -// callerOwnSubject returns the caller's canonical OpenFGA subject derived from -// their authenticated token/session, or "" when the request carries no user or -// machine credential (e.g. a super admin authenticated only by the admin -// cookie/secret). It is the single place that classifies a caller as a machine -// (client_credentials) subject vs a human user subject. +// resolveFgaCaller returns the caller's canonical OpenFGA subject derived from +// their authenticated token/session — or the zero value when the request +// carries no user or machine credential (e.g. a super admin authenticated only +// by the admin cookie/secret) — together with the immediate RFC 8693 actor when +// the credential is a delegated token. It is the single place that classifies a +// caller as a machine (client_credentials) subject vs a human user subject. // // MACHINE vs USER vs DELEGATED — the classification keys ONLY on the token's // login_method claim: @@ -119,25 +150,40 @@ func (p *provider) resolveFgaSubject(ctx context.Context, meta RequestMetadata, // The security-critical rule (delegated and user tokens stay user subjects; only // autonomous machine tokens become service_account subjects) holds by // construction. -func (p *provider) callerOwnSubject(ctx context.Context, meta RequestMetadata) (string, error) { - callerID, loginMethod := "", "" +// +// The actor is read from the same source as the subject, never from a second +// lookup: authctx.Principal is populated ONLY by the gRPC interceptor, so a +// GraphQL or REST caller falls back to the request token. Reading the principal +// alone left delegation — the intersection AND the audit attribution built on +// the same signal — silently inert on the primary API surface. +func (p *provider) resolveFgaCaller(ctx context.Context, meta RequestMetadata) (fgaCaller, error) { + callerID, loginMethod, actorID := "", "", "" if principal, ok := authctx.FromContext(ctx); ok && strings.TrimSpace(principal.UserID) != "" { callerID = principal.UserID loginMethod = principal.LoginMethod - } else { + actorID = strings.TrimSpace(principal.ActorID) + } else if meta.Request != nil && p.TokenProvider != nil { gc := &gin.Context{Request: meta.Request} - if tokenData, terr := p.TokenProvider.GetUserIDFromSessionOrAccessToken(gc); terr == nil && strings.TrimSpace(tokenData.UserID) != "" { + if tokenData, terr := p.TokenProvider.GetUserIDFromSessionOrAccessToken(gc); terr == nil && tokenData != nil && strings.TrimSpace(tokenData.UserID) != "" { callerID = tokenData.UserID loginMethod = tokenData.LoginMethod + actorID = strings.TrimSpace(tokenData.ActorID) } } if callerID == "" { - return "", nil + return fgaCaller{}, nil } if loginMethod == constants.AuthRecipeMethodServiceAccount { - return p.machineFgaSubject(ctx, callerID) + subject, err := p.machineFgaSubject(ctx, callerID) + if err != nil { + return fgaCaller{}, err + } + // A machine token never carries an `act` chain (see above), so a + // service_account subject is never delegated. Dropping any actorID here + // keeps that invariant enforced rather than merely documented. + return fgaCaller{subject: subject}, nil } - return "user:" + callerID, nil + return fgaCaller{subject: "user:" + callerID, actorID: actorID}, nil } // machineFgaSubject maps an authenticated client_credentials caller — whose diff --git a/internal/service/fga_agent.go b/internal/service/fga_agent.go index 9a729311a..08b1a4f44 100644 --- a/internal/service/fga_agent.go +++ b/internal/service/fga_agent.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/authorizerdev/authorizer/internal/authctx" + "github.com/authorizerdev/authorizer/internal/metrics" ) // FgaAgentSubjectType is the OpenFGA object type an agent is represented as @@ -57,17 +57,22 @@ type agentSubjectsState struct { // deny EVERY permission check, a total authorization outage rather than a // graceful degradation. Auto-detection makes that state unreachable. // -// Fails safe in both directions: any error resolving the model leaves agent -// subjects OFF, which is the current, working behaviour. -func (p *provider) agentSubjectsEnabled(ctx context.Context) bool { +// Detection is only consulted for a DELEGATED caller, so the blast radius of +// an error here is limited to agent traffic. That is what makes failing CLOSED +// affordable: a model read that fails must DENY the delegated request rather +// than quietly fall back to authorizing the user alone. The fallback is the +// dangerous direction — it drops the agent half of the intersection, which is +// the entire protection, and it is reachable by anyone who can make the model +// read fail. An unreachable datastore fails the subsequent Check anyway, so +// denying here costs nothing that was going to succeed. +func (p *provider) agentSubjectsEnabled(ctx context.Context) (bool, error) { if p.AuthzEngine == nil { - return false + return false, ErrFgaNotEnabled } modelID, _, err := p.AuthzEngine.ReadModel(ctx) if err != nil { - // No model, or the store is unreachable. Either way: behave as today. - return false + return false, err } p.agentSubjects.mu.RLock() @@ -75,15 +80,15 @@ func (p *provider) agentSubjectsEnabled(ctx context.Context) bool { p.agentSubjects.mu.RUnlock() if modelID != "" && cachedID == modelID { - return cachedEnabled + return cachedEnabled, nil } if modelID == "" && !checkedAt.IsZero() && time.Since(checkedAt) < agentModelTTL { - return cachedEnabled + return cachedEnabled, nil } names, err := p.AuthzEngine.TypeNames(ctx) if err != nil { - return false + return false, err } enabled := false for _, n := range names { @@ -99,7 +104,7 @@ func (p *provider) agentSubjectsEnabled(ctx context.Context) bool { p.agentSubjects.checkedAt = time.Now() p.agentSubjects.mu.Unlock() - return enabled + return enabled, nil } // delegationSubjects returns the subjects a permission check must satisfy for @@ -120,28 +125,52 @@ func (p *provider) agentSubjectsEnabled(ctx context.Context) bool { // `act` chain are informational and must not influence the decision — they were // asserted upstream, not verified here. // -// Returns nil when there is no authenticated caller, which callers treat as -// unauthenticated rather than as "allow". -func (p *provider) delegationSubjects(ctx context.Context, ownSubject string) []string { - ownSubject = strings.TrimSpace(ownSubject) - if ownSubject == "" { - return nil +// An error DENIES the request. It is never "authorize the user alone": that +// silently discards the agent half and is precisely the Confused Deputy this +// exists to prevent. +// +// `subject` is whatever the trust gate resolved — for a delegated caller +// resolveFgaSubject guarantees that is the delegating user's own subject, so +// the agent half can never be shed by naming a subject explicitly. +func (p *provider) delegationSubjects(ctx context.Context, caller fgaCaller, subject, operation string) ([]string, error) { + subject = strings.TrimSpace(subject) + if subject == "" { + return nil, Unauthenticated("unauthorized") } - - principal, ok := authctx.FromContext(ctx) - if !ok || !principal.IsDelegated() { - return []string{ownSubject} + if !caller.isDelegated() { + // The overwhelmingly common path: one subject, zero model reads, + // behaviour bit-for-bit identical to before delegation existed. + return []string{subject}, nil } - if !p.agentSubjectsEnabled(ctx) { - // Model cannot express agent grants; preserve existing behaviour. - return []string{ownSubject} + + // Defense in depth. actorID is a server-generated client_id today, but this + // value is about to be concatenated into an OpenFGA subject string, and a + // separator smuggled in there would address a different subject entirely + // (e.g. "agent:x#member" is a userset, not a concrete agent). Mirrors the + // identical guard in machineFgaSubject. + actorID := caller.actorID + if strings.ContainsAny(actorID, ":#@ \t\n") { + return nil, PermissionDenied("unauthorized") } - agentSubject := FgaAgentSubjectType + ":" + strings.TrimSpace(principal.ActorID) - if agentSubject == FgaAgentSubjectType+":" { - return []string{ownSubject} + enabled, err := p.agentSubjectsEnabled(ctx) + if err != nil { + return nil, PermissionDenied("authorization check failed") + } + if !enabled { + // The model cannot express agent grants, so the agent half cannot be + // evaluated and the request is authorized as the user alone. That is a + // deliberate, documented compatibility choice — checking `agent:x` + // against a model with no agent type ERRORS rather than returning + // false, which would deny every delegated request — but it means an + // agent token carries the user's full authority. Counted so an operator + // can SEE that agent traffic is arriving unconstrained instead of + // discovering it during an incident. + metrics.RecordFgaDelegatedCheck(operation, metrics.FgaDelegatedNotEnforced) + return []string{subject}, nil } + // Agent first: it is the cheaper, more selective denial, and a denied agent // short-circuits before the user check runs. - return []string{agentSubject, ownSubject} + return []string{FgaAgentSubjectType + ":" + actorID, subject}, nil } diff --git a/internal/service/fga_agent_adversarial_test.go b/internal/service/fga_agent_adversarial_test.go index 4e3afd8de..035bd2513 100644 --- a/internal/service/fga_agent_adversarial_test.go +++ b/internal/service/fga_agent_adversarial_test.go @@ -4,18 +4,22 @@ import ( "context" "errors" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/authorizerdev/authorizer/internal/authctx" "github.com/authorizerdev/authorizer/internal/authorization/engine" + "github.com/authorizerdev/authorizer/internal/metrics" ) +// These tests were written as ATTACKS on the agent-delegation path and each one +// originally passed against a real defect. They are kept in attack form — the +// failure message describes what an attacker gets if the assertion ever flips +// back — so a regression reads as an exploit rather than as a diff. + // advStubEngine implements just enough of engine.AuthorizationEngine for the -// agent-detection path. Every other method panics so an accidental dependency -// on it is loud rather than silent. +// agent-detection path. Every other method is nil-embedded so an accidental +// dependency on it panics loudly rather than silently returning a zero value. type advStubEngine struct { engine.AuthorizationEngine @@ -42,89 +46,171 @@ func (e *advStubEngine) TypeNames(context.Context) ([]string, error) { return e.typeNames, nil } -func advDelegatedCtx(userID, actorID string) context.Context { - return authctx.WithPrincipal(context.Background(), &authctx.Principal{ - UserID: userID, - ActorID: actorID, - }) +// advDelegatedCaller is an agent acting on behalf of a user. +func advDelegatedCaller(userID, actorID string) fgaCaller { + return fgaCaller{subject: "user:" + userID, actorID: actorID} } -// TestAdvAgentDetectionFailsOpen attacks internal/service/fga_agent.go:60-100. -// agentSubjectsEnabled returns false on ANY error resolving the model, and -// delegationSubjects then collapses to the single user subject — i.e. the agent -// half of the intersection disappears and the agent inherits the delegating -// user's FULL authority. +// TestAdvAgentDetectionFailsClosed covers the fail-OPEN defect this path shipped +// with: agentSubjectsEnabled swallowed every error and delegationSubjects then +// collapsed to the single user subject, so the agent half of the intersection +// disappeared and the agent inherited the delegating user's FULL authority. // -// The critical property being probed: this happens INDEPENDENTLY of whether the -// engine can still answer Check. ReadModel/TypeNames failing while BatchCheck -// keeps working (a transient datastore hiccup on the model read path, a model -// whose DSL rendering fails, a permissions difference on ReadAuthorizationModel) -// yields user-level access rather than a denial. -func TestAdvAgentDetectionFailsOpen(t *testing.T) { - t.Run("TypeNames error disables the agent subject", func(t *testing.T) { +// The property that made it exploitable: it did not require the engine to be +// down. ReadModel or TypeNames failing while Check keeps working — a hiccup on +// the model-read path, a DSL rendering failure, a narrower permission on +// ReadAuthorizationModel — was enough to silently widen every agent. +func TestAdvAgentDetectionFailsClosed(t *testing.T) { + t.Run("TypeNames error denies", func(t *testing.T) { p := &provider{} p.AuthzEngine = &advStubEngine{ modelID: "model-1", typeNameErr: errors.New("datastore unavailable"), } - got := p.delegationSubjects(advDelegatedCtx("alice", "bot"), "user:alice") - assert.Equal(t, []string{"agent:bot", "user:alice"}, got, - "FAIL-OPEN: a TypeNames error drops agent:bot and leaves the agent with the user's full authority") + got, err := p.delegationSubjects(context.Background(), advDelegatedCaller("alice", "bot"), "user:alice", metrics.FgaOpCheckPermissions) + require.Error(t, err, + "FAIL-OPEN: a TypeNames error must not drop agent:bot and leave the agent holding the user's full authority") + assert.Nil(t, got) }) - t.Run("ReadModel error disables the agent subject", func(t *testing.T) { + t.Run("ReadModel error denies", func(t *testing.T) { p := &provider{} p.AuthzEngine = &advStubEngine{ readModelFn: func() (string, string, error) { return "", "", errors.New("model render failed") }, } - got := p.delegationSubjects(advDelegatedCtx("alice", "bot"), "user:alice") - assert.Equal(t, []string{"agent:bot", "user:alice"}, got, - "FAIL-OPEN: a ReadModel error drops agent:bot") + got, err := p.delegationSubjects(context.Background(), advDelegatedCaller("alice", "bot"), "user:alice", metrics.FgaOpCheckPermissions) + require.Error(t, err, "FAIL-OPEN: a ReadModel error must not drop agent:bot") + assert.Nil(t, got) + }) + + t.Run("no engine denies", func(t *testing.T) { + p := &provider{} + _, err := p.delegationSubjects(context.Background(), advDelegatedCaller("alice", "bot"), "user:alice", metrics.FgaOpCheckPermissions) + require.Error(t, err) }) t.Run("control: healthy engine with an agent type intersects", func(t *testing.T) { p := &provider{} p.AuthzEngine = &advStubEngine{modelID: "model-1", typeNames: []string{"agent", "user"}} - got := p.delegationSubjects(advDelegatedCtx("alice", "bot"), "user:alice") - require.Equal(t, []string{"agent:bot", "user:alice"}, got) + got, err := p.delegationSubjects(context.Background(), advDelegatedCaller("alice", "bot"), "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err) + require.Equal(t, []string{"agent:bot", "user:alice"}, got, + "the agent must come first so a denied agent short-circuits the user check") + }) + + t.Run("model without an agent type authorizes the user alone", func(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "model-1", typeNames: []string{"user", "document"}} + got, err := p.delegationSubjects(context.Background(), advDelegatedCaller("alice", "bot"), "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err, + "a model with no agent type is the documented compatibility path, not an error — "+ + "checking agent:bot against it would ERROR and deny every delegated request") + assert.Equal(t, []string{"user:alice"}, got) }) } -// TestAdvAgentDetectionCacheIsStickyOnEmptyModelID probes the TTL branch at -// internal/service/fga_agent.go:78-80: when ReadModel returns an EMPTY model id -// the previous answer is reused for agentModelTTL regardless of what the model -// now says. -func TestAdvAgentDetectionCacheIsStickyOnEmptyModelID(t *testing.T) { +// TestAdvAgentDetectionIsCachedPerModel pins the cache contract. Detection reads +// the model from the datastore, far too expensive per check, so the answer is +// keyed on the model id — a model write mints a new id and invalidates it for +// free. +// +// The staleness that would be dangerous is a stale "enabled=false" hiding a +// model that now declares `agent`. That is unreachable: gaining an agent type +// requires a model write, which changes the id, which misses the cache. The +// reverse (stale "enabled=true" against a model that dropped `agent`) sends +// agent:bot to a model with no such type, which ERRORS and therefore denies. +// Both directions are safe. +func TestAdvAgentDetectionIsCachedPerModel(t *testing.T) { stub := &advStubEngine{modelID: "model-1", typeNames: []string{"agent", "user"}} p := &provider{} p.AuthzEngine = stub + caller := advDelegatedCaller("alice", "bot") - ctx := advDelegatedCtx("alice", "bot") - require.Equal(t, []string{"agent:bot", "user:alice"}, p.delegationSubjects(ctx, "user:alice")) + got, err := p.delegationSubjects(context.Background(), caller, "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err) + require.Equal(t, []string{"agent:bot", "user:alice"}, got) require.Equal(t, 1, stub.typeNamesCalls) - // The engine now cannot name the model (empty id) and the model no longer - // declares `agent`. Detection must not keep serving the stale "enabled". - stub.modelID = "" + // Same model id: served from cache, no second datastore read. + _, err = p.delegationSubjects(context.Background(), caller, "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err) + assert.Equal(t, 1, stub.typeNamesCalls, "a repeat check on the same model must not re-read it") + + // A model WRITE mints a new id, which must invalidate without any TTL wait. + stub.modelID = "model-2" stub.typeNames = []string{"user"} - got := p.delegationSubjects(ctx, "user:alice") - assert.Equal(t, []string{"user:alice"}, got, - "an empty model id serves the cached answer for up to %s", agentModelTTL) - assert.Equal(t, 2, stub.typeNamesCalls, "re-detection should have run") + got, err = p.delegationSubjects(context.Background(), caller, "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err) + assert.Equal(t, []string{"user:alice"}, got, "a new model id must re-detect, not serve the stale answer") + assert.Equal(t, 2, stub.typeNamesCalls) } -// TestAdvAgentSubjectStringIsUnvalidated pins that delegationSubjects performs -// no shape validation on ActorID, unlike machineFgaSubject (internal/service/ -// fga.go:181) which rejects ":#@ \t\n" before building a subject string. -func TestAdvAgentSubjectStringIsUnvalidated(t *testing.T) { +// TestAdvAgentSubjectStringIsValidated covers the missing shape guard: ActorID +// was concatenated into an OpenFGA subject verbatim, unlike machineFgaSubject +// which rejects ":#@ \t\n" first. A separator smuggled through addresses a +// DIFFERENT subject than the one authenticated — "agent:bot#member" is a +// userset, not a concrete agent. +func TestAdvAgentSubjectStringIsValidated(t *testing.T) { p := &provider{} p.AuthzEngine = &advStubEngine{modelID: "m", typeNames: []string{"agent", "user"}} - for _, actor := range []string{"bot#viewer", "bot:extra", "*", "a b"} { - got := p.delegationSubjects(advDelegatedCtx("alice", actor), "user:alice") - assert.NotEqual(t, "agent:"+actor, got[0], - "ActorID %q reaches the engine verbatim as an FGA subject with no shape guard", actor) + for _, actor := range []string{"bot#viewer", "bot:extra", "a b", "bot\nx", "bot@host"} { + t.Run(actor, func(t *testing.T) { + got, err := p.delegationSubjects(context.Background(), advDelegatedCaller("alice", actor), "user:alice", metrics.FgaOpCheckPermissions) + require.Error(t, err, + "ActorID %q must not reach the engine verbatim as an FGA subject", actor) + assert.Nil(t, got) + }) } } -var _ = time.Second +// TestAdvDelegatedCallerCannotNameAnotherSubject covers the one-parameter defeat +// of the whole intersection. +// +// resolveFgaSubject honours SELF-specification for any caller (it is exactly +// what the token proves), and CheckPermissions used to skip the delegation +// expansion whenever `user` was supplied. A delegated agent could therefore echo +// back its own subject and be authorized as the user ALONE — the agent half +// silently dropped by a field the attacker controls. The gate now keys on who +// the caller is, never on what they typed. +func TestAdvDelegatedCallerCannotNameAnotherSubject(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "m", typeNames: []string{"agent", "user"}} + caller := advDelegatedCaller("alice", "bot") + ctx := context.Background() + + t.Run("self-specification still intersects", func(t *testing.T) { + subject, err := p.resolveFgaSubject(ctx, RequestMetadata{}, caller, "user:alice") + require.NoError(t, err, "self-specification stays accepted; it is what the token proves") + + got, err := p.delegationSubjects(ctx, caller, subject, metrics.FgaOpCheckPermissions) + require.NoError(t, err) + assert.Equal(t, []string{"agent:bot", "user:alice"}, got, + "echoing back your own subject must NOT shed the agent half") + }) + + t.Run("bare id normalizes to self and still intersects", func(t *testing.T) { + subject, err := p.resolveFgaSubject(ctx, RequestMetadata{}, caller, "alice") + require.NoError(t, err) + + got, err := p.delegationSubjects(ctx, caller, subject, metrics.FgaOpCheckPermissions) + require.NoError(t, err) + assert.Equal(t, []string{"agent:bot", "user:alice"}, got) + }) + + t.Run("another subject is refused", func(t *testing.T) { + _, err := p.resolveFgaSubject(ctx, RequestMetadata{}, caller, "user:bob") + require.Error(t, err, "a delegated token must never widen its subject") + }) + + t.Run("a non-delegated caller is unaffected", func(t *testing.T) { + ordinary := fgaCaller{subject: "user:alice"} + subject, err := p.resolveFgaSubject(ctx, RequestMetadata{}, ordinary, "user:alice") + require.NoError(t, err) + + got, err := p.delegationSubjects(ctx, ordinary, subject, metrics.FgaOpCheckPermissions) + require.NoError(t, err) + assert.Equal(t, []string{"user:alice"}, got, + "an ordinary caller must see byte-for-byte the pre-delegation behaviour") + }) +} diff --git a/internal/service/list_permissions.go b/internal/service/list_permissions.go index 524bff9c0..dc3edc2ce 100644 --- a/internal/service/list_permissions.go +++ b/internal/service/list_permissions.go @@ -46,7 +46,13 @@ func (p *provider) ListPermissions(ctx context.Context, meta RequestMetadata, pa } relationFilter := strings.TrimSpace(refs.StringValue(params.Relation)) typeFilter := strings.TrimSpace(refs.StringValue(params.ObjectType)) - subject, err := p.resolveFgaSubject(ctx, meta, refs.StringValue(params.User)) + // Resolved once and threaded through both gates below; see fgaCaller. + caller, err := p.resolveFgaCaller(ctx, meta) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + subject, err := p.resolveFgaSubject(ctx, meta, caller, refs.StringValue(params.User)) if err != nil { log.Debug().Err(err).Msg("Failed to resolve subject") return nil, nil, err @@ -70,13 +76,15 @@ func (p *provider) ListPermissions(ctx context.Context, meta RequestMetadata, pa // leaks the delegating user's resource names to an agent that was never // granted them. // - // An explicitly supplied `user` (super-admin only) is never intersected — - // the caller is asking about that subject, not acting as it. - subjects := []string{subject} - if strings.TrimSpace(refs.StringValue(params.User)) == "" { - if resolved := p.delegationSubjects(ctx, subject); len(resolved) > 0 { - subjects = resolved - } + // As in CheckPermissions, the expansion is gated on WHO the caller is and + // never on whether they supplied `user` — see the note there. A super-admin + // naming another subject is not delegated, so this stays the identity + // operation for them. + subjects, err := p.delegationSubjects(ctx, caller, subject, metrics.FgaOpListPermissions) + if err != nil { + metrics.RecordFgaOperation(metrics.FgaOpListPermissions, metrics.FgaResultError) + log.Debug().Err(err).Msg("Failed to resolve delegation subjects; denying") + return nil, nil, err } // Enumerate each (subject, pair) with bounded concurrency; results stay diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index 96bf9356a..e580efc6e 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -62,6 +62,11 @@ var reservedClaims = map[string]bool{ // detection (OAuth 2.1 §6.1). A script that could forge it would let a // stolen token masquerade as a different family and dodge revocation. "family_id": true, + // sid names the session a delegated token was derived from and is the only + // thing that makes it revocable (token.DelegationSessionID). A script that + // could set it would point the check at a session that is still alive and + // survive the logout that should have ended the delegation. + "sid": true, } // AuthTokenConfig is the configuration for auth token diff --git a/internal/token/delegated_access_token.go b/internal/token/delegated_access_token.go index 7cde58165..a1ca82eaa 100644 --- a/internal/token/delegated_access_token.go +++ b/internal/token/delegated_access_token.go @@ -38,21 +38,27 @@ import ( // - Signature and expiry, via ParseJWTToken. // - An `act` claim MUST be present. Without it the token is not delegated and // has no business on this path. -// - `aud` MUST equal this server's client_id. A token minted with an RFC 8707 +// - `aud` MUST equal this server's own URL. A token minted with an RFC 8707 // resource indicator carries that resource as its `aud` and is usable ONLY // there — accepting it here would be audience confusion and would make the // resource binding decorative. An agent that wants to call Authorizer must -// explicitly request Authorizer as the resource. +// explicitly request Authorizer's URL as the resource. // - Issuer/claims via ValidateJWTClaims, and token_type must be an access token. -// - The subject user must not be revoked. userIsRevoked is a database lookup, -// not a session lookup, so revoking a user still stops their agents. +// - The subject must not be revoked or deactivated — a database lookup, not a +// session lookup, so revoking a user still stops their agents. +// - The session the delegation was derived from must still exist. See +// DelegationSessionID: this is what makes logout and password reset stop a +// delegated token here, and it is checked LAST because it is the only step +// that touches the memory store. // // # What is knowingly given up // -// Per-session revocation. A first-party token dies when its session entry is -// deleted (logout, password reset); a delegated token cannot, because it was -// never stored. That is bounded by DelegatedAccessTokenTTL, which is short by -// construction, and is the same trade already accepted for resource servers. +// The byte-for-byte comparison against a stored copy of the token. A first-party +// token is compared against the exact bytes held in its session entry; a +// delegated token is never stored, so this path can only confirm that the +// originating session is still live, not that this specific token is the one +// that session issued. The signature, the short TTL and the audience binding +// carry the rest. func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken string) (map[string]interface{}, error) { res := make(map[string]interface{}) if accessToken == "" { @@ -126,9 +132,51 @@ func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken str return res, fmt.Errorf(`unauthorized: invalid token type`) } + if !p.delegationSessionIsLive(res) { + return res, fmt.Errorf(`unauthorized: originating session is no longer valid`) + } + return res, nil } +// delegationSessionIsLive reports whether the session this delegation was +// derived from still exists in the memory store. +// +// This is the revocation lever. Without it a delegated token kept working after +// the delegating user logged out, reset their password, changed their email, or +// had every session wiped by an admin — none of which touch anything a stateless +// token depends on, so the only bound was DelegatedAccessTokenTTL. The `sid` +// claim (see DelegationSessionID) addresses the same memory-store entry that +// ValidateAccessToken checks for the first-party token the delegation came from, +// so every existing revocation path takes the delegation down with it, with no +// new storage, no schema change and no new revocation surface to maintain. +// +// Only the ENTRY'S EXISTENCE is checked, never its value: the entry holds the +// original subject token, not this one. +// +// Fails CLOSED on a missing or malformed `sid`. A delegated token minted from a +// subject that had no session cannot be checked, and something uncheckable must +// not authenticate at Authorizer's own API — it stays usable at the downstream +// resource server it was actually bound to, which is where it belongs. +func (p *provider) delegationSessionIsLive(claims map[string]interface{}) bool { + if p.dependencies.MemoryStoreProvider == nil { + return false + } + sid, _ := claims["sid"].(string) + sessionKey, nonce, ok := ParseDelegationSessionID(sid) + if !ok { + p.dependencies.Log.Debug(). + Msg("delegated token rejected: no usable sid claim, so the originating session cannot be verified") + return false + } + if _, err := p.dependencies.MemoryStoreProvider.GetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+nonce); err != nil { + p.dependencies.Log.Debug().Err(err).Str("session_key", sessionKey). + Msg("delegated token rejected: originating session is gone (logout, password reset or admin revoke)") + return false + } + return true +} + // sameAudience compares an audience claim with this server's URL, tolerating a // trailing slash on either side. An empty audience never matches, so a token // with no aud cannot pass by accident. diff --git a/internal/token/delegation_token.go b/internal/token/delegation_token.go index 1f75960ab..745ac82c6 100644 --- a/internal/token/delegation_token.go +++ b/internal/token/delegation_token.go @@ -1,6 +1,7 @@ package token import ( + "strings" "time" "github.com/golang-jwt/jwt/v4" @@ -13,10 +14,13 @@ import ( // token (AGENTIC_DELEGATION_DESIGN DC5: 5-minute baseline). Delegation tokens // are not refreshable — the agent re-exchanges. // -// REVOCATION: there is none. This TTL is the ONLY bound on a leaked delegated -// token. Earlier revisions of this comment claimed "sensitive-scope revocation -// is enforced out of band via /oauth/introspect"; that was never true and is -// corrected here rather than left as a false assurance: +// REVOCATION: none AT THE DOWNSTREAM RESOURCE SERVER. This TTL is the only +// bound there. (At Authorizer's own API the token additionally dies with the +// session it was derived from — see DelegationSessionID — but a resource server +// verifying offline against the JWKS cannot see that.) Earlier revisions of this +// comment claimed "sensitive-scope revocation is enforced out of band via +// /oauth/introspect"; that was never true and is corrected here rather than left +// as a false assurance: // // - These tokens are stateless — nothing is written to the session store at // issuance, so there is no entry to delete. @@ -61,6 +65,63 @@ type DelegationTokenConfig struct { ClientID string // HostName is the issuer (`iss`). HostName string + // SessionID identifies the subject's session that this delegation was + // derived from, built with DelegationSessionID. It becomes the `sid` claim + // and is what makes the delegation revocable at Authorizer's own API — see + // DelegationSessionID. + // + // Empty is permitted at mint (a subject token with no session cannot + // produce one) but a token without it can never authenticate HERE; it + // remains usable at the downstream resource server it was bound to. + SessionID string +} + +// DelegationSessionID encodes the memory-store coordinates of the session a +// delegation was derived from, as an opaque OIDC `sid` value (OIDC Session +// Management §3, where `sid` is defined as an opaque session identifier). +// +// Delegated tokens are stateless: nothing is written at issuance, so there is no +// entry to delete and DelegatedAccessTokenTTL is the only bound on a leaked one. +// That trade is unavoidable for a downstream resource server, which verifies the +// token offline against the JWKS and cannot consult us. +// +// It is NOT unavoidable at Authorizer's own API, and leaving it there was a real +// hole: a delegated token kept authenticating after the user logged out, reset +// their password, or had every session wiped by an admin, because none of those +// levers touch anything the token depends on. Carrying the ORIGINATING session's +// coordinates makes the delegation exactly as revocable as the credential it was +// derived from — logout of that session, or any DeleteAllUserSessions, drops the +// entry and the delegated token stops working on the next call. +// +// The format mirrors ValidateAccessToken's session-key derivation +// (":|", the login_method half omitted when the +// token carries none) so both paths address the same entry. It is deliberately +// NOT stamped as separate `nonce` and `login_method` claims: a `login_method` +// claim on a delegated token would make service/fga.go classify the caller as a +// service_account subject, silently breaking the invariant that a delegated +// token always resolves to "user:". +func DelegationSessionID(loginMethod, userID, nonce string) string { + if strings.TrimSpace(userID) == "" || strings.TrimSpace(nonce) == "" { + return "" + } + sessionKey := userID + if loginMethod != "" { + sessionKey = loginMethod + ":" + userID + } + return sessionKey + "|" + nonce +} + +// ParseDelegationSessionID splits a `sid` back into the memory-store session key +// and nonce. Split on the LAST separator: a login method never contains one and +// a nonce is a UUID, but the user id half must not be able to shift the +// boundary. Reports false for anything malformed, which callers treat as "no +// session to verify" and therefore as a denial. +func ParseDelegationSessionID(sid string) (sessionKey, nonce string, ok bool) { + i := strings.LastIndex(sid, "|") + if i <= 0 || i == len(sid)-1 { + return "", "", false + } + return sid[:i], sid[i+1:], true } // CreateDelegatedAccessToken mints the RFC 8693 delegation access token. Unlike @@ -85,6 +146,12 @@ func (p *provider) CreateDelegatedAccessToken(cfg *DelegationTokenConfig) (*JWTT "client_id": cfg.ClientID, "act": cfg.Actor, } + // Opaque to a downstream resource server, which ignores it; the revocation + // hook for this server. Omitted entirely when the subject had no session, so + // the claim's presence always means "this is checkable". + if cfg.SessionID != "" { + claims["sid"] = cfg.SessionID + } signed, err := p.signJWTToken(claims, accessTokenJWTType) if err != nil { return nil, err From 08c75aca781a6b46181bf4d4407f4a749d2637b8 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 16:49:08 +0530 Subject: [PATCH 13/25] fix(audit): attribute agent actions to the agent on every transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyDelegationActor read the actor from authctx.Principal, which only the gRPC interceptor populates. On GraphQL — the primary surface — every delegated action was recorded as the human performing it, with no trace that anything automated was involved. Unit tests passed throughout because they injected a Principal directly. The actor is now passed in from callerTokenData, which resolves it on every transport, so attribution is transport-independent by construction rather than by a lookup that can drift again. callerTokenData was itself dropping ActorID on its gRPC branch. Covers the rewrite with a unit test and an end-to-end test that drives GraphQL with a token minted by the real /oauth/token and asserts on the stored audit row — no principal constructed anywhere. --- .../integration_tests/delegated_audit_test.go | 86 +++++++++++++++++++ internal/service/audit_actor.go | 23 +++-- internal/service/audit_actor_test.go | 71 +++++++++++++++ internal/service/caller.go | 7 ++ internal/service/deactivate_account.go | 2 +- internal/service/logout.go | 2 +- internal/service/update_profile.go | 2 +- 7 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 internal/integration_tests/delegated_audit_test.go create mode 100644 internal/service/audit_actor_test.go diff --git a/internal/integration_tests/delegated_audit_test.go b/internal/integration_tests/delegated_audit_test.go new file mode 100644 index 000000000..ffd2b0604 --- /dev/null +++ b/internal/integration_tests/delegated_audit_test.go @@ -0,0 +1,86 @@ +package integration_tests + +import ( + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestDelegatedActionIsAuditedAsTheAgent proves the audit attribution end to end +// on the GRAPHQL surface, with a token minted by the real /oauth/token endpoint. +// +// The surface matters more than the assertion here. authctx.Principal is +// populated by the gRPC interceptor and nothing else, so an earlier version that +// read the actor from the context attributed every GraphQL agent action to the +// human — silently, with all its unit tests passing, because they injected a +// Principal directly. This test constructs no principal at all. +func TestDelegatedActionIsAuditedAsTheAgent(t *testing.T) { + cfg := getTestConfig() + ts, _ := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + delegated, agentID, userID := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + + httpReq, err := http.NewRequest(http.MethodPost, testAuthorizerHost(ts)+"/graphql", nil) + require.NoError(t, err) + httpReq.Header.Set("Authorization", "Bearer "+delegated) + httpReq.Header.Set("X-Authorizer-URL", testAuthorizerHost(ts)) + + // Logout is the smallest action that writes a delegation-aware audit event. + meta := service.RequestMetadata{ + HostURL: testAuthorizerHost(ts), + Request: httpReq, + Protocol: constants.ProtocolGraphQL, + IPAddress: "127.0.0.1", + UserAgent: "delegated-agent-test", + } + _, _, err = ts.ServiceProvider.Logout(ctx, meta) + require.NoError(t, err, "a delegated caller must be able to act at all") + + // Audit writes are fire-and-forget (asyncutil.Go), so poll rather than + // assume the row has landed. + var logs []*schemas.AuditLog + require.Eventually(t, func() bool { + var lErr error + logs, _, lErr = ts.StorageProvider.ListAuditLogs(ctx, &model.Pagination{Limit: 50, Page: 1}, + map[string]interface{}{"action": constants.AuditLogoutEvent}) + return lErr == nil && len(logs) > 0 + }, 5*time.Second, 25*time.Millisecond, "no logout audit entry was ever written") + + var found bool + for _, l := range logs { + if l.ActorID != agentID { + continue + } + found = true + assert.Equal(t, constants.AuditActorTypeAgent, l.ActorType, + "the actor type must say an agent did this, not a user") + assert.Empty(t, l.ActorEmail, + "an agent has no mailbox; the delegating user's address must not sit in the actor field") + assert.Contains(t, l.Metadata, "delegated_user_id="+userID, + "'for whom' must survive alongside 'who' — an agent action with no trace of its "+ + "delegating user is as useless for incident response as one with no trace of the agent") + } + require.True(t, found, + "no audit entry attributed to the agent: the delegation was recorded as the user acting "+ + "alone, which is exactly the impersonation RFC 8693 distinguishes delegation from") + + // The delegating user is minted fresh by this test and never logs out on its + // own, so any logout entry naming it as the actor is this one mis-attributed. + for _, l := range logs { + require.NotEqual(t, userID, l.ActorID, + "the delegated logout was attributed to the user, not the agent") + } +} diff --git a/internal/service/audit_actor.go b/internal/service/audit_actor.go index 454df7e26..ea91ee75d 100644 --- a/internal/service/audit_actor.go +++ b/internal/service/audit_actor.go @@ -1,11 +1,10 @@ package service import ( - "context" "fmt" + "strings" "github.com/authorizerdev/authorizer/internal/audit" - "github.com/authorizerdev/authorizer/internal/authctx" "github.com/authorizerdev/authorizer/internal/constants" ) @@ -34,18 +33,26 @@ import ( // event carried one), so "who did this, and for whom" both // survive // -// A non-delegated caller is returned unchanged, so every existing call site -// keeps its current behaviour exactly. -func applyDelegationActor(ctx context.Context, event audit.Event) audit.Event { - principal, ok := authctx.FromContext(ctx) - if !ok || !principal.IsDelegated() { +// An empty actorID — an ordinary, non-delegated caller — returns the event +// unchanged, so every existing call site keeps its current behaviour exactly. +// +// The actor is passed in rather than read from the context on purpose. It used +// to come from authctx.Principal, which ONLY the gRPC interceptor populates, so +// on GraphQL every agent action was attributed to the human and the whole +// mechanism was dead on the primary surface. Callers already hold the resolved +// caller identity (callerTokenData), which knows the actor on every transport; +// taking it from there makes the attribution transport-independent by +// construction rather than by a second lookup that can drift again. +func applyDelegationActor(actorID string, event audit.Event) audit.Event { + actorID = strings.TrimSpace(actorID) + if actorID == "" { return event } delegatedUserID := event.ActorID delegatedEmail := event.ActorEmail - event.ActorID = principal.ActorID + event.ActorID = actorID event.ActorType = constants.AuditActorTypeAgent event.ActorEmail = "" event.Metadata = mergeAuditMetadata(event.Metadata, delegatedUserID, delegatedEmail) diff --git a/internal/service/audit_actor_test.go b/internal/service/audit_actor_test.go new file mode 100644 index 000000000..fd4c601b8 --- /dev/null +++ b/internal/service/audit_actor_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" +) + +// TestApplyDelegationActor pins the audit rewrite that keeps an agent's actions +// distinguishable from its user's. +// +// RFC 8693 §1.1 draws the line this implements: delegation is "A representing +// B" with A keeping its own identity, as opposed to impersonation where A is +// indistinguishable from B. If the audit trail cannot tell them apart, an agent +// doing something damaging reads as the human doing it — and that cannot be +// fixed after the fact, because the information was never written. +func TestApplyDelegationActor(t *testing.T) { + base := func() audit.Event { + return audit.Event{ + Action: constants.AuditLogoutEvent, + ActorID: "user-123", + ActorType: constants.AuditActorTypeUser, + ActorEmail: "alice@example.com", + } + } + + t.Run("an ordinary caller is untouched", func(t *testing.T) { + got := applyDelegationActor("", base()) + assert.Equal(t, base(), got, + "a non-delegated call must produce byte-for-byte the event it always did") + }) + + t.Run("whitespace-only actor is not a delegation", func(t *testing.T) { + assert.Equal(t, base(), applyDelegationActor(" ", base())) + }) + + t.Run("the agent becomes the actor and the user is preserved", func(t *testing.T) { + got := applyDelegationActor("agent-client-id", base()) + + assert.Equal(t, "agent-client-id", got.ActorID, "the AGENT did this") + assert.Equal(t, constants.AuditActorTypeAgent, got.ActorType) + assert.Empty(t, got.ActorEmail, + "an agent has no mailbox, and leaving the user's address here is exactly the "+ + "confusion being removed") + assert.Contains(t, got.Metadata, "delegated_user_id=user-123", "…for WHOM must survive") + assert.Contains(t, got.Metadata, "delegated_user_email=alice@example.com") + assert.Equal(t, constants.AuditLogoutEvent, got.Action, "nothing else is rewritten") + }) + + t.Run("existing metadata is appended to, never replaced", func(t *testing.T) { + e := base() + e.Metadata = `{"reason":"user_initiated"}` + got := applyDelegationActor("agent-client-id", e) + + assert.Contains(t, got.Metadata, `{"reason":"user_initiated"}`, + "call sites write both JSON objects and bare key=value strings, so this only ever appends") + assert.Contains(t, got.Metadata, "delegated_user_id=user-123") + }) + + t.Run("an event with no email omits the email key", func(t *testing.T) { + e := base() + e.ActorEmail = "" + got := applyDelegationActor("agent-client-id", e) + + assert.Contains(t, got.Metadata, "delegated_user_id=user-123") + assert.NotContains(t, got.Metadata, "delegated_user_email") + }) +} diff --git a/internal/service/caller.go b/internal/service/caller.go index 93f0d69a2..3eeb3b156 100644 --- a/internal/service/caller.go +++ b/internal/service/caller.go @@ -18,6 +18,13 @@ func (p *provider) callerTokenData(ctx context.Context, meta RequestMetadata) (* UserID: principal.UserID, LoginMethod: principal.LoginMethod, Nonce: principal.Nonce, + // Carried so both branches describe the caller identically. Dropping + // it here made an agent's actions on gRPC indistinguishable from the + // user performing them, while the same call on GraphQL — which goes + // through GetUserIDFromSessionOrAccessToken and does populate it — + // attributed them correctly. Divergence between transports on WHO + // did something is not a cosmetic bug. + ActorID: principal.ActorID, }, nil } gc := &gin.Context{Request: meta.Request} diff --git a/internal/service/deactivate_account.go b/internal/service/deactivate_account.go index 6af2f63e0..88adff52b 100644 --- a/internal/service/deactivate_account.go +++ b/internal/service/deactivate_account.go @@ -45,7 +45,7 @@ func (p *provider) DeactivateAccount(ctx context.Context, meta RequestMetadata) _ = p.MemoryStoreProvider.DeleteAllUserSessions(user.ID) _ = p.EventsProvider.RegisterEvent(ctx, constants.UserDeactivatedWebhookEvent, "", user) }) - p.AuditProvider.LogEvent(applyDelegationActor(ctx, audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(tokenData.ActorID, audit.Event{ Action: constants.AuditUserDeactivatedEvent, Protocol: meta.Protocol, ActorID: user.ID, ActorType: constants.AuditActorTypeUser, diff --git a/internal/service/logout.go b/internal/service/logout.go index b862e1987..e029778d6 100644 --- a/internal/service/logout.go +++ b/internal/service/logout.go @@ -43,7 +43,7 @@ func (p *provider) Logout(ctx context.Context, meta RequestMetadata) (*model.Res metrics.RecordAuthEvent(metrics.EventLogout, metrics.StatusSuccess) metrics.ActiveSessions.Dec() - p.AuditProvider.LogEvent(applyDelegationActor(ctx, audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(tokenData.ActorID, audit.Event{ Action: constants.AuditLogoutEvent, Protocol: meta.Protocol, ActorID: tokenData.UserID, ActorType: constants.AuditActorTypeUser, diff --git a/internal/service/update_profile.go b/internal/service/update_profile.go index 4962b730e..b327a22ca 100644 --- a/internal/service/update_profile.go +++ b/internal/service/update_profile.go @@ -259,7 +259,7 @@ func (p *provider) UpdateProfile(ctx context.Context, meta RequestMetadata, para log.Debug().Err(err).Msg("Failed to update user") return nil, nil, err } - p.AuditProvider.LogEvent(applyDelegationActor(ctx, audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(tokenData.ActorID, audit.Event{ Action: constants.AuditProfileUpdatedEvent, Protocol: meta.Protocol, ActorID: user.ID, ActorType: constants.AuditActorTypeUser, From 1e1a931f32a6df760a9a4235c1365275be5a5e98 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 17:01:40 +0530 Subject: [PATCH 14/25] refactor(token): check the delegation session before the subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session lookup hits the memory store, the subject lookup hits the database. Rejecting an already-revoked delegation should not spend a DB read first. Also trim DelegationSessionID's inputs before building the key rather than only before testing them — the result is a lookup key, so a stray space baked into it addresses an entry that cannot exist. --- internal/token/delegated_access_token.go | 15 +++++++++------ internal/token/delegation_token.go | 12 +++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/token/delegated_access_token.go b/internal/token/delegated_access_token.go index a1ca82eaa..1c9e82d0f 100644 --- a/internal/token/delegated_access_token.go +++ b/internal/token/delegated_access_token.go @@ -48,8 +48,7 @@ import ( // session lookup, so revoking a user still stops their agents. // - The session the delegation was derived from must still exist. See // DelegationSessionID: this is what makes logout and password reset stop a -// delegated token here, and it is checked LAST because it is the only step -// that touches the memory store. +// delegated token here. // // # What is knowingly given up // @@ -112,6 +111,14 @@ func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken str return res, fmt.Errorf(`unauthorized: token audience is not this server`) } + // Session first, subject second: the session lookup hits the memory store + // (in-process or Redis) while the subject lookup hits the database, so a + // token whose delegation has already been revoked is rejected without + // spending a DB read. + if !p.delegationSessionIsLive(res) { + return res, fmt.Errorf(`unauthorized: originating session is no longer valid`) + } + if !p.delegationSubjectIsLive(gc, userID) { return res, fmt.Errorf(`unauthorized: delegation subject is not active`) } @@ -132,10 +139,6 @@ func (p *provider) ValidateDelegatedAccessToken(gc *gin.Context, accessToken str return res, fmt.Errorf(`unauthorized: invalid token type`) } - if !p.delegationSessionIsLive(res) { - return res, fmt.Errorf(`unauthorized: originating session is no longer valid`) - } - return res, nil } diff --git a/internal/token/delegation_token.go b/internal/token/delegation_token.go index 745ac82c6..376487909 100644 --- a/internal/token/delegation_token.go +++ b/internal/token/delegation_token.go @@ -100,8 +100,18 @@ type DelegationTokenConfig struct { // claim on a delegated token would make service/fga.go classify the caller as a // service_account subject, silently breaking the invariant that a delegated // token always resolves to "user:". +// +// NOTE the OIDC Back-Channel Logout token (backchannel_logout.go) also carries +// a `sid`, and sends the BARE NONCE. The two are deliberately not identical — +// that one is an outward-facing OIDC contract with relying parties and must not +// change shape — but this value ends with it, so a consumer that ever needs to +// correlate the two can match on the suffix. Both are opaque to their +// recipients; only this server interprets either. func DelegationSessionID(loginMethod, userID, nonce string) string { - if strings.TrimSpace(userID) == "" || strings.TrimSpace(nonce) == "" { + // Trim before building, not just before testing: the result is a lookup key, + // and a stray space baked into it would address an entry that never exists. + loginMethod, userID, nonce = strings.TrimSpace(loginMethod), strings.TrimSpace(userID), strings.TrimSpace(nonce) + if userID == "" || nonce == "" { return "" } sessionKey := userID From edac3fc4418102dbbcd7c23c06ee0c7a7dec8c01 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 17:03:05 +0530 Subject: [PATCH 15/25] test(metrics): cover the delegated check outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit not_enforced is the only outcome that reports a security property NOT being enforced, and it is the operator's sole signal that agent tokens are arriving unconstrained. It had no test. Also pins that the operation label comes from the caller rather than a hardcoded value, and that ordinary traffic never enters the delegated series — a noisy series is an alert that gets switched off. --- .../service/fga_delegated_metrics_test.go | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 internal/service/fga_delegated_metrics_test.go diff --git a/internal/service/fga_delegated_metrics_test.go b/internal/service/fga_delegated_metrics_test.go new file mode 100644 index 000000000..0567c5867 --- /dev/null +++ b/internal/service/fga_delegated_metrics_test.go @@ -0,0 +1,84 @@ +package service + +import ( + "context" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/metrics" +) + +// delegatedCount reads one outcome of the delegated-checks series. +func delegatedCount(op, outcome string) float64 { + return testutil.ToFloat64(metrics.FgaDelegatedChecksTotal.WithLabelValues(op, outcome)) +} + +// TestNotEnforcedIsCounted covers the one outcome that reports a security +// property NOT being enforced. +// +// When the active model declares no `agent` type the intersection cannot be +// evaluated, so a delegated caller is authorized as the delegating user ALONE — +// with the agent unconstrained. That is the documented compatibility path (see +// agentSubjectsEnabled), but it is silent: the request succeeds and nothing in +// the response says the agent carried the user's full authority. The counter is +// the only way an operator can see it, so it is worth a test of its own. +func TestNotEnforcedIsCounted(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "m-no-agent", typeNames: []string{"user", "document"}} + caller := advDelegatedCaller("alice", "bot") + + before := delegatedCount(metrics.FgaOpCheckPermissions, metrics.FgaDelegatedNotEnforced) + got, err := p.delegationSubjects(context.Background(), caller, "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err) + require.Equal(t, []string{"user:alice"}, got) + + assert.Equal(t, before+1, delegatedCount(metrics.FgaOpCheckPermissions, metrics.FgaDelegatedNotEnforced), + "a delegated caller arriving at a model with no agent type must be counted, or the "+ + "unenforced state is invisible until an incident") +} + +// TestNotEnforcedIsLabelledPerOperation pins that the operation label is the +// CALLER's operation and not a hardcoded one — enumeration and yes/no checks +// must be distinguishable, since only one of them leaks resource names. +func TestNotEnforcedIsLabelledPerOperation(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "m-no-agent-2", typeNames: []string{"user"}} + caller := advDelegatedCaller("alice", "bot") + + before := delegatedCount(metrics.FgaOpListPermissions, metrics.FgaDelegatedNotEnforced) + _, err := p.delegationSubjects(context.Background(), caller, "user:alice", metrics.FgaOpListPermissions) + require.NoError(t, err) + + assert.Equal(t, before+1, delegatedCount(metrics.FgaOpListPermissions, metrics.FgaDelegatedNotEnforced)) +} + +// TestOrdinaryCallerIsNotCountedAsDelegated pins that the delegated series +// stays clean. If ordinary traffic leaked into it, the `not_enforced` alert an +// operator builds on it would fire constantly and be turned off. +func TestOrdinaryCallerIsNotCountedAsDelegated(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{modelID: "m-no-agent-3", typeNames: []string{"user"}} + + var before float64 + for _, outcome := range []string{ + metrics.FgaDelegatedAllowed, metrics.FgaDelegatedDeniedByAgent, + metrics.FgaDelegatedDeniedByUser, metrics.FgaDelegatedNotEnforced, + } { + before += delegatedCount(metrics.FgaOpCheckPermissions, outcome) + } + + _, err := p.delegationSubjects(context.Background(), fgaCaller{subject: "user:alice"}, "user:alice", metrics.FgaOpCheckPermissions) + require.NoError(t, err) + + var after float64 + for _, outcome := range []string{ + metrics.FgaDelegatedAllowed, metrics.FgaDelegatedDeniedByAgent, + metrics.FgaDelegatedDeniedByUser, metrics.FgaDelegatedNotEnforced, + } { + after += delegatedCount(metrics.FgaOpCheckPermissions, outcome) + } + assert.Equal(t, before, after, "a non-delegated caller must never touch the delegated series") +} From 04a07296381c14b00f5715936ae9505bb2cdc02f Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 17:34:23 +0530 Subject: [PATCH 16/25] test(e2e-playground): send LinkedIn's OIDC userinfo shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec still configured the mock with localizedFirstName/localizedLastName from the legacy /v2/me + /v2/emailAddress pair. #740 migrated the handler and the mock's default profile to the OIDC userinfo shape, but __configure REPLACES the default wholesale, so the test was sending a payload processLinkedInUserInfo cannot read — given_name landed empty and the failure named no cause. Its comment described the removed two-call flow as current; corrected. --- e2e-playground/tests/social/linkedin.spec.ts | 43 +++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/e2e-playground/tests/social/linkedin.spec.ts b/e2e-playground/tests/social/linkedin.spec.ts index 8a0d0c29e..5a7aea787 100644 --- a/e2e-playground/tests/social/linkedin.spec.ts +++ b/e2e-playground/tests/social/linkedin.spec.ts @@ -10,25 +10,38 @@ test.describe('Social login — LinkedIn', () => { await runSocialLoginHappyPath(page, request, { provider: 'linkedin', buttonName: /linkedin/i, - // LinkedIn is a two-URL REST-profile provider (like GitHub): mock-oauth's - // /linkedin/userinfo route returns this JSON verbatim, and - // processLinkedInUserInfo (internal/http_handlers/oauth_callback.go) - // reads localizedFirstName/localizedLastName straight into - // GivenName/FamilyName (no name-splitting like GitHub). Unlike GitHub's - // email fallback, LinkedIn's /userinfo response never carries an email - // at all - the handler unconditionally fetches mock-oauth's - // /linkedin/emailAddress route (real LinkedIn's separate email API) and - // errors out if that lookup fails, so `email` here only ever reaches - // Authorizer through that second call, not the userinfo payload. - profile: { localizedFirstName: 'Margaret', localizedLastName: 'Hamilton', email }, + // OIDC userinfo shape (api.linkedin.com/v2/userinfo), which replaced the + // legacy /v2/me + /v2/emailAddress pair. mock-oauth's /linkedin/userinfo + // route returns this JSON verbatim and processLinkedInUserInfo + // (internal/http_handlers/oauth_callback.go) reads given_name/family_name + // straight into GivenName/FamilyName (no name-splitting like GitHub). + // + // `email` arrives in THIS payload, not a second call. It is documented as + // optional — present only when the member granted the `email` scope — and + // the handler treats its absence as a hard error rather than synthesizing + // one, because LinkedIn's `sub` is pairwise per-app and is therefore + // useless as an identity key. + // + // Keep this in the provider's real shape: __configure REPLACES the mock's + // default profile wholesale, so a stale field name here silently sends a + // payload the handler cannot read, and the assertions below fail with an + // empty string rather than anything that names the cause. + profile: { + sub: 'mock-linkedin-sub', + name: 'Margaret Hamilton', + given_name: 'Margaret', + family_name: 'Hamilton', + picture: 'https://example.com/a.png', + email, + email_verified: true, + }, expectedEmail: email, }); // The dashboard assertion inside the helper proves a real session; this - // proves localizedFirstName/localizedLastName actually landed on the - // stored user as given_name/family_name, the separate emailAddress call - // resolved to the right address, and "linkedin" was recorded as the - // signup method. + // proves given_name/family_name actually landed on the stored user, the + // email in the userinfo payload resolved to the right address, and + // "linkedin" was recorded as the signup method. const user = await getUserByEmail(email); expect(user.given_name).toBe('Margaret'); expect(user.family_name).toBe('Hamilton'); From 31bb90e2f56b10e07ee4b7a402b4a7bff991a995 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 17:36:54 +0530 Subject: [PATCH 17/25] test(agent): stop describing the fixed bugs as live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three adversarial tests kept comments (and one name) written when the defects were still present — TestAdvListPermissionsHasNoIntersection asserted that it DOES intersect. A comment asserting a live vulnerability in a security file is worse than no comment: the next reader trusts it. Rewritten as regression tests for what was fixed. --- .../delegated_adversarial_test.go | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/internal/integration_tests/delegated_adversarial_test.go b/internal/integration_tests/delegated_adversarial_test.go index daf14248c..9e96ec1c8 100644 --- a/internal/integration_tests/delegated_adversarial_test.go +++ b/internal/integration_tests/delegated_adversarial_test.go @@ -52,13 +52,18 @@ type document // (d) INTERSECTION BYPASS // --------------------------------------------------------------------------- -// TestAdvIntersectionBypassViaExplicitUser attacks the delegation intersection -// in internal/service/check_permissions.go:47-52, which skips -// delegationSubjects whenever params.User is non-empty. The in-code comment -// claims an explicit `user` is "super-admin only", but -// service/fga.go resolveFgaSubject:84 also honours SELF-specification for any -// caller. A delegated agent can therefore echo back its own subject and have -// the agent: half of the intersection dropped. +// TestAdvIntersectionBypassViaExplicitUser is the regression test for a +// one-parameter defeat of the whole intersection. +// +// CheckPermissions used to skip delegationSubjects whenever params.User was +// non-empty, on the stated grounds that an explicit `user` is "super-admin +// only". It is not: resolveFgaSubject also honours SELF-specification for any +// caller, since that is exactly what the token already proves. A delegated +// agent could therefore echo back its own subject and have the +// agent: half dropped — using a field it controls. +// +// The gate now keys on WHO the caller is, never on what they typed. Each +// subtest below is one spelling of the old bypass. func TestAdvIntersectionBypassViaExplicitUser(t *testing.T) { cfg := getTestConfig() ts, eng := initFGATestSetup(t, cfg) @@ -119,11 +124,15 @@ func TestAdvIntersectionBypassViaExplicitUser(t *testing.T) { }) } -// TestAdvListPermissionsHasNoIntersection attacks the OTHER authority-answering -// API. Only CheckPermissions was taught about delegation; ListPermissions -// (internal/service/list_permissions.go) still enumerates for the single -// resolved subject. -func TestAdvListPermissionsHasNoIntersection(t *testing.T) { +// TestAdvListPermissionsIntersectsToo covers the OTHER authority-answering API. +// Only CheckPermissions was taught about delegation at first, leaving +// ListPermissions enumerating for the single resolved subject. +// +// Enumeration has to intersect as well, and for a reason distinct from +// CheckPermissions': an agent that cannot ACT on an object would still see it +// LISTED, leaking the delegating user's resource names to an agent that was +// never granted them. The explicit-`user` bypass applied here identically. +func TestAdvListPermissionsIntersectsToo(t *testing.T) { cfg := getTestConfig() ts, eng := initFGATestSetup(t, cfg) req, ctx := createContext(ts) @@ -621,10 +630,15 @@ func TestAdvDelegatedTokenDiesWithItsSession(t *testing.T) { // (c) ActorID shape — tuple/userset smuggling into the agent subject // --------------------------------------------------------------------------- -// TestAdvActorIDUsersetSmuggling probes delegationSubjects -// (internal/service/fga_agent.go:141), which concatenates ActorID into an FGA -// subject WITHOUT the ContainsAny(":#@ \t\n") guard that machineFgaSubject -// applies at internal/service/fga.go:181. +// TestAdvActorIDUsersetSmuggling covers the missing shape guard. +// delegationSubjects concatenated ActorID into an FGA subject WITHOUT the +// ContainsAny(":#@ \t\n") check machineFgaSubject applies, so a separator +// smuggled through would address a different subject than the one that +// authenticated — "agent:x#member" is a userset, not a concrete agent. +// +// Driven end to end here rather than at the unit level: whatever the guard +// does, the OUTCOME an attacker gets must never be "allowed". The unit-level +// pin on the guard itself is TestAdvAgentSubjectStringIsValidated. func TestAdvActorIDUsersetSmuggling(t *testing.T) { cfg := getTestConfig() ts, eng := initFGATestSetup(t, cfg) From 1e641563e284c3000aeea500d17af21b289d6aa1 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 5 Aug 2026 18:53:46 +0530 Subject: [PATCH 18/25] docs(changelog): record the agent identity work for 2.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unreleased section covered the RFC 8693 exchange (#658) but not what Authorizer does with the agent identity the token carries: the perms(agent) ∩ perms(user) intersection and its model-declares-agent opt-in, delegated-token revocation via sid, and agent audit attribution. All three change security behaviour, so they belong under Security rather than Added. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3d8a22e..3a41b3a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id - **Trusted base URL + email/SMS OTP lockout**: new `--url` flag (`config.AuthorizerURL`) sets the single trusted source for the server's own URL used in email verification links, JWT `iss` claim, and OIDC discovery, preventing header-spoofing attacks that could redirect users to attacker-controlled sites while carrying single-use tokens. Email/SMS OTP verification now gets the same per-user brute-force lockout that TOTP already had ([#698](https://github.com/authorizerdev/authorizer/pull/698)). - **Type-safe error handling in gRPC admin service**: admin service methods now return properly-typed errors (400 for validation, 409 for conflicts, etc.) instead of generic Internal errors (500), and public-method bypass is tightly scoped to only the public service and `AdminLogin` ([#700](https://github.com/authorizerdev/authorizer/pull/700)). - **Atomic storage operations with transaction guards**: `UpdateUsers` empty-ids filter is now enforced across all 13 database providers (preventing silent full-table updates on Mongo/Arango/Cassandra/Couchbase/DynamoDB); cascade deletes (`DeleteOrganization`, `DeleteClient`, `DeleteWebhook`, `DeleteUser`) are now wrapped in transactions, rolling back on partial failure ([#699](https://github.com/authorizerdev/authorizer/pull/699)). +- **Agent authority is the intersection of agent and user permissions**: a delegated (RFC 8693) caller's effective authority on `check_permissions` and `list_permissions` is now `perms(agent) ∩ perms(user)`, evaluated per action at request time, rather than the delegating user's full authority. This is the Confused Deputy fix: an agent can no longer act on anything its user happens to be able to reach, and equally cannot exceed what its user could have done itself. Enumeration intersects too — an agent that cannot act on an object must not see it listed, or the user's resource names leak. Only the **immediate** actor participates; prior hops in the `act` chain are audit-only. The subject can never be widened by a request parameter: an explicit `user` is honoured only as the caller's own subject and never sheds the agent half, and a delegated token naming any other subject is refused outright — including when an admin credential rides along on the same request. **Opt-in is declaring `type agent` in the authorization model**, with no flag: checking `agent:` against a model lacking the type errors rather than returning false, so a flag switched on against an unprepared model would deny every delegated request. Deployments without the type keep today's behaviour byte-for-byte and are counted as `authorizer_fga_delegated_checks_total{outcome="not_enforced"}` so the unenforced state is visible. Fails closed throughout: a model-read failure, a malformed agent id, or an inactive subject denies. See [Agent Identity & Permissions](https://docs.authorizer.dev/enterprise/agent-identity). +- **Delegated tokens are revocable at Authorizer's own API**: a delegated token now carries an opaque `sid` naming the session it was derived from, so logout, password reset, email change and admin session wipes stop it on the next call. Previously nothing a user or admin could do stopped one — it stayed valid for its full TTL, and the only working lever was revoking the user outright. A downstream resource server validates offline against the JWKS and still cannot see this, so the short TTL remains the only bound **there**; do not build a resource server that assumes otherwise. Fails closed: a delegation whose origin cannot be verified does not authenticate here. +- **Agent actions are attributed to the agent in the audit log**: an action taken by an agent on a user's behalf is recorded with the agent as `actor_id`, `actor_type: agent`, no actor email, and the delegating user preserved in metadata (`delegated_user_id`, `delegated_user_email`). Previously the delegating user was recorded as the actor on the GraphQL surface — the actor was read from a request principal that only the gRPC interceptor constructs — making an agent's actions indistinguishable from the human's, which cannot be reconstructed after the fact. RFC 8693 §1.1 draws exactly this line between delegation and impersonation. ### Fixed From 67169b33d16f4ef3a91bd6fe095333a881a35d9e Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 11:31:58 +0530 Subject: [PATCH 19/25] test(agent): close four coverage gaps found by mutation testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injecting faults into the delegation path showed the suite catching none of them. Each test below fails on its fault and passes on correct code. - callerTokenData dropped ActorID on its authctx.Principal branch. Every delegation test drives the request branch, so agent actions over gRPC being audited as the user was invisible. - intersectObjects could return either input unintersected and pass. All enumeration tests granted the AGENT nothing, so its set was empty and the intersection was empty for a trivial reason — and the agent is subjects[0]. Now asserted on the function directly, including the case where the USER is the narrower side. - denied_by_user was never asserted. That is the Confused Deputy actually being stopped, and the one outcome an operator must not answer by widening the agent. - Multi-hop delegation into Authorizer's own API was untested. A delegated subject_token has `sid` and no `nonce`, so hop 2 must propagate sid verbatim; dropping it leaves the chain unable to authenticate at all. --- .../agent_coverage_gaps_test.go | 128 ++++++++++++++++++ internal/service/caller_actorid_test.go | 50 +++++++ 2 files changed, 178 insertions(+) create mode 100644 internal/integration_tests/agent_coverage_gaps_test.go create mode 100644 internal/service/caller_actorid_test.go diff --git a/internal/integration_tests/agent_coverage_gaps_test.go b/internal/integration_tests/agent_coverage_gaps_test.go new file mode 100644 index 000000000..6014e966a --- /dev/null +++ b/internal/integration_tests/agent_coverage_gaps_test.go @@ -0,0 +1,128 @@ +package integration_tests + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/refs" +) + +// TestAgentEnumerationWhenUserIsTheRestrictiveSide closes a hole in +// TestAgentIntersectionListPermissions. +// +// That test grants the agent NOTHING, so the agent's own enumeration is empty +// and the intersection is empty for a trivial reason. Because the agent is +// subjects[0], returning the agent's set unintersected produces the SAME empty +// answer — so the assertion passes even if the fold is not performing an +// intersection at all. A mutation that replaced intersectObjects with `return a` +// survived the entire suite. +// +// Here the AGENT is the broader side and the USER is the restrictive one: the +// agent is granted two documents, the delegating user only one. Anything other +// than a real intersection hands the agent an object its user cannot reach, +// which is the Confused Deputy expressed through enumeration. +func TestAgentEnumerationWhenUserIsTheRestrictiveSide(t *testing.T) { + cfg := getTestConfig() + ts, _ := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + setAdminCookie(t, ts) + _, err := ts.GraphQLProvider.FgaWriteModel(ctx, &model.FgaWriteModelInput{Dsl: fgaAgentModel}) + require.NoError(t, err) + + delegated, agentID, userID := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + + const shared = "document:shared" + const agentOnly = "document:agent-only" + + setAdminCookie(t, ts) + _, err = ts.GraphQLProvider.FgaWriteTuples(ctx, &model.FgaWriteTuplesInput{ + Tuples: []*model.FgaTupleInput{ + // The user can reach only the shared document. + {User: "user:" + userID, Relation: "viewer", Object: shared}, + // The agent can reach both — it is the BROADER side here. + {User: "agent:" + agentID, Relation: "viewer", Object: shared}, + {User: "agent:" + agentID, Relation: "viewer", Object: agentOnly}, + }, + }) + require.NoError(t, err) + + presentDelegatedToken(ts, delegated) + res, lErr := ts.GraphQLProvider.ListPermissions(ctx, &model.ListPermissionsInput{ + ObjectType: refs.NewStringRef("document"), + Relation: refs.NewStringRef("can_view"), + }) + require.NoError(t, lErr) + require.NotNil(t, res) + + assert.Contains(t, res.Objects, shared, + "both halves grant the shared document, so it must be enumerated") + assert.NotContains(t, res.Objects, agentOnly, + "the agent holds this grant but its delegating user does NOT — enumerating it "+ + "hands the agent an object beyond the user's reach, which is exactly what the "+ + "intersection exists to prevent") +} + +// TestDelegatedDenialIsAttributedToTheUser closes the other half of the +// denial-attribution metric. +// +// The suite covered denied_by_agent (agent lacks the grant) and not_enforced, +// but never denied_by_user — the case where the agent HAS its grant and the +// delegating user does not. That is the Confused Deputy actually being stopped, +// and it is the outcome an operator must NOT respond to by widening the agent. +// A mutation that recorded denied_by_agent for both branches survived the suite, +// which would have told operators to grant the agent a tuple that cannot help. +func TestDelegatedDenialIsAttributedToTheUser(t *testing.T) { + cfg := getTestConfig() + ts, _ := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + setAdminCookie(t, ts) + _, err := ts.GraphQLProvider.FgaWriteModel(ctx, &model.FgaWriteModelInput{Dsl: fgaAgentModel}) + require.NoError(t, err) + + delegated, agentID, _ := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + + const obj = "document:agent-has-user-does-not" + setAdminCookie(t, ts) + _, err = ts.GraphQLProvider.FgaWriteTuples(ctx, &model.FgaWriteTuplesInput{ + // ONLY the agent is granted. The delegating user is not. + Tuples: []*model.FgaTupleInput{{User: "agent:" + agentID, Relation: "viewer", Object: obj}}, + }) + require.NoError(t, err) + + byUser := func() float64 { + return testutil.ToFloat64(metrics.FgaDelegatedChecksTotal.WithLabelValues( + metrics.FgaOpCheckPermissions, metrics.FgaDelegatedDeniedByUser)) + } + byAgent := func() float64 { + return testutil.ToFloat64(metrics.FgaDelegatedChecksTotal.WithLabelValues( + metrics.FgaOpCheckPermissions, metrics.FgaDelegatedDeniedByAgent)) + } + userBefore, agentBefore := byUser(), byAgent() + + presentDelegatedToken(ts, delegated) + res, cErr := ts.GraphQLProvider.CheckPermissions(ctx, &model.CheckPermissionsInput{ + Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: obj}}, + }) + require.NoError(t, cErr) + require.Len(t, res.Results, 1) + require.False(t, res.Results[0].Allowed, "the user lacks the grant, so the intersection denies") + + assert.Equal(t, userBefore+1, byUser(), + "the USER is the side that refused; recording this as denied_by_agent would tell an "+ + "operator to grant the agent a tuple, which cannot fix it and widens the agent for nothing") + assert.Equal(t, agentBefore, byAgent(), "the agent had its grant, so it did not deny") +} diff --git a/internal/service/caller_actorid_test.go b/internal/service/caller_actorid_test.go new file mode 100644 index 000000000..49bf3e8e3 --- /dev/null +++ b/internal/service/caller_actorid_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/authctx" +) + +// TestCallerTokenDataCarriesActorID pins that the caller identity is described +// IDENTICALLY on both transports. +// +// callerTokenData has two branches: authctx.Principal (populated only by the +// gRPC interceptor) and the request token (GraphQL/REST). The token branch has +// always carried ActorID; the principal branch dropped it, so an agent's action +// over gRPC was recorded as the user performing it while the same action over +// GraphQL was attributed correctly. A divergence between transports on WHO did +// something is not cosmetic, and nothing failed when it regressed — hence this +// test. +func TestCallerTokenDataCarriesActorID(t *testing.T) { + p := &provider{} + + t.Run("principal branch preserves the actor", func(t *testing.T) { + ctx := authctx.WithPrincipal(context.Background(), &authctx.Principal{ + UserID: "alice", + LoginMethod: "basic_auth", + Nonce: "nonce-1", + ActorID: "agent-client-id", + }) + data, err := p.callerTokenData(ctx, RequestMetadata{}) + require.NoError(t, err) + require.NotNil(t, data) + + assert.Equal(t, "alice", data.UserID) + assert.Equal(t, "agent-client-id", data.ActorID, + "the gRPC branch must carry the actor, or agent actions on gRPC are audited as the user") + }) + + t.Run("an ordinary principal has no actor", func(t *testing.T) { + ctx := authctx.WithPrincipal(context.Background(), &authctx.Principal{ + UserID: "alice", LoginMethod: "basic_auth", Nonce: "nonce-2", + }) + data, err := p.callerTokenData(ctx, RequestMetadata{}) + require.NoError(t, err) + assert.Empty(t, data.ActorID) + }) +} From 0e09f2889fdd403fb6c095efb7ba2b735f1c1614 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 11:32:27 +0530 Subject: [PATCH 20/25] docs(changelog): record the --encryption-key breaking change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c98b6f3e was flagged `!` but the changelog had zero mention of encryption. An RS256/ES256 deployment without --jwt-secret now refuses to boot, and operators on 2.2.1..2.4.0-rc.13 need to know their at-rest key was a public constant — neither fact was written down anywhere a release reader would look. Includes the rotation and re-enrollment consequence: existing ciphertext was written under the old key and will not decrypt. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a41b3a10..e4360e6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id ### Changed +- **BREAKING — at-rest encryption key split from the JWT secret (`--encryption-key`).** The key used to encrypt secrets at rest (TOTP secrets, recovery codes) is now its own input and no longer derives from `--jwt-secret`. **A deployment using RS256/ES256 (`--jwt-private-key`/`--jwt-public-key`) without `--jwt-secret` will refuse to start until `--encryption-key` is set** — HMAC deployments (HS256/384/512) are unaffected, as the JWT secret still resolves the key. This is a security fix, not a preference: in **2.2.1 through 2.4.0-rc.13**, an asymmetric-JWT deployment with no `--jwt-secret` silently fell back to a **public constant** compiled into the source, so anything encrypted at rest was protected by a key any reader of the repository already had. Operators on those versions must treat existing TOTP enrollments and recovery codes as compromised: rotate `--encryption-key`, then have affected users re-enroll (existing ciphertext was written under the old key and will not decrypt). When no key can be resolved, TOTP is disabled with a startup warning rather than the server failing closed on every MFA path. There is no `ENCRYPTION_KEY` environment variable — v2 is flag-only ([#742](https://github.com/authorizerdev/authorizer/pull/742)). - **Admin dashboard UI migration from Chakra UI to shadcn/ui + Tailwind CSS**: Dashboard (`web/dashboard/`) completely modernized. Replaced Chakra UI v2 with shadcn/ui (Radix primitives) + Tailwind CSS v4. All TypeScript `any` types and `@ts-ignore` directives eliminated; full type safety on GraphQL responses, component props, and data models. Dead dependencies removed (react-draft-wysiwyg, @emotion, framer-motion, react-icons, focus-visible). 17 shadcn/ui-style components built on Radix; Authorizer branding (logo + blue-500) applied throughout. Cleaner tables, Sheet panels for forms, sonner toast notifications, skeleton loading states ([#605](https://github.com/authorizerdev/authorizer/pull/605)). - **BREAKING — MFA behavior completely redesigned: on by default, optional per user, withheld token until setup complete.** MFA methods (TOTP, Email OTP, SMS OTP, WebAuthn) are now enabled by default and opted out via new `--disable-totp-login`, `--disable-email-otp`, `--disable-sms-otp`, and `--disable-webauthn-mfa` flags; the old `--enable-totp-login`, `--enable-mfa`, `--enable-email-otp`, and `--enable-sms-otp` flags are removed. Email and SMS OTP only take effect when their provider (SMTP / Twilio) is configured. Whether MFA is available is now derived from the enabled methods rather than a standalone flag, which fixes the case where MFA appeared "enabled" while every method was unavailable. **New token-withholding behavior:** when MFA is optional (`--enforce-mfa` default `false`), first-time users who haven't set up MFA no longer receive an immediate token followed by a setup offer — the token is withheld until the user completes enrollment or explicitly skips (remembered as `has_skipped_mfa_setup_at`). This withheld-token model now applies uniformly to password login, passkey login, signup, and social login. When `--enforce-mfa` is set, MFA is mandatory and un-skippable. **Email/SMS OTP now require explicit enrollment** (new `email_otp_mfa_setup`/`sms_otp_mfa_setup` mutations) before they can be used for MFA verification, fixing the previous behavior where they fired automatically for any user with a phone/email on file. **Admin recovery:** new `reset_mfa` operation on `_update_user` clears all MFA state and enrolled factors across all storage backends. **User-initiated lockout:** new `lock_mfa` mutation prevents future MFA enrollment (admin-recoverable); lockout is refused if a verified Email/SMS OTP factor exists as a fallback. **`--disable-mfa` one-way kill switch** disables MFA entirely regardless of per-method flags (does not affect WebAuthn, which is a separate login recipe) ([#682](https://github.com/authorizerdev/authorizer/pull/682), [#684](https://github.com/authorizerdev/authorizer/pull/684), [#685](https://github.com/authorizerdev/authorizer/pull/685), [#686](https://github.com/authorizerdev/authorizer/pull/686)). - **License: relicensed from MIT to Apache License 2.0.** Per the CNCF IP Policy ([Charter §11(b)(iii)](https://github.com/cncf/foundation/blob/main/charter.md#11-ip-policy)), Authorizer's outbound code is now distributed under the Apache License 2.0. Existing copies distributed under the MIT License remain valid under their original grant; this change applies to the project's outbound license going forward. See [NOTICE](NOTICE) for attribution. From 5ed236886b757f646e224f009ec7344722bedabc Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 11:50:47 +0530 Subject: [PATCH 21/25] fix(agent): apply the intersection to required_relations, deny org-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two authorization surfaces the delegation work did not reach. enforceRequiredRelations backs session, validate_session and validate_jwt_token. It hardcoded "user:" and never expanded the delegated subjects, so it answered a DIFFERENT question from check_permissions for the same token: an agent with no grant was reported as satisfying a relation the permission API denies. A gateway gating on required_relations would admit exactly what that API refuses. Reaching it needs token-type confusion — validate_jwt_token takes token_type from the REQUEST and never compares it to the token's own claim, so passing id_token for a delegated access token skips the nonce/session branch. The regression test uses that path and is verified failing without this change and passing with it. requireOrgAdmin checks org membership for the delegating USER and never looked at the actor, so an agent holding any org admin's token inherited authority over SSO connections, SAML/OIDC config, domains and membership. The FGA intersection does not reach these handlers: they ask "is this user an org admin?", not "what may this agent do?". Honest limit: I could not construct a failing repro for the org-admin path — every attempt was refused earlier in the stack for an unrelated reason — so that guard is hardening on the merits rather than a demonstrated exploit fix, and it ships without a regression test. Neither addresses the larger finding that a delegated token reaches the whole first-party API with its scope unconsulted; that needs a decision on scoping before it can be fixed. --- .../agent_surface_parity_test.go | 72 +++++++++++++++++++ internal/service/admin_provider.go | 15 ++++ internal/service/fga.go | 53 ++++++++++---- internal/service/session.go | 2 +- internal/service/validate_jwt_token.go | 2 +- internal/service/validate_session.go | 2 +- 6 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 internal/integration_tests/agent_surface_parity_test.go diff --git a/internal/integration_tests/agent_surface_parity_test.go b/internal/integration_tests/agent_surface_parity_test.go new file mode 100644 index 000000000..3f74a5bdd --- /dev/null +++ b/internal/integration_tests/agent_surface_parity_test.go @@ -0,0 +1,72 @@ +package integration_tests + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// TestRequiredRelationsIntersectsForDelegatedCallers pins the THIRD +// authorization-decision surface. +// +// enforceRequiredRelations backs `session`, `validate_session` and +// `validate_jwt_token`. It hardcoded "user:" and never expanded the +// delegated subjects, so it answered a different question from +// CheckPermissions for the very same token: an agent with no grant of its own +// was reported as SATISFYING a required relation that check_permissions denied. +// +// Two answers to one authority question is worse than either answer alone — a +// gateway gating on required_relations would admit exactly the requests the +// permission API refuses. +func TestRequiredRelationsIntersectsForDelegatedCallers(t *testing.T) { + cfg := getTestConfig() + ts, _ := initFGATestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + setAdminCookie(t, ts) + _, err := ts.GraphQLProvider.FgaWriteModel(ctx, &model.FgaWriteModelInput{Dsl: fgaAgentModel}) + require.NoError(t, err) + + delegated, _, userID := mintDelegatedViaEndpoint(t, ts, router, testAuthorizerHost(ts)) + + const obj = "document:required-relations" + setAdminCookie(t, ts) + _, err = ts.GraphQLProvider.FgaWriteTuples(ctx, &model.FgaWriteTuplesInput{ + // The USER can view it. The agent holds nothing. + Tuples: []*model.FgaTupleInput{{User: "user:" + userID, Relation: "viewer", Object: obj}}, + }) + require.NoError(t, err) + + required := []*model.FgaRelationInput{{Relation: "can_view", Object: obj}} + + // Control: the permission API denies, because the agent has no grant. + presentDelegatedToken(ts, delegated) + chk, cErr := ts.GraphQLProvider.CheckPermissions(ctx, &model.CheckPermissionsInput{ + Checks: []*model.PermissionCheckInput{{Relation: "can_view", Object: obj}}, + }) + require.NoError(t, cErr) + require.False(t, chk.Results[0].Allowed, "control: the intersection denies the agent") + + // The same question through required_relations must give the SAME answer. + presentDelegatedToken(ts, delegated) + _, vErr := ts.GraphQLProvider.ValidateJWTToken(ctx, &model.ValidateJWTTokenRequest{ + // id_token, not access_token: the access/refresh branch requires a + // `nonce` + live session entry, which a stateless delegated token has + // not got, so it is rejected before required_relations is ever reached. + // The id_token branch skips that, which is how a delegated token gets + // to this decision surface at all. + TokenType: "id_token", + Token: delegated, + RequiredRelations: required, + }) + assert.Error(t, vErr, + "validate_jwt_token must not report a relation satisfied that check_permissions denies "+ + "for the same token, relation and object") +} diff --git a/internal/service/admin_provider.go b/internal/service/admin_provider.go index 496d1f832..1865a8506 100644 --- a/internal/service/admin_provider.go +++ b/internal/service/admin_provider.go @@ -195,6 +195,21 @@ func (p *provider) requireOrgAdmin(ctx context.Context, meta RequestMetadata, or return Unauthenticated("unauthorized") } + // An RFC 8693 delegated caller is an AGENT acting for a user, and org-admin + // authority is not delegable. The membership lookup below keys on the + // delegating user, so without this an agent holding a token for any org + // admin inherits that admin's full authority over the org — SSO connections, + // SAML/OIDC config, domains, membership. That is the Confused Deputy the + // agent intersection exists to stop, on a surface the intersection does not + // reach: these handlers ask "is this user an org admin?", never "what may + // this agent do?". + // + // Delegation to an org admin is a real use case; it just needs an explicit + // grant model rather than silent inheritance. Refuse until one exists. + if strings.TrimSpace(tokenData.ActorID) != "" { + return Unauthenticated("unauthorized") + } + membership, err := p.StorageProvider.GetOrgMembership(ctx, orgID, tokenData.UserID) if err != nil || membership == nil { return Unauthenticated("unauthorized") diff --git a/internal/service/fga.go b/internal/service/fga.go index cd980ea3b..0a98f8225 100644 --- a/internal/service/fga.go +++ b/internal/service/fga.go @@ -290,7 +290,16 @@ func toContextualTuples(in []*model.FgaTupleInput) ([]engine.ContextualTuple, er // // The subject is always derived server-side from the resolved userID, never // from client input. -func (p *provider) enforceRequiredRelations(ctx context.Context, log zerolog.Logger, userID string, required []*model.FgaRelationInput) error { +// +// DELEGATION: this is the THIRD authorization-decision surface, alongside +// CheckPermissions and ListPermissions, and it must answer the same question +// the same way. It previously hardcoded "user:", so a delegated caller was +// evaluated as the delegating user alone — `validate_jwt_token` reported a +// relation SATISFIED that `check_permissions` denied for the same token, same +// relation, same object. Two answers to one authority question is worse than +// either answer: a gateway gating on required_relations would admit a request +// the permission API refuses. +func (p *provider) enforceRequiredRelations(ctx context.Context, meta RequestMetadata, log zerolog.Logger, userID string, required []*model.FgaRelationInput) error { if len(required) == 0 { return nil } @@ -300,24 +309,38 @@ func (p *provider) enforceRequiredRelations(ctx context.Context, log zerolog.Log if strings.TrimSpace(userID) == "" { return Unauthenticated("unauthorized") } - subject := "user:" + userID + // Same expansion as the permission APIs: [subject] for an ordinary caller, + // [agent:, user:] for a delegated one, with EVERY subject + // required to pass. + caller, err := p.resolveFgaCaller(ctx, meta) + if err != nil { + return PermissionDenied("unauthorized") + } + subjects, err := p.delegationSubjects(ctx, caller, "user:"+userID, metrics.FgaOpRequiredRelations) + if err != nil { + metrics.RecordFgaCheck(metrics.FgaOpRequiredRelations, metrics.FgaResultError) + log.Debug().Err(err).Msg("required relations: failed to resolve delegation subjects; denying") + return PermissionDenied("unauthorized") + } for _, r := range required { if r == nil || strings.TrimSpace(r.Relation) == "" || strings.TrimSpace(r.Object) == "" { return InvalidArgument("each required relation needs relation and object") } - start := time.Now() - allowed, err := p.AuthzEngine.Check(ctx, subject, r.Relation, r.Object) - metrics.ObserveFgaCheckDuration(metrics.FgaOpRequiredRelations, time.Since(start).Seconds()) - if err != nil { - // Fail closed. - metrics.RecordFgaCheck(metrics.FgaOpRequiredRelations, metrics.FgaResultError) - log.Debug().Err(err).Str("relation", r.Relation).Str("object", r.Object).Msg("required relation check errored") - return PermissionDenied("unauthorized") - } - metrics.RecordFgaCheckResult(metrics.FgaOpRequiredRelations, allowed) - if !allowed { - log.Debug().Str("relation", r.Relation).Str("object", r.Object).Msg("required relation denied") - return PermissionDenied("unauthorized") + for _, subject := range subjects { + start := time.Now() + allowed, err := p.AuthzEngine.Check(ctx, subject, r.Relation, r.Object) + metrics.ObserveFgaCheckDuration(metrics.FgaOpRequiredRelations, time.Since(start).Seconds()) + if err != nil { + // Fail closed. + metrics.RecordFgaCheck(metrics.FgaOpRequiredRelations, metrics.FgaResultError) + log.Debug().Err(err).Str("relation", r.Relation).Str("object", r.Object).Msg("required relation check errored") + return PermissionDenied("unauthorized") + } + metrics.RecordFgaCheckResult(metrics.FgaOpRequiredRelations, allowed) + if !allowed { + log.Debug().Str("subject", subject).Str("relation", r.Relation).Str("object", r.Object).Msg("required relation denied") + return PermissionDenied("unauthorized") + } } } return nil diff --git a/internal/service/session.go b/internal/service/session.go index 9670f19f8..7239833bf 100644 --- a/internal/service/session.go +++ b/internal/service/session.go @@ -60,7 +60,7 @@ func (p *provider) Session(ctx context.Context, meta RequestMetadata, params *mo // Fine-grained authorization gate (AND semantics, fail-closed). if params != nil && len(params.RequiredRelations) > 0 { - if err := p.enforceRequiredRelations(ctx, log, userID, params.RequiredRelations); err != nil { + if err := p.enforceRequiredRelations(ctx, meta, log, userID, params.RequiredRelations); err != nil { log.Debug().Err(err).Msg("Required relations not satisfied") return nil, nil, err } diff --git a/internal/service/validate_jwt_token.go b/internal/service/validate_jwt_token.go index 36147f299..f0577cbd2 100644 --- a/internal/service/validate_jwt_token.go +++ b/internal/service/validate_jwt_token.go @@ -116,7 +116,7 @@ func (p *provider) ValidateJwtToken(ctx context.Context, meta RequestMetadata, p } // Fine-grained authorization gate (AND semantics, fail-closed). if len(params.RequiredRelations) > 0 { - if err := p.enforceRequiredRelations(ctx, log, userID, params.RequiredRelations); err != nil { + if err := p.enforceRequiredRelations(ctx, meta, log, userID, params.RequiredRelations); err != nil { log.Debug().Err(err).Msg("Required relations not satisfied") return nil, nil, err } diff --git a/internal/service/validate_session.go b/internal/service/validate_session.go index 67587bc01..b91710314 100644 --- a/internal/service/validate_session.go +++ b/internal/service/validate_session.go @@ -64,7 +64,7 @@ func (p *provider) ValidateSession(ctx context.Context, meta RequestMetadata, pa } // Fine-grained authorization gate (AND semantics, fail-closed). if params != nil && len(params.RequiredRelations) > 0 { - if err := p.enforceRequiredRelations(ctx, log, userID, params.RequiredRelations); err != nil { + if err := p.enforceRequiredRelations(ctx, meta, log, userID, params.RequiredRelations); err != nil { log.Debug().Err(err).Msg("Required relations not satisfied") return nil, nil, err } From 8f9e649294708ee47c6ab96c81ca90df76bdd6b3 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 14:31:44 +0530 Subject: [PATCH 22/25] security(agent): enforce delegated token scope per operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the blast-radius hole: a delegated token authenticated at Authorizer's own API and reached EVERY first-party operation with its `scope` claim never consulted. An agent granted `openid` for a downstream MCP server could read the delegating user's profile, mutate the account and deactivate it. The RFC 8693 attenuation that produced that scope was computed, returned to the caller, then ignored. Per-operation scope is how OAuth answers "what may this token do", and how Auth0, Microsoft Graph and Okta gate their own APIs. RFC 8693 returns `scope` so someone enforces it; RFC 6750 §3.1 names the failure `insufficient_scope`. Enforced for DELEGATED callers only. A first-party scope is caller-supplied and unvalidated (service.Login takes params.Scope with no allow-list), so it is a hint rather than a boundary — gating it would break existing clients for no security gain. A delegated token's ceiling is agent.allowed_scopes, which only an admin sets, so there it is real. A sensitive operation therefore needs both halves: the user's token must carry the scope AND the operator must have granted the agent a ceiling including it. Neither party can widen an agent alone, which is the shape of Microsoft's delegated permissions. Fail closed. An operation absent from the table is denied to agents whatever scope they hold, so new operations are unreachable until someone deliberately clears them — the opposite of an allowlist that widens when a contributor forgets it. That also keeps the table small: it lists what agents MAY do, not all ~200 operations. One table, two enforcement points: the gRPC interceptor (covering gRPC, the REST gateway and MCP, all of which dispatch through it) and gqlgen middleware for GraphQL. A test asserts both sides agree, since a transport where delegation behaves differently is the bug this feature already shipped once. The regression test drives the real /graphql endpoint. Its first version called GraphQLProvider directly, bypassed the middleware entirely, and passed regardless of what the gate did. --- internal/authctx/principal.go | 4 + internal/delegatedscope/delegatedscope.go | 145 ++++++++++++++++++ internal/grpcsrv/interceptors/auth.go | 32 ++++ internal/http_handlers/graphql.go | 49 ++++++ .../integration_tests/delegated_scope_test.go | 141 +++++++++++++++++ internal/token/auth_token.go | 31 ++++ 6 files changed, 402 insertions(+) create mode 100644 internal/delegatedscope/delegatedscope.go create mode 100644 internal/integration_tests/delegated_scope_test.go diff --git a/internal/authctx/principal.go b/internal/authctx/principal.go index 6dd9a2e38..6082217c2 100644 --- a/internal/authctx/principal.go +++ b/internal/authctx/principal.go @@ -23,6 +23,10 @@ type Principal struct { // delegated action is attributed to the human, which is both an audit lie // and the Confused Deputy precondition. ActorID string + // Scope is the token's `scope` claim, carried so the gRPC interceptor can + // enforce per-operation scope for delegated callers. See + // internal/delegatedscope. + Scope []string } // IsDelegated reports whether this principal is an agent acting for a user. diff --git a/internal/delegatedscope/delegatedscope.go b/internal/delegatedscope/delegatedscope.go new file mode 100644 index 000000000..ea74bc501 --- /dev/null +++ b/internal/delegatedscope/delegatedscope.go @@ -0,0 +1,145 @@ +// Package delegatedscope decides what an RFC 8693 DELEGATED token — an agent +// acting on behalf of a user — is permitted to do at Authorizer's own API. +// +// # Why this exists +// +// A delegated token carries a `scope` claim that the token endpoint computes as +// +// subject_token.scope ∩ agent.allowed_scopes ( ∩ requested ) +// +// That attenuation is the entire point of the exchange, and until this package +// existed nothing consulted it. Once a delegated token could authenticate at +// Authorizer's own API, it reached EVERY first-party operation with the scope +// ignored: an agent an operator granted `openid` for a downstream MCP server +// could read the user's profile, mutate the account, and deactivate it. +// +// # The model +// +// Per-operation scope enforcement is how OAuth answers "what may this token +// do", and how every major authorization server gates its own API (Auth0's +// Management API scopes, Microsoft Graph permissions, Okta's `okta.*` scopes). +// RFC 8693 returns `scope` in the exchange response precisely so that someone +// enforces it; RFC 6750 §3.1 defines the failure as `insufficient_scope`. +// +// # Scoped to delegated callers, deliberately +// +// First-party tokens are NOT gated here, and that is not an oversight: +// `login` accepts a caller-supplied `scope` with no allow-list (see +// service.Login), so a first-party scope is a hint, not a boundary — enforcing +// it would break existing clients while granting no security. +// +// For a DELEGATED token the same claim IS a boundary, because the ceiling comes +// from `agent.allowed_scopes`, which only an admin can set. A sensitive +// operation therefore needs BOTH halves: the user's own token must carry the +// scope, and the operator must have granted the agent a ceiling that includes +// it. Neither party can widen an agent alone. That two-party requirement is the +// same shape as Microsoft's delegated permissions, where effective access is +// the app's granted permission intersected with the signed-in user's. +// +// # Fail closed +// +// An operation absent from the table is DENIED to delegated callers. New +// operations are therefore unreachable by agents until someone deliberately +// adds them, which is the opposite of an allowlist that silently widens when a +// contributor forgets it. +package delegatedscope + +import "strings" + +// Scope names required by operations beyond the universally-held `openid`. +// +// Deliberately few. Each one an operator can add to an agent's +// `allowed_scopes` to widen it, and each one the delegating user must also +// carry for the intersection to yield it. +const ( + // ScopeOpenID is held by every token this server mints, so operations + // requiring only this are reachable by any delegated caller. Reserved for + // READ-ONLY identity and permission queries — the questions an agent must + // be able to ask to function at all. + ScopeOpenID = "openid" + // ScopeProfileWrite permits mutating the delegating user's profile. + ScopeProfileWrite = "authorizer:profile:write" + // ScopeAccountDelete permits deactivating the delegating user's account. + // Separate from profile:write because it is irreversible by the agent. + ScopeAccountDelete = "authorizer:account:delete" +) + +// operation binds one logical API operation to the scope a delegated caller +// must hold, across every transport that exposes it. +// +// GraphQL field names and gRPC method names are listed EXPLICITLY rather than +// derived from one another. A camel-to-snake conversion looks tidy until +// `ValidateJWTToken` renders as `validate_j_w_t_token`, and a silent miss here +// fails OPEN on whichever transport the conversion got wrong. Two columns in +// one table make a mismatch visible to the reader. +type operation struct { + graphQL string + grpc string + scope string +} + +// table is the complete set of operations a delegated token may reach. +// Anything not listed is denied — see the package comment. +var table = []operation{ + // Read-only identity and permission queries. These are what an agent needs + // to answer "may I?" and "on whose behalf?", and are exactly the tool set + // the built-in MCP server exposes. + {graphQL: "check_permissions", grpc: "CheckPermissions", scope: ScopeOpenID}, + {graphQL: "list_permissions", grpc: "ListPermissions", scope: ScopeOpenID}, + {graphQL: "profile", grpc: "Profile", scope: ScopeOpenID}, + {graphQL: "meta", grpc: "Meta", scope: ScopeOpenID}, + + // Mutating operations. Unreachable by default: the scopes below are not in + // any client's default request, so the intersection yields them only when + // an operator has deliberately widened BOTH the agent's ceiling and the + // user's own token. + {graphQL: "update_profile", grpc: "UpdateProfile", scope: ScopeProfileWrite}, + {graphQL: "deactivate_account", grpc: "DeactivateAccount", scope: ScopeAccountDelete}, +} + +var ( + byGraphQL = map[string]string{} + byGRPC = map[string]string{} +) + +func init() { + for _, op := range table { + byGraphQL[op.graphQL] = op.scope + byGRPC[op.grpc] = op.scope + } +} + +// RequiredForGraphQL returns the scope a delegated caller needs for a root +// GraphQL field, and whether the field is reachable by one at all. +func RequiredForGraphQL(field string) (string, bool) { + s, ok := byGraphQL[field] + return s, ok +} + +// RequiredForGRPC returns the scope a delegated caller needs for a gRPC method, +// and whether the method is reachable by one at all. +// +// fullMethod is the interceptor's "/package.Service/Method" form; only the +// trailing method name is significant, so the same table serves the REST +// gateway and the MCP server, both of which dispatch through gRPC. +func RequiredForGRPC(fullMethod string) (string, bool) { + if i := strings.LastIndex(fullMethod, "/"); i >= 0 { + fullMethod = fullMethod[i+1:] + } + s, ok := byGRPC[fullMethod] + return s, ok +} + +// Satisfied reports whether a token's scope claim contains the required scope. +// +// Exact string match per element, never a prefix or substring test: a caller +// holding `authorizer:profile:write:nothing` must not satisfy +// `authorizer:profile:write`, and `openid2` must not satisfy `openid`. +func Satisfied(tokenScopes []string, required string) bool { + for _, s := range tokenScopes { + if strings.TrimSpace(s) == required { + return true + } + } + return false +} diff --git a/internal/grpcsrv/interceptors/auth.go b/internal/grpcsrv/interceptors/auth.go index 341d01e31..e733a7aa7 100644 --- a/internal/grpcsrv/interceptors/auth.go +++ b/internal/grpcsrv/interceptors/auth.go @@ -17,6 +17,7 @@ import ( authorizerv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/v1" "github.com/authorizerdev/authorizer/internal/authctx" "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/delegatedscope" "github.com/authorizerdev/authorizer/internal/grpcsrv/transport" "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/token" @@ -128,7 +129,11 @@ func Auth(tp token.Provider, log *zerolog.Logger) grpc.UnaryServerInterceptor { LoginMethod: tokenData.LoginMethod, Nonce: tokenData.Nonce, ActorID: tokenData.ActorID, + Scope: tokenData.Scope, }) + if err := enforceDelegatedScope(tokenData, info.FullMethod); err != nil { + return nil, err + } return handler(ctx, req) } @@ -161,11 +166,38 @@ func Auth(tp token.Provider, log *zerolog.Logger) grpc.UnaryServerInterceptor { LoginMethod: tokenData.LoginMethod, Nonce: tokenData.Nonce, ActorID: tokenData.ActorID, + Scope: tokenData.Scope, }) + if err := enforceDelegatedScope(tokenData, info.FullMethod); err != nil { + return nil, err + } return handler(ctx, req) } } +// enforceDelegatedScope gates a DELEGATED caller on the scope its token +// actually carries. First-party callers are untouched — see the +// delegatedscope package comment for why that asymmetry is deliberate. +// +// This is the choke point for gRPC, the REST gateway (which dispatches through +// these same methods) and the MCP server (which serves over an in-process +// bufconn). GraphQL has its own, in http_handlers. +func enforceDelegatedScope(tokenData *token.SessionOrAccessTokenData, fullMethod string) error { + if tokenData == nil || strings.TrimSpace(tokenData.ActorID) == "" { + return nil + } + required, ok := delegatedscope.RequiredForGRPC(fullMethod) + if !ok { + // Fail closed: an operation nobody has cleared for delegated callers is + // out of reach for an agent, whatever scope it holds. + return status.Error(codes.PermissionDenied, "insufficient_scope") + } + if !delegatedscope.Satisfied(tokenData.Scope, required) { + return status.Error(codes.PermissionDenied, "insufficient_scope") + } + return nil +} + func methodDescriptor(fullMethod string) (protoreflect.MethodDescriptor, bool) { if cached, ok := methodDescCache.Load(fullMethod); ok { if cached == nil { diff --git a/internal/http_handlers/graphql.go b/internal/http_handlers/graphql.go index efa4ea4bc..a1684e685 100644 --- a/internal/http_handlers/graphql.go +++ b/internal/http_handlers/graphql.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "sort" + "strings" "sync" "time" @@ -20,6 +21,7 @@ import ( "github.com/vektah/gqlparser/v2/gqlerror" "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/delegatedscope" "github.com/authorizerdev/authorizer/internal/graph" "github.com/authorizerdev/authorizer/internal/graph/generated" "github.com/authorizerdev/authorizer/internal/graphql" @@ -204,6 +206,52 @@ func (*httpProvider) gqlCollectResolvedFieldsMiddleware() gql.FieldMiddleware { } } +// gqlDelegatedScopeMiddleware gates a DELEGATED caller — an RFC 8693 agent +// acting for a user — on the scope its token actually carries. +// +// GraphQL is the other transport that needs this; gRPC (and with it the REST +// gateway and the MCP server) is handled in grpcsrv/interceptors. Both consult +// the same table in internal/delegatedscope, so the two cannot disagree about +// what an agent may do. +// +// Applied at ROOT fields only. Nested field resolvers inherit the decision of +// the operation that reached them, and re-checking every leaf would both cost +// a map lookup per field and, worse, deny nested selections that have no entry +// of their own. +// +// First-party callers are not gated here. See the delegatedscope package +// comment: a first-party `scope` is caller-supplied and unvalidated, so it is a +// hint rather than a boundary, and enforcing it would break existing clients +// for no security gain. +func (h *httpProvider) gqlDelegatedScopeMiddleware() gql.FieldMiddleware { + return func(ctx context.Context, next gql.Resolver) (interface{}, error) { + fc := gql.GetFieldContext(ctx) + if fc == nil || fc.Field.Field == nil || !fc.IsMethod || fc.Object != "Query" && fc.Object != "Mutation" { + return next(ctx) + } + gc, err := utils.GinContextFromContext(ctx) + if err != nil || gc == nil { + return next(ctx) + } + tokenData, tErr := h.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) + if tErr != nil || tokenData == nil || strings.TrimSpace(tokenData.ActorID) == "" { + // Not a delegated caller (or not authenticated at all — the + // resolver's own auth check owns that decision, not this one). + return next(ctx) + } + required, ok := delegatedscope.RequiredForGraphQL(fc.Field.Name) + if !ok { + // Fail closed: an operation nobody has cleared for delegated + // callers is out of reach for an agent, whatever scope it holds. + return nil, gqlerror.Errorf("insufficient_scope") + } + if !delegatedscope.Satisfied(tokenData.Scope, required) { + return nil, gqlerror.Errorf("insufficient_scope") + } + return next(ctx) + } +} + // gqlMetricsMiddleware records GraphQL operation duration and errors. // It captures errors returned in HTTP 200 responses (GraphQL convention). func (h *httpProvider) gqlMetricsMiddleware() gql.OperationMiddleware { @@ -298,6 +346,7 @@ func (h *httpProvider) GraphqlHandler() gin.HandlerFunc { srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) srv.AroundFields(h.gqlCollectResolvedFieldsMiddleware()) + srv.AroundFields(h.gqlDelegatedScopeMiddleware()) srv.AroundOperations(h.gqlMetricsMiddleware()) if h.Config.EnableGraphQLIntrospection { srv.Use(extension.Introspection{}) diff --git a/internal/integration_tests/delegated_scope_test.go b/internal/integration_tests/delegated_scope_test.go new file mode 100644 index 000000000..571d49e68 --- /dev/null +++ b/internal/integration_tests/delegated_scope_test.go @@ -0,0 +1,141 @@ +package integration_tests + +import ( + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/delegatedscope" +) + +// TestDelegatedTokenScopeIsEnforced is the regression test for the blast-radius +// vulnerability: a delegated token authenticated at Authorizer's own API and +// reached EVERY first-party operation with its `scope` claim never consulted. +// +// An agent an operator granted `openid` for a downstream MCP server could read +// the delegating user's profile, mutate the account, and deactivate it. The +// RFC 8693 attenuation that produced that scope was computed, returned to the +// caller, and then ignored. +// +// Driven through the REAL /graphql endpoint, not the GraphQLProvider directly. +// Enforcement lives in gqlgen middleware, so a test that calls the service +// layer bypasses it entirely and would pass no matter what the gate did — the +// first version of this test did exactly that. +func TestDelegatedTokenScopeIsEnforced(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + tokenRouter := gin.New() + tokenRouter.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + delegated, _, _ := mintDelegatedViaEndpoint(t, ts, tokenRouter, testAuthorizerHost(ts)) + + claims, err := ts.TokenProvider.ParseJWTToken(delegated) + require.NoError(t, err) + t.Logf("delegated scope: %v", claims["scope"]) + + router := setupTestRouter(ts) + post := func(t *testing.T, query string) string { + t.Helper() + body := `{"query":` + jsonQuote(query) + `}` + w := sendTestRequest(t, router, "POST", "/graphql", body, map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + delegated, + "Origin": "http://localhost:3000", + "X-Authorizer-URL": testAuthorizerHost(ts), + }) + return w.Body.String() + } + + t.Run("read-only permission queries stay reachable", func(t *testing.T) { + // What the agent feature exists for, and exactly the tool set the + // built-in MCP server exposes. Gating these would make it useless. + out := post(t, `query { check_permissions(params: {checks: [{relation: "can_view", object: "document:x"}]}) { results { allowed } } }`) + assert.NotContains(t, out, "insufficient_scope", + "check_permissions requires only openid and must stay reachable") + }) + + t.Run("profile stays readable", func(t *testing.T) { + out := post(t, `query { profile { id email } }`) + assert.NotContains(t, out, "insufficient_scope", + "profile requires only openid and must stay reachable") + }) + + t.Run("update_profile is REFUSED", func(t *testing.T) { + out := post(t, `mutation { update_profile(params: {given_name: "mutated-by-agent"}) { message } }`) + assert.Contains(t, out, "insufficient_scope", + "an agent scoped openid/email/profile must not be able to mutate the account") + }) + + t.Run("deactivate_account is REFUSED", func(t *testing.T) { + out := post(t, `mutation { deactivate_account { message } }`) + assert.Contains(t, out, "insufficient_scope", + "an agent must never be able to deactivate its delegating user's account") + }) + + t.Run("an unlisted operation fails closed", func(t *testing.T) { + // Nothing cleared webauthn_credentials for delegated callers, so it is + // denied without anyone having had to remember to deny it. + out := post(t, `query { webauthn_credentials { id } }`) + assert.Contains(t, out, "insufficient_scope", + "operations absent from the table must be unreachable by agents by default") + }) +} + +// TestFirstPartyTokensAreNotScopeGated pins the deliberate asymmetry. +// +// A first-party `scope` is caller-supplied and unvalidated (service.Login takes +// params.Scope with no allow-list), so it is a hint, not a boundary. Gating on +// it would break every existing client while granting no security. The gate +// exists only where the ceiling is admin-controlled: delegated tokens. +func TestFirstPartyTokensAreNotScopeGated(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := setupTestRouter(ts) + + token := testAccessToken(t, ts) + body := `{"query":` + jsonQuote(`mutation { update_profile(params: {given_name: "changed-by-the-user"}) { message } }`) + `}` + w := sendTestRequest(t, router, "POST", "/graphql", body, map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + token, + "Origin": "http://localhost:3000", + "X-Authorizer-URL": testAuthorizerHost(ts), + }) + assert.NotContains(t, w.Body.String(), "insufficient_scope", + "a user updating their own profile must be unaffected by delegated-scope enforcement") +} + +// TestDelegatedScopeTableCoversBothTransports guards the one way the two +// enforcement points could disagree: an operation listed for GraphQL but not +// gRPC (or vice versa) would be reachable on one transport and denied on the +// other — the same class of bug as delegation being inert on GraphQL while +// working on gRPC. +func TestDelegatedScopeTableCoversBothTransports(t *testing.T) { + pairs := []struct{ graphQL, grpc string }{ + {"check_permissions", "CheckPermissions"}, + {"list_permissions", "ListPermissions"}, + {"profile", "Profile"}, + {"meta", "Meta"}, + {"update_profile", "UpdateProfile"}, + {"deactivate_account", "DeactivateAccount"}, + } + for _, p := range pairs { + gqlScope, gqlOK := delegatedscope.RequiredForGraphQL(p.graphQL) + grpcScope, grpcOK := delegatedscope.RequiredForGRPC("/authorizer.v1.AuthorizerService/" + p.grpc) + require.True(t, gqlOK, "%s missing from the GraphQL side of the table", p.graphQL) + require.True(t, grpcOK, "%s missing from the gRPC side of the table", p.grpc) + assert.Equal(t, gqlScope, grpcScope, + "%s/%s must require the same scope on both transports", p.graphQL, p.grpc) + } + + _, ok := delegatedscope.RequiredForGRPC("/authorizer.v1.AuthorizerService/SomethingNobodyCleared") + assert.False(t, ok, "an unknown method must not resolve to a scope") +} + +// jsonQuote escapes a GraphQL document for embedding in a JSON request body. +func jsonQuote(s string) string { + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\t", `\t`) + return `"` + r.Replace(s) + `"` +} diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index e580efc6e..627249b77 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -811,6 +811,13 @@ type SessionOrAccessTokenData struct { // decision: they were asserted by an upstream party, not verified here. // They remain available in the raw token for audit reconstruction. ActorID string + // Scope is the token's `scope` claim. + // + // Carried because for a DELEGATED token it is a privilege boundary: the + // token endpoint attenuates it to subject ∩ agent-ceiling, and + // internal/delegatedscope enforces it per operation. Without it here the + // attenuation is computed, returned to the caller, and then ignored. + Scope []string } // ImmediateActor extracts the `act.sub` of an RFC 8693 delegated token. @@ -885,9 +892,33 @@ func (p *provider) GetUserIDFromSessionOrAccessToken(gc *gin.Context) (*SessionO LoginMethod: loginMethod, Nonce: nonce, ActorID: ImmediateActor(claims), + Scope: claimToScopeSlice(claims["scope"]), }, nil } +// claimToScopeSlice normalises a `scope` claim to a slice. Tokens minted here +// carry a JSON array, but a string form ("openid email profile") is the OAuth +// wire convention and appears in tokens from other issuers, so accept both +// rather than silently yield an empty scope — which, being fail-closed, would +// deny every delegated call for a non-obvious reason. +func claimToScopeSlice(v interface{}) []string { + switch t := v.(type) { + case []string: + return t + case string: + return strings.Fields(t) + case []interface{}: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + const scriptTimeout = 5 * time.Second const scriptTimeoutMsg = "script execution timeout: exceeded 5 seconds" From 5823a633296c061aa8fe1bc226f6fd549dc62078 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 14:46:15 +0530 Subject: [PATCH 23/25] docs(changelog): record delegated per-operation scope enforcement --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4360e6a4..44d9b297a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id - **Trusted base URL + email/SMS OTP lockout**: new `--url` flag (`config.AuthorizerURL`) sets the single trusted source for the server's own URL used in email verification links, JWT `iss` claim, and OIDC discovery, preventing header-spoofing attacks that could redirect users to attacker-controlled sites while carrying single-use tokens. Email/SMS OTP verification now gets the same per-user brute-force lockout that TOTP already had ([#698](https://github.com/authorizerdev/authorizer/pull/698)). - **Type-safe error handling in gRPC admin service**: admin service methods now return properly-typed errors (400 for validation, 409 for conflicts, etc.) instead of generic Internal errors (500), and public-method bypass is tightly scoped to only the public service and `AdminLogin` ([#700](https://github.com/authorizerdev/authorizer/pull/700)). - **Atomic storage operations with transaction guards**: `UpdateUsers` empty-ids filter is now enforced across all 13 database providers (preventing silent full-table updates on Mongo/Arango/Cassandra/Couchbase/DynamoDB); cascade deletes (`DeleteOrganization`, `DeleteClient`, `DeleteWebhook`, `DeleteUser`) are now wrapped in transactions, rolling back on partial failure ([#699](https://github.com/authorizerdev/authorizer/pull/699)). +- **Delegated tokens are gated per operation by their `scope` claim**: an RFC 8693 delegated token may only reach operations cleared for delegated callers, and only while its `scope` carries the scope that operation requires. Refusals are `insufficient_scope` (RFC 6750 §3.1). Until this existed the attenuation the token endpoint computes — `subject_token.scope ∩ agent.allowed_scopes` — was returned to the caller and then never consulted, so a delegated token reached **every** first-party operation: an agent an operator granted `openid` for a downstream MCP server could read the delegating user's profile, mutate the account, and deactivate it. Read-only identity and permission queries (`check_permissions`, `list_permissions`, `profile`, `meta` — exactly the built-in MCP tool set) require only `openid` and are unaffected. Mutating operations require a scope no client requests by default: `authorizer:profile:write` for `update_profile`, `authorizer:account:delete` for `deactivate_account`. Because a delegated scope is the intersection of the user's and the agent's, a sensitive operation needs **both** halves — the user's own token must carry the scope and an admin must have granted the agent a ceiling including it — so neither party can widen an agent alone. **Fails closed**: any operation not explicitly cleared is denied to delegated callers whatever scope they hold, so new operations are unreachable by agents until deliberately added. **First-party tokens are deliberately not gated** — `login` accepts a caller-supplied `scope` with no allow-list, making it a hint rather than a boundary, so enforcing it there would break existing clients while granting no security. Enforced identically on GraphQL and on gRPC (which also covers the REST gateway and the MCP server) ([#742](https://github.com/authorizerdev/authorizer/pull/742)). - **Agent authority is the intersection of agent and user permissions**: a delegated (RFC 8693) caller's effective authority on `check_permissions` and `list_permissions` is now `perms(agent) ∩ perms(user)`, evaluated per action at request time, rather than the delegating user's full authority. This is the Confused Deputy fix: an agent can no longer act on anything its user happens to be able to reach, and equally cannot exceed what its user could have done itself. Enumeration intersects too — an agent that cannot act on an object must not see it listed, or the user's resource names leak. Only the **immediate** actor participates; prior hops in the `act` chain are audit-only. The subject can never be widened by a request parameter: an explicit `user` is honoured only as the caller's own subject and never sheds the agent half, and a delegated token naming any other subject is refused outright — including when an admin credential rides along on the same request. **Opt-in is declaring `type agent` in the authorization model**, with no flag: checking `agent:` against a model lacking the type errors rather than returning false, so a flag switched on against an unprepared model would deny every delegated request. Deployments without the type keep today's behaviour byte-for-byte and are counted as `authorizer_fga_delegated_checks_total{outcome="not_enforced"}` so the unenforced state is visible. Fails closed throughout: a model-read failure, a malformed agent id, or an inactive subject denies. See [Agent Identity & Permissions](https://docs.authorizer.dev/enterprise/agent-identity). - **Delegated tokens are revocable at Authorizer's own API**: a delegated token now carries an opaque `sid` naming the session it was derived from, so logout, password reset, email change and admin session wipes stop it on the next call. Previously nothing a user or admin could do stopped one — it stayed valid for its full TTL, and the only working lever was revoking the user outright. A downstream resource server validates offline against the JWKS and still cannot see this, so the short TTL remains the only bound **there**; do not build a resource server that assumes otherwise. Fails closed: a delegation whose origin cannot be verified does not authenticate here. - **Agent actions are attributed to the agent in the audit log**: an action taken by an agent on a user's behalf is recorded with the agent as `actor_id`, `actor_type: agent`, no actor email, and the delegating user preserved in metadata (`delegated_user_id`, `delegated_user_email`). Previously the delegating user was recorded as the actor on the GraphQL surface — the actor was read from a request principal that only the gRPC interceptor constructs — making an agent's actions indistinguishable from the human's, which cannot be reconstructed after the fact. RFC 8693 §1.1 draws exactly this line between delegation and impersonation. From fdbe1f113262cf8e206677ac502deffcc6bedcf1 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 17:23:14 +0530 Subject: [PATCH 24/25] security(agent): decide the scope gate from the JWT alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-audit of the gate I added in 8f9e6492 found two defects in it. FAIL-OPEN. The GraphQL gate resolved the caller through GetUserIDFromSessionOrAccessToken and skipped itself whenever that errored. For a delegated token that call reads the session store and the user row, so a transient storage failure between it and the resolver's own auth would skip the gate on a request that still succeeded — the one path whose whole job is to fail closed, failing open. Whether a token is delegated, and what it is scoped to, are properties of the signed token; neither needs a database. Now decided from ParseJWTToken alone. That also makes it ~free, which matters: it runs per root field, and the old shape did a storage read each time. IsMethod. The root-field guard also tested fc.IsMethod, which reports how gqlgen resolves a field — an implementation detail of generated code. A false there would have skipped the gate entirely. Object is the schema-level fact the check actually depends on. Adds a permanent adversarial test: alias, named operation, fragment spread on the root, __typename alongside a denied field, and a denied root field batched with an allowed one. All six repelled. Verified the gRPC side covers every path that accepts a bearer token; the other two (admin secret, browser session) cannot carry a delegated token. --- internal/http_handlers/graphql.go | 41 ++++++++++-- .../delegated_scope_adversarial_test.go | 66 +++++++++++++++++++ internal/token/auth_token.go | 13 ++++ 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 internal/integration_tests/delegated_scope_adversarial_test.go diff --git a/internal/http_handlers/graphql.go b/internal/http_handlers/graphql.go index a1684e685..f9f088dd3 100644 --- a/internal/http_handlers/graphql.go +++ b/internal/http_handlers/graphql.go @@ -27,6 +27,7 @@ import ( "github.com/authorizerdev/authorizer/internal/graphql" "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -226,26 +227,54 @@ func (*httpProvider) gqlCollectResolvedFieldsMiddleware() gql.FieldMiddleware { func (h *httpProvider) gqlDelegatedScopeMiddleware() gql.FieldMiddleware { return func(ctx context.Context, next gql.Resolver) (interface{}, error) { fc := gql.GetFieldContext(ctx) - if fc == nil || fc.Field.Field == nil || !fc.IsMethod || fc.Object != "Query" && fc.Object != "Mutation" { + // Root fields only. Deliberately NOT also gated on fc.IsMethod: that + // reports how gqlgen resolves the field, which is an implementation + // detail of the generated code, and a false there would skip the gate + // entirely. Object is the schema-level fact this actually depends on. + if fc == nil || fc.Field.Field == nil || (fc.Object != "Query" && fc.Object != "Mutation") { return next(ctx) } gc, err := utils.GinContextFromContext(ctx) if err != nil || gc == nil { return next(ctx) } - tokenData, tErr := h.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) - if tErr != nil || tokenData == nil || strings.TrimSpace(tokenData.ActorID) == "" { - // Not a delegated caller (or not authenticated at all — the - // resolver's own auth check owns that decision, not this one). + + // Decided from the JWT alone — signature and claims, no storage. + // + // The first version resolved the caller through + // GetUserIDFromSessionOrAccessToken and skipped the gate whenever that + // returned an error. For a delegated token that call reads the session + // store and the user row, so a transient storage failure between this + // check and the resolver's own auth would have skipped the gate while + // the request still succeeded: fail-OPEN on the one path that exists to + // fail closed. Whether a token is delegated, and what it is scoped to, + // are properties of the signed token itself; nothing about that needs + // a database. + // + // It is also ~free, which matters because this runs per root field. + raw, tErr := h.TokenProvider.GetAccessToken(gc) + if tErr != nil || strings.TrimSpace(raw) == "" { + // No bearer token at all — a cookie/session or anonymous caller. + // Neither can be delegated; the resolver's own auth owns them. + return next(ctx) + } + claims, cErr := h.TokenProvider.ParseJWTToken(raw) + if cErr != nil { + // Unparseable or badly signed. Not our decision to make — the + // resolver's auth will reject it. return next(ctx) } + if token.ImmediateActor(claims) == "" { + return next(ctx) // first-party token; see the package comment. + } + required, ok := delegatedscope.RequiredForGraphQL(fc.Field.Name) if !ok { // Fail closed: an operation nobody has cleared for delegated // callers is out of reach for an agent, whatever scope it holds. return nil, gqlerror.Errorf("insufficient_scope") } - if !delegatedscope.Satisfied(tokenData.Scope, required) { + if !delegatedscope.Satisfied(token.ClaimScopes(claims), required) { return nil, gqlerror.Errorf("insufficient_scope") } return next(ctx) diff --git a/internal/integration_tests/delegated_scope_adversarial_test.go b/internal/integration_tests/delegated_scope_adversarial_test.go new file mode 100644 index 000000000..a70403036 --- /dev/null +++ b/internal/integration_tests/delegated_scope_adversarial_test.go @@ -0,0 +1,66 @@ +package integration_tests + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// Attacks the delegated scope gate added in gqlDelegatedScopeMiddleware. +// Every subtest is an attempt to reach update_profile / deactivate_account +// with a token scoped openid/email/profile. +func TestDelegatedScopeGateAdversarial(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + tokenRouter := gin.New() + tokenRouter.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + delegated, _, _ := mintDelegatedViaEndpoint(t, ts, tokenRouter, testAuthorizerHost(ts)) + + router := setupTestRouter(ts) + post := func(t *testing.T, query string) string { + t.Helper() + body := `{"query":` + jsonQuote(query) + `}` + w := sendTestRequest(t, router, "POST", "/graphql", body, map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer " + delegated, + "Origin": "http://localhost:3000", + "X-Authorizer-URL": testAuthorizerHost(ts), + }) + return w.Body.String() + } + + t.Run("ATTACK: alias the field name", func(t *testing.T) { + out := post(t, `mutation { renamed: update_profile(params: {given_name: "via-alias"}) { message } }`) + require.Contains(t, out, "insufficient_scope", + "an alias must not bypass the gate — fc.Field.Name must be the schema name, not the alias") + }) + + t.Run("ATTACK: hide the denied field behind an allowed one", func(t *testing.T) { + out := post(t, `mutation { update_profile(params: {given_name: "hidden"}) { message } }`) + require.Contains(t, out, "insufficient_scope") + }) + + t.Run("ATTACK: two root fields, one allowed one denied", func(t *testing.T) { + out := post(t, `query { profile { id } webauthn_credentials { id } }`) + require.Contains(t, out, "insufficient_scope", + "a denied root field must still be denied when batched with an allowed one") + }) + + t.Run("ATTACK: named operation", func(t *testing.T) { + out := post(t, `mutation Evil { update_profile(params: {given_name: "named"}) { message } }`) + require.Contains(t, out, "insufficient_scope") + }) + + t.Run("ATTACK: fragment spread on the root", func(t *testing.T) { + out := post(t, `mutation { ...F } fragment F on Mutation { update_profile(params: {given_name: "frag"}) { message } }`) + require.Contains(t, out, "insufficient_scope", + "a fragment spread must not route around a root-field check") + }) + + t.Run("ATTACK: __typename alongside a denied field", func(t *testing.T) { + out := post(t, `mutation { __typename update_profile(params: {given_name: "tn"}) { message } }`) + require.Contains(t, out, "insufficient_scope") + }) +} diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index 627249b77..504b0078a 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -896,6 +896,19 @@ func (p *provider) GetUserIDFromSessionOrAccessToken(gc *gin.Context) (*SessionO }, nil } +// ClaimScopes returns the `scope` claim of already-parsed claims as a slice. +// +// Exported so a caller that has only the raw JWT — the GraphQL scope gate, +// which deliberately avoids any storage read — can reach the same +// normalisation the session path uses, rather than reimplementing it and +// drifting on the string-vs-array form. +func ClaimScopes(claims map[string]interface{}) []string { + if claims == nil { + return nil + } + return claimToScopeSlice(claims["scope"]) +} + // claimToScopeSlice normalises a `scope` claim to a slice. Tokens minted here // carry a JSON array, but a string form ("openid email profile") is the OAuth // wire convention and appears in tokens from other issuers, so accept both From e209de1ebf2d0fb8ed8db4ff9cfbf245389d68ad Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 6 Aug 2026 20:12:45 +0530 Subject: [PATCH 25/25] test(agent): restore the chained-delegation regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing which injected faults the suite actually catches found three of four covered and the fourth — hop-2 dropping the incoming `sid` — caught by nothing. TestChainedDelegationKeepsItsSessionBinding had been written and verified earlier but was not in the committed file; that file holds a mix of two drafts, and this test was the casualty. Multi-hop delegation into Authorizer's own API is otherwise untested. A delegated subject_token carries `sid` and no `nonce`, so hop 2 cannot rebuild the binding and must propagate it verbatim; without it the chain cannot authenticate here at all, and the failure reads as a permissions problem rather than a lost session binding. Verified failing against the fault and passing without it. --- .../agent_coverage_gaps_test.go | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/integration_tests/agent_coverage_gaps_test.go b/internal/integration_tests/agent_coverage_gaps_test.go index 6014e966a..ec78a3521 100644 --- a/internal/integration_tests/agent_coverage_gaps_test.go +++ b/internal/integration_tests/agent_coverage_gaps_test.go @@ -1,6 +1,9 @@ package integration_tests import ( + "encoding/json" + "net/http" + "net/url" "testing" "github.com/gin-gonic/gin" @@ -126,3 +129,64 @@ func TestDelegatedDenialIsAttributedToTheUser(t *testing.T) { "operator to grant the agent a tuple, which cannot fix it and widens the agent for nothing") assert.Equal(t, agentBefore, byAgent(), "the agent had its grant, so it did not deny") } + +// TestChainedDelegationKeepsItsSessionBinding covers MULTI-HOP delegation into +// Authorizer's own API, which nothing else exercises. +// +// A delegated token carries `sid` (the originating session) and NO `nonce`. A +// second hop therefore cannot rebuild the binding from nonce + login_method the +// way a first hop does — it must propagate the incoming `sid` verbatim. Drop +// that and hop 2 mints a token with no `sid`, which fails the session check and +// cannot authenticate here at all: multi-hop agents break, and the failure +// surfaces as a bare "unauthorized" that reads like a permissions problem. +// +// It matters in the other direction too: a hop-2 token that kept working +// WITHOUT a binding would outlive the logout that ended the session it grew +// from. +func TestChainedDelegationKeepsItsSessionBinding(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + host := testAuthorizerHost(ts) + + // Hop 1: user -> agent A, bound to Authorizer itself. + firstHop, _, userID := mintDelegatedViaEndpoint(t, ts, router, host) + + // Hop 2: agent B re-exchanges agent A's delegated token. + agentBID, agentBSecret := newDelegationAgent(t, ts, "openid,profile,email") + agentBActor := agentAccessToken(t, ts, router, agentBID, agentBSecret) + + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", firstHop) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", agentBActor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", host) + rec := postTokenExchange(ts, router, form, agentBID, agentBSecret) + require.Equal(t, http.StatusOK, rec.Code, "the second hop must be permitted; body=%s", rec.Body.String()) + + var out struct { + AccessToken string `json:"access_token"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.NotEmpty(t, out.AccessToken) + + claims, err := ts.TokenProvider.ParseJWTToken(out.AccessToken) + require.NoError(t, err) + require.Equal(t, userID, claims["sub"], "the subject stays the original user across hops") + sid, _ := claims["sid"].(string) + require.NotEmpty(t, sid, + "the second hop must carry the originating session forward — a delegated subject_token "+ + "has no nonce to rebuild it from, so dropping the incoming sid leaves the chain unable "+ + "to authenticate here at all") + + // And it must work end to end, not merely carry the claim. + presentDelegatedToken(ts, out.AccessToken) + data, aErr := ts.TokenProvider.GetUserIDFromSessionOrAccessToken(ts.GinContext) + require.NoError(t, aErr, "a two-hop delegated token naming Authorizer must authenticate here") + assert.Equal(t, userID, data.UserID) + assert.Equal(t, agentBID, data.ActorID, "the IMMEDIATE actor is the second-hop agent") +}