rack-jwt-verifier 0.2.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: c35bb9f8356d419cb0d0344da15e969046789c110923fdf67c5818766b542693
4
- data.tar.gz: 69b50273533a7dce2ab5abd341f226b3ccafcf10e5aa7a21650d2bfaceddc86a
3
+ metadata.gz: 68a95279b6745f43c94283fc1c16a94cd2221fdcd7206bc07d276c50668fe742
4
+ data.tar.gz: 569c5fa0708beaae3109f7c80e6343e340b626e7a58f6888d10a3405d69c3111
5
5
  SHA512:
6
- metadata.gz: d96ea044955d67e95bd37b2dc7575d807b2a17a8c92d5da24ce14d65f6ad45eac14c5f2cae2ffed9a9f321d126bcf64e9c4375ab761e2a74a3899f45852b6185
7
- data.tar.gz: b4a0d02c27b1bcab756864585237d6881c869823aa2f1ca04e40539466bd02c2f35298fe2881f1c03dcef192e69366c0edfa98fae10faf2cfd32d6365fdbc2fe
6
+ metadata.gz: 60f7ec0e2955d7abd1d28db30888eec1d17d4cd3ef9106c388420f465555f197c8ee6a927157de117932f13eae18779e8c6329eb8034836c8c6952d2418ce4da
7
+ data.tar.gz: 33f8b1224fde05a660d1d64d6df41abeeaa8b2393044bb0668b696a4bc982cf50452958ad7e3bfdb1ba265468a87efc6fc609d111d021c63be2b237b657fa68b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
95
  ## [0.2.0] - 2026-09-12
11
96
 
12
97
  A security and correctness release. **Read the *Security* section before upgrading**: an
@@ -96,6 +181,7 @@ before), and a key outage answers `503` instead of `500`.
96
181
  - Initial release: `RackJwtVerifier::Middleware`, `Verifier` with pluggable cache store,
97
182
  `InProcessCache`, and `JwtHelper`.
98
183
 
99
- [Unreleased]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.2.0...HEAD
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
100
186
  [0.2.0]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.1.0...v0.2.0
101
187
  [0.1.0]: https://github.com/danielefrisanco/rack_jwt_verifier/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,19 +1,23 @@
1
1
  RackJwtVerifier
2
2
  ===============
3
3
 
4
- A Rack middleware that authenticates requests with JSON Web Tokens (JWT) signed by an external identity provider (SSO / OIDC).
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
- It verifies the signature against the provider's public key — from a **JWKS endpoint**, a **PEM URL** or a **static key** — 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.
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
- * **Three key sources:** `jwks_url` (what Keycloak, Auth0, Okta, Entra ID, Cognito, Google… publish), `public_key_url` (a PEM public key or X.509 certificate), or a static `public_key`.
12
- * **Algorithms:** `RS256` by default; any algorithm ruby-jwt supports (`RS*`, `PS*`, `ES*`, `EdDSA`) via `algorithms:`.
13
- * **Claim validation:** `exp` and `nbf` always; `iss`, `aud` and `sub` as soon as you configure them.
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.
14
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.
15
19
  * **Caching:** in-process by default; plug in any `read`/`write` cache store (e.g. `ActiveSupport::Cache`) so all workers share one fetched key.
16
- * **Hardened fetch:** HTTPS enforced, 5 s timeouts, 64 KB size cap, bad responses never cached.
20
+ * **Hardened fetch:** HTTPS enforced, redirects never followed, 5 s timeouts, 64 KB size cap, bad responses never cached.
17
21
  * **Rack 2 and 3**, `Rack::Lint`-clean responses, RFC 6750 `WWW-Authenticate` challenges, optional JSON error bodies, path skipping, custom error hook.
18
22
 
19
23
  Installation
@@ -27,7 +31,7 @@ gem 'rack-jwt-verifier'
27
31
  $ bundle install
28
32
  ```
29
33
 
30
- Requires Ruby 3.0+ and Rack 2.2 or 3.x.
34
+ Requires Ruby 3.1+, Rack 2.2 or 3.x, and ruby-jwt 2.8+ or 3.x.
31
35
 
32
36
  Quick start
33
37
  -----------
@@ -42,10 +46,14 @@ Rails.application.config.middleware.use RackJwtVerifier::Middleware,
42
46
  }
43
47
  ```
44
48
 
49
+ `iss` and `aud` are required: the middleware refuses to boot without them (see [Claim validation](#claim-validation-decode_options)).
50
+
45
51
  Then, in your application:
46
52
 
47
53
  ```ruby
48
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", ...]
49
57
  ```
50
58
 
51
59
  Key sources
@@ -58,6 +66,7 @@ Exactly one of these must be given.
58
66
  | `:jwks_url` | A JSON Web Key Set (`{"keys":[…]}`) | Tokens are matched by their `kid` header. Recommended — this is what nearly every provider publishes. |
59
67
  | `:public_key_url` | A single PEM public key **or** an X.509 certificate | Fine for providers that expose one key. |
60
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. |
61
70
 
62
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.
63
72
 
@@ -76,6 +85,25 @@ use RackJwtVerifier::Middleware,
76
85
 
77
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.
78
87
 
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.
89
+
90
+ ### Shared secret (HMAC) — internal services only
91
+
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.
93
+
94
+ ```ruby
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
+
79
107
  Caching and key rotation
80
108
  ------------------------
81
109
 
@@ -108,12 +136,15 @@ Options
108
136
  | `:json_errors` | `false` | Render `401`/`503` bodies as `{"error": "...", "error_description": "..."}` with `content-type: application/json`. |
109
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`. |
110
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*. |
111
142
 
112
143
  ### Key fetching
113
144
 
114
145
  | Option | Default | Purpose |
115
146
  | -- | -- | -- |
116
- | `:algorithms` | `["RS256"]` | Accepted signing algorithms. |
147
+ | `:algorithms` | `["RS256"]` (`["HS256"]` with `shared_secret`) | Accepted signing algorithms. `HS*` only with `shared_secret`; never mixed. |
117
148
  | `:cache_store` | `InProcessCache.new` | See *Caching* above. |
118
149
  | `:cache_ttl` | `300` | Seconds to cache fetched key material. |
119
150
  | `:refetch_interval` | `60` | Minimum seconds between rotation-triggered refetches. |
@@ -128,17 +159,65 @@ Everything here is handed to `JWT.decode`.
128
159
 
129
160
  | Option | Default | Purpose |
130
161
  | -- | -- | -- |
131
- | `:iss` | — | **Recommended.** The issuer the token must carry. |
132
- | `:aud` | — | **Recommended.** The audience the token must carry. |
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. |
133
164
  | `:sub` | — | The subject the token must carry. |
134
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. |
135
167
  | `:verify_expiration` | `true` | Check `exp`. Leave on. |
136
168
  | `:verify_not_before` | `true` | Check `nbf`. Leave on. |
137
169
  | `:allow_nil_kid` | `false` | JWKS only: accept tokens without a `kid`. |
138
170
 
139
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.
140
172
 
141
- The middleware logs a warning at boot if neither `iss` nor `aud` is configured: a key alone proves who *signed* a token, not who it was *for*.
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.
142
221
 
143
222
  Request flow
144
223
  ------------
@@ -149,19 +228,68 @@ Request flow
149
228
  3. Key material is read from the cache, or fetched on a miss.
150
229
  4. Signature and claims are verified.
151
230
  * Success: the claims are stored in `env["rack_jwt_verifier.payload"]` and the request continues.
152
- * Invalid token (expired, bad signature, wrong issuer/audience, unknown `kid`): `401` with `WWW-Authenticate: Bearer error="invalid_token", error_description="…"`.
153
- * Key material unavailable (endpoint down, timeout, not a key): `503` with `Retry-After: 5` — the failure is on our side, not the client's.
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.
154
234
 
155
235
  `JWT::DecodeError`s raised by *your* application are never intercepted; only the middleware's own verification step is guarded.
156
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`):
269
+
270
+ ```ruby
271
+ Rails.application.config.middleware.use RackJwtVerifier::Middleware,
272
+ shared_secret: { env: "JWT_SERVICE_SECRET" }, # the same secret
273
+ algorithms: ["HS256"], # the same algorithm
274
+ decode_options: {
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
280
+ ```
281
+
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
+
157
284
  Security considerations
158
285
  -----------------------
159
286
 
160
- * **Always set `iss` and `aud`.** Without them any token signed by the provider — for any application — is accepted.
161
- * **Use HTTPS for key URLs.** `allow_insecure_http` exists for local development only.
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.
162
289
  * **Prefer `jwks_url`.** It supports multiple keys and `kid`-based rotation; a single PEM URL cannot express an overlap period.
163
290
  * **Keep `leeway` small.** 60 s covers real clock skew; larger values extend the life of expired tokens.
164
- * **Stick to asymmetric algorithms.** The middleware only ever holds public keys; do not add `HS*` to `algorithms`.
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.
165
293
 
166
294
  Design notes
167
295
  ------------
@@ -173,19 +301,25 @@ A few choices that are not obvious from the code:
173
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.
174
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.
175
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.
176
307
 
177
308
  Development
178
309
  -----------
179
310
 
180
- RSpec, WebMock (no real network in tests), Timecop and RuboCop. CI runs the suite on Ruby 3.03.4 against both Rack 2 and Rack 3.
311
+ RSpec, WebMock (no real network in tests), Timecop and RuboCop. CI runs the suite on Ruby 3.14.0 against Rack 2, Rack 3 and ruby-jwt 2/3.
181
312
 
182
313
  ```bash
183
314
  $ bundle install
184
315
  $ bundle exec rake # specs + rubocop
185
316
  $ bundle exec rspec
186
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
187
319
  ```
188
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
+
189
323
  License
190
324
  -------
191
325
 
@@ -1,8 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "jwt"
4
+
3
5
  module RackJwtVerifier
6
+ # Base class for every error this gem raises on its own behalf. Token
7
+ # verification failures keep raising ruby-jwt's JWT::DecodeError family.
8
+ class Error < StandardError; end
9
+
10
+ # Raised at boot for an unusable option set: no key source, a shared secret
11
+ # next to a public key, HS* mixed with RS*/ES*, a too-short secret, missing
12
+ # iss/aud, ... Raised from Middleware.new / Verifier.new, never per request.
13
+ class ConfigurationError < Error; end
14
+
4
15
  # Raised when the verification key material cannot be obtained or parsed:
5
16
  # the remote endpoint is down or slow, returned something that is not a
6
17
  # key, or the configured static key does not parse.
7
- class KeyFetchError < StandardError; end
18
+ class KeyFetchError < Error; end
19
+
20
+ # Raised when the replay cache (jti store) cannot be read or written. The
21
+ # middleware answers 503: the operator asked for replay protection, so a
22
+ # request whose jti cannot be checked is refused rather than waved through.
23
+ class ReplayCacheError < Error; end
24
+
25
+ # Raised when a token's jti has already been seen. Subclasses ruby-jwt's
26
+ # InvalidJtiError so it travels the same 401 path as any other bad claim.
27
+ class ReplayedTokenError < JWT::InvalidJtiError; end
28
+
29
+ # Handed to the :on_error hook when a token lacks a scope listed in
30
+ # require_scopes. Carries what was required and what was missing.
31
+ class InsufficientScopeError < Error
32
+ attr_reader :required, :missing
33
+
34
+ def initialize(required:, missing:)
35
+ @required = required
36
+ @missing = missing
37
+ super("Token lacks required scope(s): #{missing.join(' ')}")
38
+ end
39
+ end
8
40
  end
@@ -12,39 +12,41 @@ module RackJwtVerifier
12
12
  # correction, DST) cannot extend or cut short an entry's life.
13
13
  MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
14
14
 
15
+ # Expired entries are swept whenever the store grows past this many
16
+ # entries, and the threshold then doubles from whatever survived. Amortised
17
+ # O(1) per write, so a jti-per-request workload cannot grow the store
18
+ # without bound while a single-key workload never pays for a sweep.
19
+ SWEEP_THRESHOLD = 1024
20
+
15
21
  # @param clock [#call] Returns the current time in seconds; injectable for tests.
16
22
  def initialize(clock: MONOTONIC_CLOCK)
17
23
  @store = {}
18
24
  @lock = Mutex.new # Ensure thread safety for multi-threaded environments
19
25
  @clock = clock
26
+ @sweep_at = SWEEP_THRESHOLD
20
27
  end
21
28
 
22
29
  # Reads the value for a given key. Automatically checks for expiry.
23
30
  # @param key [String] The cache key.
24
31
  # @return [Object, nil] The cached value or nil if expired or not found.
25
32
  def read(key)
26
- @lock.synchronize do
27
- entry = @store[key]
28
- return nil unless entry
29
-
30
- value, expires_at = entry
31
-
32
- # Check if the entry is expired
33
- return nil if @clock.call >= expires_at
34
-
35
- value
36
- end
33
+ @lock.synchronize { live_value(key) }
37
34
  end
38
35
 
39
36
  # Writes a value to the cache with an optional expiration time.
40
37
  # @param key [String] The cache key.
41
38
  # @param value [Object] The value to store.
42
- # @param options [Hash] Options, expecting :expires_in (seconds).
43
- # @return [Object] The stored value.
39
+ # @param options [Hash] :expires_in (seconds); :unless_exist (true to keep
40
+ # an existing live entry, as ActiveSupport stores do).
41
+ # @return [Object, false] The stored value, or false when :unless_exist
42
+ # was given and a live entry already existed.
44
43
  def write(key, value, options = {})
45
44
  @lock.synchronize do
45
+ return false if options[:unless_exist] && !live_value(key).nil?
46
+
46
47
  expiry = options[:expires_in] || DEFAULT_EXPIRY
47
48
  @store[key] = [value, @clock.call + expiry]
49
+ sweep_if_needed
48
50
  value
49
51
  end
50
52
  end
@@ -58,5 +60,41 @@ module RackJwtVerifier
58
60
  entry&.first
59
61
  end
60
62
  end
63
+
64
+ # Removes every entry.
65
+ def clear
66
+ @lock.synchronize do
67
+ @store.clear
68
+ @sweep_at = SWEEP_THRESHOLD
69
+ end
70
+ nil
71
+ end
72
+
73
+ # Number of entries held, expired ones included until the next sweep.
74
+ def size
75
+ @lock.synchronize { @store.size }
76
+ end
77
+
78
+ private
79
+
80
+ # Caller holds the lock.
81
+ def live_value(key)
82
+ entry = @store[key]
83
+ return nil unless entry
84
+
85
+ value, expires_at = entry
86
+ return nil if @clock.call >= expires_at
87
+
88
+ value
89
+ end
90
+
91
+ # Caller holds the lock.
92
+ def sweep_if_needed
93
+ return if @store.size <= @sweep_at
94
+
95
+ now = @clock.call
96
+ @store.delete_if { |_, (_, expires_at)| now >= expires_at }
97
+ @sweep_at = [@store.size * 2, SWEEP_THRESHOLD].max
98
+ end
61
99
  end
62
100
  end
@@ -4,16 +4,49 @@ require 'jwt'
4
4
  require 'openssl'
5
5
 
6
6
  module RackJwtVerifier
7
- # Issues (and, for round-trip checks, decodes) RS256 tokens from an RSA
8
- # private key. This is the *signing* side: use it in tests, or in the service
9
- # that mints tokens. The middleware itself only ever needs the public key.
7
+ # @deprecated Will be removed in 0.4.0. Issues (and, for round-trip checks,
8
+ # decodes) RS256 tokens from an RSA private key.
9
+ #
10
+ # This is a second token issuer living inside the verifier: it sets neither
11
+ # `iss`, `aud`, `nbf` nor `jti`, so what it mints does not pass the claim
12
+ # policy the middleware now enforces. Issue tokens with the `jwt_auth_client`
13
+ # gem instead (HMAC today, RS256/ES256 from its 0.3.0); in a test suite,
14
+ # sign with `JWT.encode` directly — see spec/support/token_factory.rb in this
15
+ # repository for a helper you can copy.
10
16
  class JwtHelper
11
17
  ALGORITHM = 'RS256'
12
18
 
19
+ DEPRECATION = 'RackJwtVerifier::JwtHelper is deprecated and will be removed in 0.4.0: issue tokens with the ' \
20
+ 'jwt_auth_client gem, or sign test tokens with JWT.encode. Set ' \
21
+ 'RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS=1 to silence this warning.'
22
+
23
+ @warned = false
24
+ @warn_lock = Mutex.new
25
+
26
+ class << self
27
+ # Emits the deprecation warning once per process.
28
+ def warn_deprecated
29
+ return if ENV['RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS'] == '1'
30
+
31
+ @warn_lock.synchronize do
32
+ return if @warned
33
+
34
+ @warned = true
35
+ end
36
+ Kernel.warn(DEPRECATION, uplevel: 2)
37
+ end
38
+
39
+ # Forget that the warning was emitted. Intended for test suites.
40
+ def reset_deprecation_warning!
41
+ @warn_lock.synchronize { @warned = false }
42
+ end
43
+ end
44
+
13
45
  attr_reader :private_key, :public_key
14
46
 
15
47
  # @param private_key_pem [String, OpenSSL::PKey::RSA] The RSA private key used for signing.
16
48
  def initialize(private_key_pem)
49
+ self.class.warn_deprecated
17
50
  @private_key = private_key_pem.is_a?(OpenSSL::PKey::RSA) ? private_key_pem : OpenSSL::PKey::RSA.new(private_key_pem)
18
51
  @public_key = @private_key.public_key
19
52
  end