omniauth_oidc 1.0.1 → 1.1.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: cce0adf88c627aec578c583501b037291603060f20738e6556aa4cdb99113012
4
- data.tar.gz: 4c1342ba1adf61d390e2b52dd41f82bc6a949d32f872427a6451f6b97de55204
3
+ metadata.gz: e94e09a2103532e642e008b239bf772fab6a609e9a9fcde6b943687b89ee25d9
4
+ data.tar.gz: e22f3910b293c1e4fcf9ea3746a27bf8258764b697acd2dd3fe7bce8425cb16c
5
5
  SHA512:
6
- metadata.gz: 016d2b5695b3423cb2e91f5319b0dbf616ebc0dde2ea057a38e72eeb52a51191b62ec3d2b8d65394291fcb2a4e2d232a321b81e0fb8a9c0f1e59ffcce09509c6
7
- data.tar.gz: 3a6bd0338037be812a0547967e8e292b795ed4c4ad5a846f2b1a17a54e54703aced331a2a45280f28579da5c5a0139e098e300ddbaa3e822fdd2cc235eba9796
6
+ metadata.gz: 0a26fcaae36be1ba4f3f5b0f69e97e5528e0dd041fe29add4bbcede7b84ddf8377222c9a3dc7c8af6da53218a7acbb1e0183e816cc848b974247fc82e3c2e4dc
7
+ data.tar.gz: 3c1cbe6e6ce3c57b028c59346614719424693b894d018eccb8708d6e2ec621ef63870571a40b96cf487e26da1fb1c3afc3f5578329155e6832fdd4518fe79613
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  ## [Released]
2
2
 
3
+ ## [1.1.0] - 2026-10-14
4
+
5
+ ### Security
6
+ - **ID token algorithm no longer taken from the token header**: Previously, with `client_signing_alg` unset (the default), the gem verified an ID token with whatever algorithm its own header named. An unsigned `alg: none` token was accepted, which allowed forging a login for any `sub` in the `response_type: "id_token"` flow. Accepted algorithms now come from `client_signing_alg`, otherwise the discovery document's `id_token_signing_alg_values_supported`, otherwise `RS256`. `none` is always rejected. Raises `OmniauthOidc::InvalidAlgorithmError`.
7
+ - **Empty HMAC secret refused**: A `jwt_secret_base64` that decodes to an empty string is rejected with `OmniauthOidc::ConfigurationError` instead of being used as the HS256/384/512 key (CVE-2026-45363).
8
+ - **jwt dependency**: Now requires `jwt >= 2.10.3` and excludes 3.0.0 to 3.1.2, the releases affected by CVE-2026-45363.
9
+ - **Nonce read from session only**: The expected nonce is no longer taken from the callback's request params, which the caller controls. It now comes only from the value stored in the session during the request phase. This also removes a call to ActiveSupport's `presence`, which raised `NoMethodError` in non-Rails apps.
10
+ - **Nonce required when one was sent**: With `send_nonce: true` (the default), a callback whose session holds no nonce now fails with `OmniauthOidc::InvalidNonceError` instead of skipping the check. The `response_type: "id_token"` flow always sends and requires a nonce, as OIDC Core 3.2.2.1 requires. The nonce is consumed before any other claim check, so it stays single use even when verification fails, and its value is no longer written to error messages.
11
+ - **PKCE verifier read from session only**: The `code_verifier` is no longer taken from callback request params. With PKCE on, a callback whose session holds no verifier fails with `OmniauthOidc::TokenError`.
12
+ - **Issuer check cannot be skipped**: A discovery document with a missing or `null` `issuer` previously disabled the `iss` check (or crashed with `NoMethodError`). Discovery documents without `issuer` or `authorization_endpoint` are now rejected with `OmniauthOidc::ConfigurationError`.
13
+ - **`exp` claim required**: ID tokens without an `exp` claim, or with a non-numeric one, are now rejected with `OmniauthOidc::TokenVerificationError`, as OIDC Core 3.1.3.7 requires.
14
+ - **UserInfo `sub` must match the ID token**: A UserInfo response whose `sub` differs from the ID token's is ignored, and the login uses the ID token claims only (OIDC Core 5.3.4).
15
+ - **Login without a subject fails**: A callback that cannot determine a `sub` now fails with `OmniauthOidc::TokenVerificationError` instead of completing with a `nil` uid.
16
+ - **Safer HTTP redirects**: Redirects from HTTPS to HTTP are refused, and the `Authorization` header is dropped when a redirect leaves the original host, so a UserInfo access token cannot leak to another host or over cleartext.
17
+
18
+ ### Changed
19
+ - **PKCE is enabled by default** (`pkce: true`). Authorization servers that do not support PKCE ignore the extra parameters (RFC 6749 3.1). Set `pkce: false` to opt out.
20
+ - **Discovery document cached across requests**: OmniAuth copies the strategy for every request, so the discovery document was fetched on every request and callback. It is now cached per process for `discovery_cache_ttl` seconds (default `3600`, `0` disables). Failed fetches are not cached.
21
+ - **Concurrent cache access**: The JWKS cache no longer holds a global lock during network fetches. A slow JWKS endpoint for one provider no longer blocks cache hits for every other provider, and concurrent misses for the same URI trigger a single fetch.
22
+ - **HTTP timeouts**: Requests to the provider now time out after 5 seconds to connect and 10 seconds to read, instead of the 60 second `Net::HTTP` defaults.
23
+ - **Fewer signature checks**: The ID token is verified once per callback and the result is reused, instead of being decoded and verified up to three times.
24
+ - **Configuration and JWKS errors no longer return 500**: `OmniauthOidc::ConfigurationError` and `OmniauthOidc::JwksError` raised during the request or callback phase are passed to OmniAuth's failure handler with the `configuration_error` or `jwks_error` key.
25
+
26
+ ### Added
27
+ - **`clock_skew_leeway` option** (default `60` seconds): Tolerates clock drift between the app and the provider when checking the ID token `exp` and `nbf` claims. Set to `0` for the previous strict behavior. Values other than a non-negative Integer raise `OmniauthOidc::ConfigurationError`.
28
+ - **`discovery_cache_ttl` option** (default `3600` seconds).
29
+
30
+ ### Fixed
31
+ - **`jwks_cache_ttl` was ignored**: The option is now applied to the JWKS cache.
32
+ - **Signing key rotation**: An ID token whose `kid` is not in the cached JWKS now triggers a JWKS refresh, rate limited to once per minute per JWKS URI. Previously the refresh path never ran, and logins failed until the cache expired.
33
+ - **Missing `code` or `id_token` in the callback**: The failure response is now returned immediately, instead of being discarded while the callback continued to the token endpoint.
34
+ - **Discovery documents without optional fields**: A missing `userinfo_endpoint`, `end_session_endpoint` or `scopes_supported` no longer raises `NoMethodError`.
35
+
36
+ ### Deprecated
37
+ - **`uid_field` option**: It has never had an effect. The uid has always been the `sub` claim, the only identifier OIDC guarantees to be stable and unique per issuer, and that does not change. Setting `uid_field` to anything other than `"sub"` now logs a deprecation warning. The option will be removed in 2.0.0.
38
+
39
+ ### Upgrade notes
40
+ - Logins that are in progress during the upgrade (request phase on 1.0.x, callback on 1.1.0) fail once, because the session holds no PKCE verifier. Users can simply sign in again.
41
+ - Apps that set `require_state: false` to accept IdP-initiated logins also need `send_nonce: false`, since no nonce is stored for a login the app did not start.
42
+ - If a provider signs ID tokens with an algorithm it does not advertise in its discovery document, set `client_signing_alg` explicitly.
43
+
3
44
  ## [1.0.1] - 2026-03-23
4
45
 
5
46
  - Update jwt dependency
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  OmniAuth strategy for OpenID Connect (OIDC) authentication. Supports multiple OIDC providers, PKCE, JWKS key rotation, and both `code` and `id_token` response types.
4
4
 
5
- Minimal dependencies: only `omniauth` and `jwt` gems required. All HTTP requests use Ruby's built-in `Net::HTTP` — no Faraday, HTTParty, or other external HTTP clients.
5
+ Minimal dependencies: only `omniauth` and `jwt` gems required. All HTTP requests use Ruby's built-in `Net::HTTP`, with no Faraday, HTTParty, or other external HTTP clients.
6
6
 
7
7
  Developed with reference to [omniauth-openid-connect](https://github.com/jjbohn/omniauth-openid-connect) and [omniauth_openid_connect](https://github.dev/omniauth/omniauth_openid_connect).
8
8
 
@@ -50,7 +50,6 @@ Devise.setup do |config|
50
50
  name: :simple_provider,
51
51
  scope: %w[openid email profile address],
52
52
  response_type: :code,
53
- uid_field: "preferred_username",
54
53
  client_options: {
55
54
  identifier: '23575f4602bebbd9a17dbc38d85bd1a77',
56
55
  secret: ENV['SIMPLE_PROVIDER_CLIENT_SECRET'],
@@ -73,10 +72,11 @@ Rails.application.config.middleware.use OmniAuth::Builder do
73
72
  require_state: true,
74
73
  response_mode: :query,
75
74
  prompt: :login,
76
- send_nonce: false,
77
- uid_field: "sub",
78
- pkce: false,
75
+ send_nonce: true, # always on for response_type 'id_token'
76
+ pkce: true, # default since 1.1.0
79
77
  jwks_cache_ttl: 3600, # JWKS cache TTL in seconds (default: 3600)
78
+ discovery_cache_ttl: 3600, # Discovery document cache TTL in seconds (default: 3600)
79
+ clock_skew_leeway: 60, # Allowed clock drift in seconds for exp/nbf (default: 60)
80
80
  client_options: {
81
81
  identifier: '23575f4602bebbd9a17dbc38d85bd1a77',
82
82
  secret: ENV['COMPLEX_PROVIDER_CLIENT_SECRET'],
@@ -271,13 +271,13 @@ OmniauthOidc::Logging.logger = Rails.logger
271
271
 
272
272
  If ActiveSupport::Notifications is available (e.g. in Rails), the gem publishes instrumentation events:
273
273
 
274
- - `config.fetch.omniauth_oidc` — OIDC discovery document fetch
275
- - `token.exchange.omniauth_oidc` — authorization code to token exchange
276
- - `userinfo.fetch.omniauth_oidc` — userinfo endpoint call
277
- - `jwks.fetch.omniauth_oidc` — JWKS fetch
278
- - `request_phase.start.omniauth_oidc` — authorization redirect
279
- - `callback_phase.start.omniauth_oidc` — callback processing
280
- - `id_token.verify.omniauth_oidc` — ID token verification
274
+ - `config.fetch.omniauth_oidc`: OIDC discovery document fetch
275
+ - `token.exchange.omniauth_oidc`: authorization code to token exchange
276
+ - `userinfo.fetch.omniauth_oidc`: userinfo endpoint call
277
+ - `jwks.fetch.omniauth_oidc`: JWKS fetch
278
+ - `request_phase.start.omniauth_oidc`: authorization redirect
279
+ - `callback_phase.start.omniauth_oidc`: callback processing
280
+ - `id_token.verify.omniauth_oidc`: ID token verification
281
281
 
282
282
  ### Error Handling
283
283
 
@@ -286,19 +286,43 @@ The gem raises specific error classes that you can rescue in your callback handl
286
286
  | Error Class | When Raised |
287
287
  |---|---|
288
288
  | `OmniauthOidc::MissingConfigurationError` | Required options (`identifier`, `secret`, `config_endpoint`) are missing |
289
- | `OmniauthOidc::ConfigurationError` | OIDC discovery endpoint fetch fails |
290
- | `OmniauthOidc::TokenError` | Token exchange fails |
291
- | `OmniauthOidc::TokenVerificationError` | JWT format is invalid |
292
- | `OmniauthOidc::TokenExpiredError` | ID token `exp` claim is in the past |
293
- | `OmniauthOidc::InvalidAlgorithmError` | JWT algorithm does not match `client_signing_alg` |
289
+ | `OmniauthOidc::ConfigurationError` | OIDC discovery endpoint fetch fails, the discovery document has no `issuer` or `authorization_endpoint`, or an option is invalid (negative `clock_skew_leeway`, empty HMAC secret) |
290
+ | `OmniauthOidc::TokenError` | Token exchange fails, or PKCE is enabled and no code verifier is stored in the session |
291
+ | `OmniauthOidc::TokenVerificationError` | JWT format is invalid, the ID token has no numeric `exp` claim, or no subject (`sub`) could be determined |
292
+ | `OmniauthOidc::TokenExpiredError` | ID token `exp` claim is in the past, beyond `clock_skew_leeway` |
293
+ | `OmniauthOidc::InvalidAlgorithmError` | JWT algorithm is not accepted (see [Security Defaults](#security-defaults)) |
294
294
  | `OmniauthOidc::InvalidSignatureError` | JWT signature verification fails |
295
295
  | `OmniauthOidc::InvalidIssuerError` | ID token `iss` does not match expected issuer |
296
296
  | `OmniauthOidc::InvalidAudienceError` | ID token `aud` does not match client identifier |
297
- | `OmniauthOidc::InvalidNonceError` | ID token `nonce` does not match stored nonce |
297
+ | `OmniauthOidc::InvalidNonceError` | ID token `nonce` does not match the stored nonce, or no nonce is stored when one is required |
298
298
  | `OmniauthOidc::JwksFetchError` | JWKS endpoint fetch fails |
299
299
 
300
300
  All error classes inherit from `OmniauthOidc::Error < RuntimeError`.
301
301
 
302
+ During the request and callback phases these errors do not propagate as exceptions. They are passed to OmniAuth's
303
+ failure handler, which by default redirects to `/auth/failure?message=<key>` with one of these keys: `csrf_detected`,
304
+ `missing_code`, `missing_id_token`, `token_error`, `http_error`, `timeout`, `failed_to_connect`, `configuration_error`
305
+ or `jwks_error`. The exception itself is available in `request.env['omniauth.error']`.
306
+
307
+ ### Security Defaults
308
+
309
+ The gem verifies every ID token as required by [OIDC Core 3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation):
310
+
311
+ - **Signature and algorithm**: the accepted algorithms come from configuration, never from the token header. They are
312
+ `client_signing_alg` when set, otherwise the provider's `id_token_signing_alg_values_supported`, otherwise `RS256`.
313
+ Unsigned (`alg: none`) tokens are always rejected, and an empty HMAC secret is refused.
314
+ - **Claims**: `iss` must equal the discovery `issuer`, `aud` must contain the client `identifier`, `exp` is required,
315
+ and `exp`/`nbf` allow `clock_skew_leeway` seconds of drift.
316
+ - **Nonce**: generated per login and stored in the session. It is required when `send_nonce` is on (the default) and
317
+ always for `response_type: 'id_token'`. It is never read from request parameters.
318
+ - **PKCE**: on by default. The code verifier is read only from the session.
319
+ - **UserInfo**: the response is used only when its `sub` matches the ID token `sub` (OIDC Core 5.3.4).
320
+ - **HTTP**: requests time out after 5s (connect) and 10s (read). Redirects never downgrade from HTTPS to HTTP, and the
321
+ `Authorization` header is dropped when a redirect leaves the original host.
322
+ - **Caching**: the discovery document and JWKS are cached per process. An ID token with an unknown `kid` triggers at
323
+ most one JWKS refresh per minute, so signing key rotation is picked up without letting forged tokens flood the
324
+ provider.
325
+
302
326
  ### Advanced Configuration
303
327
 
304
328
  You can customize the OIDC strategy further by adding additional configuration options:
@@ -308,7 +332,7 @@ You can customize the OIDC strategy further by adding additional configuration o
308
332
  | name | Arbitrary string to identify OIDC provider and segregate it from other OIDC providers | no | `"oidc"` | `:simple_provider` |
309
333
  | issuer | Root url for the OIDC authorization server | no | retrieved from config_endpoint | `"https://simpleprovider.com"` |
310
334
  | fetch_user_info | Fetches user information from userinfo endpoint using the access token. If false, user info is extracted from ID token claims | no | `true` | `fetch_user_info: false` |
311
- | client_signing_alg | Expected JWT signing algorithm. If set, tokens signed with a different algorithm are rejected | no | `nil` (any algorithm accepted) | `"RS256"`, `"HS256"` |
335
+ | client_signing_alg | Expected JWT signing algorithm. Tokens signed with any other algorithm are rejected | no | `nil` (provider-advertised, else RS256) | `"RS256"`, `"HS256"` |
312
336
  | scope | OIDC scopes to request. Accepts Array or String; always sent as a space-delimited string | `openid` is required | all scopes offered by OIDC provider | `%w[openid profile email]` |
313
337
  | response_type | OAuth2 response type expected from OIDC provider during authorization | no | `"code"` | `"code"` or `"id_token"` |
314
338
  | state | Value to be used for the OAuth2 state parameter on the authorization request. Can be a proc that generates a string | no | Random 16 character string | `Proc.new { SecureRandom.hex(32) }` |
@@ -316,21 +340,23 @@ You can customize the OIDC strategy further by adding additional configuration o
316
340
  | response_mode | The response mode per [OIDC spec](https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html) | no | `nil` | `:query`, `:fragment`, `:form_post` or `:web_message` |
317
341
  | display | Specifies how OIDC authorization server should display the authentication and consent UI pages to the end user | no | `nil` | `:page`, `:popup`, `:touch` or `:wap` |
318
342
  | prompt | Specifies whether the OIDC authorization server prompts the end user for reauthentication and consent | no | `nil` | `:none`, `:login`, `:consent` or `:select_account` |
319
- | send_nonce | Include a nonce in the authorization request and verify it in the ID token | no | `true` | `true` or `false` |
343
+ | send_nonce | Include a nonce in the authorization request and require it in the ID token. Always on for `response_type: 'id_token'` | no | `true` | `true` or `false` |
320
344
  | send_scope_to_token_endpoint | Should the scope parameter be sent to the authorization token endpoint | no | `true` | `true` or `false` |
321
345
  | post_logout_redirect_uri | Logout redirect uri to use per the [session management draft](https://openid.net/specs/openid-connect-session-1_0.html) | no | `nil` | `"https://your_app.com/logout/callback"` |
322
- | uid_field | Field of the user info response to be used as a unique ID | no | `"sub"` | `"sub"` or `"preferred_username"` |
346
+ | uid_field | **Deprecated**, removed in 2.0.0. Has never had an effect: the uid is always the `sub` claim | no | `"sub"` | Remove from configuration |
323
347
  | extra_authorize_params | Hash of extra fixed parameters that will be merged to the authorization request | no | `{}` | `{"tenant" => "common"}` |
324
348
  | allow_authorize_params | List of allowed dynamic parameters that will be merged to the authorization request | no | `[]` | `[:screen_name]` |
325
- | pkce | Enable [PKCE flow](https://oauth.net/2/pkce/) | no | `false` | `true` or `false` |
349
+ | pkce | Enable [PKCE flow](https://oauth.net/2/pkce/) | no | `true` | `true` or `false` |
326
350
  | pkce_verifier | Specify custom PKCE verifier code | no | Random 128-character string | `Proc.new { SecureRandom.hex(64) }` |
327
- | pkce_options | Specify custom implementation of the PKCE code challenge/method | no | SHA256(code_challenge) in hex | Proc to customise the code challenge generation |
351
+ | pkce_options | Specify custom implementation of the PKCE code challenge/method | no | S256 (base64url SHA-256 of verifier) | Proc to customise the code challenge generation |
328
352
  | client_options | Hash of client options detailed below in a separate table | yes | see below | see below |
329
353
  | jwt_secret_base64 | Specify the base64-encoded secret used to sign the JWT token for HMAC with SHA2 (e.g. HS256) signing algorithms | no | `client_options.secret` | `"bXlzZWNyZXQ=\n"` |
330
354
  | client_jwk_signing_key | JWK or JWKS (as JSON string or Hash) for local signature verification without fetching from `jwks_uri` | no | `nil` | `'{"kty":"RSA","n":"...","e":"AQAB"}'` |
331
355
  | client_x509_signing_key | X.509 certificate (PEM format) for local signature verification | no | `nil` | PEM string |
332
356
  | logout_path | Log out is only triggered when the request path ends on this path | no | `"/logout"` | `"/sign_out"` |
333
- | jwks_cache_ttl | JWKS cache time-to-live in seconds | no | `3600` (1 hour) | `7200` |
357
+ | jwks_cache_ttl | JWKS cache time-to-live in seconds, shared by all requests in the process | no | `3600` (1 hour) | `7200` |
358
+ | discovery_cache_ttl | Discovery document cache time-to-live in seconds, shared by all requests in the process | no | `3600` (1 hour) | `0` to disable |
359
+ | clock_skew_leeway | Seconds of clock drift allowed when checking the ID token `exp` and `nbf` claims | no | `60` | `0` to disable |
334
360
  | hd | Google-specific: hosted domain parameter | no | `nil` | `"example.com"` |
335
361
  | max_age | Maximum authentication age in seconds | no | `nil` | `3600` |
336
362
  | acr_values | Authentication Class Reference (ACR) values per [RFC9470](https://www.rfc-editor.org/rfc/rfc9470.html) | no | `nil` | `"c1 c2"` |
@@ -3,12 +3,30 @@
3
3
  module OmniauthOidc
4
4
  # Fetches and parses OpenID Connect configuration from .well-known endpoint
5
5
  class ConfigFetcher
6
+ DEFAULT_CACHE_TTL = 3600 # 1 hour in seconds
7
+
8
+ # Discovery fields the gem reads. Missing ones return nil instead of raising NoMethodError.
9
+ STANDARD_FIELDS = %i[
10
+ issuer authorization_endpoint token_endpoint userinfo_endpoint jwks_uri
11
+ end_session_endpoint scopes_supported id_token_signing_alg_values_supported
12
+ ].freeze
13
+
14
+ # Fields without which the gem cannot verify tokens or start a login
15
+ REQUIRED_FIELDS = %i[issuer authorization_endpoint].freeze
16
+
17
+ CACHE = TtlCache.new(ttl: DEFAULT_CACHE_TTL, name: "Discovery")
18
+ private_constant :CACHE
19
+
6
20
  # Config object with dynamic attribute access for OIDC discovery fields
7
21
  class Config
8
22
  def initialize(attributes = {})
9
23
  @attributes = attributes
10
24
  end
11
25
 
26
+ STANDARD_FIELDS.each do |field|
27
+ define_method(field) { self[field] }
28
+ end
29
+
12
30
  def [](key)
13
31
  @attributes[key.to_sym] || @attributes[key.to_s]
14
32
  end
@@ -48,8 +66,7 @@ module OmniauthOidc
48
66
  retries = 0
49
67
  begin
50
68
  response = OmniauthOidc::HttpClient.get(endpoint_url)
51
- symbolized_config = deep_symbolize_keys(response)
52
- Config.new(symbolized_config)
69
+ config = Config.new(deep_symbolize_keys(response))
53
70
  rescue OmniauthOidc::HttpClient::HttpError => e
54
71
  retries += 1
55
72
  retry if retries < max_retries
@@ -57,10 +74,31 @@ module OmniauthOidc
57
74
  rescue StandardError => e
58
75
  raise OmniauthOidc::ConfigurationError, "Failed to fetch OIDC configuration: #{e.message}"
59
76
  end
77
+
78
+ validate!(config, endpoint_url)
79
+ config
80
+ end
81
+
82
+ # Fetches the discovery document, reusing it across requests for ttl seconds.
83
+ # Failed fetches are not cached. A ttl of 0 disables caching.
84
+ def fetch_cached(endpoint_url, ttl: DEFAULT_CACHE_TTL)
85
+ CACHE.fetch(endpoint_url, ttl: ttl) { fetch(endpoint_url) }
86
+ end
87
+
88
+ def clear_cache!
89
+ CACHE.clear!
60
90
  end
61
91
 
62
92
  private
63
93
 
94
+ def validate!(config, endpoint_url)
95
+ missing = REQUIRED_FIELDS.reject { |field| config[field].is_a?(String) && !config[field].empty? }
96
+ return if missing.empty?
97
+
98
+ raise OmniauthOidc::ConfigurationError,
99
+ "OIDC configuration from #{endpoint_url} is missing required fields: #{missing.join(", ")}"
100
+ end
101
+
64
102
  # Recursively converts keys of a hash to symbols while retaining the original string keys
65
103
  def deep_symbolize_keys(hash)
66
104
  result = {}
@@ -11,6 +11,12 @@ module OmniauthOidc
11
11
 
12
12
  MAX_REDIRECTS = 5
13
13
 
14
+ # Explicit timeouts in seconds. Net::HTTP defaults to 60s each, which would let a
15
+ # hanging provider tie up the calling thread for minutes.
16
+ OPEN_TIMEOUT = 5
17
+ READ_TIMEOUT = 10
18
+ WRITE_TIMEOUT = 10
19
+
14
20
  def self.get(url, headers: {})
15
21
  uri = URI.parse(url)
16
22
  request = Net::HTTP::Get.new(uri)
@@ -33,7 +39,8 @@ module OmniauthOidc
33
39
 
34
40
  def self.execute_request(uri, request)
35
41
  OmniauthOidc::Logging.instrument("http.request", method: request.method, uri: uri.to_s) do
36
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
42
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT,
43
+ read_timeout: READ_TIMEOUT, write_timeout: WRITE_TIMEOUT) do |http|
37
44
  http.request(request)
38
45
  end
39
46
  end
@@ -55,7 +62,8 @@ module OmniauthOidc
55
62
  redirect_uri = URI.join(url, location) unless redirect_uri.host
56
63
 
57
64
  OmniauthOidc::Logging.debug("Following redirect", from: url, to: redirect_uri.to_s)
58
- follow_redirect(redirect_uri, headers: headers, redirects_remaining: redirects_remaining - 1)
65
+ follow_redirect(redirect_uri, headers: redirect_headers(URI.parse(url), redirect_uri, headers),
66
+ redirects_remaining: redirects_remaining - 1)
59
67
  else
60
68
  raise HttpError, "HTTP request failed: #{response.code} #{response.message} for #{url}"
61
69
  end
@@ -71,6 +79,20 @@ module OmniauthOidc
71
79
  end
72
80
  private_class_method :follow_redirect
73
81
 
82
+ # Refuses HTTPS to HTTP downgrades, and drops credentials when a redirect leaves the
83
+ # original origin, so a bearer token is never sent in cleartext or to another host.
84
+ def self.redirect_headers(from, to, headers)
85
+ if from.scheme == "https" && to.scheme != "https"
86
+ raise HttpError, "Refusing to follow redirect from #{from} to non-HTTPS #{to}"
87
+ end
88
+
89
+ same_origin = [from.scheme, from.host, from.port] == [to.scheme, to.host, to.port]
90
+ return headers if same_origin
91
+
92
+ headers.reject { |key, _| key.to_s.casecmp?("Authorization") }
93
+ end
94
+ private_class_method :redirect_headers
95
+
74
96
  def self.parse_json_response(body)
75
97
  JSON.parse(body)
76
98
  rescue JSON::ParserError => e
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniauthOidc
4
- # Thread-safe JWKS cache with configurable TTL and force refresh capability
5
- class JwksCache
4
+ # Process-wide JWKS cache with configurable TTL and force refresh capability
5
+ class JwksCache < TtlCache
6
6
  DEFAULT_TTL = 3600 # 1 hour in seconds
7
-
8
- CacheEntry = Struct.new(:keys, :fetched_at, keyword_init: true)
7
+ INSTANCE_MUTEX = Mutex.new
8
+ private_constant :INSTANCE_MUTEX
9
9
 
10
10
  class << self
11
11
  def instance
12
- @instance ||= new
12
+ @instance || INSTANCE_MUTEX.synchronize { @instance ||= new }
13
13
  end
14
14
 
15
15
  # Clear all cached keys (useful for testing)
@@ -24,66 +24,7 @@ module OmniauthOidc
24
24
  end
25
25
 
26
26
  def initialize(ttl: DEFAULT_TTL)
27
- @ttl = ttl
28
- @cache = {}
29
- @mutex = Mutex.new
30
- end
31
-
32
- # Fetch JWKS, using cache if valid
33
- def fetch(jwks_uri, force_refresh: false, &block)
34
- @mutex.synchronize do
35
- entry = @cache[jwks_uri]
36
-
37
- if !force_refresh && entry && !expired?(entry)
38
- Logging.debug("JWKS cache hit", jwks_uri: jwks_uri)
39
- return entry.keys
40
- end
41
-
42
- Logging.info("JWKS cache miss, fetching", jwks_uri: jwks_uri, force_refresh: force_refresh)
43
- keys = block.call
44
- @cache[jwks_uri] = CacheEntry.new(keys: keys, fetched_at: Time.now)
45
- keys
46
- end
47
- end
48
-
49
- # Check if a specific URI's cache is valid
50
- def valid?(jwks_uri)
51
- @mutex.synchronize do
52
- entry = @cache[jwks_uri]
53
- entry && !expired?(entry)
54
- end
55
- end
56
-
57
- # Invalidate a specific URI's cache
58
- def invalidate(jwks_uri)
59
- @mutex.synchronize do
60
- @cache.delete(jwks_uri)
61
- Logging.debug("JWKS cache invalidated", jwks_uri: jwks_uri)
62
- end
63
- end
64
-
65
- # Clear entire cache
66
- def clear!
67
- @mutex.synchronize do
68
- @cache.clear
69
- Logging.debug("JWKS cache cleared")
70
- end
71
- end
72
-
73
- # Get current TTL
74
- attr_reader :ttl
75
-
76
- # Update TTL (affects future expiration checks)
77
- def ttl=(value)
78
- @mutex.synchronize do
79
- @ttl = value
80
- end
81
- end
82
-
83
- private
84
-
85
- def expired?(entry)
86
- Time.now - entry.fetched_at > @ttl
27
+ super(ttl: ttl, name: "JWKS")
87
28
  end
88
29
  end
89
30
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniauthOidc
4
+ # Thread-safe cache with a per-entry TTL.
5
+ #
6
+ # Reads take a short global lock only. Fetches run under a per-key lock, so a
7
+ # slow or hanging upstream for one key never blocks cache hits for other keys,
8
+ # and concurrent misses for the same key trigger a single fetch.
9
+ class TtlCache
10
+ Entry = Struct.new(:value, :fetched_at, keyword_init: true)
11
+
12
+ attr_reader :ttl
13
+
14
+ def initialize(ttl:, name: "TTL")
15
+ @ttl = ttl
16
+ @name = name
17
+ @entries = {}
18
+ @locks = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Returns the cached value for key, calling the block to fetch it on a miss.
23
+ #
24
+ # ttl: seconds an entry stays valid (defaults to the cache TTL)
25
+ # force_refresh: fetch even if the entry is still valid
26
+ # min_age: with force_refresh, reuse an entry fetched less than this many
27
+ # seconds ago. Rate limits refreshes that callers can trigger.
28
+ def fetch(key, force_refresh: false, ttl: nil, min_age: 0, &block)
29
+ ttl ||= @ttl
30
+ entry = read(key)
31
+ return hit(key, entry) if !force_refresh && younger_than?(entry, ttl)
32
+
33
+ lock_for(key).synchronize do
34
+ # Another thread may have fetched while this one waited for the lock
35
+ entry = read(key)
36
+ return hit(key, entry) if younger_than?(entry, force_refresh ? min_age : ttl)
37
+
38
+ Logging.info("#{@name} cache miss, fetching", key: key, force_refresh: force_refresh)
39
+ value = block.call
40
+ @mutex.synchronize { @entries[key] = Entry.new(value: value, fetched_at: Time.now) }
41
+ value
42
+ end
43
+ end
44
+
45
+ def valid?(key)
46
+ younger_than?(read(key), @ttl)
47
+ end
48
+
49
+ def invalidate(key)
50
+ @mutex.synchronize { @entries.delete(key) }
51
+ Logging.debug("#{@name} cache invalidated", key: key)
52
+ end
53
+
54
+ def clear!
55
+ @mutex.synchronize { @entries.clear }
56
+ Logging.debug("#{@name} cache cleared")
57
+ end
58
+
59
+ def ttl=(value)
60
+ @mutex.synchronize { @ttl = value }
61
+ end
62
+
63
+ private
64
+
65
+ def read(key)
66
+ @mutex.synchronize { @entries[key] }
67
+ end
68
+
69
+ def hit(key, entry)
70
+ Logging.debug("#{@name} cache hit", key: key)
71
+ entry.value
72
+ end
73
+
74
+ def younger_than?(entry, seconds)
75
+ !entry.nil? && Time.now - entry.fetched_at < seconds
76
+ end
77
+
78
+ def lock_for(key)
79
+ @mutex.synchronize { @locks[key] ||= Mutex.new }
80
+ end
81
+ end
82
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniauthOidc
4
- VERSION = "1.0.1"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -9,11 +9,10 @@ module OmniAuth
9
9
  OmniauthOidc::Logging.instrument("callback_phase.start", provider: name) do
10
10
  handle_callback_errors do
11
11
  validate_callback_params!
12
+ next missing_response_type_failure unless params.key?(configured_response_type)
12
13
 
13
14
  options.issuer = issuer if options.issuer.nil? || options.issuer.empty?
14
15
 
15
- verify_id_token!(params["id_token"]) if configured_response_type == "id_token"
16
-
17
16
  client.redirect_uri = redirect_uri
18
17
 
19
18
  if configured_response_type == "id_token"
@@ -21,6 +20,7 @@ module OmniAuth
21
20
  else
22
21
  handle_code_response
23
22
  end
23
+ verify_subject!
24
24
 
25
25
  super
26
26
  end
@@ -34,6 +34,9 @@ module OmniAuth
34
34
  rescue CallbackError => e
35
35
  OmniauthOidc::Logging.error("Callback error", error: e.error, reason: e.error_reason)
36
36
  fail!(e.error, e)
37
+ rescue OmniauthOidc::ConfigurationError, OmniauthOidc::JwksError => e
38
+ OmniauthOidc::Logging.error("Provider setup error", error: e.class.name, message: e.message)
39
+ fail!(e.is_a?(OmniauthOidc::JwksError) ? :jwks_error : :configuration_error, e)
37
40
  rescue OmniauthOidc::TokenError => e
38
41
  OmniauthOidc::Logging.error("Token error", error: e.class.name, message: e.message)
39
42
  fail!(:token_error, e)
@@ -48,7 +51,7 @@ module OmniAuth
48
51
  fail!(:failed_to_connect, e)
49
52
  end
50
53
 
51
- def validate_callback_params! # rubocop:disable Naming/PredicateMethod
54
+ def validate_callback_params!
52
55
  error = params["error_reason"] || params["error"]
53
56
  error_description = params["error_description"] || params["error_reason"]
54
57
  invalid_state = (options.require_state && params["state"].to_s.empty?) || params["state"] != stored_state
@@ -58,25 +61,29 @@ module OmniAuth
58
61
  error_uri: params["error_uri"]
59
62
  end
60
63
  raise CallbackError, error: :csrf_detected, error_reason: "Invalid 'state' parameter" if invalid_state
64
+ end
61
65
 
62
- valid_response_type?
66
+ def missing_response_type_failure
67
+ error_attrs = RESPONSE_TYPE_EXCEPTIONS[configured_response_type]
68
+ fail!(error_attrs[:key], error_attrs[:exception_class].new(params["error"]))
63
69
  end
64
70
 
65
71
  def handle_code_response
66
72
  # Get access token via token exchange
67
73
  @access_token = fetch_access_token
68
74
 
69
- # Verify the ID token from the token response
70
- verify_id_token!(@access_token.id_token) if @access_token.id_token
75
+ # Verify the ID token from the token response once, and reuse the result below
76
+ @verified_id_token = verify_id_token!(@access_token.id_token)
71
77
 
72
78
  # Fetch and set user info
73
79
  @user_info = fetch_user_info
74
80
  end
75
81
 
76
82
  def handle_id_token_response
77
- # For id_token response type, extract user data directly from the token
78
- decoded_token = decode_id_token(params["id_token"])
79
- @user_info = OmniauthOidc::ResponseObjects::UserInfo.new(decoded_token.raw_attributes)
83
+ # For id_token response type, extract user data directly from the verified token
84
+ # to_s: a bare "?id_token" param is nil and must fail verification, not skip it
85
+ @verified_id_token = verify_id_token!(params["id_token"].to_s)
86
+ @user_info = OmniauthOidc::ResponseObjects::UserInfo.new(@verified_id_token.raw_attributes)
80
87
 
81
88
  # Create a minimal access token structure for credentials
82
89
  @access_token = OmniauthOidc::ResponseObjects::AccessToken.new(
@@ -95,10 +102,8 @@ module OmniAuth
95
102
  redirect_uri: redirect_uri
96
103
  }
97
104
 
98
- if options.pkce
99
- token_request_params[:code_verifier] =
100
- params["code_verifier"] || session.delete(session_key("pkce.verifier"))
101
- end
105
+ # The verifier comes only from the session. Request params are attacker controlled.
106
+ token_request_params[:code_verifier] = stored_pkce_verifier if options.pkce
102
107
 
103
108
  set_client_options_for_callback_phase
104
109
 
@@ -106,44 +111,49 @@ module OmniAuth
106
111
  end
107
112
  end
108
113
 
114
+ def stored_pkce_verifier
115
+ verifier = session.delete(session_key("pkce.verifier"))
116
+ return verifier unless verifier.to_s.empty?
117
+
118
+ raise OmniauthOidc::TokenError, "No PKCE code verifier stored in the session for this login"
119
+ end
120
+
109
121
  def fetch_user_info
110
122
  return minimal_user_info_from_token unless options.fetch_user_info
111
123
 
112
- OmniauthOidc::Logging.instrument("userinfo.fetch", provider: name) do
113
- # Use our custom client to fetch userinfo
114
- userinfo_data = client.userinfo!(@access_token.access_token).raw_attributes
115
-
116
- # Merge with ID token claims if available
117
- if @access_token.id_token
118
- id_token_claims = decode_id_token(@access_token.id_token).raw_attributes
119
- userinfo_data = id_token_claims.merge(userinfo_data)
120
- end
124
+ userinfo_data = OmniauthOidc::Logging.instrument("userinfo.fetch", provider: name) do
125
+ client.userinfo!(@access_token.access_token).raw_attributes
126
+ end
127
+ return OmniauthOidc::ResponseObjects::UserInfo.new(userinfo_data) unless @verified_id_token
121
128
 
122
- OmniauthOidc::ResponseObjects::UserInfo.new(userinfo_data)
129
+ id_token_claims = @verified_id_token.raw_attributes
130
+ # OIDC Core 5.3.4: UserInfo values must not be used unless its sub matches the ID token
131
+ unless userinfo_data["sub"] == id_token_claims["sub"]
132
+ OmniauthOidc::Logging.error("UserInfo sub does not match ID token sub, using ID token claims only")
133
+ return minimal_user_info_from_token
123
134
  end
135
+
136
+ OmniauthOidc::ResponseObjects::UserInfo.new(id_token_claims.merge(userinfo_data))
124
137
  rescue StandardError => e
125
138
  OmniauthOidc::Logging.warn("Failed to fetch userinfo, falling back to ID token", error: e.message)
126
139
  minimal_user_info_from_token
127
140
  end
128
141
 
129
142
  def minimal_user_info_from_token
130
- return empty_user_info unless @access_token&.id_token
131
-
132
- decoded = decode_id_token(@access_token.id_token)
133
- OmniauthOidc::ResponseObjects::UserInfo.new(decoded.raw_attributes)
134
- end
143
+ return empty_user_info unless @verified_id_token
135
144
 
136
- def empty_user_info
137
- OmniauthOidc::ResponseObjects::UserInfo.new({})
145
+ OmniauthOidc::ResponseObjects::UserInfo.new(@verified_id_token.raw_attributes)
138
146
  end
139
147
 
140
- def valid_response_type?
141
- return true if params.key?(configured_response_type)
148
+ # A login without a subject must never complete: a nil uid can match the wrong local account
149
+ def verify_subject!
150
+ return unless uid.to_s.empty?
142
151
 
143
- error_attrs = RESPONSE_TYPE_EXCEPTIONS[configured_response_type]
144
- fail!(error_attrs[:key], error_attrs[:exception_class].new(params["error"]))
152
+ raise OmniauthOidc::TokenVerificationError, "Neither the ID token nor UserInfo provided a subject (sub)"
153
+ end
145
154
 
146
- false
155
+ def empty_user_info
156
+ OmniauthOidc::ResponseObjects::UserInfo.new({})
147
157
  end
148
158
 
149
159
  def configured_response_type
@@ -7,12 +7,12 @@ module OmniAuth
7
7
  module Request
8
8
  def request_phase
9
9
  OmniauthOidc::Logging.instrument("request_phase.start", provider: name) do
10
- @identifier = client_options.identifier
11
- @secret = secret
12
-
13
10
  set_client_options_for_request_phase
14
11
  redirect authorize_uri
15
12
  end
13
+ rescue OmniauthOidc::ConfigurationError => e
14
+ OmniauthOidc::Logging.error("Configuration error", error: e.class.name, message: e.message)
15
+ fail!(:configuration_error, e)
16
16
  end
17
17
 
18
18
  def authorize_uri # rubocop:disable Metrics/AbcSize
@@ -50,7 +50,7 @@ module OmniAuth
50
50
  ui_locales: params["ui_locales"],
51
51
  claims_locales: params["claims_locales"],
52
52
  prompt: options.prompt,
53
- nonce: (new_nonce if options.send_nonce),
53
+ nonce: (new_nonce if nonce_required?),
54
54
  hd: options.hd,
55
55
  acr_values: options.acr_values
56
56
  }
@@ -75,7 +75,7 @@ module OmniAuth
75
75
  client_options.userinfo_endpoint = config.userinfo_endpoint
76
76
  client_options.jwks_uri = config.jwks_uri
77
77
 
78
- return unless config.respond_to?(:end_session_endpoint) && config.end_session_endpoint
78
+ return unless config.end_session_endpoint
79
79
 
80
80
  client_options.end_session_endpoint = config.end_session_endpoint
81
81
  end
@@ -5,6 +5,15 @@ module OmniAuth
5
5
  class Oidc
6
6
  # Token verification phase
7
7
  module Verify # rubocop:disable Metrics/ModuleLength
8
+ HMAC_ALGORITHMS = %w[HS256 HS384 HS512].freeze
9
+
10
+ # Algorithm assumed when neither client_signing_alg nor the discovery document names one
11
+ DEFAULT_SIGNING_ALGORITHM = "RS256"
12
+
13
+ # Minimum seconds between JWKS refreshes triggered by an unknown kid, so tokens with
14
+ # made-up kids cannot be used to hammer the provider's JWKS endpoint.
15
+ JWKS_REFRESH_MIN_INTERVAL = 60
16
+
8
17
  def secret
9
18
  base64_decoded_jwt_secret || client_options.secret
10
19
  end
@@ -27,19 +36,20 @@ module OmniAuth
27
36
  end
28
37
  end
29
38
 
30
- # Force refresh JWKS cache and retry verification
39
+ # Refetch the JWKS, at most once per JWKS_REFRESH_MIN_INTERVAL for each jwks_uri
31
40
  def public_key_with_refresh
32
- OmniauthOidc::Logging.info("Force refreshing JWKS cache")
33
- OmniauthOidc::JwksCache.invalidate(config.jwks_uri)
34
- @public_key = nil
35
- @fetch_key = nil
36
- public_key
41
+ return public_key if configured_public_key || config.jwks_uri.nil?
42
+
43
+ OmniauthOidc::Logging.info("Refreshing JWKS", jwks_uri: config.jwks_uri)
44
+ @public_key = fetch_key(force_refresh: true)
37
45
  end
38
46
 
39
47
  private
40
48
 
41
- def fetch_key
42
- @fetch_key ||= OmniauthOidc::JwksCache.instance.fetch(config.jwks_uri) do
49
+ def fetch_key(force_refresh: false)
50
+ OmniauthOidc::JwksCache.instance.fetch(config.jwks_uri, force_refresh: force_refresh,
51
+ ttl: options.jwks_cache_ttl.to_i,
52
+ min_age: JWKS_REFRESH_MIN_INTERVAL) do
43
53
  OmniauthOidc::Logging.instrument("jwks.fetch", jwks_uri: config.jwks_uri) do
44
54
  response = OmniauthOidc::HttpClient.get(config.jwks_uri)
45
55
  OmniauthOidc::JwkHandler.parse_jwks(response)
@@ -56,6 +66,14 @@ module OmniAuth
56
66
  Base64.decode64(options.jwt_secret_base64)
57
67
  end
58
68
 
69
+ # An empty key lets anyone forge HS* tokens (CVE-2026-45363), so never verify with one
70
+ def hmac_secret
71
+ key = secret.to_s
72
+ return key unless key.empty?
73
+
74
+ raise OmniauthOidc::ConfigurationError, "HMAC secret for ID token verification is empty"
75
+ end
76
+
59
77
  def verify_id_token!(id_token)
60
78
  return unless id_token
61
79
 
@@ -66,37 +84,67 @@ module OmniAuth
66
84
  end
67
85
  end
68
86
 
69
- def verify_claims!(decoded_token) # rubocop:disable Metrics/MethodLength
87
+ def verify_claims!(decoded_token)
70
88
  claims = decoded_token.raw_attributes
71
89
 
72
- # Verify issuer
73
- if config.issuer && claims["iss"] != config.issuer
74
- raise OmniauthOidc::InvalidIssuerError,
75
- "Issuer mismatch. Expected: #{config.issuer}, Got: #{claims["iss"]}"
76
- end
90
+ # Consume the nonce before any check can fail, so it stays single use
91
+ expected_nonce = stored_nonce
92
+
93
+ verify_issuer!(claims)
94
+ verify_audience!(claims)
95
+ verify_nonce!(claims, expected_nonce)
96
+ verify_expiration!(claims)
77
97
 
78
- # Verify audience
98
+ decoded_token
99
+ end
100
+
101
+ # The discovery document is validated to always carry an issuer, so this check
102
+ # cannot be skipped by a provider that omits it.
103
+ def verify_issuer!(claims)
104
+ return if claims["iss"] == config.issuer
105
+
106
+ raise OmniauthOidc::InvalidIssuerError,
107
+ "Issuer mismatch. Expected: #{config.issuer}, Got: #{claims["iss"]}"
108
+ end
109
+
110
+ def verify_audience!(claims)
79
111
  audience = claims["aud"]
80
112
  expected_aud = client_options.identifier
81
- unless audience_matches?(audience, expected_aud)
82
- raise OmniauthOidc::InvalidAudienceError,
83
- "Audience mismatch. Expected: #{expected_aud}, Got: #{audience}"
84
- end
113
+ return if audience_matches?(audience, expected_aud)
85
114
 
86
- # Verify nonce if present
87
- expected_nonce = params["nonce"].presence || stored_nonce
88
- if expected_nonce && claims["nonce"] != expected_nonce
89
- raise OmniauthOidc::InvalidNonceError,
90
- "Nonce mismatch. Expected: #{expected_nonce}, Got: #{claims["nonce"]}"
91
- end
115
+ raise OmniauthOidc::InvalidAudienceError,
116
+ "Audience mismatch. Expected: #{expected_aud}, Got: #{audience}"
117
+ end
92
118
 
93
- # Verify expiration
94
- if claims["exp"] && Time.at(claims["exp"].to_i) < Time.now
95
- raise OmniauthOidc::TokenExpiredError,
96
- "Token expired at #{Time.at(claims["exp"].to_i)}"
119
+ # The expected nonce comes only from the session. Never read it from request params:
120
+ # the caller controls those. Nonce values are left out of messages because they end
121
+ # up in logs.
122
+ def verify_nonce!(claims, expected_nonce)
123
+ if expected_nonce.nil?
124
+ return unless nonce_required?
125
+
126
+ raise OmniauthOidc::InvalidNonceError, "No nonce stored in the session for this login"
97
127
  end
128
+ return if claims["nonce"] == expected_nonce
98
129
 
99
- decoded_token
130
+ raise OmniauthOidc::InvalidNonceError, "ID token nonce does not match the authorization request"
131
+ end
132
+
133
+ # OIDC Core 3.2.2.1 requires a nonce for the implicit (id_token) flow, so it is
134
+ # always used there, regardless of send_nonce.
135
+ def nonce_required?
136
+ options.send_nonce || options.response_type.to_s == "id_token"
137
+ end
138
+
139
+ # exp is required by OIDC Core 3.1.3.7 and must be a NumericDate (RFC 7519 2).
140
+ # Clock skew between us and the provider is tolerated up to clock_skew_leeway.
141
+ def verify_expiration!(claims)
142
+ exp = claims["exp"]
143
+ raise OmniauthOidc::TokenVerificationError, "ID token is missing the exp claim" if exp.nil?
144
+ raise OmniauthOidc::TokenVerificationError, "ID token exp claim is not a number" unless exp.is_a?(Numeric)
145
+ return if Time.now.to_i < exp + clock_skew_leeway
146
+
147
+ raise OmniauthOidc::TokenExpiredError, "Token expired at #{Time.at(exp)}"
100
148
  end
101
149
 
102
150
  def audience_matches?(audience, expected)
@@ -106,16 +154,23 @@ module OmniAuth
106
154
  false
107
155
  end
108
156
 
157
+ def clock_skew_leeway
158
+ leeway = options.clock_skew_leeway
159
+ return leeway if leeway.is_a?(Integer) && leeway >= 0
160
+
161
+ raise OmniauthOidc::ConfigurationError,
162
+ "clock_skew_leeway must be a non-negative Integer, got #{leeway.inspect}"
163
+ end
164
+
109
165
  def decode_id_token(id_token)
110
166
  # First decode without verification to get the algorithm and kid
111
167
  _unverified_payload, unverified_header = JWT.decode(id_token, nil, false)
112
- algorithm = unverified_header["alg"]
113
- kid = unverified_header["kid"]
168
+ algorithm = unverified_header["alg"].to_s
114
169
 
115
- validate_client_algorithm!(algorithm.to_sym)
170
+ validate_algorithm!(algorithm)
116
171
 
117
172
  # Get the appropriate key/secret for verification
118
- key = keyset_for_algorithm(algorithm.to_sym, kid)
173
+ key = keyset_for_algorithm(algorithm, unverified_header["kid"])
119
174
 
120
175
  # Decode and verify
121
176
  verify_signature!(id_token, key, algorithm)
@@ -124,56 +179,55 @@ module OmniAuth
124
179
  end
125
180
 
126
181
  def keyset_for_algorithm(algorithm, kid = nil)
127
- case algorithm
128
- when :HS256, :HS384, :HS512
129
- secret
130
- else
131
- keys = public_key
132
- if keys.is_a?(Array)
133
- OmniauthOidc::JwkHandler.find_key(keys, kid)
134
- else
135
- keys
136
- end
137
- end
182
+ return hmac_secret if HMAC_ALGORITHMS.include?(algorithm)
183
+
184
+ keys = public_key
185
+ return keys unless keys.is_a?(Array)
186
+
187
+ # The provider may have rotated its signing keys since the JWKS was cached
188
+ keys = public_key_with_refresh if kid && keys.none? { |key| key.kid == kid }
189
+ OmniauthOidc::JwkHandler.find_key(keys, kid)
138
190
  end
139
191
 
140
192
  def verify_signature!(id_token, key, algorithm)
141
- # Use jwt gem to decode and verify
142
193
  payload, _header = JWT.decode(
143
194
  id_token,
144
195
  key,
145
196
  true, # verify signature
146
- {
147
- algorithm: algorithm,
148
- verify_expiration: false # We verify this manually in verify_claims!
149
- }
197
+ # exp is verified manually in verify_claims!
198
+ { algorithm: algorithm, verify_expiration: false, nbf_leeway: clock_skew_leeway }
150
199
  )
151
200
 
152
201
  # Create our custom IdToken object
153
202
  OmniauthOidc::ResponseObjects::IdToken.new(payload.merge("algorithm" => algorithm))
154
203
  rescue JWT::VerificationError => e
155
- # Try refreshing JWKS cache and retry once
156
- if key.is_a?(Array) && !@signature_retry_attempted
157
- @signature_retry_attempted = true
158
- OmniauthOidc::Logging.warn("Signature verification failed, refreshing JWKS and retrying")
159
- refreshed_key = public_key_with_refresh
160
- return verify_signature!(id_token, refreshed_key, algorithm)
161
- end
162
204
  raise OmniauthOidc::InvalidSignatureError, "JWT signature verification failed: #{e.message}"
163
205
  rescue JWT::IncorrectAlgorithm => e
164
206
  raise OmniauthOidc::InvalidAlgorithmError, "Unexpected JWT algorithm: #{e.message}"
165
207
  end
166
208
 
167
- # Check for jwt to match defined client_signing_alg
168
- def validate_client_algorithm!(algorithm)
169
- client_signing_alg = options.client_signing_alg&.to_sym
209
+ # The token header is attacker controlled, so the accepted algorithms come from
210
+ # configuration only. "none" is never accepted.
211
+ def validate_algorithm!(algorithm)
212
+ allowed = allowed_signing_algorithms
213
+ return if allowed.include?(algorithm)
214
+
215
+ raise OmniauthOidc::InvalidAlgorithmError,
216
+ "Received JWT is signed with #{algorithm.empty? ? "no algorithm" : algorithm}, " \
217
+ "but only #{allowed.empty? ? "signed tokens" : allowed.join(", ")} are accepted"
218
+ end
170
219
 
171
- return unless client_signing_alg
172
- return if algorithm == client_signing_alg
220
+ # client_signing_alg when set, otherwise the algorithms the discovery document
221
+ # advertises for ID tokens, otherwise RS256.
222
+ def allowed_signing_algorithms
223
+ algorithms = if options.client_signing_alg
224
+ [options.client_signing_alg]
225
+ else
226
+ Array(config.id_token_signing_alg_values_supported)
227
+ end
228
+ algorithms = [DEFAULT_SIGNING_ALGORITHM] if algorithms.empty?
173
229
 
174
- reason = "Received JWT is signed with #{algorithm}, but client_signing_alg is " \
175
- "configured for #{client_signing_alg}"
176
- raise OmniauthOidc::InvalidAlgorithmError, reason
230
+ algorithms.map(&:to_s) - ["none"]
177
231
  end
178
232
 
179
233
  def configured_public_key
@@ -72,8 +72,8 @@ module OmniAuth
72
72
  option :post_logout_redirect_uri
73
73
  option :extra_authorize_params, {}
74
74
  option :allow_authorize_params, []
75
- option :uid_field, "sub"
76
- option :pkce, false
75
+ option :uid_field, "sub" # Deprecated: never had an effect, the uid is always `sub`. Removed in 2.0.0
76
+ option :pkce, true
77
77
  option :pkce_verifier, nil
78
78
  option :pkce_options, {
79
79
  code_challenge: proc { |verifier|
@@ -84,11 +84,21 @@ module OmniAuth
84
84
 
85
85
  option :logout_path, "/logout"
86
86
 
87
- # JWKS cache configuration
88
- option :jwks_cache_ttl, 3600 # 1 hour default
87
+ # JWKS and discovery document cache TTLs in seconds
88
+ option :jwks_cache_ttl, OmniauthOidc::JwksCache::DEFAULT_TTL
89
+ option :discovery_cache_ttl, OmniauthOidc::ConfigFetcher::DEFAULT_CACHE_TTL
89
90
 
91
+ # Seconds of clock drift tolerated when checking ID token exp and nbf
92
+ option :clock_skew_leeway, 60
93
+
94
+ def initialize(app, *args, &block)
95
+ super
96
+ warn_uid_field_deprecation
97
+ end
98
+
99
+ # Always the subject claim: the only identifier OIDC guarantees to be stable and unique per issuer
90
100
  def uid
91
- user_info.raw_attributes[options.uid_field.to_sym] || user_info.sub
101
+ user_info.sub
92
102
  end
93
103
 
94
104
  info do
@@ -143,12 +153,15 @@ module OmniAuth
143
153
  client_options.end_session_endpoint ||= config.end_session_endpoint
144
154
  end
145
155
 
146
- # Config is built from the json response from the OIDC config endpoint
156
+ # Config is built from the json response from the OIDC config endpoint.
157
+ # OmniAuth dups the strategy per request, so the process-wide cache is what avoids
158
+ # refetching the discovery document on every request.
147
159
  def config
148
160
  validate_configuration!
149
161
 
150
- @config ||= OmniauthOidc::Logging.instrument("config.fetch", config_endpoint: client_options.config_endpoint) do
151
- OmniauthOidc::ConfigFetcher.fetch(client_options.config_endpoint)
162
+ endpoint = client_options.config_endpoint
163
+ @config ||= OmniauthOidc::Logging.instrument("config.fetch", config_endpoint: endpoint) do
164
+ OmniauthOidc::ConfigFetcher.fetch_cached(endpoint, ttl: options.discovery_cache_ttl.to_i)
152
165
  end
153
166
  end
154
167
 
@@ -173,6 +186,13 @@ module OmniAuth
173
186
 
174
187
  private
175
188
 
189
+ def warn_uid_field_deprecation
190
+ return if options.uid_field.to_s == "sub"
191
+
192
+ OmniauthOidc::Logging.warn("The uid_field option is deprecated and has no effect: the uid is always the " \
193
+ "sub claim. Remove it from the #{name} provider; it is removed in 2.0.0.")
194
+ end
195
+
176
196
  def validate_configuration!
177
197
  missing = []
178
198
  missing << :identifier if client_options.identifier.to_s.empty?
data/lib/omniauth_oidc.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "omniauth/oidc/version"
4
4
  require_relative "omniauth/oidc/errors"
5
5
  require_relative "omniauth/oidc/logging"
6
+ require_relative "omniauth/oidc/ttl_cache"
6
7
  require_relative "omniauth/oidc/jwks_cache"
7
8
  require_relative "omniauth/oidc/http_client"
8
9
  require_relative "omniauth/oidc/response_objects"
@@ -33,7 +33,8 @@ Gem::Specification.new do |spec|
33
33
  spec.require_paths = ["lib"]
34
34
 
35
35
  # Runtime dependencies
36
- spec.add_dependency "jwt", ">= 2.7", "< 4.0"
36
+ # Excludes releases affected by CVE-2026-45363 (empty-key HMAC bypass): fixed in 2.10.3 and 3.2.0
37
+ spec.add_dependency "jwt", ">= 2.10.3", "!= 3.0.0", "!= 3.1.0", "!= 3.1.1", "!= 3.1.2", "< 4.0"
37
38
  spec.add_dependency "omniauth", "~> 2.1"
38
39
 
39
40
  # Ruby 4.0+ compatibility - ostruct no longer in default gems
@@ -56,6 +56,9 @@ module OmniauthOidc
56
56
  end
57
57
 
58
58
  MAX_REDIRECTS: Integer
59
+ OPEN_TIMEOUT: Integer
60
+ READ_TIMEOUT: Integer
61
+ WRITE_TIMEOUT: Integer
59
62
 
60
63
  def self.get: (String url, ?headers: Hash[String, String]) -> Hash[String, untyped]
61
64
  def self.post: (String url, ?body: String?, ?headers: Hash[String, String]) -> Hash[String, untyped]
@@ -91,41 +94,60 @@ module OmniauthOidc
91
94
  def self.find_key: (Array[KeyWithId] keys, ?String? kid) -> OpenSSL::PKey::PKey?
92
95
  end
93
96
 
94
- # JWKS Cache
95
- class JwksCache
96
- DEFAULT_TTL: Integer
97
-
98
- class CacheEntry
99
- attr_accessor keys: untyped
97
+ # Thread-safe TTL cache
98
+ class TtlCache
99
+ class Entry
100
+ attr_accessor value: untyped
100
101
  attr_accessor fetched_at: Time
101
102
 
102
- def initialize: (?keys: untyped, ?fetched_at: Time) -> void
103
+ def initialize: (?value: untyped, ?fetched_at: Time) -> void
103
104
  end
104
105
 
106
+ attr_reader ttl: Numeric
107
+
108
+ def initialize: (ttl: Numeric, ?name: String) -> void
109
+ def fetch: (String key, ?force_refresh: bool, ?ttl: Numeric?, ?min_age: Numeric) { () -> untyped } -> untyped
110
+ def valid?: (String key) -> bool
111
+ def invalidate: (String key) -> void
112
+ def clear!: () -> void
113
+ def ttl=: (Numeric value) -> void
114
+ end
115
+
116
+ # JWKS Cache
117
+ class JwksCache < TtlCache
118
+ DEFAULT_TTL: Integer
119
+
105
120
  def self.instance: () -> JwksCache
106
121
  def self.clear!: () -> void
107
122
  def self.invalidate: (String jwks_uri) -> void
108
123
 
109
- attr_reader ttl: Integer
110
-
111
- def initialize: (?ttl: Integer) -> void
112
- def fetch: (String jwks_uri, ?force_refresh: bool) { () -> untyped } -> untyped
113
- def valid?: (String jwks_uri) -> bool
114
- def invalidate: (String jwks_uri) -> void
115
- def clear!: () -> void
116
- def ttl=: (Integer value) -> void
124
+ def initialize: (?ttl: Numeric) -> void
117
125
  end
118
126
 
119
127
  # Config Fetcher
120
128
  class ConfigFetcher
129
+ DEFAULT_CACHE_TTL: Integer
130
+ STANDARD_FIELDS: Array[Symbol]
131
+ REQUIRED_FIELDS: Array[Symbol]
132
+
121
133
  class Config
122
134
  def initialize: (?Hash[Symbol | String, untyped] attributes) -> void
123
135
  def []: (String | Symbol key) -> untyped
124
136
  def []=: (String | Symbol key, untyped value) -> untyped
125
137
  def respond_to_missing?: (Symbol method_name, ?bool include_private) -> bool
138
+ def issuer: () -> String?
139
+ def authorization_endpoint: () -> String?
140
+ def token_endpoint: () -> String?
141
+ def userinfo_endpoint: () -> String?
142
+ def jwks_uri: () -> String?
143
+ def end_session_endpoint: () -> String?
144
+ def scopes_supported: () -> Array[String]?
145
+ def id_token_signing_alg_values_supported: () -> Array[String]?
126
146
  end
127
147
 
128
148
  def self.fetch: (String endpoint_url, ?max_retries: Integer) -> Config
149
+ def self.fetch_cached: (String endpoint_url, ?ttl: Numeric) -> Config
150
+ def self.clear_cache!: () -> void
129
151
  end
130
152
 
131
153
  # Response Objects
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth_oidc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Suleyman Musayev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-23 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt
@@ -16,7 +16,19 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '2.7'
19
+ version: 2.10.3
20
+ - - "!="
21
+ - !ruby/object:Gem::Version
22
+ version: 3.0.0
23
+ - - "!="
24
+ - !ruby/object:Gem::Version
25
+ version: 3.1.0
26
+ - - "!="
27
+ - !ruby/object:Gem::Version
28
+ version: 3.1.1
29
+ - - "!="
30
+ - !ruby/object:Gem::Version
31
+ version: 3.1.2
20
32
  - - "<"
21
33
  - !ruby/object:Gem::Version
22
34
  version: '4.0'
@@ -26,7 +38,19 @@ dependencies:
26
38
  requirements:
27
39
  - - ">="
28
40
  - !ruby/object:Gem::Version
29
- version: '2.7'
41
+ version: 2.10.3
42
+ - - "!="
43
+ - !ruby/object:Gem::Version
44
+ version: 3.0.0
45
+ - - "!="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.1.0
48
+ - - "!="
49
+ - !ruby/object:Gem::Version
50
+ version: 3.1.1
51
+ - - "!="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.1.2
30
54
  - - "<"
31
55
  - !ruby/object:Gem::Version
32
56
  version: '4.0'
@@ -67,6 +91,7 @@ files:
67
91
  - lib/omniauth/oidc/jwks_cache.rb
68
92
  - lib/omniauth/oidc/logging.rb
69
93
  - lib/omniauth/oidc/response_objects.rb
94
+ - lib/omniauth/oidc/ttl_cache.rb
70
95
  - lib/omniauth/oidc/version.rb
71
96
  - lib/omniauth/strategies/oidc.rb
72
97
  - lib/omniauth/strategies/oidc/callback.rb