rack-jwt-verifier 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +136 -80
- data/lib/rack-jwt-verifier.rb +5 -0
- data/lib/rack_jwt_verifier/errors.rb +8 -0
- data/lib/rack_jwt_verifier/in_process_cache.rb +13 -7
- data/lib/rack_jwt_verifier/jwt_helper.rb +22 -31
- data/lib/rack_jwt_verifier/key_source.rb +299 -0
- data/lib/rack_jwt_verifier/middleware.rb +158 -30
- data/lib/rack_jwt_verifier/verifier.rb +110 -62
- data/lib/rack_jwt_verifier/version.rb +1 -1
- data/lib/rack_jwt_verifier.rb +7 -11
- metadata +27 -90
- data/.rspec_status +0 -26
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -63
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c35bb9f8356d419cb0d0344da15e969046789c110923fdf67c5818766b542693
|
|
4
|
+
data.tar.gz: 69b50273533a7dce2ab5abd341f226b3ccafcf10e5aa7a21650d2bfaceddc86a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d96ea044955d67e95bd37b2dc7575d807b2a17a8c92d5da24ce14d65f6ad45eac14c5f2cae2ffed9a9f321d126bcf64e9c4375ab761e2a74a3899f45852b6185
|
|
7
|
+
data.tar.gz: b4a0d02c27b1bcab756864585237d6881c869823aa2f1ca04e40539466bd02c2f35298fe2881f1c03dcef192e69366c0edfa98fae10faf2cfd32d6365fdbc2fe
|
data/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
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.2.0] - 2026-09-12
|
|
11
|
+
|
|
12
|
+
A security and correctness release. **Read the *Security* section before upgrading**: an
|
|
13
|
+
`http://` key URL now fails at boot, `iss`/`aud` are enforced when set (they silently were not
|
|
14
|
+
before), and a key outage answers `503` instead of `500`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`jwks_url:`** — verify against a JSON Web Key Set, matching tokens by `kid`. An unknown `kid`
|
|
18
|
+
triggers a rate-limited refetch so rotated keys are picked up immediately. A set with no keys,
|
|
19
|
+
or a body that is not JSON, is rejected before it can be cached.
|
|
20
|
+
- **`public_key:`** — a static PEM public key, X.509 certificate PEM, or `OpenSSL::PKey`; no
|
|
21
|
+
network access.
|
|
22
|
+
- X.509 certificate PEMs are accepted from `public_key_url` (what Keycloak/Auth0 `.pem` endpoints
|
|
23
|
+
serve). EC and Ed keys parse too.
|
|
24
|
+
- Key rotation for `public_key_url`: on a signature mismatch the key is refetched once and the
|
|
25
|
+
token retried. `refetch_interval:` (default 60 s) rate-limits rotation-triggered refetches.
|
|
26
|
+
- `algorithms:` option (default `["RS256"]`). A list given in `decode_options` is no longer
|
|
27
|
+
silently overridden by the `RS256` default (ruby-jwt reads `:algorithm` before `:algorithms`).
|
|
28
|
+
- `cache_ttl:` option.
|
|
29
|
+
- Middleware: `skip:` (exact path, regexp or callable), `env_key:`, `json_errors:` and an
|
|
30
|
+
`on_error:` hook receiving `(env, reason, exception)`.
|
|
31
|
+
- The `401` challenge now carries `error_description` (sanitised to RFC 6750's quoted-string
|
|
32
|
+
alphabet).
|
|
33
|
+
- Cache keys are scoped to the URL, so two verifiers sharing one cache store no longer read each
|
|
34
|
+
other's key.
|
|
35
|
+
- Single-flight fetching on a cold cache; parsed key material is memoised per body instead of
|
|
36
|
+
re-parsing the PEM on every request.
|
|
37
|
+
- `require_token:` middleware option — reject requests that carry no token with a bare
|
|
38
|
+
`WWW-Authenticate: Bearer` challenge instead of passing them through.
|
|
39
|
+
- `logger:` middleware option; falls back to `env["rack.logger"]`, then to silence. Rejected
|
|
40
|
+
tokens log at `warn`, key-fetch failures at `error`. Replaces the unconditional `Kernel#warn`.
|
|
41
|
+
- A one-time boot warning when neither `iss` nor `aud` is configured.
|
|
42
|
+
- `content-length` on the middleware's own responses.
|
|
43
|
+
- `allow_insecure_http:` and `http_timeout:` middleware options.
|
|
44
|
+
- The key fetch sends `User-Agent: rack_jwt_verifier/<version>` and an `Accept` header.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- Requires Ruby >= 3.0 and Rack >= 2.2 (< 4). Depends on the `logger` gem explicitly, as it leaves
|
|
48
|
+
Ruby's default gems in 4.0.
|
|
49
|
+
- A failing cache store (Redis down) no longer breaks authentication: reads are treated as
|
|
50
|
+
misses and writes as no-ops, logged at `warn`; key material is fetched per request until the
|
|
51
|
+
store recovers.
|
|
52
|
+
- `InProcessCache` measures expiry on the monotonic clock, so wall-clock jumps cannot extend or
|
|
53
|
+
cut short an entry; the clock is injectable for tests.
|
|
54
|
+
- `JwtHelper#encode` normalises claim keys so a caller's `'exp'` and the generated `:exp` cannot
|
|
55
|
+
both land in the JSON; `#decode` accepts extra `JWT.decode` options; an `OpenSSL::PKey::RSA` is
|
|
56
|
+
accepted in place of a PEM.
|
|
57
|
+
- `KeyFetchError` is now `RackJwtVerifier::KeyFetchError`; `Verifier::KeyFetchError` still
|
|
58
|
+
resolves to the same class.
|
|
59
|
+
- Giving none, or more than one, of `public_key`, `public_key_url`, `jwks_url` raises
|
|
60
|
+
`ArgumentError` at boot (previously a `KeyError` for the missing URL).
|
|
61
|
+
|
|
62
|
+
### Security
|
|
63
|
+
- `iss`, `aud` and `sub` values in `decode_options` are now actually enforced. Previously the
|
|
64
|
+
underlying `jwt` gem silently ignored them unless `verify_iss`/`verify_aud`/`verify_sub` was
|
|
65
|
+
also set — a token from any issuer was accepted even with `iss:` configured. The matching
|
|
66
|
+
`verify_*` flag is now enabled automatically whenever a value is supplied.
|
|
67
|
+
- `public_key_url` must be an `https://` URL. A plaintext `http://` URL is rejected at boot
|
|
68
|
+
unless `allow_insecure_http: true` is passed, since a key fetched over HTTP can be
|
|
69
|
+
substituted by an on-path attacker.
|
|
70
|
+
- The public key fetch now has a 5-second open/read timeout (configurable via `http_timeout:`)
|
|
71
|
+
and refuses response bodies over 64 KB, so a slow or misbehaving SSO endpoint cannot pin
|
|
72
|
+
request threads.
|
|
73
|
+
- `JWT::DecodeError` raised by the downstream application is no longer caught by the
|
|
74
|
+
middleware and turned into a 401; only the middleware's own verification step is guarded.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
- Response headers are lowercase (`content-type`, `www-authenticate`), as Rack 3 requires;
|
|
78
|
+
`Rack::Lint` previously rejected the 401 response.
|
|
79
|
+
- A `200` response whose body is not a valid key (e.g. an HTML maintenance page) is no longer
|
|
80
|
+
written to the cache. Previously it poisoned the cache for the full TTL, failing every
|
|
81
|
+
request for five minutes after the SSO had recovered.
|
|
82
|
+
- A key-fetch failure (endpoint down, timeout, bad key) now yields `503 Service Unavailable`
|
|
83
|
+
with `Retry-After: 5` instead of an unhandled `KeyFetchError` (a 500).
|
|
84
|
+
- `require "rack_jwt_verifier/verifier"` on its own no longer raises `NameError` for
|
|
85
|
+
`InProcessCache`; the file requires its own dependencies.
|
|
86
|
+
- `gem "rack-jwt-verifier"` now loads without a `require:` override: a `lib/rack-jwt-verifier.rb`
|
|
87
|
+
shim matches the gem name. The README install snippet pointed at a non-existent gem name.
|
|
88
|
+
- The `Bearer` scheme is matched case-insensitively (RFC 7235) and whitespace around the token
|
|
89
|
+
is tolerated. `bearer <token>` was previously treated as "no token" and passed through.
|
|
90
|
+
- `InProcessCache#delete` returns the deleted value, as documented, rather than the internal
|
|
91
|
+
`[value, expires_at]` pair.
|
|
92
|
+
|
|
93
|
+
## [0.1.0] - 2025-10-20
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
- Initial release: `RackJwtVerifier::Middleware`, `Verifier` with pluggable cache store,
|
|
97
|
+
`InProcessCache`, and `JwtHelper`.
|
|
98
|
+
|
|
99
|
+
[Unreleased]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.2.0...HEAD
|
|
100
|
+
[0.2.0]: https://github.com/danielefrisanco/rack_jwt_verifier/compare/v0.1.0...v0.2.0
|
|
101
|
+
[0.1.0]: https://github.com/danielefrisanco/rack_jwt_verifier/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,136 +1,192 @@
|
|
|
1
1
|
RackJwtVerifier
|
|
2
2
|
===============
|
|
3
3
|
|
|
4
|
-
A
|
|
4
|
+
A Rack middleware that authenticates requests with JSON Web Tokens (JWT) signed by an external identity provider (SSO / OIDC).
|
|
5
5
|
|
|
6
|
-
|
|
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.
|
|
7
7
|
|
|
8
8
|
Features
|
|
9
9
|
--------
|
|
10
10
|
|
|
11
|
-
* **
|
|
12
|
-
|
|
13
|
-
* **
|
|
14
|
-
|
|
15
|
-
* **
|
|
16
|
-
|
|
17
|
-
* **Rack
|
|
18
|
-
|
|
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.
|
|
14
|
+
* **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
|
+
* **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.
|
|
17
|
+
* **Rack 2 and 3**, `Rack::Lint`-clean responses, RFC 6750 `WWW-Authenticate` challenges, optional JSON error bodies, path skipping, custom error hook.
|
|
19
18
|
|
|
20
19
|
Installation
|
|
21
20
|
------------
|
|
22
21
|
|
|
23
|
-
Add this line to your application's `Gemfile`:
|
|
24
|
-
|
|
25
22
|
```ruby
|
|
26
|
-
gem '
|
|
23
|
+
gem 'rack-jwt-verifier'
|
|
27
24
|
```
|
|
28
25
|
|
|
29
|
-
And then execute:
|
|
30
|
-
|
|
31
26
|
```bash
|
|
32
27
|
$ bundle install
|
|
33
28
|
```
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
---------------------
|
|
30
|
+
Requires Ruby 3.0+ and Rack 2.2 or 3.x.
|
|
37
31
|
|
|
38
|
-
|
|
32
|
+
Quick start
|
|
33
|
+
-----------
|
|
39
34
|
|
|
40
|
-
|
|
35
|
+
```ruby
|
|
36
|
+
# config/application.rb (Rails) or config.ru (plain Rack)
|
|
37
|
+
Rails.application.config.middleware.use RackJwtVerifier::Middleware,
|
|
38
|
+
jwks_url: "https://sso.example.com/.well-known/jwks.json",
|
|
39
|
+
decode_options: {
|
|
40
|
+
iss: "https://sso.example.com", # who must have issued the token
|
|
41
|
+
aud: "my-api" # who the token must be for
|
|
42
|
+
}
|
|
43
|
+
```
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
Then, in your application:
|
|
43
46
|
|
|
44
47
|
```ruby
|
|
45
|
-
|
|
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
|
|
48
|
+
claims = request.env["rack_jwt_verifier.payload"] # Hash of claims, or nil if no token was sent
|
|
50
49
|
```
|
|
51
|
-
### 2\. Production Setup with Redis Caching
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
Key sources
|
|
52
|
+
-----------
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
Exactly one of these must be given.
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
| Option | What it serves | Notes |
|
|
57
|
+
| -- | -- | -- |
|
|
58
|
+
| `:jwks_url` | A JSON Web Key Set (`{"keys":[…]}`) | Tokens are matched by their `kid` header. Recommended — this is what nearly every provider publishes. |
|
|
59
|
+
| `:public_key_url` | A single PEM public key **or** an X.509 certificate | Fine for providers that expose one key. |
|
|
60
|
+
| `:public_key` | A PEM string, certificate PEM, or `OpenSSL::PKey` | No network access at all. Handy for `ENV["SSO_PUBLIC_KEY"]`. |
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
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.
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
```ruby
|
|
65
|
+
# Static key from the environment
|
|
66
|
+
use RackJwtVerifier::Middleware,
|
|
67
|
+
public_key: ENV.fetch("SSO_PUBLIC_KEY"),
|
|
68
|
+
decode_options: { iss: "https://sso.example.com", aud: "my-api" }
|
|
69
|
+
|
|
70
|
+
# EC keys need the algorithm list widened
|
|
71
|
+
use RackJwtVerifier::Middleware,
|
|
72
|
+
jwks_url: "https://sso.example.com/.well-known/jwks.json",
|
|
73
|
+
algorithms: %w[RS256 ES256],
|
|
74
|
+
decode_options: { iss: "https://sso.example.com", aud: "my-api" }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
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
|
+
|
|
79
|
+
Caching and key rotation
|
|
80
|
+
------------------------
|
|
81
|
+
|
|
82
|
+
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:
|
|
62
83
|
|
|
63
84
|
```ruby
|
|
64
|
-
#
|
|
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
|
|
85
|
+
# config/initializers/rack_jwt_verifier.rb
|
|
72
86
|
Rails.application.config.middleware.use RackJwtVerifier::Middleware,
|
|
73
|
-
|
|
74
|
-
cache_store:
|
|
87
|
+
jwks_url: ENV.fetch("SSO_JWKS_URL"),
|
|
88
|
+
cache_store: Rails.cache, # any ActiveSupport::Cache::Store works
|
|
89
|
+
decode_options: { iss: ENV.fetch("SSO_ISSUER"), aud: "my-api" }
|
|
75
90
|
```
|
|
76
91
|
|
|
77
|
-
|
|
92
|
+
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.
|
|
78
93
|
|
|
79
|
-
|
|
94
|
+
**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.
|
|
80
95
|
|
|
81
|
-
|
|
96
|
+
Within one process only one thread performs a fetch on a cold cache; the others wait for it.
|
|
82
97
|
|
|
83
|
-
|
|
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. |
|
|
98
|
+
Options
|
|
99
|
+
-------
|
|
89
100
|
|
|
90
|
-
|
|
101
|
+
### Middleware
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
| Option | Default | Purpose |
|
|
104
|
+
| -- | -- | -- |
|
|
105
|
+
| `: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. |
|
|
106
|
+
| `: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`. |
|
|
107
|
+
| `:env_key` | `"rack_jwt_verifier.payload"` | Rack env key that receives the verified claims. |
|
|
108
|
+
| `:json_errors` | `false` | Render `401`/`503` bodies as `{"error": "...", "error_description": "..."}` with `content-type: application/json`. |
|
|
109
|
+
| `: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
|
+
| `: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. |
|
|
111
|
+
|
|
112
|
+
### Key fetching
|
|
113
|
+
|
|
114
|
+
| Option | Default | Purpose |
|
|
115
|
+
| -- | -- | -- |
|
|
116
|
+
| `:algorithms` | `["RS256"]` | Accepted signing algorithms. |
|
|
117
|
+
| `:cache_store` | `InProcessCache.new` | See *Caching* above. |
|
|
118
|
+
| `:cache_ttl` | `300` | Seconds to cache fetched key material. |
|
|
119
|
+
| `:refetch_interval` | `60` | Minimum seconds between rotation-triggered refetches. |
|
|
120
|
+
| `:http_timeout` | `5` | Open and read timeout, in seconds, for the key fetch. |
|
|
121
|
+
| `:allow_insecure_http` | `false` | Permit a plain `http://` URL. Development only. |
|
|
122
|
+
|
|
123
|
+
Responses over 64 KB are refused — a PEM key is under 1 KB and a JWKS a few KB.
|
|
124
|
+
|
|
125
|
+
### Claim validation (`:decode_options`)
|
|
126
|
+
|
|
127
|
+
Everything here is handed to `JWT.decode`.
|
|
128
|
+
|
|
129
|
+
| Option | Default | Purpose |
|
|
130
|
+
| -- | -- | -- |
|
|
131
|
+
| `:iss` | — | **Recommended.** The issuer the token must carry. |
|
|
132
|
+
| `:aud` | — | **Recommended.** The audience the token must carry. |
|
|
133
|
+
| `:sub` | — | The subject the token must carry. |
|
|
134
|
+
| `:leeway` | `60` | Clock-skew tolerance, in seconds, for `exp` and `nbf`. `0` for strict timing. |
|
|
135
|
+
| `:verify_expiration` | `true` | Check `exp`. Leave on. |
|
|
136
|
+
| `:verify_not_before` | `true` | Check `nbf`. Leave on. |
|
|
137
|
+
| `:allow_nil_kid` | `false` | JWKS only: accept tokens without a `kid`. |
|
|
138
|
+
|
|
139
|
+
> 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.
|
|
102
140
|
|
|
103
|
-
|
|
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*.
|
|
142
|
+
|
|
143
|
+
Request flow
|
|
104
144
|
------------
|
|
105
145
|
|
|
106
|
-
1.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
1. If the path matches a `:skip` rule, the request goes straight through.
|
|
147
|
+
2. The token is read from `Authorization: Bearer <token>` (scheme matched case-insensitively).
|
|
148
|
+
* No token: passed through with no payload — or `401` with `WWW-Authenticate: Bearer` if `require_token: true`.
|
|
149
|
+
3. Key material is read from the cache, or fetched on a miss.
|
|
150
|
+
4. Signature and claims are verified.
|
|
151
|
+
* 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.
|
|
154
|
+
|
|
155
|
+
`JWT::DecodeError`s raised by *your* application are never intercepted; only the middleware's own verification step is guarded.
|
|
156
|
+
|
|
157
|
+
Security considerations
|
|
158
|
+
-----------------------
|
|
159
|
+
|
|
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.
|
|
162
|
+
* **Prefer `jwks_url`.** It supports multiple keys and `kid`-based rotation; a single PEM URL cannot express an overlap period.
|
|
163
|
+
* **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`.
|
|
165
|
+
|
|
166
|
+
Design notes
|
|
167
|
+
------------
|
|
168
|
+
|
|
169
|
+
A few choices that are not obvious from the code:
|
|
170
|
+
|
|
171
|
+
* **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.
|
|
172
|
+
* **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.
|
|
173
|
+
* **`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
|
+
* **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
|
+
* **Rotation refetches are rate-limited** (`refetch_interval`) so a stream of forged tokens cannot be turned into a stream of requests to the provider.
|
|
120
176
|
|
|
121
177
|
Development
|
|
122
178
|
-----------
|
|
123
179
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
To run the full suite:
|
|
180
|
+
RSpec, WebMock (no real network in tests), Timecop and RuboCop. CI runs the suite on Ruby 3.0–3.4 against both Rack 2 and Rack 3.
|
|
127
181
|
|
|
128
182
|
```bash
|
|
129
183
|
$ bundle install
|
|
184
|
+
$ bundle exec rake # specs + rubocop
|
|
130
185
|
$ bundle exec rspec
|
|
186
|
+
$ BUNDLE_GEMFILE=gemfiles/rack_2.gemfile bundle exec rspec # the Rack 2 leg
|
|
131
187
|
```
|
|
132
188
|
|
|
133
189
|
License
|
|
134
190
|
-------
|
|
135
191
|
|
|
136
|
-
|
|
192
|
+
MIT — see [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RackJwtVerifier
|
|
4
|
+
# Raised when the verification key material cannot be obtained or parsed:
|
|
5
|
+
# the remote endpoint is down or slow, returned something that is not a
|
|
6
|
+
# key, or the configured static key does not parse.
|
|
7
|
+
class KeyFetchError < StandardError; end
|
|
8
|
+
end
|
|
@@ -8,9 +8,15 @@ module RackJwtVerifier
|
|
|
8
8
|
# The cache lifespan in seconds (5 minutes)
|
|
9
9
|
DEFAULT_EXPIRY = 300
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# Expiry is measured on the monotonic clock, so a wall-clock jump (NTP
|
|
12
|
+
# correction, DST) cannot extend or cut short an entry's life.
|
|
13
|
+
MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
14
|
+
|
|
15
|
+
# @param clock [#call] Returns the current time in seconds; injectable for tests.
|
|
16
|
+
def initialize(clock: MONOTONIC_CLOCK)
|
|
12
17
|
@store = {}
|
|
13
18
|
@lock = Mutex.new # Ensure thread safety for multi-threaded environments
|
|
19
|
+
@clock = clock
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
# Reads the value for a given key. Automatically checks for expiry.
|
|
@@ -22,10 +28,10 @@ module RackJwtVerifier
|
|
|
22
28
|
return nil unless entry
|
|
23
29
|
|
|
24
30
|
value, expires_at = entry
|
|
25
|
-
|
|
31
|
+
|
|
26
32
|
# Check if the entry is expired
|
|
27
|
-
return nil if
|
|
28
|
-
|
|
33
|
+
return nil if @clock.call >= expires_at
|
|
34
|
+
|
|
29
35
|
value
|
|
30
36
|
end
|
|
31
37
|
end
|
|
@@ -38,8 +44,7 @@ module RackJwtVerifier
|
|
|
38
44
|
def write(key, value, options = {})
|
|
39
45
|
@lock.synchronize do
|
|
40
46
|
expiry = options[:expires_in] || DEFAULT_EXPIRY
|
|
41
|
-
|
|
42
|
-
@store[key] = [value, expires_at]
|
|
47
|
+
@store[key] = [value, @clock.call + expiry]
|
|
43
48
|
value
|
|
44
49
|
end
|
|
45
50
|
end
|
|
@@ -49,7 +54,8 @@ module RackJwtVerifier
|
|
|
49
54
|
# @return [Object, nil] The deleted entry value or nil.
|
|
50
55
|
def delete(key)
|
|
51
56
|
@lock.synchronize do
|
|
52
|
-
@store.delete(key)
|
|
57
|
+
entry = @store.delete(key)
|
|
58
|
+
entry&.first
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
end
|
|
@@ -3,53 +3,44 @@
|
|
|
3
3
|
require 'jwt'
|
|
4
4
|
require 'openssl'
|
|
5
5
|
|
|
6
|
-
# A helper class for handling JWT creation and verification using RSA keys (RS256).
|
|
7
|
-
# This is typically used by the application or a separate service to *create* tokens.
|
|
8
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.
|
|
9
10
|
class JwtHelper
|
|
10
|
-
|
|
11
|
+
ALGORITHM = 'RS256'
|
|
12
|
+
|
|
11
13
|
attr_reader :private_key, :public_key
|
|
12
14
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# @param private_key_pem [String] The PEM string of the RSA Private Key.
|
|
15
|
+
# @param private_key_pem [String, OpenSSL::PKey::RSA] The RSA private key used for signing.
|
|
16
16
|
def initialize(private_key_pem)
|
|
17
|
-
|
|
18
|
-
# This key signs the tokens.
|
|
19
|
-
@private_key = OpenSSL::PKey::RSA.new(private_key_pem)
|
|
17
|
+
@private_key = private_key_pem.is_a?(OpenSSL::PKey::RSA) ? private_key_pem : OpenSSL::PKey::RSA.new(private_key_pem)
|
|
20
18
|
@public_key = @private_key.public_key
|
|
21
19
|
end
|
|
22
20
|
|
|
23
|
-
# Encodes a payload into a JWT
|
|
21
|
+
# Encodes a payload into a JWT, adding `iat` and `exp`.
|
|
24
22
|
#
|
|
25
|
-
# @param payload [Hash]
|
|
26
|
-
# @param expires_in [Integer]
|
|
23
|
+
# @param payload [Hash] Claims (string or symbol keys; an explicit `exp` in the payload wins).
|
|
24
|
+
# @param expires_in [Integer] Seconds until the token expires (default: 1 hour).
|
|
27
25
|
# @return [String] The signed JWT string.
|
|
28
26
|
def encode(payload, expires_in = 3600)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
JWT.encode(payload_with_claims, @private_key, 'RS256')
|
|
27
|
+
now = Time.now.to_i
|
|
28
|
+
# Normalise to string keys first so a caller's 'exp' and our :exp cannot
|
|
29
|
+
# both end up in the JSON as duplicate "exp" members.
|
|
30
|
+
claims = { 'iat' => now, 'exp' => now + expires_in }.merge(payload.transform_keys(&:to_s))
|
|
31
|
+
JWT.encode(claims, @private_key, ALGORITHM)
|
|
37
32
|
end
|
|
38
33
|
|
|
39
|
-
# Decodes and verifies a JWT
|
|
40
|
-
#
|
|
41
|
-
# NOTE: This method is used primarily for self-testing in the application
|
|
42
|
-
# but the primary verification logic for the middleware is in the Verifier class.
|
|
34
|
+
# Decodes and verifies a JWT with the public key. Meant for self-checks;
|
|
35
|
+
# the middleware's verification lives in Verifier.
|
|
43
36
|
#
|
|
44
37
|
# @param token [String] The JWT string to decode.
|
|
38
|
+
# @param options [Hash] Extra options for JWT.decode (leeway, iss, verify_iss, ...).
|
|
45
39
|
# @return [Hash] The decoded payload if verification is successful.
|
|
46
|
-
# @raise [JWT::
|
|
47
|
-
def decode(token)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
decoded = JWT.decode(token, @public_key, true, { algorithm: 'RS256' })
|
|
51
|
-
# Returns only the payload (the first element of the array).
|
|
52
|
-
decoded.first
|
|
40
|
+
# @raise [JWT::DecodeError] If the token is invalid or expired.
|
|
41
|
+
def decode(token, options = {})
|
|
42
|
+
payload, _header = JWT.decode(token, @public_key, true, { algorithm: ALGORITHM }.merge(options))
|
|
43
|
+
payload
|
|
53
44
|
end
|
|
54
45
|
end
|
|
55
46
|
end
|