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 +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +50 -24
- data/lib/omniauth/oidc/config_fetcher.rb +40 -2
- data/lib/omniauth/oidc/http_client.rb +24 -2
- data/lib/omniauth/oidc/jwks_cache.rb +6 -65
- data/lib/omniauth/oidc/ttl_cache.rb +82 -0
- data/lib/omniauth/oidc/version.rb +1 -1
- data/lib/omniauth/strategies/oidc/callback.rb +45 -35
- data/lib/omniauth/strategies/oidc/request.rb +5 -5
- data/lib/omniauth/strategies/oidc/verify.rb +119 -65
- data/lib/omniauth/strategies/oidc.rb +28 -8
- data/lib/omniauth_oidc.rb +1 -0
- data/omniauth_oidc.gemspec +2 -1
- data/sig/omniauth_oidc.rbs +37 -15
- metadata +29 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e94e09a2103532e642e008b239bf772fab6a609e9a9fcde6b943687b89ee25d9
|
|
4
|
+
data.tar.gz: e22f3910b293c1e4fcf9ea3746a27bf8258764b697acd2dd3fe7bce8425cb16c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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:
|
|
77
|
-
|
|
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
|
|
275
|
-
- `token.exchange.omniauth_oidc
|
|
276
|
-
- `userinfo.fetch.omniauth_oidc
|
|
277
|
-
- `jwks.fetch.omniauth_oidc
|
|
278
|
-
- `request_phase.start.omniauth_oidc
|
|
279
|
-
- `callback_phase.start.omniauth_oidc
|
|
280
|
-
- `id_token.verify.omniauth_oidc
|
|
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
|
|
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.
|
|
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
|
|
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 |
|
|
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/)
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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:
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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!
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
79
|
-
@
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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 @
|
|
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
|
-
|
|
137
|
-
OmniauthOidc::ResponseObjects::UserInfo.new({})
|
|
145
|
+
OmniauthOidc::ResponseObjects::UserInfo.new(@verified_id_token.raw_attributes)
|
|
138
146
|
end
|
|
139
147
|
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
152
|
+
raise OmniauthOidc::TokenVerificationError, "Neither the ID token nor UserInfo provided a subject (sub)"
|
|
153
|
+
end
|
|
145
154
|
|
|
146
|
-
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
39
|
+
# Refetch the JWKS, at most once per JWKS_REFRESH_MIN_INTERVAL for each jwks_uri
|
|
31
40
|
def public_key_with_refresh
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@
|
|
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
|
-
|
|
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)
|
|
87
|
+
def verify_claims!(decoded_token)
|
|
70
88
|
claims = decoded_token.raw_attributes
|
|
71
89
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
validate_algorithm!(algorithm)
|
|
116
171
|
|
|
117
172
|
# Get the appropriate key/secret for verification
|
|
118
|
-
key = keyset_for_algorithm(algorithm
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
88
|
-
option :jwks_cache_ttl,
|
|
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.
|
|
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
|
-
|
|
151
|
-
|
|
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"
|
data/omniauth_oidc.gemspec
CHANGED
|
@@ -33,7 +33,8 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
spec.require_paths = ["lib"]
|
|
34
34
|
|
|
35
35
|
# Runtime dependencies
|
|
36
|
-
|
|
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
|
data/sig/omniauth_oidc.rbs
CHANGED
|
@@ -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
|
-
#
|
|
95
|
-
class
|
|
96
|
-
|
|
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: (?
|
|
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
|
-
|
|
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
|
|
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-
|
|
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:
|
|
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:
|
|
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
|