rack-jwt-verifier 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e35869423f2fe2b5072d382a8c3404d899f4d1eb67ca2ac8026b341d6f055b52
4
- data.tar.gz: 2367980ac3f213c2340ffd7af9158a16262c69d73e09a75762c593017ac3df2e
3
+ metadata.gz: 68a95279b6745f43c94283fc1c16a94cd2221fdcd7206bc07d276c50668fe742
4
+ data.tar.gz: 569c5fa0708beaae3109f7c80e6343e340b626e7a58f6888d10a3405d69c3111
5
5
  SHA512:
6
- metadata.gz: b2c4f85b03428e8ff39073083dda5090101dfbbd2e1c8dec51a0c89e08ca6efffea64802042ddbee7f29f6ff5372f12b6c1ec1417498fd08890aedb7e876fbc7
7
- data.tar.gz: 4a5d3e3e6f4e4039ed0622644dda9e2db72f83d57a645f38e9eee2893fa68cfbac6f6656a82390399ee7c68570e790e5757b4c56a1bef85643230303a5b39c8e
6
+ metadata.gz: 60f7ec0e2955d7abd1d28db30888eec1d17d4cd3ef9106c388420f465555f197c8ee6a927157de117932f13eae18779e8c6329eb8034836c8c6952d2418ce4da
7
+ data.tar.gz: 33f8b1224fde05a660d1d64d6df41abeeaa8b2393044bb0668b696a4bc982cf50452958ad7e3bfdb1ba265468a87efc6fc609d111d021c63be2b237b657fa68b
data/CHANGELOG.md CHANGED
@@ -0,0 +1,187 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-09-13
11
+
12
+ Interop with [`jwt_auth_client`](https://github.com/danielefrisanco/jwt_auth_client) 0.2.0 and a
13
+ stricter claim policy. **Breaking**: `iss` and `aud` must now be configured, tokens must carry
14
+ `exp`, and boot-time option errors raise `ConfigurationError` instead of `ArgumentError`. See the
15
+ upgrade notes.
16
+
17
+ ### Added
18
+ - **`shared_secret:`** key source — a `String` or `{ env: "VAR_NAME" }` — enabling `HS256`/`HS384`/
19
+ `HS512`. This is what `jwt_auth_client` 0.2.x signs with. Guardrails, all at boot: the secret must
20
+ be at least 32/48/64 bytes (RFC 7518 §3.2, matching the issuer's rule); a secret cannot be given
21
+ alongside `public_key`/`public_key_url`/`jwks_url`; `HS*` cannot be listed without a secret nor
22
+ next to `RS*`/`ES*`/`PS*`; `none` is refused in any spelling, including via `decode_options`.
23
+ Asymmetric keys remain the default and the recommended path; HMAC is documented as the mode for a
24
+ small trusted set of internal services.
25
+ - **`require_scopes:`** middleware option: a token lacking a listed scope gets `403` with an RFC
26
+ 6750 `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"` challenge (reason
27
+ `:insufficient_scope` for `on_error`/JSON bodies, carrying an `InsufficientScopeError` with
28
+ `#required`/`#missing`). Implies `require_token: true`.
29
+ - **`RackJwtVerifier::Scopes`** helper (`.from`, `.include?`, `.missing`) reading a `scopes` Array
30
+ (jwt_auth_client) or an OAuth-style space-delimited `scope` String, for per-route checks.
31
+ - **`replay_cache:`** option (default off): `true` records each `jti` in `cache_store` (or a fresh
32
+ `InProcessCache`); a store object records it there. A token is accepted once until `exp` +
33
+ leeway; a second presentation is `401`; a token with no `jti` is `401`; an unavailable replay
34
+ store answers `503` (reason `:replay_cache_unavailable`) — fail closed.
35
+ - **`require_iss_aud:`** option (default `true`); `false` restores the 0.2.0 boot warning.
36
+ - `decode_options[:required_claims]` (default `["exp"]`).
37
+ - `Verifier#algorithms` exposes the effective, policy-checked algorithm list.
38
+ - Error hierarchy: `RackJwtVerifier::Error` > `ConfigurationError`, `KeyFetchError`,
39
+ `ReplayCacheError`, `InsufficientScopeError`; `ReplayedTokenError < JWT::InvalidJtiError`.
40
+ - `InProcessCache`: `write(..., unless_exist: true)` (returns `false` when a live entry exists),
41
+ `#clear`, `#size`, and amortised eviction of expired entries so a jti-per-request workload cannot
42
+ grow it without bound.
43
+ - Round-trip interop specs driving `jwt_auth_client`'s `TokenIssuer`, `Issuable` and `HttpClient`
44
+ output through the middleware (HS256/384/512, iss/aud, leeway, scopes, replay), plus the same
45
+ payload shape re-signed with RS256/ES256 + `kid` through a JWKS as a preview of jwt_auth_client
46
+ 0.3.0. `jwt_auth_client` is an optional path development dependency (`JWT_AUTH_CLIENT_PATH`).
47
+ - CI: Ruby 3.1–4.0 and a ruby-jwt 3 leg (`gemfiles/jwt_3.gemfile`); the gemspec allows `jwt >= 2.8, < 4`.
48
+ - The key fetch applies `write_timeout` as well as open/read; a spec pins down that redirects are
49
+ never followed.
50
+
51
+ ### Changed
52
+ - **Requires Ruby >= 3.1** (was 3.0, which is end-of-life; `jwt_auth_client` needs 3.1 too).
53
+ - **`iss` and `aud` are required.** `Middleware.new` raises `ConfigurationError` unless
54
+ `decode_options` sets both (non-blank; `iss` may be an Array), or `require_iss_aud: false` is
55
+ passed. Previously a warning was logged when *neither* was set.
56
+ - **`exp` is required.** ruby-jwt only checks `exp` when the claim is present, so a token without
57
+ one was previously valid forever. Override with `decode_options: { required_claims: [] }`.
58
+ - Boot-time option errors (no/several key sources, bad URL, unparsable key, bad `skip:` rule) raise
59
+ `RackJwtVerifier::ConfigurationError` (was `ArgumentError`).
60
+ - `decode_options[:algorithm]`/`[:algorithms]` are folded into the same algorithm policy as the
61
+ top-level `algorithms:` instead of bypassing it.
62
+ - `decode_options[:leeway]` is validated at boot (non-negative Numeric).
63
+ - README: `EdDSA` is no longer listed as supported — ruby-jwt 2.x only provides it through the
64
+ native `rbnacl` gem (3.x through `jwt-eddsa`), neither of which is a dependency.
65
+
66
+ ### Deprecated
67
+ - **`RackJwtVerifier::JwtHelper`** — a second token issuer inside the verifier, minting tokens
68
+ without `iss`/`aud`/`nbf`/`jti` that no longer pass the claim policy. Issue tokens with
69
+ `jwt_auth_client`; in test suites sign with `JWT.encode` (see `spec/support/token_factory.rb`
70
+ for a helper to copy). It warns once per process (`RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS=1`
71
+ silences it) and will be removed in 0.4.0.
72
+
73
+ ### Security
74
+ - Algorithm confusion is ruled out at boot: HMAC and asymmetric algorithms can never be enabled
75
+ on the same verifier, a shared secret can never sit next to a public key, and `none` is refused
76
+ everywhere. ruby-jwt 2.x itself accepts any non-empty String as an HMAC key, so the RFC 7518
77
+ minimum length is enforced by this gem.
78
+ - Tokens without `exp` are refused (see *Changed*).
79
+
80
+ ### Upgrade notes (0.2.0 → 0.3.0)
81
+ 1. Ruby 3.1 or newer is required.
82
+ 2. Set `decode_options: { iss: "...", aud: "..." }` on every middleware. If you truly cannot check
83
+ one of them, pass `require_iss_aud: false` and accept the boot warning.
84
+ 3. Tokens must carry `exp`. If your provider omits it, pass
85
+ `decode_options: { required_claims: [] }` — and reconsider the provider.
86
+ 4. Code rescuing `ArgumentError` around `Middleware.new`/`Verifier.new` should rescue
87
+ `RackJwtVerifier::ConfigurationError` (or `RackJwtVerifier::Error`).
88
+ 5. Replace `RackJwtVerifier::JwtHelper` with `jwt_auth_client` (or `JWT.encode` in tests) before
89
+ 0.4.0.
90
+ 6. To verify `jwt_auth_client` tokens: `shared_secret: { env: "JWT_SERVICE_SECRET" }`,
91
+ `algorithms: ["HS256"]` (the issuer's `config.algorithm`), `iss:` = the issuer's
92
+ `config.issuer`, `aud:` = the `target_service` name. See the README's *Pairing with
93
+ jwt_auth_client*.
94
+
95
+ ## [0.2.0] - 2026-09-12
96
+
97
+ A security and correctness release. **Read the *Security* section before upgrading**: an
98
+ `http://` key URL now fails at boot, `iss`/`aud` are enforced when set (they silently were not
99
+ before), and a key outage answers `503` instead of `500`.
100
+
101
+ ### Added
102
+ - **`jwks_url:`** — verify against a JSON Web Key Set, matching tokens by `kid`. An unknown `kid`
103
+ triggers a rate-limited refetch so rotated keys are picked up immediately. A set with no keys,
104
+ or a body that is not JSON, is rejected before it can be cached.
105
+ - **`public_key:`** — a static PEM public key, X.509 certificate PEM, or `OpenSSL::PKey`; no
106
+ network access.
107
+ - X.509 certificate PEMs are accepted from `public_key_url` (what Keycloak/Auth0 `.pem` endpoints
108
+ serve). EC and Ed keys parse too.
109
+ - Key rotation for `public_key_url`: on a signature mismatch the key is refetched once and the
110
+ token retried. `refetch_interval:` (default 60 s) rate-limits rotation-triggered refetches.
111
+ - `algorithms:` option (default `["RS256"]`). A list given in `decode_options` is no longer
112
+ silently overridden by the `RS256` default (ruby-jwt reads `:algorithm` before `:algorithms`).
113
+ - `cache_ttl:` option.
114
+ - Middleware: `skip:` (exact path, regexp or callable), `env_key:`, `json_errors:` and an
115
+ `on_error:` hook receiving `(env, reason, exception)`.
116
+ - The `401` challenge now carries `error_description` (sanitised to RFC 6750's quoted-string
117
+ alphabet).
118
+ - Cache keys are scoped to the URL, so two verifiers sharing one cache store no longer read each
119
+ other's key.
120
+ - Single-flight fetching on a cold cache; parsed key material is memoised per body instead of
121
+ re-parsing the PEM on every request.
122
+ - `require_token:` middleware option — reject requests that carry no token with a bare
123
+ `WWW-Authenticate: Bearer` challenge instead of passing them through.
124
+ - `logger:` middleware option; falls back to `env["rack.logger"]`, then to silence. Rejected
125
+ tokens log at `warn`, key-fetch failures at `error`. Replaces the unconditional `Kernel#warn`.
126
+ - A one-time boot warning when neither `iss` nor `aud` is configured.
127
+ - `content-length` on the middleware's own responses.
128
+ - `allow_insecure_http:` and `http_timeout:` middleware options.
129
+ - The key fetch sends `User-Agent: rack_jwt_verifier/<version>` and an `Accept` header.
130
+
131
+ ### Changed
132
+ - Requires Ruby >= 3.0 and Rack >= 2.2 (< 4). Depends on the `logger` gem explicitly, as it leaves
133
+ Ruby's default gems in 4.0.
134
+ - A failing cache store (Redis down) no longer breaks authentication: reads are treated as
135
+ misses and writes as no-ops, logged at `warn`; key material is fetched per request until the
136
+ store recovers.
137
+ - `InProcessCache` measures expiry on the monotonic clock, so wall-clock jumps cannot extend or
138
+ cut short an entry; the clock is injectable for tests.
139
+ - `JwtHelper#encode` normalises claim keys so a caller's `'exp'` and the generated `:exp` cannot
140
+ both land in the JSON; `#decode` accepts extra `JWT.decode` options; an `OpenSSL::PKey::RSA` is
141
+ accepted in place of a PEM.
142
+ - `KeyFetchError` is now `RackJwtVerifier::KeyFetchError`; `Verifier::KeyFetchError` still
143
+ resolves to the same class.
144
+ - Giving none, or more than one, of `public_key`, `public_key_url`, `jwks_url` raises
145
+ `ArgumentError` at boot (previously a `KeyError` for the missing URL).
146
+
147
+ ### Security
148
+ - `iss`, `aud` and `sub` values in `decode_options` are now actually enforced. Previously the
149
+ underlying `jwt` gem silently ignored them unless `verify_iss`/`verify_aud`/`verify_sub` was
150
+ also set — a token from any issuer was accepted even with `iss:` configured. The matching
151
+ `verify_*` flag is now enabled automatically whenever a value is supplied.
152
+ - `public_key_url` must be an `https://` URL. A plaintext `http://` URL is rejected at boot
153
+ unless `allow_insecure_http: true` is passed, since a key fetched over HTTP can be
154
+ substituted by an on-path attacker.
155
+ - The public key fetch now has a 5-second open/read timeout (configurable via `http_timeout:`)
156
+ and refuses response bodies over 64 KB, so a slow or misbehaving SSO endpoint cannot pin
157
+ request threads.
158
+ - `JWT::DecodeError` raised by the downstream application is no longer caught by the
159
+ middleware and turned into a 401; only the middleware's own verification step is guarded.
160
+
161
+ ### Fixed
162
+ - Response headers are lowercase (`content-type`, `www-authenticate`), as Rack 3 requires;
163
+ `Rack::Lint` previously rejected the 401 response.
164
+ - A `200` response whose body is not a valid key (e.g. an HTML maintenance page) is no longer
165
+ written to the cache. Previously it poisoned the cache for the full TTL, failing every
166
+ request for five minutes after the SSO had recovered.
167
+ - A key-fetch failure (endpoint down, timeout, bad key) now yields `503 Service Unavailable`
168
+ with `Retry-After: 5` instead of an unhandled `KeyFetchError` (a 500).
169
+ - `require "rack_jwt_verifier/verifier"` on its own no longer raises `NameError` for
170
+ `InProcessCache`; the file requires its own dependencies.
171
+ - `gem "rack-jwt-verifier"` now loads without a `require:` override: a `lib/rack-jwt-verifier.rb`
172
+ shim matches the gem name. The README install snippet pointed at a non-existent gem name.
173
+ - The `Bearer` scheme is matched case-insensitively (RFC 7235) and whitespace around the token
174
+ is tolerated. `bearer <token>` was previously treated as "no token" and passed through.
175
+ - `InProcessCache#delete` returns the deleted value, as documented, rather than the internal
176
+ `[value, expires_at]` pair.
177
+
178
+ ## [0.1.0] - 2025-10-20
179
+
180
+ ### Added
181
+ - Initial release: `RackJwtVerifier::Middleware`, `Verifier` with pluggable cache store,
182
+ `InProcessCache`, and `JwtHelper`.
183
+
184
+ [Unreleased]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.3.0...HEAD
185
+ [0.3.0]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.2.0...v0.3.0
186
+ [0.2.0]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.1.0...v0.2.0
187
+ [0.1.0]: https://github.com/danielefrisanco/rack_jwt_verifier/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,136 +1,326 @@
1
1
  RackJwtVerifier
2
2
  ===============
3
3
 
4
- A robust and production-ready Rack middleware for authenticating requests using JSON Web Tokens (JWT) signed by an external Single Sign-On (SSO) provider.
4
+ A Rack middleware that authenticates requests with JSON Web Tokens (JWT) signed by an external identity provider (SSO / OIDC) or by your own internal services.
5
5
 
6
- This gem handles the cryptographic verification, caching of public keys, and integration into any Rack application (including Ruby on Rails).
6
+ It verifies the signature against the provider's public key — from a **JWKS endpoint**, a **PEM URL** or a **static key** — or, as an explicit opt-in for internal services, against a **shared HMAC secret**; validates the standard claims, caches key material, handles key rotation, and puts the verified claims into the Rack environment for your application. Works with any Rack application, including Ruby on Rails.
7
+
8
+ It is the verifying half of a pair: [`jwt_auth_client`](https://github.com/danielefrisanco/jwt_auth_client) issues the tokens on the calling side. See [Pairing with jwt_auth_client](#pairing-with-jwt_auth_client).
7
9
 
8
10
  Features
9
11
  --------
10
12
 
11
- * **Cryptographic Verification:** Verifies the JWT signature using the `RS256` algorithm and public keys fetched from a remote URL.
12
-
13
- * **Flexible Caching:** Uses a configurable cache store for public keys, defaulting to in-memory caching but easily upgradable to **Redis** or **Memcached** for multi-process environments.
14
-
15
- * **Claim Validation:** Enforces standard JWT claims, including expiration (`exp`) and not-before (`nbf`).
16
-
17
- * **Rack Middleware:** Protects application routes by halting unauthorized requests with a `401 Unauthorized` response.
18
-
13
+ * **Key sources:** `jwks_url` (what Keycloak, Auth0, Okta, Entra ID, Cognito, Google… publish), `public_key_url` (a PEM public key or X.509 certificate), a static `public_key`, or — opt-in — a `shared_secret` for HMAC.
14
+ * **Algorithms:** `RS256` by default; `RS*`, `PS*`, `ES*` via `algorithms:`; `HS256`/`HS384`/`HS512` only with `shared_secret`. HS and RS/ES can never be enabled together, and `none` is always refused — at boot.
15
+ * **Claim validation:** `exp` (required), `nbf`, and `iss`/`aud` which you must configure, or opt out of explicitly.
16
+ * **Scopes:** `require_scopes:` answers `403` with an RFC 6750 `insufficient_scope` challenge; `RackJwtVerifier::Scopes` reads scopes for per-route checks.
17
+ * **Replay protection:** optional `replay_cache:` remembers each `jti` until the token expires.
18
+ * **Key rotation:** unknown `kid` or a signature mismatch triggers a rate-limited refetch, so rotated keys are picked up without waiting for the cache TTL.
19
+ * **Caching:** in-process by default; plug in any `read`/`write` cache store (e.g. `ActiveSupport::Cache`) so all workers share one fetched key.
20
+ * **Hardened fetch:** HTTPS enforced, redirects never followed, 5 s timeouts, 64 KB size cap, bad responses never cached.
21
+ * **Rack 2 and 3**, `Rack::Lint`-clean responses, RFC 6750 `WWW-Authenticate` challenges, optional JSON error bodies, path skipping, custom error hook.
19
22
 
20
23
  Installation
21
24
  ------------
22
25
 
23
- Add this line to your application's `Gemfile`:
24
-
25
26
  ```ruby
26
- gem 'rack_jwt_verifier'
27
+ gem 'rack-jwt-verifier'
27
28
  ```
28
29
 
29
- And then execute:
30
-
31
30
  ```bash
32
31
  $ bundle install
33
32
  ```
34
33
 
35
- Usage and Integration
36
- ---------------------
34
+ Requires Ruby 3.1+, Rack 2.2 or 3.x, and ruby-jwt 2.8+ or 3.x.
35
+
36
+ Quick start
37
+ -----------
37
38
 
38
- ### 1\. Basic Setup (In-Memory Caching)
39
+ ```ruby
40
+ # config/application.rb (Rails) or config.ru (plain Rack)
41
+ Rails.application.config.middleware.use RackJwtVerifier::Middleware,
42
+ jwks_url: "https://sso.example.com/.well-known/jwks.json",
43
+ decode_options: {
44
+ iss: "https://sso.example.com", # who must have issued the token
45
+ aud: "my-api" # who the token must be for
46
+ }
47
+ ```
39
48
 
40
- The simplest way to integrate is by providing the URL where your SSO provider exposes its public key (usually a PEM-encoded RSA key). The public key will be cached in memory for **5 minutes** per worker process.
49
+ `iss` and `aud` are required: the middleware refuses to boot without them (see [Claim validation](#claim-validation-decode_options)).
41
50
 
42
- **In `config/application.rb` (for Rails) or `config.ru` (for generic Rack):**
51
+ Then, in your application:
43
52
 
44
53
  ```ruby
45
- # Requires `rack_jwt_verifier` implicitly in Rails, or explicitly in config.ru
46
- # Replace the URL with your actual SSO Public Key endpoint
47
- PUBLIC_KEY_URL = "https://sso.example.com/api/v1/public_key"
48
- Rails.application.config.middleware.use RackJwtVerifier::Middleware,
49
- public_key_url: PUBLIC_KEY_URL
54
+ claims = request.env["rack_jwt_verifier.payload"] # Hash of claims, or nil if no token was sent
55
+ claims["sub"] # the subject
56
+ RackJwtVerifier::Scopes.from(claims) # => ["read:invoices", ...]
50
57
  ```
51
- ### 2\. Production Setup with Redis Caching
52
58
 
53
- For horizontally scaled applications (e.g., using Puma or multiple Docker containers), the default in-memory cache is inefficient. To prevent a "cache stampede" where all workers simultaneously fetch the key, you must use a distributed cache like Redis.
59
+ Key sources
60
+ -----------
61
+
62
+ Exactly one of these must be given.
63
+
64
+ | Option | What it serves | Notes |
65
+ | -- | -- | -- |
66
+ | `:jwks_url` | A JSON Web Key Set (`{"keys":[…]}`) | Tokens are matched by their `kid` header. Recommended — this is what nearly every provider publishes. |
67
+ | `:public_key_url` | A single PEM public key **or** an X.509 certificate | Fine for providers that expose one key. |
68
+ | `:public_key` | A PEM string, certificate PEM, or `OpenSSL::PKey` | No network access at all. Handy for `ENV["SSO_PUBLIC_KEY"]`. |
69
+ | `:shared_secret` | An HMAC secret: a `String`, or `{ env: "VAR_NAME" }` | Enables `HS256`/`HS384`/`HS512`. For a small trusted set of internal services only — see below. |
70
+
71
+ URLs must be `https://`. Pass `allow_insecure_http: true` to permit `http://` **in development only** — over plaintext HTTP an attacker on the network path can swap the key and mint arbitrary tokens.
54
72
 
55
- **Prerequisites:** You need a Redis client library installed (e.g., `redis-rails` or `connection\_pool`).
73
+ ```ruby
74
+ # Static key from the environment
75
+ use RackJwtVerifier::Middleware,
76
+ public_key: ENV.fetch("SSO_PUBLIC_KEY"),
77
+ decode_options: { iss: "https://sso.example.com", aud: "my-api" }
78
+
79
+ # EC keys need the algorithm list widened
80
+ use RackJwtVerifier::Middleware,
81
+ jwks_url: "https://sso.example.com/.well-known/jwks.json",
82
+ algorithms: %w[RS256 ES256],
83
+ decode_options: { iss: "https://sso.example.com", aud: "my-api" }
84
+ ```
85
+
86
+ Tokens without a `kid` header are rejected when using `jwks_url`. If your provider does not set one, add `decode_options: { allow_nil_kid: true }` — the first key in the set is then used.
56
87
 
57
- #### Example: Using Redis as the Cache Store
88
+ `EdDSA` is not in scope: ruby-jwt 2.x only provides it through the native `rbnacl` gem and 3.x through `jwt-eddsa`; neither is a dependency here.
58
89
 
59
- The `RackJwtVerifier` expects a cache object that responds to the standard Ruby cache interface: `#read(key)` and `#write(key, value, expires\_in: ttl)`.
90
+ ### Shared secret (HMAC) internal services only
60
91
 
61
- **Example using `ActiveSupport::Cache::RedisCacheStore` (Common in Rails apps):**
92
+ Asymmetric keys are the default and the recommended path: the verifier only ever holds a *public* key, so a compromised API cannot mint tokens. A shared secret is different — **every service holding it can mint tokens for every audience**. Use `shared_secret` for a small, trusted set of internal services that you control on both ends (this is what `jwt_auth_client` 0.2.x needs), keep the set of holders small, and move to asymmetric keys when the issuer supports them.
62
93
 
63
94
  ```ruby
64
- # In config/initializers/rack_jwt_verifier.rb
65
-
66
- # 1. Configure your Redis cache client
67
- # This example assumes you have Redis configured via Rails:
68
- REDIS_CACHE_CLIENT = ActiveSupport::Cache.lookup_store(:redis_cache_store, {
69
- url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"),
70
- reconnect_attempts: 1 })
71
- # 2. Configure the Verifier with the Redis client
95
+ use RackJwtVerifier::Middleware,
96
+ shared_secret: { env: "JWT_SERVICE_SECRET" }, # or the String itself: ENV.fetch("JWT_SERVICE_SECRET")
97
+ algorithms: ["HS256"], # default with shared_secret; HS384/HS512 also allowed
98
+ decode_options: { iss: "main_app_sso", aud: "billing_api" }
99
+ ```
100
+
101
+ Guardrails, all enforced at boot with a `RackJwtVerifier::ConfigurationError`:
102
+
103
+ * The secret must be at least **32 / 48 / 64 bytes** for HS256 / HS384 / HS512 (RFC 7518 §3.2) — the same rule `jwt_auth_client` applies when signing. `openssl rand -hex 32` produces a 64-byte hex string that satisfies all three.
104
+ * `shared_secret` cannot be combined with `public_key`, `public_key_url` or `jwks_url`, and `HS*` cannot appear in `algorithms` without it, nor next to `RS*`/`ES*`/`PS*`. This closes the classic algorithm-confusion attack in which an attacker signs a token with `HS256` using the *public* key as the secret.
105
+ * `none` is refused everywhere, in any spelling, including through `decode_options`.
106
+
107
+ Caching and key rotation
108
+ ------------------------
109
+
110
+ Fetched key material is cached for `cache_ttl` seconds (default 300). The default store is a per-process `InProcessCache`; for multi-process or multi-host deployments pass any object with the standard cache-store interface — `read(key)` and `write(key, value, expires_in: seconds)` — so the provider is contacted once per TTL rather than once per worker:
111
+
112
+ ```ruby
113
+ # config/initializers/rack_jwt_verifier.rb
72
114
  Rails.application.config.middleware.use RackJwtVerifier::Middleware,
73
- public_key_url: ENV.fetch("SSO_PUBLIC_KEY_URL"),
74
- cache_store: REDIS_CACHE_CLIENT
115
+ jwks_url: ENV.fetch("SSO_JWKS_URL"),
116
+ cache_store: Rails.cache, # any ActiveSupport::Cache::Store works
117
+ decode_options: { iss: ENV.fetch("SSO_ISSUER"), aud: "my-api" }
75
118
  ```
76
119
 
77
- By passing the Redis cache client via the `cache\_store` option, all your application workers will share a single cache, ensuring the public key is fetched from the network only once every 5 minutes (or whatever TTL is configured internally).
120
+ The object must be a cache **store**, not a raw client a `redis-rb` connection does not respond to `read`/`write`; wrap it in `ActiveSupport::Cache::RedisCacheStore`. Cache keys are namespaced (`rack_jwt_verifier:jwks:<url digest>`) so several middlewares can share one store.
121
+
122
+ **Rotation.** When a token's `kid` is not in the cached set (JWKS), or its signature does not verify against the cached key (PEM), the middleware refetches once and retries. Refetches are rate-limited to one per `refetch_interval` seconds (default 60) so a flood of forged tokens cannot become a flood of requests to your provider.
123
+
124
+ Within one process only one thread performs a fetch on a cold cache; the others wait for it.
125
+
126
+ Options
127
+ -------
128
+
129
+ ### Middleware
78
130
 
79
- ### 3\. Customizing JWT Decoding
131
+ | Option | Default | Purpose |
132
+ | -- | -- | -- |
133
+ | `:require_token` | `false` | `true`: a request with no `Bearer` token gets a `401`. `false`: it is passed through with no payload set, and your application decides. |
134
+ | `:skip` | `[]` | Paths that bypass the middleware entirely: exact strings (`"/health"`), regexps (`%r{\A/public/}`), or callables on the env (`->(env) { env["REQUEST_METHOD"] == "OPTIONS" }`). Matched against `SCRIPT_NAME + PATH_INFO`. |
135
+ | `:env_key` | `"rack_jwt_verifier.payload"` | Rack env key that receives the verified claims. |
136
+ | `:json_errors` | `false` | Render `401`/`503` bodies as `{"error": "...", "error_description": "..."}` with `content-type: application/json`. |
137
+ | `:on_error` | — | `->(env, reason, exception) { … }` returning a Rack response to use instead of the default, or `nil` to keep the default. `reason` is `:missing_token`, `:invalid_token` or `:key_unavailable`. |
138
+ | `:logger` | `env["rack.logger"]` | Rejected tokens log at `warn`, key-fetch failures at `error`. Falls back to the request's `rack.logger` (`Rails.logger` in Rails), then to silence. |
139
+ | `:require_scopes` | `[]` | Scopes every token must grant. A token lacking one gets `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. Implies `require_token: true`. See *Scopes*. |
140
+ | `:require_iss_aud` | `true` | Refuse to boot unless `decode_options` sets both `iss` and `aud`. `false` logs a warning instead. |
141
+ | `:replay_cache` | off | `true` to record each `jti` in `cache_store`, or a cache store to record it in. See *Replay protection*. |
80
142
 
81
- You can pass a `:decode\_options` hash to the middleware to override the default settings for the JWT gem.
143
+ ### Key fetching
82
144
 
83
- | Option | Default Value | Purpose |
84
- | -- | -- | -- |
85
- | `:leeway` | `60` seconds | Sets clock skew tolerance for `exp` and `nbf` checks. Set to `0` for strict timing. |
86
- | `:algorithm` | `"RS256"` | The expected signing algorithm. |
87
- | `:iss` | (None) | **RECOMMENDED**: Set this to enforce a specific issuer claim. |
88
- | `:aud` | (None) | **RECOMMENDED**: Set this to enforce an audience claim. |
145
+ | Option | Default | Purpose |
146
+ | -- | -- | -- |
147
+ | `:algorithms` | `["RS256"]` (`["HS256"]` with `shared_secret`) | Accepted signing algorithms. `HS*` only with `shared_secret`; never mixed. |
148
+ | `:cache_store` | `InProcessCache.new` | See *Caching* above. |
149
+ | `:cache_ttl` | `300` | Seconds to cache fetched key material. |
150
+ | `:refetch_interval` | `60` | Minimum seconds between rotation-triggered refetches. |
151
+ | `:http_timeout` | `5` | Open and read timeout, in seconds, for the key fetch. |
152
+ | `:allow_insecure_http` | `false` | Permit a plain `http://` URL. Development only. |
89
153
 
90
- **Example: Strict Expiration and Issuer Check:**
154
+ Responses over 64 KB are refused — a PEM key is under 1 KB and a JWKS a few KB.
155
+
156
+ ### Claim validation (`:decode_options`)
157
+
158
+ Everything here is handed to `JWT.decode`.
159
+
160
+ | Option | Default | Purpose |
161
+ | -- | -- | -- |
162
+ | `:iss` | — | **Required.** The issuer the token must carry (a String, or an Array of accepted issuers). |
163
+ | `:aud` | — | **Required.** The audience the token must carry. |
164
+ | `:sub` | — | The subject the token must carry. |
165
+ | `:leeway` | `60` | Clock-skew tolerance, in seconds, for `exp` and `nbf`. `0` for strict timing. |
166
+ | `:required_claims` | `["exp"]` | Claims that must be present. ruby-jwt only *checks* `exp` when it is there; requiring it means a token without an expiry is refused rather than valid forever. |
167
+ | `:verify_expiration` | `true` | Check `exp`. Leave on. |
168
+ | `:verify_not_before` | `true` | Check `nbf`. Leave on. |
169
+ | `:allow_nil_kid` | `false` | JWKS only: accept tokens without a `kid`. |
170
+
171
+ > The `jwt` gem only checks `iss`/`aud`/`sub` when the matching `verify_iss`/`verify_aud`/`verify_sub` flag is also `true`. This middleware switches the flag on automatically whenever you supply a value, so `iss: "…"` really is enforced. An explicit `verify_iss: false` next to `iss:` is respected.
172
+
173
+ **`iss` and `aud` are required.** A key proves who *signed* a token, not who it was *for* — and a shared secret proves even less. `Middleware.new` raises `ConfigurationError` when either is missing. If you genuinely cannot check them (a provider that sets no `aud`, say), pass `require_iss_aud: false`; the middleware then logs a warning at boot instead.
174
+
175
+ Scopes
176
+ ------
177
+
178
+ The verified claims are in `env["rack_jwt_verifier.payload"]`, `sub` included. Scopes are read from a `scopes` claim (an Array of Strings, what `jwt_auth_client` emits) or, failing that, an OAuth-style space-delimited `scope` String.
179
+
180
+ Require scopes globally on the middleware:
181
+
182
+ ```ruby
183
+ use RackJwtVerifier::Middleware, jwks_url: "…", decode_options: { … },
184
+ require_scopes: ["read:invoices"]
185
+ ```
186
+
187
+ A token lacking any of them is refused with `403` and
188
+
189
+ ```
190
+ WWW-Authenticate: Bearer error="insufficient_scope", error_description="Token lacks required scope(s): read:invoices", scope="read:invoices"
191
+ ```
192
+
193
+ (`reason` is `:insufficient_scope` for `on_error` and JSON bodies; the hook receives a `RackJwtVerifier::InsufficientScopeError` with `#required` and `#missing`). `require_scopes` implies `require_token: true`.
194
+
195
+ For per-route checks, use the helper in your application:
196
+
197
+ ```ruby
198
+ claims = request.env["rack_jwt_verifier.payload"]
199
+ RackJwtVerifier::Scopes.from(claims) # => ["read:invoices"]
200
+ RackJwtVerifier::Scopes.include?(claims, "write:invoices") # => false
201
+ RackJwtVerifier::Scopes.missing(claims, %w[read:x write:x]) # => ["write:x"]
202
+ ```
203
+
204
+ Replay protection
205
+ -----------------
206
+
207
+ Off by default. With `replay_cache:` each verified token's `jti` is remembered until the token's `exp` (plus leeway), and a second presentation is refused with `401 invalid_token`. Tokens without a `jti` are refused too.
208
+
209
+ ```ruby
210
+ use RackJwtVerifier::Middleware, …,
211
+ cache_store: Rails.cache,
212
+ replay_cache: true # record jtis in cache_store …
213
+ # replay_cache: Rails.cache # … or in a store of their own
214
+ ```
215
+
216
+ * The store is the same `read`/`write` abstraction as `cache_store`. `true` uses `cache_store` (or a fresh `InProcessCache` when none was given). An **in-process store only detects replays within one worker**; use a shared store (`Rails.cache` on Redis/Memcached) for real protection.
217
+ * `jwt_auth_client` mints a fresh `jti` per request by default (`token_reuse_seconds = 0`), so replay protection works out of the box; with `token_reuse_seconds > 0` on the issuer, do not enable it here.
218
+ * The check runs only after every other check passed, so an expired or mis-signed token cannot "burn" a `jti`.
219
+ * A replay store that raises makes the request fail **closed**: `503` with `Retry-After`, reason `:replay_cache_unavailable`. You asked for the guarantee; skipping it silently would be worse than a retryable error. (The key cache, by contrast, fails open — a fetch still verifies the signature.)
220
+ * Keys are `rack_jwt_verifier:jti:<sha256 of jti>`, written with `unless_exist: true` so stores that support it (ActiveSupport's do) close the check-then-write window.
221
+
222
+ Request flow
223
+ ------------
224
+
225
+ 1. If the path matches a `:skip` rule, the request goes straight through.
226
+ 2. The token is read from `Authorization: Bearer <token>` (scheme matched case-insensitively).
227
+ * No token: passed through with no payload — or `401` with `WWW-Authenticate: Bearer` if `require_token: true`.
228
+ 3. Key material is read from the cache, or fetched on a miss.
229
+ 4. Signature and claims are verified.
230
+ * Success: the claims are stored in `env["rack_jwt_verifier.payload"]` and the request continues.
231
+ * Invalid token (expired, bad signature, wrong issuer/audience, unknown `kid`, replayed `jti`): `401` with `WWW-Authenticate: Bearer error="invalid_token", error_description="…"`.
232
+ * Valid token without a required scope: `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`.
233
+ * Key material unavailable (endpoint down, timeout, not a key), or the replay store down: `503` with `Retry-After: 5` — the failure is on our side, not the client's.
234
+
235
+ `JWT::DecodeError`s raised by *your* application are never intercepted; only the middleware's own verification step is guarded.
236
+
237
+ Errors
238
+ ------
239
+
240
+ | Class | Raised when |
241
+ | -- | -- |
242
+ | `RackJwtVerifier::ConfigurationError` | An unusable option set, at boot (`Middleware.new` / `Verifier.new`): no or several key sources, `HS*` mixed with `RS*`, a short secret, missing `iss`/`aud`, an `http://` URL, … |
243
+ | `RackJwtVerifier::KeyFetchError` | Key material could not be fetched or parsed. The middleware turns it into a `503`. |
244
+ | `RackJwtVerifier::ReplayCacheError` | The replay store could not be read or written. `503`. |
245
+ | `RackJwtVerifier::ReplayedTokenError` (`< JWT::InvalidJtiError`) | A `jti` was presented twice. `401`. |
246
+ | `RackJwtVerifier::InsufficientScopeError` | Handed to `on_error` for a `403`; carries `#required` and `#missing`. |
247
+
248
+ All but `ReplayedTokenError` inherit from `RackJwtVerifier::Error`. Token verification failures are ruby-jwt's `JWT::DecodeError` family.
249
+
250
+ Pairing with jwt_auth_client
251
+ ----------------------------
252
+
253
+ [`jwt_auth_client`](https://github.com/danielefrisanco/jwt_auth_client) is the issuing half: it mints `{ iss, sub, aud, scopes, iat, nbf, exp, jti, … }` tokens and sends them as `Bearer` tokens from one internal service to another. Today (0.2.x) it signs with `HS256`/`HS384`/`HS512` and a shared secret; asymmetric signing with a `kid` is planned for its 0.3.0, at which point the verifier side below becomes a `jwks_url`.
254
+
255
+ Issuer (`config/initializers/jwt_auth_client.rb` in the *calling* service):
256
+
257
+ ```ruby
258
+ JwtAuthClient.configure do |config|
259
+ config.shared_secret = ENV.fetch("JWT_SERVICE_SECRET") # >= 32 bytes; openssl rand -hex 32
260
+ config.issuer = "main_app_sso"
261
+ config.algorithm = "HS256"
262
+ config.service_urls = { billing_api: "https://billing.internal" }
263
+ end
264
+
265
+ BILLING = JwtAuthClient::HttpClient.call(user_id: "etl", target_service: :billing_api, scopes: ["read:invoices"])
266
+ ```
267
+
268
+ Verifier (in the *receiving* service, `billing_api`):
91
269
 
92
270
  ```ruby
93
271
  Rails.application.config.middleware.use RackJwtVerifier::Middleware,
94
- public_key_url: ENV.fetch("SSO_PUBLIC_KEY_URL"),
272
+ shared_secret: { env: "JWT_SERVICE_SECRET" }, # the same secret
273
+ algorithms: ["HS256"], # the same algorithm
95
274
  decode_options: {
96
- # No clock skew tolerance
97
- leeway: 0,
98
- # Ensure the token was issued by our expected SSO provider
99
- iss: "[https://my-sso.com/token-service](https://my-sso.com/token-service)"
100
- }
275
+ iss: "main_app_sso", # jwt_auth_client's config.issuer
276
+ aud: "billing_api" # the target_service the caller names
277
+ },
278
+ require_scopes: ["read:invoices"], # optional
279
+ replay_cache: true, cache_store: Rails.cache # optional
101
280
  ```
102
281
 
103
- How it Works
282
+ The claims then arrive as `env["rack_jwt_verifier.payload"]`: `sub` is the caller's `user_id`, `scopes` its scopes, and any custom claims from `Issuable#jwt_claims` (`user_id`, `email`, …) come through unchanged. The [interop spec](spec/rack_jwt_verifier/interop_spec.rb) exercises exactly this round trip against the real gem.
283
+
284
+ Security considerations
285
+ -----------------------
286
+
287
+ * **`iss` and `aud` are mandatory** for a reason: without them any token signed by the provider — for any application — is accepted. Opt out only when you cannot check them, and know what that means.
288
+ * **Use HTTPS for key URLs.** `allow_insecure_http` exists for local development only. Redirects are never followed.
289
+ * **Prefer `jwks_url`.** It supports multiple keys and `kid`-based rotation; a single PEM URL cannot express an overlap period.
290
+ * **Keep `leeway` small.** 60 s covers real clock skew; larger values extend the life of expired tokens.
291
+ * **Prefer asymmetric algorithms.** With a public key the verifier cannot mint tokens; with a shared secret it can. `HS*` and `RS*`/`ES*` are never accepted together — the middleware refuses to boot if you try — so a public key can never be reinterpreted as an HMAC secret.
292
+ * **Replay protection needs a shared store** to mean anything across workers or hosts.
293
+
294
+ Design notes
104
295
  ------------
105
296
 
106
- 1. **Request Flow:** On every incoming HTTP request, the middleware intercepts the call.
107
-
108
- 2. **Token Extraction:** It looks for a token in the `Authorization: Bearer <token>` header.
109
-
110
- 3. **Key Retrieval:** The Verifier attempts to read the public key PEM string from the configured `cache\_store` (Redis or In-Memory).
111
- * If the key is present, it's used immediately.
112
- * If the key is missing or expired, a network request is made to the `public\_key\_url`, and the key is written back to the cache for 5 minutes.
113
-
114
- 4. **Verification:** The public key is used to cryptographically verify the JWT's signature and validate its claims (`exp`, `nbf`, `iss`, etc.).
115
-
116
- 5. **Authorization:**
117
- * If verification succeeds, the request passes to your application.
118
- * If verification fails (e.g., token expired, bad signature, or missing token), the request is halted, and a `401 Unauthorized` response is immediately returned.
119
-
297
+ A few choices that are not obvious from the code:
298
+
299
+ * **The gem is `rack-jwt-verifier`, the require path `rack_jwt_verifier`.** The hyphenated name was published first and is what users already depend on, so it stays; `lib/rack-jwt-verifier.rb` is a one-line shim so Bundler's auto-require works.
300
+ * **Options are a positional hash, not keyword arguments.** `middleware.use Klass, hash` hands the hash over positionally, so a keyword signature would break Rails users on Ruby 3.
301
+ * **`iss`/`aud` switch their `verify_*` flag on automatically.** ruby-jwt ignores an expected claim value unless the flag is set; requiring users to pass both is how the 0.1.0 README ended up recommending a configuration that enforced nothing.
302
+ * **Key-fetch failures answer 503, not 401.** The client did nothing wrong; a 401 would make it discard a valid token and re-authenticate.
303
+ * **Rotation refetches are rate-limited** (`refetch_interval`) so a stream of forged tokens cannot be turned into a stream of requests to the provider.
304
+ * **The HMAC key-length rule lives here, not in ruby-jwt.** ruby-jwt 2.x verifies with any non-empty String; the RFC 7518 minimum is enforced by this gem so both halves of the pair agree.
305
+ * **The replay store fails closed, the key cache fails open.** A key-cache miss still ends in a verified signature; a skipped replay check silently drops a guarantee the operator asked for.
306
+ * **`JwtHelper` is deprecated** (removal in 0.4.0). It was a second token issuer inside the verifier, signing without `iss`/`aud`/`nbf`/`jti`. Issue with `jwt_auth_client`; in test suites sign with `JWT.encode` (see `spec/support/token_factory.rb`). It warns once per process; `RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS=1` silences it.
120
307
 
121
308
  Development
122
309
  -----------
123
310
 
124
- The testing setup uses RSpec, WebMock for network request mocking, and Timecop for robust time-dependent testing (like expiration checks).
125
-
126
- To run the full suite:
311
+ RSpec, WebMock (no real network in tests), Timecop and RuboCop. CI runs the suite on Ruby 3.1–4.0 against Rack 2, Rack 3 and ruby-jwt 2/3.
127
312
 
128
313
  ```bash
129
314
  $ bundle install
315
+ $ bundle exec rake # specs + rubocop
130
316
  $ bundle exec rspec
317
+ $ BUNDLE_GEMFILE=gemfiles/rack_2.gemfile bundle exec rspec # the Rack 2 leg
318
+ $ BUNDLE_GEMFILE=gemfiles/jwt_3.gemfile bundle exec rspec # the ruby-jwt 3 leg
131
319
  ```
132
320
 
321
+ The interop specs need a checkout of `jwt_auth_client` next to this repository (or `JWT_AUTH_CLIENT_PATH=/path/to/it`); they skip otherwise.
322
+
133
323
  License
134
324
  -------
135
325
 
136
- The gem is available as open source under the terms of the [MIT License](https://www.google.com/search?q=LICENSE.txt).
326
+ MIT see [LICENSE.md](LICENSE.md).
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires a gem by its name, so `gem "rack-jwt-verifier"` looks
4
+ # for this file. The real entry point lives under the underscore name.
5
+ require_relative "rack_jwt_verifier"