jwt_auth_client 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 +67 -0
- data/LICENSE +21 -0
- data/README.md +168 -79
- data/lib/jwt_auth_client/configuration.rb +142 -26
- data/lib/jwt_auth_client/errors.rb +15 -0
- data/lib/jwt_auth_client/http_client.rb +99 -36
- data/lib/jwt_auth_client/issuable.rb +53 -0
- data/lib/jwt_auth_client/json_decoder.rb +18 -0
- data/lib/jwt_auth_client/token_issuer.rb +50 -38
- data/lib/jwt_auth_client/version.rb +1 -1
- data/lib/jwt_auth_client.rb +8 -13
- metadata +42 -38
- data/Gemfile +0 -27
- data/Gemfile.lock +0 -109
- data/Rakefile +0 -27
- data/lib/jwt_auth_client/billing_client.rb +0 -13
- data/pkg/jwt_auth_client-0.1.0.gem +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c01a3a1fcd5ad0c6e46b7802a105e2c129a9705a0004358ab1c4970076b3dde7
|
|
4
|
+
data.tar.gz: 73b684a98f9f583c4223d118e73c094366f6a5bb08e90293a981cc80fe2b1f6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd7348b5b0377f94aedc6dbd87651d2d1b02b1c6cb88e228dcedd20515db829ea22ea285174f51a73135b493420fe0215d43de97f21f006374b56b9216ec5475
|
|
7
|
+
data.tar.gz: b8c916e4122f642a82630dad2ed0c3f1945556230494e9ad1ff1f098e07e5afebbd47215ec3fba5021f2d61644c416ac35684baa97c3d30dcfa909094c2ed054
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-09-13
|
|
8
|
+
|
|
9
|
+
Security-hardening release. Several changes are **breaking**; see the upgrade notes below.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
- **No more default secret.** `shared_secret` now defaults to `ENV["JWT_SERVICE_SECRET"]` with no
|
|
13
|
+
literal fallback. A missing, empty or too-short secret raises `ConfigurationError` at
|
|
14
|
+
`JwtAuthClient.configure` time (was: silently signing with `"development_secret"`).
|
|
15
|
+
- **Algorithm allow-list.** Only `HS256`, `HS384` and `HS512` are accepted; `none` and anything else
|
|
16
|
+
raise `ConfigurationError` (was: passed straight to `JWT.encode`, so `none` produced unsigned tokens).
|
|
17
|
+
- **Minimum key length** enforced per RFC 7518 §3.2 (32/48/64 bytes for HS256/384/512).
|
|
18
|
+
- `issuer` is now required (was: silently `"main_sso_app"`).
|
|
19
|
+
- Tokens now carry an `nbf` claim (= `iat`).
|
|
20
|
+
- `user_id` (the `sub` claim) must be present; `nil`/empty raises `ArgumentError` (was: `"sub": null`).
|
|
21
|
+
- Custom claims can never override the registered claims `iss`, `sub`, `iat`, `nbf`, `exp`, `jti`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Expired tokens on long-lived connections.** The JWT was minted once when the connection was built
|
|
25
|
+
and memoised, so any connection held longer than `default_expiry_seconds` sent expired tokens. The
|
|
26
|
+
token is now issued lazily per request (via Faraday's callable authorization header).
|
|
27
|
+
- `scopes: nil` raised `NoMethodError`; it is now treated as no scopes.
|
|
28
|
+
- JSON response parsing failed with `Faraday::ParsingError` when the `json` gem >= 3.0 was installed
|
|
29
|
+
(Faraday passes a positional options hash that `json` 3 rejects). A small decoder shim forwards
|
|
30
|
+
options as keywords and works with both `json` 2.x and 3.x.
|
|
31
|
+
- `raise_error` now runs after JSON parsing, so `Faraday::Error#response[:body]` contains the decoded
|
|
32
|
+
body instead of a raw string.
|
|
33
|
+
- Documentation: `HttpClient.call` returns a `Faraday::Connection`, not a response; the README's
|
|
34
|
+
"base_url only" example (which raised `ArgumentError`) has been corrected.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `JwtAuthClient::Issuable` mixin: `include` it in a model, define `#jwt_claims`, and call
|
|
38
|
+
`#to_jwt(expiry_seconds: nil, target_service: nil, scopes: [])`.
|
|
39
|
+
- `TokenIssuer.call` accepts `claims:` (extra custom claims) and `expiry_seconds:` (per-call override).
|
|
40
|
+
- Configurable HTTP timeouts: `open_timeout` (2 s), `read_timeout` (5 s), `write_timeout` (5 s).
|
|
41
|
+
- Opt-in retries via `retry_options` (requires the `faraday-retry` gem).
|
|
42
|
+
- Opt-in token reuse via `token_reuse_seconds` (default `0` = fresh `jti` per request); tokens are
|
|
43
|
+
always re-issued within 30 s of expiry.
|
|
44
|
+
- `HttpClient.call` yields the Faraday connection so callers can add middleware.
|
|
45
|
+
- Error hierarchy: `JwtAuthClient::Error` > `ConfigurationError` > `UnknownServiceError`, and `TokenError`.
|
|
46
|
+
- `Configuration#validate!` and `JwtAuthClient.reset_configuration!` (handy in test suites).
|
|
47
|
+
- `required_ruby_version >= 3.1`, CI matrix on Ruby 3.1–3.4, `rubygems_mfa_required` metadata, LICENSE file.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- **`BillingClient` removed from the gem** and moved to `examples/billing_client.rb`. It hard-coded an
|
|
51
|
+
application-specific service into a generic library; copy the example into your app instead.
|
|
52
|
+
- `Configuration#base_url_for` raises `UnknownServiceError` (a `ConfigurationError`) instead of `ArgumentError`.
|
|
53
|
+
- `activesupport` is no longer a runtime dependency (it was required but never used).
|
|
54
|
+
- Development dependencies are declared in the gemspec only; `Gemfile.lock` and built `.gem` files
|
|
55
|
+
are no longer tracked.
|
|
56
|
+
|
|
57
|
+
### Upgrade notes (0.1.x → 0.2.0)
|
|
58
|
+
1. Set a real secret of at least 32 bytes (`openssl rand -hex 32`) in `JWT_SERVICE_SECRET` or
|
|
59
|
+
`config.shared_secret`, and set `config.issuer`. Boot will fail loudly until you do.
|
|
60
|
+
2. If you rescued `ArgumentError` around `HttpClient.call` for unknown services, rescue
|
|
61
|
+
`JwtAuthClient::UnknownServiceError` (or `JwtAuthClient::Error`) instead.
|
|
62
|
+
3. If you used `JwtAuthClient::BillingClient`, copy `examples/billing_client.rb` into your application.
|
|
63
|
+
4. Verifiers should now expect `nbf` and can apply a small leeway (e.g. 30 s) for clock skew.
|
|
64
|
+
|
|
65
|
+
## [0.1.0] - 2025-10-25
|
|
66
|
+
|
|
67
|
+
- Initial release: `TokenIssuer`, `Configuration`, `HttpClient`, `BillingClient`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Daniele Frisanco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,116 +1,205 @@
|
|
|
1
|
-
JwtAuthClient
|
|
2
|
-
=============
|
|
1
|
+
# JwtAuthClient
|
|
3
2
|
|
|
4
|
-
A
|
|
3
|
+
A small, hardened Ruby client for issuing short-lived JWTs and sending them as `Bearer` tokens
|
|
4
|
+
between your own services.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
- **Token issuing** — `iss`, `sub`, `iat`, `nbf`, `exp`, `jti`, plus optional `aud`, `scopes` and
|
|
7
|
+
custom claims. HMAC only (`HS256`/`HS384`/`HS512`) with enforced key length; `none` is rejected.
|
|
8
|
+
- **Faraday client** — a connection that mints a fresh token per request, with timeouts,
|
|
9
|
+
JSON encode/decode, error raising and opt-in retries.
|
|
10
|
+
- **`Issuable` mixin** — give a model a `#to_jwt` method (e.g. an SSO hub handing an identity
|
|
11
|
+
token to a client app).
|
|
12
|
+
- **Fails at boot, not at runtime** — misconfiguration raises `JwtAuthClient::ConfigurationError`
|
|
13
|
+
from `JwtAuthClient.configure`.
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
--------
|
|
15
|
+
Requires Ruby >= 3.1. Pairs with a verifier such as `rack_jwt_verifier` on the receiving side.
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* **Service-Specific Scopes:** Allows injection of custom claims (`aud`, `scopes`) to authorize access for specific target services.
|
|
14
|
-
|
|
15
|
-
* **Faraday Integration:** Wraps around Faraday to automatically attach the generated JWT as a `Bearer` token in the `Authorization` header.
|
|
16
|
-
|
|
17
|
-
* **High-Level Clients:** Supports creating specialized clients (e.g., `BillingClient`) for clean API consumption.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Installation
|
|
21
|
-
------------
|
|
22
|
-
|
|
23
|
-
Add this line to your application's Gemfile:
|
|
17
|
+
## Installation
|
|
24
18
|
|
|
25
19
|
```ruby
|
|
26
|
-
gem
|
|
20
|
+
gem "jwt_auth_client", "~> 0.2"
|
|
21
|
+
# optional, only if you enable retries:
|
|
22
|
+
# gem "faraday-retry"
|
|
27
23
|
```
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
$ bundle install
|
|
33
|
-
```
|
|
25
|
+
## Configuration
|
|
34
26
|
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
Configure once at boot (e.g. `config/initializers/jwt_auth_client.rb`). The block is validated
|
|
28
|
+
when it returns, so a bad configuration fails the boot.
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
```ruby
|
|
31
|
+
JwtAuthClient.configure do |config|
|
|
32
|
+
# Required. >= 32 bytes for HS256 (48 for HS384, 64 for HS512). Generate with:
|
|
33
|
+
# openssl rand -hex 32
|
|
34
|
+
# Defaults to ENV["JWT_SERVICE_SECRET"]; there is deliberately NO fallback value.
|
|
35
|
+
config.shared_secret = ENV.fetch("JWT_SERVICE_SECRET")
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
| **shared_secret** | String | The cryptographic key known to all services (used for signing and verification). **CRITICAL.**
|
|
43
|
-
| **algorithm** | String | The JWT signing algorithm (e.g., '`HS256`').
|
|
44
|
-
| **default_expiry_seconds** | Integer | Default lifespan for the tokens (e.g., `300` seconds = 5 minutes).
|
|
45
|
-
| **issuer** | String | The identifier of the application issuing the token (e.g., '`main_sso_app`').
|
|
46
|
-
| **service_urls** | Hash | Map of internal service keys to their base URLs.
|
|
37
|
+
# Required. Identifies this application in the `iss` claim.
|
|
38
|
+
config.issuer = "main_app_sso"
|
|
47
39
|
|
|
48
|
-
|
|
49
|
-
#
|
|
40
|
+
config.algorithm = "HS256" # HS256 | HS384 | HS512 (default HS256)
|
|
41
|
+
config.default_expiry_seconds = 300 # token lifetime (default 300)
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
# Load the secret from an environment variable!
|
|
53
|
-
config.shared_secret = ENV.fetch('JWT_SERVICE_SECRET') { 'a_fallback_secret_for_dev' }
|
|
54
|
-
config.algorithm = 'HS256'
|
|
55
|
-
config.default_expiry_seconds = 300 # 5 minutes
|
|
56
|
-
config.issuer = 'main_app_sso'
|
|
57
|
-
# Configure base URLs for your internal services
|
|
43
|
+
# Service name => base URL, used by HttpClient. Always use https:// in production.
|
|
58
44
|
config.service_urls = {
|
|
59
|
-
billing_api:
|
|
60
|
-
user_data_api:
|
|
45
|
+
billing_api: "https://billing.internal",
|
|
46
|
+
user_data_api: "https://users.internal"
|
|
61
47
|
}
|
|
48
|
+
|
|
49
|
+
# HTTP timeouts in seconds (defaults: 2 / 5 / 5)
|
|
50
|
+
config.open_timeout = 2
|
|
51
|
+
config.read_timeout = 5
|
|
52
|
+
config.write_timeout = 5
|
|
53
|
+
|
|
54
|
+
# Optional: reuse a token for up to N seconds instead of signing one per request.
|
|
55
|
+
# 0 (default) = fresh token and fresh `jti` per request, best for replay detection.
|
|
56
|
+
config.token_reuse_seconds = 0
|
|
57
|
+
|
|
58
|
+
# Optional: retries (needs the faraday-retry gem). nil (default) = no retries.
|
|
59
|
+
# config.retry_options = { max: 2, interval: 0.1, backoff_factor: 2, retry_statuses: [502, 503, 504] }
|
|
62
60
|
end
|
|
63
61
|
```
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
| Setting | Type | Default | Notes |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| `shared_secret` | String | `ENV["JWT_SERVICE_SECRET"]` | **Required.** Min 32/48/64 bytes for HS256/384/512. |
|
|
66
|
+
| `issuer` | String | — | **Required.** The `iss` claim. |
|
|
67
|
+
| `algorithm` | String | `"HS256"` | One of `HS256`, `HS384`, `HS512`. |
|
|
68
|
+
| `default_expiry_seconds` | Integer | `300` | Token lifetime. |
|
|
69
|
+
| `service_urls` | Hash | `{}` | `{ service_name: "https://..." }` |
|
|
70
|
+
| `open_timeout` / `read_timeout` / `write_timeout` | Numeric | `2` / `5` / `5` | Seconds. |
|
|
71
|
+
| `token_reuse_seconds` | Integer | `0` | Reuse window for a minted token; always re-issued within 30 s of expiry. |
|
|
72
|
+
| `retry_options` | Hash / nil | `nil` | Passed to Faraday's `:retry` middleware. |
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
## Usage
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
### Authenticated HTTP client
|
|
77
|
+
|
|
78
|
+
`HttpClient.call` returns a `Faraday::Connection`. Build it once per service and keep it —
|
|
79
|
+
the token is issued lazily on each request, so a long-lived connection never sends an expired token.
|
|
71
80
|
|
|
72
81
|
```ruby
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
scopes: ['read:invoices', 'write:payments']
|
|
82
|
+
BILLING = JwtAuthClient::HttpClient.call(
|
|
83
|
+
user_id: "service-account-etl", # `sub` claim
|
|
84
|
+
target_service: :billing_api, # `aud` claim; must be a key in service_urls
|
|
85
|
+
scopes: ["read:invoices"] # `scopes` claim
|
|
78
86
|
)
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
response
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
end
|
|
88
|
+
response = BILLING.get("/v1/invoices/latest") # => Faraday::Response, body already parsed
|
|
89
|
+
response.body["total"]
|
|
90
|
+
|
|
91
|
+
BILLING.post("/v1/payments", { amount: 10 }) # body encoded as JSON automatically
|
|
85
92
|
```
|
|
86
93
|
|
|
87
|
-
|
|
94
|
+
4xx/5xx responses raise `Faraday::ClientError` / `Faraday::ServerError` with the parsed body available
|
|
95
|
+
as `error.response[:body]`.
|
|
88
96
|
|
|
89
|
-
|
|
97
|
+
Override *where* the request goes (a canary host, a test server) without changing *who the token
|
|
98
|
+
is for* — `target_service` is always required so every token carries an `aud`:
|
|
90
99
|
|
|
91
100
|
```ruby
|
|
92
|
-
|
|
93
|
-
user_id:
|
|
94
|
-
target_service: :user_data_api, # Must be a key defined in service_urls
|
|
95
|
-
scopes: ['read:all_users']
|
|
96
|
-
)
|
|
97
|
-
# Override the base URL dynamically if needed
|
|
98
|
-
override_client = JwtAuthClient::HttpClient.call(
|
|
99
|
-
user_id: 'guest',
|
|
100
|
-
base_url: 'http://temporary-api.test' # Overrides configured service_urls
|
|
101
|
+
canary = JwtAuthClient::HttpClient.call(
|
|
102
|
+
user_id: "etl", target_service: :billing_api, base_url: "https://billing-canary.internal"
|
|
101
103
|
)
|
|
102
104
|
```
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
Add your own middleware (logging, instrumentation) with a block; it runs before the adapter is set:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
client = JwtAuthClient::HttpClient.call(user_id: "etl", target_service: :billing_api) do |conn|
|
|
110
|
+
conn.response :logger, Rails.logger, headers: false
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A service-specific wrapper keeps call sites short — see [`examples/billing_client.rb`](examples/billing_client.rb):
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
class BillingClient < JwtAuthClient::HttpClient
|
|
118
|
+
def self.call(user_id:, scopes: [], base_url: nil, &block)
|
|
119
|
+
super(user_id: user_id, target_service: :billing_api, scopes: scopes, base_url: base_url, &block)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Token only
|
|
105
125
|
|
|
106
|
-
|
|
126
|
+
For non-HTTP transports (message queues, redirects, ...):
|
|
107
127
|
|
|
108
128
|
```ruby
|
|
109
129
|
token = JwtAuthClient::TokenIssuer.call(
|
|
110
|
-
user_id:
|
|
111
|
-
target_service:
|
|
112
|
-
scopes: [
|
|
130
|
+
user_id: "system-job-id",
|
|
131
|
+
target_service: :data_pipeline, # optional `aud`
|
|
132
|
+
scopes: ["process:orders"], # optional
|
|
133
|
+
claims: { tenant: "acme" }, # optional custom claims (registered claims are protected)
|
|
134
|
+
expiry_seconds: 60 # optional, overrides default_expiry_seconds
|
|
113
135
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### `Issuable` — tokens from a model
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class User < ApplicationRecord
|
|
142
|
+
include JwtAuthClient::Issuable
|
|
143
|
+
|
|
144
|
+
def jwt_claims
|
|
145
|
+
{ user_id: sso_id, email: email }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
user.to_jwt # sub = jwt_claims[:user_id] (falls back to #id)
|
|
150
|
+
user.to_jwt(expiry_seconds: 60, target_service: :client_app)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Override `#jwt_subject` to choose a different `sub`.
|
|
154
|
+
|
|
155
|
+
### Token payload
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"iss": "main_app_sso",
|
|
160
|
+
"sub": "service-account-etl",
|
|
161
|
+
"aud": "billing_api",
|
|
162
|
+
"scopes": ["read:invoices"],
|
|
163
|
+
"iat": 1700000000,
|
|
164
|
+
"nbf": 1700000000,
|
|
165
|
+
"exp": 1700000300,
|
|
166
|
+
"jti": "9c1a3f0e-..."
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Errors
|
|
171
|
+
|
|
172
|
+
| Class | Raised when |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `JwtAuthClient::ConfigurationError` | Configuration is missing/invalid (secret, algorithm, issuer, timeouts, ...). |
|
|
175
|
+
| `JwtAuthClient::UnknownServiceError` (< `ConfigurationError`) | `target_service` has no entry in `service_urls` and no `base_url` was given. |
|
|
176
|
+
| `JwtAuthClient::TokenError` | The `jwt` gem failed to sign the payload. |
|
|
177
|
+
| `ArgumentError` | Bad call arguments (`user_id`/`target_service` blank, invalid `expiry_seconds`, ...). |
|
|
178
|
+
|
|
179
|
+
All gem errors inherit from `JwtAuthClient::Error`.
|
|
180
|
+
|
|
181
|
+
## Security notes
|
|
182
|
+
|
|
183
|
+
- **Transport:** bearer tokens are credentials. Only send them over TLS (or an mTLS service mesh);
|
|
184
|
+
never set `ssl: { verify: false }` on the connection.
|
|
185
|
+
- **Secret handling:** load the secret from the environment or a secret manager, rotate it, and never
|
|
186
|
+
commit it. Every service holding the secret can mint tokens for every `aud`, so keep the set of
|
|
187
|
+
holders small. Asymmetric signing (RS256/ES256, private key on the issuer only) is planned.
|
|
188
|
+
- **Verifier side:** verify the signature *and* `iss`, `aud`, `exp`, `nbf`; allow a small leeway
|
|
189
|
+
(e.g. 30 s) for clock skew; optionally track `jti` to detect replays.
|
|
190
|
+
- **Lifetime:** keep `default_expiry_seconds` short (minutes). Use `expiry_seconds:` for one-off tokens
|
|
191
|
+
that should live even less.
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
bundle install
|
|
197
|
+
bundle exec rspec
|
|
198
|
+
bundle exec rake build # builds pkg/jwt_auth_client-x.y.z.gem
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes and upgrade steps.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -1,48 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
1
5
|
module JwtAuthClient
|
|
2
6
|
class Configuration
|
|
3
|
-
#
|
|
7
|
+
# Only HMAC algorithms are supported until asymmetric signing lands.
|
|
8
|
+
# "none" is deliberately absent: it produces unsigned tokens.
|
|
9
|
+
SUPPORTED_ALGORITHMS = %w[HS256 HS384 HS512].freeze
|
|
10
|
+
|
|
11
|
+
# RFC 7518 §3.2: an HMAC key must be at least as long as the hash output.
|
|
12
|
+
MIN_SECRET_BYTES = { "HS256" => 32, "HS384" => 48, "HS512" => 64 }.freeze
|
|
13
|
+
|
|
14
|
+
# The cryptographic key shared with the verifying services. Required.
|
|
15
|
+
# Defaults to ENV["JWT_SERVICE_SECRET"]; there is intentionally no literal fallback.
|
|
4
16
|
attr_accessor :shared_secret
|
|
5
|
-
|
|
6
|
-
# The algorithm
|
|
17
|
+
|
|
18
|
+
# The signing algorithm; one of SUPPORTED_ALGORITHMS.
|
|
7
19
|
attr_accessor :algorithm
|
|
8
|
-
|
|
9
|
-
#
|
|
20
|
+
|
|
21
|
+
# Token validity period in seconds.
|
|
10
22
|
attr_accessor :default_expiry_seconds
|
|
11
|
-
|
|
12
|
-
# The
|
|
23
|
+
|
|
24
|
+
# The `iss` claim identifying the issuing application. Required.
|
|
13
25
|
attr_accessor :issuer
|
|
14
26
|
|
|
15
|
-
#
|
|
27
|
+
# Mapping of target service names (Symbol) to base URLs (String).
|
|
16
28
|
attr_accessor :service_urls
|
|
17
29
|
|
|
30
|
+
# HTTP timeouts (seconds) applied to every HttpClient connection.
|
|
31
|
+
attr_accessor :open_timeout, :read_timeout, :write_timeout
|
|
32
|
+
|
|
33
|
+
# How long (seconds) an HttpClient may reuse a token before issuing a new
|
|
34
|
+
# one. 0 (default) issues a fresh token — and a fresh `jti` — per request,
|
|
35
|
+
# which is best for replay detection on the verifier side. Raise it on hot
|
|
36
|
+
# paths to skip the per-request signature. Tokens are always re-issued
|
|
37
|
+
# within HttpClient::REFRESH_MARGIN_SECONDS of expiry regardless.
|
|
38
|
+
attr_accessor :token_reuse_seconds
|
|
39
|
+
|
|
40
|
+
# Options for Faraday's :retry middleware (requires the faraday-retry gem),
|
|
41
|
+
# e.g. { max: 2, interval: 0.1, backoff_factor: 2 }. nil disables retries.
|
|
42
|
+
attr_accessor :retry_options
|
|
43
|
+
|
|
18
44
|
def initialize
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
|
|
23
|
-
@shared_secret = ENV['JWT_SERVICE_SECRET'] || 'development_secret'
|
|
45
|
+
@shared_secret = ENV.fetch("JWT_SERVICE_SECRET", nil)
|
|
46
|
+
@algorithm = "HS256"
|
|
47
|
+
@default_expiry_seconds = 300
|
|
48
|
+
@issuer = nil
|
|
24
49
|
@service_urls = {}
|
|
50
|
+
@open_timeout = 2
|
|
51
|
+
@read_timeout = 5
|
|
52
|
+
@write_timeout = 5
|
|
53
|
+
@token_reuse_seconds = 0
|
|
54
|
+
@retry_options = nil
|
|
25
55
|
end
|
|
26
56
|
|
|
27
57
|
# Retrieves the base URL for a given target service.
|
|
28
|
-
# The HttpClient relies on this method to determine where to send the request.
|
|
29
58
|
#
|
|
30
|
-
# @param target_service [Symbol, String]
|
|
31
|
-
# @return [String]
|
|
32
|
-
# @raise [
|
|
59
|
+
# @param target_service [Symbol, String]
|
|
60
|
+
# @return [String]
|
|
61
|
+
# @raise [UnknownServiceError] if the service is not configured.
|
|
33
62
|
def base_url_for(target_service)
|
|
34
|
-
|
|
35
|
-
raise
|
|
36
|
-
|
|
63
|
+
key = target_service.to_s
|
|
64
|
+
raise UnknownServiceError, "target_service must not be blank" if key.empty?
|
|
65
|
+
|
|
66
|
+
service_urls[key.to_sym] or
|
|
67
|
+
raise UnknownServiceError,
|
|
68
|
+
"Base URL for service '#{key}' is not configured in JwtAuthClient.configuration.service_urls."
|
|
37
69
|
end
|
|
38
|
-
end
|
|
39
70
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@
|
|
71
|
+
# Validates the configuration, raising on the first problem found.
|
|
72
|
+
#
|
|
73
|
+
# @return [self]
|
|
74
|
+
# @raise [ConfigurationError]
|
|
75
|
+
def validate!
|
|
76
|
+
validate_algorithm!
|
|
77
|
+
validate_shared_secret!
|
|
78
|
+
validate_issuer!
|
|
79
|
+
validate_expiry!
|
|
80
|
+
validate_service_urls!
|
|
81
|
+
validate_http_options!
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate_algorithm!
|
|
88
|
+
return if SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
89
|
+
|
|
90
|
+
raise ConfigurationError,
|
|
91
|
+
"algorithm must be one of #{SUPPORTED_ALGORITHMS.join(', ')} (got #{algorithm.inspect})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_shared_secret!
|
|
95
|
+
unless shared_secret.is_a?(String) && !shared_secret.empty?
|
|
96
|
+
raise ConfigurationError,
|
|
97
|
+
"shared_secret is required (set JwtAuthClient.configuration.shared_secret or JWT_SERVICE_SECRET)"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
min = MIN_SECRET_BYTES.fetch(algorithm)
|
|
101
|
+
return if shared_secret.bytesize >= min
|
|
102
|
+
|
|
103
|
+
raise ConfigurationError,
|
|
104
|
+
"shared_secret must be at least #{min} bytes for #{algorithm} (got #{shared_secret.bytesize})"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_issuer!
|
|
108
|
+
return if issuer.is_a?(String) && !issuer.empty?
|
|
109
|
+
|
|
110
|
+
raise ConfigurationError, "issuer is required"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_expiry!
|
|
114
|
+
return if default_expiry_seconds.is_a?(Integer) && default_expiry_seconds.positive?
|
|
115
|
+
|
|
116
|
+
raise ConfigurationError, "default_expiry_seconds must be a positive Integer"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_service_urls!
|
|
120
|
+
return if service_urls.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
raise ConfigurationError, "service_urls must be a Hash of service name => base URL"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_http_options!
|
|
126
|
+
{ open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout }.each do |name, value|
|
|
127
|
+
next if value.is_a?(Numeric) && value.positive?
|
|
128
|
+
|
|
129
|
+
raise ConfigurationError, "#{name} must be a positive number of seconds"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
unless token_reuse_seconds.is_a?(Integer) && token_reuse_seconds >= 0
|
|
133
|
+
raise ConfigurationError, "token_reuse_seconds must be a non-negative Integer"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
return if retry_options.nil? || retry_options.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
raise ConfigurationError, "retry_options must be nil or a Hash"
|
|
139
|
+
end
|
|
43
140
|
end
|
|
44
141
|
|
|
45
|
-
|
|
46
|
-
|
|
142
|
+
class << self
|
|
143
|
+
# @return [Configuration] the global configuration.
|
|
144
|
+
def configuration
|
|
145
|
+
@configuration ||= Configuration.new
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Yields the global configuration and validates it afterwards, so
|
|
149
|
+
# misconfiguration surfaces at boot rather than on the first request.
|
|
150
|
+
#
|
|
151
|
+
# @yieldparam config [Configuration]
|
|
152
|
+
# @return [Configuration]
|
|
153
|
+
# @raise [ConfigurationError]
|
|
154
|
+
def configure
|
|
155
|
+
yield(configuration)
|
|
156
|
+
configuration.validate!
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Discards the global configuration. Intended for test suites.
|
|
160
|
+
def reset_configuration!
|
|
161
|
+
@configuration = nil
|
|
162
|
+
end
|
|
47
163
|
end
|
|
48
164
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthClient
|
|
4
|
+
# Base class for all errors raised by this gem.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the global configuration is missing or invalid.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a target service has no base URL configured.
|
|
11
|
+
class UnknownServiceError < ConfigurationError; end
|
|
12
|
+
|
|
13
|
+
# Raised when a token cannot be issued (wraps errors from the jwt gem).
|
|
14
|
+
class TokenError < Error; end
|
|
15
|
+
end
|