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/CHANGELOG.md b/CHANGELOG.md index 8e3d8a22e..44d9b297a 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. @@ -72,6 +73,10 @@ 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. ### Fixed 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..c9a92105c 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) @@ -434,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() diff --git a/internal/authctx/principal.go b/internal/authctx/principal.go index d92232bc0..6082217c2 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,25 @@ 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 + // 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. +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/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/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/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/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/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/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 } 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 5fd115f80..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" @@ -127,7 +128,12 @@ func Auth(tp token.Provider, log *zerolog.Logger) grpc.UnaryServerInterceptor { UserID: tokenData.UserID, 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) } @@ -159,11 +165,39 @@ func Auth(tp token.Provider, log *zerolog.Logger) grpc.UnaryServerInterceptor { UserID: tokenData.UserID, 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..f9f088dd3 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,11 +21,13 @@ 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" "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -204,6 +207,80 @@ 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) + // 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) + } + + // 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(token.ClaimScopes(claims), 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 +375,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/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/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/agent_coverage_gaps_test.go b/internal/integration_tests/agent_coverage_gaps_test.go new file mode 100644 index 000000000..ec78a3521 --- /dev/null +++ b/internal/integration_tests/agent_coverage_gaps_test.go @@ -0,0 +1,192 @@ +package integration_tests + +import ( + "encoding/json" + "net/http" + "net/url" + "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") +} + +// 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") +} 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/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/integration_tests/delegated_adversarial_test.go b/internal/integration_tests/delegated_adversarial_test.go new file mode 100644 index 000000000..9e96ec1c8 --- /dev/null +++ b/internal/integration_tests/delegated_adversarial_test.go @@ -0,0 +1,749 @@ +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" + "github.com/authorizerdev/authorizer/internal/token" +) + +// 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 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) + 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") + }) +} + +// 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) + + _, 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.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 +// 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.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") +} + +// --------------------------------------------------------------------------- +// (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} + + // 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, + "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"}, + "sid": sid, + "aud": aud, + } + } + + // 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 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", ""}, + {"another resource server", "https://mcp.example.com"}, + } + 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("reject: 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") + }) + + // 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 +// 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 +// --------------------------------------------------------------------------- + +// 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} + + 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 + } + + // 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("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) + require.Error(t, err, "wiping every session must also stop the delegated token") + }) + + 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) + require.Error(t, err, "a password reset must stop the delegated token") + }) + + 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() + u.RevokedTimestamp = &now + _, err = ts.StorageProvider.UpdateUser(ctx, u) + require.NoError(t, err) + _, err = ts.TokenProvider.ValidateDelegatedAccessToken(gc, tok) + 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") + }) +} + +// --------------------------------------------------------------------------- +// (c) ActorID shape — tuple/userset smuggling into the agent subject +// --------------------------------------------------------------------------- + +// 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) + 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") + }) + } +} + +// 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) + + _, 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", 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)) + + // 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") + + // 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"}}, + }) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.False(t, res.Results[0].Allowed, + "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_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/integration_tests/delegated_revocation_test.go b/internal/integration_tests/delegated_revocation_test.go new file mode 100644 index 000000000..90ca7745a --- /dev/null +++ b/internal/integration_tests/delegated_revocation_test.go @@ -0,0 +1,125 @@ +package integration_tests + +import ( + "context" + "testing" + "time" + + "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) + + // 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), + SessionID: token.DelegationSessionID(constants.AuthRecipeMethodServiceAccount, sa.ID, nonce), + }) + 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_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/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/integration_tests/delegated_token_api_test.go b/internal/integration_tests/delegated_token_api_test.go new file mode 100644 index 000000000..a0d7646e1 --- /dev/null +++ b/internal/integration_tests/delegated_token_api_test.go @@ -0,0 +1,141 @@ +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. 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), + SessionID: token.DelegationSessionID(loginMethod, subject, nonce), + }) + require.NoError(t, err) + require.NotNil(t, tok) + 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. +// +// 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, 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"]) + 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, 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/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") + }) +} 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/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/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 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/metrics/metrics.go b/internal/metrics/metrics.go index 528cfcb04..69face0a3 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|not_enforced", + }, + []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,38 @@ 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" + // 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 +// 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/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_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/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/service/audit_actor.go b/internal/service/audit_actor.go new file mode 100644 index 000000000..ea91ee75d --- /dev/null +++ b/internal/service/audit_actor.go @@ -0,0 +1,74 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/authorizerdev/authorizer/internal/audit" + "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 +// +// 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 = 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/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/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/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/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) + }) +} diff --git a/internal/service/check_permissions.go b/internal/service/check_permissions.go index 55801e7d5..fa9331b2b 100644 --- a/internal/service/check_permissions.go +++ b/internal/service/check_permissions.go @@ -33,26 +33,56 @@ 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 } - 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") - } - ctxTuples, err := toContextualTuples(c.ContextualTuples) - if err != nil { - return nil, nil, err + // 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. + // + // 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 + // 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 +93,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/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() diff --git a/internal/service/deactivate_account.go b/internal/service/deactivate_account.go index 057e2132f..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(audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(tokenData.ActorID, 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.go b/internal/service/fga.go index bca3f10b2..0a98f8225 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 @@ -244,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 } @@ -254,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/fga_agent.go b/internal/service/fga_agent.go new file mode 100644 index 000000000..08b1a4f44 --- /dev/null +++ b/internal/service/fga_agent.go @@ -0,0 +1,176 @@ +package service + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/authorizerdev/authorizer/internal/metrics" +) + +// 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. +// +// 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, ErrFgaNotEnabled + } + + modelID, _, err := p.AuthzEngine.ReadModel(ctx) + if err != nil { + return false, err + } + + 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, nil + } + if modelID == "" && !checkedAt.IsZero() && time.Since(checkedAt) < agentModelTTL { + return cachedEnabled, nil + } + + names, err := p.AuthzEngine.TypeNames(ctx) + if err != nil { + return false, err + } + 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, nil +} + +// 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. +// +// 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") + } + 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 + } + + // 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") + } + + 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{FgaAgentSubjectType + ":" + actorID, subject}, 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..035bd2513 --- /dev/null +++ b/internal/service/fga_agent_adversarial_test.go @@ -0,0 +1,216 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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 is nil-embedded so an accidental +// dependency on it panics loudly rather than silently returning a zero value. +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 +} + +// advDelegatedCaller is an agent acting on behalf of a user. +func advDelegatedCaller(userID, actorID string) fgaCaller { + return fgaCaller{subject: "user:" + userID, actorID: actorID} +} + +// 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 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, 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 denies", func(t *testing.T) { + p := &provider{} + p.AuthzEngine = &advStubEngine{ + readModelFn: func() (string, string, error) { return "", "", errors.New("model render failed") }, + } + 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, 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) + }) +} + +// 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") + + 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) + + // 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, 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) +} + +// 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", "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) + }) + } +} + +// 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/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") +} 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/list_permissions.go b/internal/service/list_permissions.go index b47042311..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 @@ -60,22 +66,60 @@ 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. + // + // 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 + // 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 +187,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/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/logout.go b/internal/service/logout.go index cabe8e8f7..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(audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(tokenData.ActorID, 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/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/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/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/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/signup.go b/internal/service/signup.go index c9dac0dbd..2cdbfb2bc 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{ @@ -277,7 +303,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 { @@ -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", @@ -357,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 } @@ -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, 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/update_profile.go b/internal/service/update_profile.go index c7e96c720..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(audit.Event{ + p.AuditProvider.LogEvent(applyDelegationActor(tokenData.ActorID, 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/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 } diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index c634d0548..0769b235d 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`) } @@ -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 } 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) diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index f3f0302d5..504b0078a 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 @@ -796,6 +801,37 @@ 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 + // 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. +// +// 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 +863,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,9 +891,47 @@ func (p *provider) GetUserIDFromSessionOrAccessToken(gc *gin.Context) (*SessionO UserID: userID, LoginMethod: loginMethod, Nonce: nonce, + ActorID: ImmediateActor(claims), + Scope: claimToScopeSlice(claims["scope"]), }, 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 +// 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" diff --git a/internal/token/delegated_access_token.go b/internal/token/delegated_access_token.go new file mode 100644 index 000000000..1c9e82d0f --- /dev/null +++ b/internal/token/delegated_access_token.go @@ -0,0 +1,235 @@ +package token + +import ( + "fmt" + "net/url" + "strings" + + "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 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's URL as the resource. +// - Issuer/claims via ValidateJWTClaims, and token_type must be an access token. +// - 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. +// +// # What is knowingly given up +// +// 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 == "" { + 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 { + 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`) + } + + 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 !sameAudience(aud, hostname) { + if u, uErr := url.Parse(aud); uErr == nil && u.IsAbs() { + 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`) + } + + // 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`) + } + + // 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 +} + +// 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. +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 +} diff --git a/internal/token/delegation_token.go b/internal/token/delegation_token.go index 1f75960ab..376487909 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,73 @@ 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:". +// +// 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 { + // 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 + 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 +156,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 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 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 \