diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac2b1b2..15ae579e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Authorizer will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.4.0] - 2026-08-19 Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML IdP + verified domains + home realm discovery), WebAuthn/passkey login, SCIM 2.0 groups, redesigned MFA behavior, and OAuth 2.1/MCP hardening. @@ -51,6 +51,8 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id - **OIDC Discovery caching**: discovery document and JWKS are now cached server-side with strict expiry, reducing external provider request load during token validation for social logins like Twitter ([#668](https://github.com/authorizerdev/authorizer/pull/668)). - **Graceful shutdown for background work**: detached goroutines that fire request side effects (email/SMS sends, webhook events, audit log writes) are now tracked and drained on shutdown instead of being silently killed mid-flight, and a panic inside one is recovered and logged instead of crashing the whole process ([#696](https://github.com/authorizerdev/authorizer/pull/696)). +- **`/mcp` accepts RFC 8693 delegated tokens**: an agent can now call the MCP surface with the delegated token that proves its delegation — `check_permissions`, the operation that asks Authorizer about an agent's own authority, was previously unreachable with exactly that credential, leaving agent delegation over MCP a stdio-only story. `ValidateMCPAccessToken` falls back to a delegated validation only when the token carries an `act` claim, so an ordinary wrong-audience token is still rejected on the first pass rather than paying a second validation on an internet-facing endpoint. The fallback uses a separate entry point rather than relaxing `ValidateDelegatedAccessToken`, whose single caller backs `/graphql`, `/v1/*` and gRPC — widening it in place would have turned every MCP-bound delegated token into a full first-party credential. The audience match stays exact in both directions: a token bound to the bare server URL is refused at `/mcp`, and an MCP-bound token is refused everywhere else. One token, one surface ([#773](https://github.com/authorizerdev/authorizer/pull/773)). + ### Changed - **BREAKING — `--url` is now required; the server refuses to start without it.** Every deployment must pass its own canonical address (e.g. `--url=https://auth.example.com`). Previously optional, in which case the server derived its own host from request headers. That fallback is a takeover primitive: an attacker sends a password-reset or email-verification request with a forged `Host`/`X-Forwarded-Host`, and the victim is emailed a **genuine** single-use link pointing at a domain the attacker controls — no prior access and no mailbox compromise needed (CWE-640). It also affects the JWT `iss` claim, magic-link URLs and OIDC discovery. Making the flag mandatory is the only fix that closes the class: validating the derived host against `--allowed-origins` would do nothing on the default `*`, which is the configuration the attack targets. An unusable value is rejected rather than silently ignored — `--url=auth.example.com` (no scheme) or a URL carrying user info would otherwise start in the vulnerable configuration while looking configured. This costs no supported capability: setting `--url` already collapsed an instance to a single canonical host, so multi-host operation only ever worked on the vulnerable path (verified org domains are email-domain-to-organization routing, not HTTP virtual hosting, and are unaffected). **Note `--url` is not `--allowed-origins`** — the first is this server's own address, the second is the apps it may redirect to; you need both ([#764](https://github.com/authorizerdev/authorizer/pull/764)). @@ -69,6 +71,11 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id - **BREAKING: `/userinfo` now strictly filters claims by scope per OIDC Core §5.4.** The endpoint returns only `sub` plus the claims permitted by the standard scope groups (`profile`, `email`, `phone`, `address`) encoded in the access token. Previously, `/userinfo` returned the full user object regardless of scopes. Clients that request only the `openid` scope but read profile/email claims from `/userinfo` **must** now request those scopes explicitly. See https://docs.authorizer.dev/core/oauth2-oidc for the full scope→claim mapping. - **OAuth 2.1 standards compliance**: refresh-token reuse detection revokes the user's entire session family on replay (RFC 8707 compliance); `resource` parameter binding on authorization code flow (binds access token `aud` claim); new `--oauth21-strict` flag (default off) gates implicit-grant and PKCE-plain removal behind opt-in. New `GET /.well-known/oauth-authorization-server` thin alias of OIDC discovery for MCP compliance ([#693](https://github.com/authorizerdev/authorizer/pull/693)). +- **Spec-defined outbound fetches refuse redirects**: backchannel logout and the client-ID-metadata-document fetch now reject a redirect instead of following it, matching the sibling JWKS fetch. Both retrieve a resource the spec requires to be *at* the URL named — a hop away from a `client_id` URL serves a document for a different identifier, and Go re-issues a redirected POST as a bodyless GET, so a redirecting RP was already not receiving its logout, silently. The pinned dialer already prevented a redirect from reaching a new host, so this closes no hole; it removes a policy that was present at half the call sites and one relaxed dialer away from being one. Webhook delivery and the admin test-endpoint probe deliberately keep following redirects — those URLs are the operator's own and the final status is already recorded in `WebhookLog.HttpStatus` ([#775](https://github.com/authorizerdev/authorizer/pull/775)). +- **Kubernetes workload identity is bounded by the address the cluster publishes, and the docs now say which clusters that affects.** EKS, GKE and AKS publish a public HTTPS issuer with public OIDC discovery by default, so the feature works there with no configuration beyond the trusted issuer. Clusters on the default service-account issuer (kubeadm, kind) publish an in-cluster issuer and an RFC 1918 `jwks_uri`, which `validators.SafeHTTPClient` refuses — the operator-visible symptom is a bare `400 invalid_client` with nothing naming the refused fetch. The fix is one line of configuration: point `jwks_url` at a reachable mirror, since `issuer_url` is only matched against the assertion's `iss` and is never dialed. README and the `performTokenReview` doc comment previously read as "this does not work", which was an overstatement drawn from one cluster. New `make test-k8s` provisions a throwaway kind/k3d cluster and derives its expectations from the address the cluster actually publishes, so the suite is truthful on whichever cluster it is pointed at ([#778](https://github.com/authorizerdev/authorizer/pull/778), [#782](https://github.com/authorizerdev/authorizer/pull/782)). +- **SPIFFE JWT-SVID is labelled preview** in the README, which is what `grant_types.go` has said since it landed and nothing user-facing repeated ([#777](https://github.com/authorizerdev/authorizer/pull/777)). +- **Telemetry that existed only as constants now fires**: `AuditPasswordChangedEvent` and `AuditMFADisabledEvent` are emitted from `UpdateProfile` — in addition to `AuditProfileUpdatedEvent`, so nothing consuming that today breaks — because a password change and an MFA disable were previously indistinguishable from a display-name edit. `metrics.EventMagicLink` and `metrics.EventVerifyOTP` are now recorded; magic-link and OTP volume were invisible next to login/signup/oauth on the same dashboard ([#779](https://github.com/authorizerdev/authorizer/pull/779)). + ### Deprecated - **`--mcp-authorizer-url`** — **has no effect as of 2.4.0**; pass `--url` with the same value instead. The two fed different mechanisms: `--url` sets the trusted URL, consulted *before* any header, while this flag only stamped an `x-authorizer-url` header. `authorizer mcp` now honours `--url` (see Fixed), which supplies the value outright — so the flag is read by nothing and a stale or wrong value can no longer affect issuer validation. It is still **parsed**, so a 2.3.x invocation keeps starting rather than dying on `unknown flag`; it warns and is ignored. Removed in 2.5.0 with the subcommand. Setting `--mcp-bearer` without `--url` is now refused at startup with an explanatory message, rather than failing later as a bare `Unauthenticated` on every tool call. @@ -94,6 +101,10 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id - **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. +- **Revocation is now visible at `/oauth/introspect` and `/oauth/revoke`.** The session entry is this codebase's only revocation record — logout, password reset, admin session-wipe and `/oauth/revoke` all revoke by deleting it — and neither token endpoint consulted it. Introspection checked signature, `exp`, `iss`, `aud` and the user's `RevokedTimestamp`, so a token `ValidateAccessToken` already rejected still answered `active: true` and disclosed `sub`, `scope` and `aud` with it; any resource server trusting the endpoint accepted a logged-out token for the rest of its TTL (RFC 7662 §2.2). `/oauth/revoke` advertised `token_type_hint=access_token` as supported and then only ever looked up `refresh_token_`, so an access token matched nothing and got a `200` with nothing revoked — and RFC 7009 §2.2 mandates that `200` either way, so no client could tell. The hint now orders the lookup rather than restricting it (§2.1), and both endpoints read the entry through one helper so they cannot drift. `id_token` is carved out by token type: it is never registered in the store, so requiring an entry would report every one inactive. A store outage now answers `inactive`, matching `validateStatefulAccessToken` ([#774](https://github.com/authorizerdev/authorizer/pull/774)). +- **A logged-out subject can no longer seed new delegations.** Token exchange verified a `subject_token`'s signature, issuer and token type and nothing else; session liveness was checked only on the *resulting* token, and only at Authorizer's own surfaces. A delegated token is bound to a third-party `resource` and validated by that server, which has no view of this session store — so after a logout the user's agent kept minting fresh, externally valid credentials on their behalf until the subject token expired, with nothing downstream able to tell. The subject's session is now checked at exchange time, when there is something to check: a `sid` is either well-formed or absent, and its absence is a documented state meaning the subject had no session. Service-account subjects stay exempt — a `client_credentials` token has no browser session and its liveness comes from the `IsActive` check — or every agent-to-agent hop would break. The rejection reuses the opaque `invalid_grant` of the invalid-subject path, so the endpoint cannot be used to probe who is currently signed in. **Delegation is also now observable**: all fourteen refusal paths on the exchange grant emit an audit row (`token.exchange_failed`, actor = the calling agent, metadata = a fixed reason constant, never the subject id or the token) and a metric, where previously only success was recorded and an agent probing the endpoint left no trail. Agent scope-ceiling refusals and delegated-token validation refusals are metered too, so an agent hitting its ceiling — and revocation actually firing — are visible in dashboards rather than discovered during an incident ([#776](https://github.com/authorizerdev/authorizer/pull/776)). +- **SSRF block list: the two remaining reachable special-purpose ranges are blocked.** `198.18.0.0/15` and `192.88.99.0/24` are on the same IANA special-purpose registry as the TEST-NET ranges already covered, and were the only members of it still reachable. `198.18.0.0/15` is the one with real exposure: not being globally routable is exactly why organisations use it internally, so an SSRF there reaches a live internal service through an address that reads as public to every "is this private?" intuition — including this guard's. `192.88.99.0/24` (6to4 relay anycast, deprecated by RFC 7526) carries far less risk and is blocked so the registry is covered as a set. Found by fetching a container on a `198.18.0.0/24` docker network straight through the unmodified guard. No deployment can depend on this: both ranges are unroutable on the public internet by definition ([#781](https://github.com/authorizerdev/authorizer/pull/781)). + ### Fixed - **`authorizer mcp` silently ignored `--url`**: the subcommand inherits the root flag set, so `--url` was always *accepted* there, but `parsers.SetTrustedURL` was only called from the server's own startup path. Passing `--url` to the stdio MCP subcommand therefore looked configured and did nothing, leaving issuer validation on header derivation and making `--mcp-authorizer-url` the only mechanism that worked. `runMCP` now pins the trusted URL exactly as the server does. A flag that is accepted and ignored is worse than one that is rejected. @@ -104,11 +115,20 @@ Targets the 2.4.0 release. Significant additions include enterprise SSO (SAML Id - **OIDC ID token `nonce`**: now echoed in the ID token whenever it was supplied in the auth request, regardless of the flow used (OIDC Core §2). - **Admin service error mapping and InviteMembers**: gRPC error responses now use proper status codes (not all `codes.Internal`); the gRPC `public` bypass is scoped correctly; `InviteMembers` no longer has redundant re-fetches, missing `continue` statements, or unbounded batch sizes ([#700](https://github.com/authorizerdev/authorizer/pull/700)). +- **Trusted-issuer `key_source_type` and `issuer_type` are allow-listed instead of merely non-empty.** Any string was previously stored verbatim, and the value that mattered was `spiffe_bundle_endpoint`: a declared constant with no implementation, so an operator got a `200` and a row that looked configured and learned it was dead only when the first workload tried to authenticate. A plain typo failed identically, at the same unhelpful moment. The dashboard dropdown offered the same dead option and no longer does — the two lists are now one list. Existing rows are unaffected: validation is write-time only and no update request can set either field ([#777](https://github.com/authorizerdev/authorizer/pull/777)). +- **`Cache-Control: max-age` is parsed strictly**: `fmt.Sscanf` stopped at the first non-digit and still reported success, so `max-age=600junk` parsed as `600`. The clamp already made every outcome safe, so there was no live defect ([#775](https://github.com/authorizerdev/authorizer/pull/775)). +- **Housekeeping**: a repo-wide sweep corrected comments asserting behaviour the code does not have (two named tests that do not exist; three places claiming seven storage providers where there are six), and removed exported identifiers nothing referenced ([#779](https://github.com/authorizerdev/authorizer/pull/779)). A `.gitignore` rule justified itself by citing a path that was itself gitignored and never committed; it now states its reason inline ([#783](https://github.com/authorizerdev/authorizer/pull/783)). New end-to-end coverage exercises the RFC 7523 `client_assertion` path against a real JWKS fetch rather than a substituted seam — the one thing that decides whether the feature works in production is the address ([#782](https://github.com/authorizerdev/authorizer/pull/782)). + ### Removed - **`authorizer_client_id_not_found_total`**: replaced by **`authorizer_client_id_header_missing_total`**, which matches the actual behavior (header omitted, request still allowed). Update dashboards and alerts accordingly. - **OIDC Discovery — `registration_endpoint`**: previously pointed to the signup UI rather than an RFC 7591 dynamic client registration endpoint. (It returns in 2.4.0, pointing at a real RFC 7591 endpoint and only when `--enable-dynamic-client-registration` is set.) +- **13 authorization webhook event constants** (resource/scope/policy/permission events): nothing fired them *and* nothing could subscribe to them — they were never added to the valid webhook event names, so registering a webhook for one was rejected as invalid ([#779](https://github.com/authorizerdev/authorizer/pull/779)). +- **`GoogleUserInfoURL` and `MicrosoftUserInfoURL`**: both providers resolve via OIDC discovery ([#779](https://github.com/authorizerdev/authorizer/pull/779)). +- **`TokenTypeURNRefreshToken` / `TokenTypeURNIDToken`**: RFC 8693 token-type URNs this server does not accept; the block now states what Authorizer accepts rather than copying the registry ([#779](https://github.com/authorizerdev/authorizer/pull/779)). +- **`AuthMethodX509MTLS`**: nothing read it, `AddTrustedIssuer` hardcoded `jwt_assertion`, and no request field could set it — unreachable in every direction. It returns with an implementation, not ahead of one ([#777](https://github.com/authorizerdev/authorizer/pull/777)). + ## [2.2.1-rc.0] - 2026-04-06 Pre-release. See [2.2.1-rc.0](https://github.com/authorizerdev/authorizer/releases/tag/2.2.1-rc.0) on GitHub. diff --git a/e2e-playground/docker-compose.yml b/e2e-playground/docker-compose.yml index ccc2ab80..f132cd55 100644 --- a/e2e-playground/docker-compose.yml +++ b/e2e-playground/docker-compose.yml @@ -5,7 +5,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8080:8080" + - "${AUTHORIZER_HOST_PORT:-8080}:8080" command: - "--http-port=8080" - "--url=http://authorizer:8080" @@ -107,7 +107,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8081:8080" + - "${AUTHORIZER_SSO_HOST_PORT:-8081}:8080" command: - "--http-port=8080" - "--url=http://authorizer-sso:8080" @@ -183,7 +183,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8082:8080" + - "${AUTHORIZER_WEBAUTHN_HOST_PORT:-8082}:8080" networks: default: aliases: @@ -241,7 +241,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8083:8080" + - "${AUTHORIZER_MAGIC_LINK_HOST_PORT:-8083}:8080" command: - "--http-port=8080" - "--url=http://authorizer-magic-link:8080" @@ -291,7 +291,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8086:8080" + - "${AUTHORIZER_EMAIL_VERIFY_HOST_PORT:-8086}:8080" command: - "--http-port=8080" - "--url=http://authorizer-email-verify:8080" @@ -343,7 +343,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8084:8080" + - "${AUTHORIZER_MFA_ENFORCED_HOST_PORT:-8084}:8080" command: - "--http-port=8080" - "--url=http://authorizer-mfa-enforced:8080" @@ -394,7 +394,7 @@ services: context: .. dockerfile: Dockerfile ports: - - "8085:8080" + - "${AUTHORIZER_MFA_MAGIC_LINK_HOST_PORT:-8085}:8080" command: - "--http-port=8080" - "--url=http://authorizer-mfa-magic-link:8080" diff --git a/web/app/package-lock.json b/web/app/package-lock.json index 6fb9d076..d38feb7d 100644 --- a/web/app/package-lock.json +++ b/web/app/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { - "@authorizerdev/authorizer-react": "2.2.0-rc.7", + "@authorizerdev/authorizer-react": "2.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-is": "^18.3.1", @@ -28,9 +28,9 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "version": "4.0.0-rc.0", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-4.0.0-rc.0.tgz", - "integrity": "sha512-BVxzi9Dm4J5O6kDvvS18BrZwEaxkff2wKqW29cyMsj5s93T+OA+/bYaOlXOwLtPsabuoGUKONp+ZX05JksZ3UA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-4.0.0.tgz", + "integrity": "sha512-DMUwztj9+BY149oW87BvbB+7LWBEdKQub3HXwtFo3rpyNZT6dnQdWbSzQSXHEhpuPdu/XH512+FNu6eA7PRUXg==", "license": "MIT", "dependencies": { "cross-fetch": "^4.1.0" @@ -43,12 +43,12 @@ } }, "node_modules/@authorizerdev/authorizer-react": { - "version": "2.2.0-rc.7", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.2.0-rc.7.tgz", - "integrity": "sha512-45W/6vIyGCSSsGo847tc/RWr8ApuqLknuxWQW0IiupKNZWDMqsP8Vao2QR28qe8mRAXgzETfca5Hz6d3Yn4wEA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.2.0.tgz", + "integrity": "sha512-VWyOhvyltG5GJv8aXxpyqyZwSZgDyib16vgsR4TxJrEunakn8zuLN6Ldn/pq4XEcekp59tqhROcBAzD8QmnTxQ==", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "^4.0.0-rc.0", + "@authorizerdev/authorizer-js": "^4.0.0", "validator": "^13.11.0" }, "engines": { diff --git a/web/app/package.json b/web/app/package.json index 052f583e..97386d6e 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -13,7 +13,7 @@ "author": "Lakhan Samani", "license": "Apache-2.0", "dependencies": { - "@authorizerdev/authorizer-react": "2.2.0-rc.7", + "@authorizerdev/authorizer-react": "2.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-is": "^18.3.1",