Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c98b6f3
security(crypto)!: split at-rest encryption key from JWT secret
lakhansamani Aug 4, 2026
67ae055
security(replay): make single-use claims atomic via SetCacheNX
lakhansamani Aug 4, 2026
18c1981
fix(storage): unify the not-found contract across all backends
lakhansamani Aug 4, 2026
48ce42a
chore(memory-store): drain expired-state cleanup via asyncutil.Go
lakhansamani Aug 4, 2026
fb08ed0
security(crypto): warn when the at-rest key falls back to --jwt-secret
lakhansamani Aug 4, 2026
f53b2f5
fix(crypto): accept PKCS#8 and PKIX keys for RS*/ES*
lakhansamani Aug 4, 2026
e5d483a
fix(events): emit user.signup when the account is created
lakhansamani Aug 4, 2026
ba04063
fix(mfa): carry the requested scope across the MFA interruption
lakhansamani Aug 4, 2026
843ea48
test(mfa): cover scope carry and signup event emission
lakhansamani Aug 4, 2026
ec4feb2
feat(agent): agent identity, delegated API access and FGA intersection
lakhansamani Aug 5, 2026
beef015
fix(agent): make the delegated path reachable and close two liveness …
lakhansamani Aug 5, 2026
f8422f1
fix(agent): close the intersection bypasses and bind delegations to a…
lakhansamani Aug 5, 2026
08c75ac
fix(audit): attribute agent actions to the agent on every transport
lakhansamani Aug 5, 2026
1e1a931
refactor(token): check the delegation session before the subject
lakhansamani Aug 5, 2026
edac3fc
test(metrics): cover the delegated check outcomes
lakhansamani Aug 5, 2026
04a0729
test(e2e-playground): send LinkedIn's OIDC userinfo shape
lakhansamani Aug 5, 2026
31bb90e
test(agent): stop describing the fixed bugs as live
lakhansamani Aug 5, 2026
5e6b793
Merge remote-tracking branch 'origin/main' into security/at-rest-key-…
lakhansamani Aug 5, 2026
1e64156
docs(changelog): record the agent identity work for 2.4.0
lakhansamani Aug 5, 2026
67169b3
test(agent): close four coverage gaps found by mutation testing
lakhansamani Aug 6, 2026
0e09f28
docs(changelog): record the --encryption-key breaking change
lakhansamani Aug 6, 2026
5ed2368
fix(agent): apply the intersection to required_relations, deny org-admin
lakhansamani Aug 6, 2026
8f9e649
security(agent): enforce delegated token scope per operation
lakhansamani Aug 6, 2026
5823a63
docs(changelog): record delegated per-operation scope enforcement
lakhansamani Aug 6, 2026
fdbe1f1
security(agent): decide the scope gate from the JWT alone
lakhansamani Aug 6, 2026
e209de1
test(agent): restore the chained-delegation regression test
lakhansamani Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:<id>` 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

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
23 changes: 22 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
21 changes: 21 additions & 0 deletions internal/authctx/principal.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Package authctx carries authentication principal details on context.Context.
package authctx

import "strings"

import "context"

type principalContextKey struct{}
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion internal/authenticators/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Loading
Loading