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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a68ca9dd3459f00b139bd71b8a52a5a24112fab256c0aadeebb594f4704159e8
4
- data.tar.gz: 8a9dfabd1723a404c01fa213fd2589abf8f6f5a4500a7a42f25ce90f5d1938ee
3
+ metadata.gz: c01a3a1fcd5ad0c6e46b7802a105e2c129a9705a0004358ab1c4970076b3dde7
4
+ data.tar.gz: 73b684a98f9f583c4223d118e73c094366f6a5bb08e90293a981cc80fe2b1f6e
5
5
  SHA512:
6
- metadata.gz: 3014ed4df9591c1a3115380a78c222df936ca0e81c46cb22fa1721308f582e563049eaa6fe9a602505b90daf38a792cd60f0c5ab73883ba140cf4332cedac407
7
- data.tar.gz: c59efce14020d02c89c77c7ac18fed487f618862e4150f93d63c7e6d41f9bbb9c3dc46724360f756e758d0a2455836c3811753b1640c7fca00410549c99716bd
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 minimal, robust Ruby client for internal service-to-service authentication using JSON Web Tokens (JWTs).
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
- This gem simplifies the process of creating, signing, and injecting short-lived JWTs into outgoing HTTP requests, ensuring secure communication between your internal microservices.
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
- Features
9
- --------
15
+ Requires Ruby >= 3.1. Pairs with a verifier such as `rack_jwt_verifier` on the receiving side.
10
16
 
11
- * **Token Generation:** Creates industry-standard JWTs with required claims (`iss`, `sub`, `iat`, `exp`, `jti`).
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 'jwt_auth_client', '~> 0.1.0'
20
+ gem "jwt_auth_client", "~> 0.2"
21
+ # optional, only if you enable retries:
22
+ # gem "faraday-retry"
27
23
  ```
28
24
 
29
- And then execute:
30
-
31
- ```bash
32
- $ bundle install
33
- ```
25
+ ## Configuration
34
26
 
35
- Configuration
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
- You must configure the client once in your application's initialization file (e.g., `config/initializers/jwt_auth_client.rb` in a Rails app).
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
- Setting | Type | Description |
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
- ```ruby
49
- # config/initializers/jwt_auth_client.rb
40
+ config.algorithm = "HS256" # HS256 | HS384 | HS512 (default HS256)
41
+ config.default_expiry_seconds = 300 # token lifetime (default 300)
50
42
 
51
- JwtAuthClient.configure do |config|
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: 'http://billing-service.internal',
60
- user_data_api: 'http://user-service.internal'
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
- Usage
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
- ### 1\. High-Level Service Client (Recommended)
74
+ ## Usage
69
75
 
70
- Use the built-in or custom client wrappers for clean dependency management. These clients automatically use the configured `target_service`.
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
- # The BillingClient is a specialized wrapper around HttpClient
74
- # it defaults target_service to :billing_api
75
- client = JwtAuthClient::BillingClient.call(
76
- user_id: 'user-id-456',
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
- # client is a Faraday connection object
81
- response = client.get('/v1/invoices/latest')
82
- if response.success?
83
- puts "Invoices: #{response.body}"
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
- ### 2\. General HTTP Client (Advanced)
94
+ 4xx/5xx responses raise `Faraday::ClientError` / `Faraday::ServerError` with the parsed body available
95
+ as `error.response[:body]`.
88
96
 
89
- If you need dynamic control over the target service, you can use the base `HttpClient`.
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
- client = JwtAuthClient::HttpClient.call(
93
- user_id: 'service-account-etl',
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
- ### 3\. Token Generation Only
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
- If you only need the raw JWT string for non-HTTP purposes (e.g., message queues), use the `TokenIssuer`.
126
+ For non-HTTP transports (message queues, redirects, ...):
107
127
 
108
128
  ```ruby
109
129
  token = JwtAuthClient::TokenIssuer.call(
110
- user_id: 'system-job-id',
111
- target_service: 'data_pipeline',
112
- scopes: ['process:orders']
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
- # token will be the signed JWT string
115
- # puts token
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
- # The cryptographic key known to all internal services
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 used for signing (e.g., 'HS256')
17
+
18
+ # The signing algorithm; one of SUPPORTED_ALGORITHMS.
7
19
  attr_accessor :algorithm
8
-
9
- # Default token validity period in seconds (e.g., 300 seconds = 5 minutes)
20
+
21
+ # Token validity period in seconds.
10
22
  attr_accessor :default_expiry_seconds
11
-
12
- # The issuer of the token (conventionally the main application ID)
23
+
24
+ # The `iss` claim identifying the issuing application. Required.
13
25
  attr_accessor :issuer
14
26
 
15
- # The mapping of target service names (Symbol) to their base URLs (String)
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
- @algorithm = 'HS256'
20
- @default_expiry_seconds = 300
21
- @issuer = 'main_sso_app'
22
- # Added a default value for local testing if ENV variable is missing
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] The name of the service (e.g., :billing_api).
31
- # @return [String] The base URL.
32
- # @raise [ArgumentError] If the service is not configured.
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
- url = service_urls[target_service.to_sym]
35
- raise ArgumentError, "Base URL for service '#{target_service}' is not configured in JwtAuthClient.service_urls." unless url
36
- url
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
- # Class method to expose the configuration object and the configuration block
41
- def self.configuration
42
- @configuration ||= Configuration.new
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
- def self.configure
46
- yield(configuration)
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