doorkeeper-openid_connect 2.0.0.beta1 → 2.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: 960d2cf9c359d5bae10fc7be2eff5fba0eb5309b359422d81ea0e0a3c451f192
4
- data.tar.gz: e2efc3c51af4c54d599dd884264b9ce41cf136fa94f291d4222186dac3e266bd
3
+ metadata.gz: 8eba70212cad992603afa10fe0f3ae1db4461c03938897418e8659f6a95c64bb
4
+ data.tar.gz: 74cc66dd6b502f6897428d36173f8faa0b68293dd456b99d7d80343714b2bb54
5
5
  SHA512:
6
- metadata.gz: f349f5d093f30c5ed07a77349cc63d0651047185587c149172ec5d38eb29bd4644a74f3b10fd54674f43beda2885f58b7b400094e19afda4d24daadd56dc1e8d
7
- data.tar.gz: 73e1e48f7fa439a9f7df513db74ba0a5df969a19a32abdf9c1b8bda34db08356abde81b3b3fdf425a0bbc46c8f258b669d1368cf25d067f187405e2f7cc8efd0
6
+ metadata.gz: 751bf66f9a2a58009f787a13c50d9a22a9f7b76117c8fbf1062e22896237ef7d8b66778e4834d59092fa7cdc623f48850b4e61eb796ba36f035418f3d4a39512
7
+ data.tar.gz: 1df8e6e7c4600a43e81833f5dcc3a210d99d4003a7f8a5bcde2ae53b8df662ced2147e7961d022b13a20607692f9147c44c7eee31704fd2ef11f289b0dd1ddac
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ Upgrading? [Migration from Old Versions](https://github.com/doorkeeper-gem/doork
4
4
 
5
5
  - Add entry here
6
6
 
7
+ ## v2.1.0 (2026-10-20)
8
+
9
+ - [#401] Fix `max_age` reauthentication loop when `auth_time_from_session` returns a String — parse it as a timestamp instead of falling through to `to_i` (same class of bug as [#248]) — and treat a future auth_time as stale (60 s clock-skew leeway)
10
+ - [#407] Prepend `AuthorizationsExtension` and `MetadataExtension` when Zeitwerk loads `Doorkeeper::AuthorizationsController` and `Doorkeeper::MetadataController`, instead of from `to_prepare`. Naming the controllers in `to_prepare` autoloaded them on every boot, together with `Doorkeeper::ApplicationController` and, through its default `ActionController::Base` superclass, every helper in the host application
11
+ - This gem no longer loads `ActionController::Base` during boot, so an Action Controller load hook that names an application constant such as `ApplicationController` should move to `config.to_prepare`
12
+ - [#408] Drop the `ostruct` runtime dependency (#210, added in #218). `Doorkeeper::OpenidConnect.configuration.claims` is now a Hash keyed by Symbol claim name instead of an `OpenStruct`. The OpenStruct-only forms (`claims.nickname`, `claims.nickname = ...`, String keys in `[]`, `[]=` and `dig`, and `delete_field`) still work but print a deprecation warning and will be removed in the next major version; use `claims[:nickname]`.
13
+ - [#402] Fix a boot failure (`MissingConfiguration`) when the access grant model loads before `Doorkeeper::OpenidConnect.configure` — e.g. from an initializer sorting between `doorkeeper.rb` and `doorkeeper_openid_connect.rb` (regression since v1.10.3)
14
+
15
+ ## v2.0.0 (2026-09-22)
16
+
17
+ >[!IMPORTANT]
18
+ >
19
+ >- **Breaking (class overrides):** a configured `id_token_class` / `user_info_class` must now inherit from `Doorkeeper::OpenidConnect::IdToken` / `UserInfo` ([#364])
20
+ >- **Breaking (settings removed):** the deprecated `jws_private_key` / `jws_public_key` initializer settings are gone ([#387])
21
+ >- **Breaking (constant renamed):** `Doorkeeper::OpenidConnect::HybridIdTokenConcern` is now `AtHashConcern`, with no alias for the old name ([#399])
22
+ >
23
+ >**Coming from 1.10.x?** Every breaking change listed under 2.0.0.beta1 below applies as well — the `post_logout_redirect_uris` migration ([#243]), the `id_token token` response type change ([#337]), the removal of `IdTokenToken` ([#338]) and the Dynamic Client Registration defaults ([#350]). Those notes name `HybridIdTokenConcern`; read them as `AtHashConcern`, which is what 2.0.0 ships.
24
+ >
25
+ >[Migration from Old Versions](https://github.com/doorkeeper-gem/doorkeeper-openid_connect/wiki/Migration-from-Old-Versions) walks through each one.
26
+
27
+ - **Security:** Register a dynamically registered client with the server's `default_scopes` when the registration request omits `scope`, instead of persisting an empty scope set. Only deployments that enable `dynamic_client_registration` are affected.
28
+ - [#364] **Breaking:** Require `id_token_class` / `user_info_class` overrides to inherit from `Doorkeeper::OpenidConnect::IdToken` / `UserInfo` ([#344](https://github.com/doorkeeper-gem/doorkeeper-openid_connect/issues/344)). Adds an `IdToken#select_key` hook returning the signing key and its algorithm together (`IdToken::SigningKey`) for per-client, rotating or multi-tenant keys, and binds the `at_hash` digest to that algorithm (OIDC Core §3.2.2.10) rather than the global `signing_algorithm`
29
+ - [#387] **Breaking:** Remove the deprecated `jws_private_key` and `jws_public_key` initializer settings
30
+ - [#399] **Breaking:** Rename `Doorkeeper::OpenidConnect::HybridIdTokenConcern` to `AtHashConcern`. The old name described the `id_token token` response type as a hybrid flow, but OpenID Connect Core defines that response type under the Implicit Flow (§3.2) — the Hybrid Flow response types of §3.3 are not implemented by this gem. Removed without an alias: the constant only ever shipped in 2.0.0.beta1
31
+
7
32
  ## v2.0.0.beta1 (2026-08-20)
8
33
 
9
34
  >[!IMPORTANT]
@@ -28,6 +28,10 @@ module Doorkeeper
28
28
 
29
29
  private
30
30
 
31
+ # One method per metadata document: the provider metadata is a flat
32
+ # listing of endpoints and capabilities, and splitting it would hide the
33
+ # shape of the published document.
34
+ # rubocop:disable Metrics/AbcSize
31
35
  def provider_response
32
36
  doorkeeper = ::Doorkeeper.configuration
33
37
  openid_connect = ::Doorkeeper::OpenidConnect.configuration
@@ -79,6 +83,7 @@ module Doorkeeper
79
83
  code_challenge_methods_supported: code_challenge_methods_supported(doorkeeper),
80
84
  }.compact
81
85
  end
86
+ # rubocop:enable Metrics/AbcSize
82
87
 
83
88
  def response_modes_supported(doorkeeper)
84
89
  doorkeeper.authorization_response_flows.flat_map(&:response_mode_matches).uniq
@@ -16,10 +16,7 @@ module Doorkeeper
16
16
  client = Doorkeeper.configuration.application_model.create!(application_params(registration))
17
17
  render json: registration_response(client, registration), status: :created
18
18
  rescue ActiveRecord::RecordInvalid => e
19
- render json: {
20
- error: registration_error_code(e.record),
21
- error_description: e.record.errors.full_messages.join(", "),
22
- }, status: :bad_request
19
+ render json: record_invalid_response(e.record), status: :bad_request
23
20
  end
24
21
 
25
22
  private
@@ -49,6 +46,13 @@ module Doorkeeper
49
46
  end
50
47
  end
51
48
 
49
+ def record_invalid_response(record)
50
+ {
51
+ error: registration_error_code(record),
52
+ error_description: record.errors.full_messages.join(", "),
53
+ }
54
+ end
55
+
52
56
  # RFC 7591 §3.2.2 registration error codes: `invalid_redirect_uri` for
53
57
  # redirect URI problems, `invalid_client_metadata` for everything else.
54
58
  def registration_error_code(record)
@@ -78,6 +82,10 @@ module Doorkeeper
78
82
  Doorkeeper.config.application_model.column_names.include?("post_logout_redirect_uris")
79
83
  end
80
84
 
85
+ # One method per metadata document: the registration response echoes
86
+ # the client metadata as RFC 7591 §3.2.1 lists it, and splitting it
87
+ # would hide the shape of the published document.
88
+ # rubocop:disable Metrics/AbcSize
81
89
  def registration_response(doorkeeper_application, registration)
82
90
  response = {
83
91
  client_id: doorkeeper_application.uid,
@@ -111,6 +119,7 @@ module Doorkeeper
111
119
 
112
120
  response
113
121
  end
122
+ # rubocop:enable Metrics/AbcSize
114
123
  end
115
124
  end
116
125
  end
@@ -11,7 +11,7 @@ module Doorkeeper
11
11
  # serves the default and any custom id_token_class alike.
12
12
  id_token_token = Doorkeeper::OpenidConnect.configuration.id_token_model
13
13
  .new(auth.token, pre_auth.nonce)
14
- .extend(Doorkeeper::OpenidConnect::HybridIdTokenConcern)
14
+ .extend(Doorkeeper::OpenidConnect::AtHashConcern)
15
15
 
16
16
  IdTokenTokenResponse.new(pre_auth, auth, id_token_token)
17
17
  end
@@ -2,11 +2,16 @@
2
2
 
3
3
  module Doorkeeper
4
4
  module OpenidConnect
5
- # Adds the `at_hash` claim required by the hybrid `id_token token` flow.
6
- # The host object must implement `#claims` and expose the access token
7
- # via an `#access_token` reader (public or private), as
8
- # `Doorkeeper::OpenidConnect::IdToken` does.
9
- module HybridIdTokenConcern
5
+ # Adds the `at_hash` claim required when an ID Token is issued alongside an
6
+ # access token — the `id_token token` response type, which OpenID Connect
7
+ # Core §3.2 defines as part of the Implicit Flow, not the Hybrid Flow of §3.3
8
+ # (whose response types this gem does not implement).
9
+ #
10
+ # The host object must implement `#claims`, expose the access token via an
11
+ # `#access_token` reader, and resolve its signing key via `#selected_key` —
12
+ # all provided by `Doorkeeper::OpenidConnect::IdToken`, which configured
13
+ # `id_token_class` overrides must inherit from.
14
+ module AtHashConcern
10
15
  def claims
11
16
  super.merge(at_hash: at_hash)
12
17
  end
@@ -37,8 +42,12 @@ module Doorkeeper
37
42
  Base64.urlsafe_encode64(first_half).tr("=", "")
38
43
  end
39
44
 
45
+ # OIDC Core §3.2.2.10 requires the digest to match the `alg` of the ID
46
+ # Token's actual JOSE header — derived from the key the token is signed
47
+ # with (`selected_key`, shared with `as_jws_token`), not from the global
48
+ # `signing_algorithm`, which a `select_key` override may diverge from.
40
49
  def at_hash_digest
41
- case Doorkeeper::OpenidConnect.signing_algorithm.to_s
50
+ case selected_key.algorithm.to_s
42
51
  when /256\z/ then Digest::SHA256
43
52
  when /384\z/ then Digest::SHA384
44
53
  when /512\z/ then Digest::SHA512
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ostruct"
4
-
5
3
  module Doorkeeper
6
4
  module OpenidConnect
7
5
  class ClaimsBuilder
@@ -18,15 +16,22 @@ module Doorkeeper
18
16
  end
19
17
 
20
18
  Doorkeeper::OpenidConnect.configuration.claims.to_h.map do |name, claim|
21
- if claim.scopes.any? { |scope| access_token.scopes.exists?(scope) } &&
22
- claim.response.include?(response)
19
+ if claim_applies?(claim, access_token, response)
23
20
  [name, claim.generator.call(resource_owner, access_token.scopes, access_token)]
24
21
  end
25
22
  end.compact.to_h
26
23
  end
27
24
 
25
+ # A claim is included when the token carries one of its scopes and it is
26
+ # configured for this response type (:id_token / :user_info).
27
+ def self.claim_applies?(claim, access_token, response)
28
+ claim.scopes.any? { |scope| access_token.scopes.exists?(scope) } &&
29
+ claim.response.include?(response)
30
+ end
31
+ private_class_method :claim_applies?
32
+
28
33
  def initialize(&block)
29
- @claims = OpenStruct.new
34
+ @claims = ClaimsHash.new
30
35
  instance_eval(&block)
31
36
  end
32
37
 
@@ -35,13 +40,14 @@ module Doorkeeper
35
40
  end
36
41
 
37
42
  def normal_claim(name, response: [:user_info], scope: nil, &block)
38
- @claims[name] =
43
+ claim =
39
44
  Claims::NormalClaim.new(
40
45
  name: name,
41
46
  response: response,
42
47
  scope: scope,
43
48
  generator: block,
44
49
  )
50
+ @claims[claim.name] = claim
45
51
  end
46
52
  alias claim normal_claim
47
53
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ module OpenidConnect
5
+ # The configured claims, keyed by Symbol claim name.
6
+ #
7
+ # This used to be an `OpenStruct`. It is a Hash now, but it still answers the
8
+ # OpenStruct-only forms (`claims.nickname`, `claims.nickname = ...`, String keys
9
+ # in `[]`, `[]=` and `dig`, and `delete_field`) with a deprecation warning, so
10
+ # apps that relied on them keep working until the next major version, when this
11
+ # becomes a plain Hash.
12
+ class ClaimsHash < Hash
13
+ # Emit the deprecation at most once per process; claims are read on every
14
+ # ID Token and UserInfo response.
15
+ @deprecation_warned = false
16
+
17
+ class << self
18
+ def warn_deprecation
19
+ return if @deprecation_warned
20
+
21
+ @deprecation_warned = true
22
+ warn "DEPRECATION WARNING: `Doorkeeper::OpenidConnect.configuration.claims` is a Hash " \
23
+ "keyed by Symbol claim name. Reading or writing it method-style " \
24
+ "(`claims.nickname`), with String keys (`claims[\"nickname\"]`) or with " \
25
+ "`delete_field` is deprecated and will stop working in the next major version. " \
26
+ "Use `claims[:nickname]` and `claims.delete(:nickname)` instead."
27
+ end
28
+
29
+ # Reset the deprecation flag (test helper).
30
+ def reset_deprecation_warning!
31
+ @deprecation_warned = false
32
+ end
33
+ end
34
+
35
+ def [](name)
36
+ return super unless name.is_a?(String)
37
+
38
+ self.class.warn_deprecation
39
+ super(name.to_sym)
40
+ end
41
+
42
+ def []=(name, value)
43
+ if name.is_a?(String)
44
+ self.class.warn_deprecation
45
+ name = name.to_sym
46
+ end
47
+ super
48
+ end
49
+
50
+ def dig(name, *rest)
51
+ return super unless name.is_a?(String)
52
+
53
+ self.class.warn_deprecation
54
+ super(name.to_sym, *rest)
55
+ end
56
+
57
+ # Like OpenStruct#delete_field: raises NameError for an unknown claim unless given a block.
58
+ def delete_field(name)
59
+ self.class.warn_deprecation
60
+ key = name.to_sym
61
+ return delete(key) if key?(key)
62
+ return yield if block_given?
63
+
64
+ raise NameError.new("no field `#{key}' in #{self.class}", key)
65
+ end
66
+
67
+ def method_missing(method_name, *args, &block)
68
+ name = method_name.to_s
69
+ if name.end_with?("=") && args.size == 1 && block.nil?
70
+ self.class.warn_deprecation
71
+ self[name.chomp("=").to_sym] = args.first
72
+ elsif args.empty? && block.nil?
73
+ self.class.warn_deprecation
74
+ fetch(method_name, nil)
75
+ else
76
+ super
77
+ end
78
+ end
79
+
80
+ def respond_to_missing?(method_name, include_private = false)
81
+ key?(method_name) || method_name.to_s.end_with?("=") || super
82
+ end
83
+ end
84
+ end
85
+ end
@@ -10,6 +10,12 @@ module Doorkeeper
10
10
 
11
11
  @config = Config::Builder.new(&block).build
12
12
  validate_issuer_consistency
13
+ # Access grant models that loaded before this point deferred their
14
+ # `openid_request` association because it needs `open_id_request_class`.
15
+ # The namespace is spelled out: a bare `AccessGrant` would resolve to
16
+ # Doorkeeper's own grant model through the enclosing lexical scope if the
17
+ # autoload were ever dropped.
18
+ OpenidConnect::AccessGrant.wire_pending_hosts
13
19
  @config
14
20
  end
15
21
 
@@ -68,15 +74,6 @@ module Doorkeeper
68
74
  def build
69
75
  @config
70
76
  end
71
-
72
- def jws_public_key(*_args)
73
- warn "DEPRECATION WARNING: `jws_public_key` is not needed anymore and will be removed in a future version, please remove it from config/initializers/doorkeeper_openid_connect.rb"
74
- end
75
-
76
- def jws_private_key(*args)
77
- warn "DEPRECATION WARNING: `jws_private_key` has been replaced by `signing_key` and will be removed in a future version, please remove it from config/initializers/doorkeeper_openid_connect.rb"
78
- signing_key(*args)
79
- end
80
77
  end
81
78
 
82
79
  mattr_reader(:builder_class) { Config::Builder }
@@ -140,15 +137,19 @@ module Doorkeeper
140
137
  option :open_id_request_class, default: "Doorkeeper::OpenidConnect::Request"
141
138
 
142
139
  # A class that provides custom behavior for generating ID tokens.
143
- # Should probably inherit from `Doorkeeper::OpenidConnect::IdToken`, but may also be completely custom
144
- # so long as it responds to `#as_json`, `#as_jws_token`, `#issuer`, exposes the access token via
145
- # an `#access_token` reader (public or private), and has the same initializer. The reader is what
146
- # `HybridIdTokenConcern` uses to compute the `at_hash` claim in the hybrid `id_token token` flow.
140
+ # Must inherit from `Doorkeeper::OpenidConnect::IdToken`, which carries the
141
+ # security-critical invariants (required-claim enforcement, the merge order that keeps the
142
+ # `claims` configuration block from overriding `sub`/`aud`/`exp`, nonce and `at_hash`
143
+ # handling), so a subclass only overrides what it actually needs — typically `claims`
144
+ # (call `super.merge(...)` so the registered claims are always present; a key you add that
145
+ # collides with one of them does replace it, so keep your keys distinct unless replacing it
146
+ # is what you mean), `audience`, or `select_key` for custom signing-key selection.
147
147
  option :id_token_class, default: "Doorkeeper::OpenidConnect::IdToken"
148
148
 
149
149
  # A class that provides custom behavior for generating the UserInfo response.
150
- # Should probably inherit from `Doorkeeper::OpenidConnect::UserInfo`, but may also be completely custom
151
- # so long as it responds to `#as_json` and has the same initializer.
150
+ # Must inherit from `Doorkeeper::OpenidConnect::UserInfo`; a subclass typically only
151
+ # overrides `claims` (call `super.merge(...)`, and leave `sub` out of the merged hash unless
152
+ # replacing the canonical subject identifier is what you mean).
152
153
  option :user_info_class, default: "Doorkeeper::OpenidConnect::UserInfo"
153
154
 
154
155
  # Doorkeeper OpenID Request model class.
@@ -160,26 +161,32 @@ module Doorkeeper
160
161
  end
161
162
 
162
163
  def id_token_model
163
- resolve_validated_model(:id_token, id_token_class, %i[as_json as_jws_token issuer access_token])
164
+ resolve_validated_model(:id_token, id_token_class, IdToken)
164
165
  end
165
166
 
166
167
  def user_info_model
167
- resolve_validated_model(:user_info, user_info_class, %i[as_json])
168
+ resolve_validated_model(:user_info, user_info_class, UserInfo)
168
169
  end
169
170
 
170
171
  private
171
172
 
172
173
  # Resolves an `id_token_class` / `user_info_class` override to its class
173
- # and validates that the required methods exist (presence only, not
174
- # correctness). Both happen lazily at first use rather than inside
175
- # `Doorkeeper::OpenidConnect.configure`: constantizing an app-defined
176
- # class while initializers run breaks zeitwerk on Rails 7+, because
177
- # reloadable constants must not be referenced during boot — the same
178
- # reason `open_id_request_model` constantizes lazily. The class is also
179
- # deliberately not memoized, so code reloading in development never
180
- # hands back a stale class; only the validation result is cached, keyed
181
- # on the resolved class so a reloaded class is re-validated.
182
- def resolve_validated_model(kind, class_name, required_methods)
174
+ # and validates that it inherits from the corresponding default. The
175
+ # ancestry check replaced the earlier method-presence list: presence
176
+ # could be satisfied by any class (ActiveSupport defines `as_json` on
177
+ # `Object`), while inheritance also carries the security-critical
178
+ # behavior — required-claim enforcement, the claim merge order, nonce
179
+ # and `at_hash` handling — that a from-scratch implementation would
180
+ # have to reproduce. Both resolution and validation happen lazily at
181
+ # first use rather than inside `Doorkeeper::OpenidConnect.configure`:
182
+ # constantizing an app-defined class while initializers run breaks
183
+ # zeitwerk on Rails 7+, because reloadable constants must not be
184
+ # referenced during boot — the same reason `open_id_request_model`
185
+ # constantizes lazily. The class is also deliberately not memoized, so
186
+ # code reloading in development never hands back a stale class; only
187
+ # the validation result is cached, keyed on the resolved class so a
188
+ # reloaded class is re-validated.
189
+ def resolve_validated_model(kind, class_name, base_model)
183
190
  # `safe_constantize` (unlike a bare `constantize` rescue) only reports
184
191
  # nil when the configured constant itself is missing; a NameError
185
192
  # raised while loading the class body still surfaces as-is.
@@ -192,18 +199,15 @@ module Doorkeeper
192
199
  @validated_models ||= {}
193
200
  return model if @validated_models[kind] == model
194
201
 
195
- missing_methods = required_methods.reject do |method|
196
- model.method_defined?(method) || model.private_method_defined?(method)
197
- end
202
+ validate_model_inheritance!(kind, class_name, model, base_model)
203
+ @validated_models[kind] = model
204
+ end
198
205
 
199
- unless missing_methods.empty?
200
- raise Errors::InvalidConfiguration,
201
- "The configured #{kind}_class (#{class_name}) is missing the following " \
202
- "required methods: #{missing_methods.join(", ")}"
203
- end
206
+ def validate_model_inheritance!(kind, class_name, model, base_model)
207
+ return if model.is_a?(Class) && model <= base_model
204
208
 
205
- @validated_models[kind] = model
206
- model
209
+ raise Errors::InvalidConfiguration,
210
+ "The configured #{kind}_class (#{class_name}) must inherit from #{base_model.name}"
207
211
  end
208
212
  end
209
213
  end
@@ -7,14 +7,18 @@ module Doorkeeper
7
7
  Doorkeeper::OpenidConnect::Rails::Routes.install!
8
8
  end
9
9
 
10
- config.to_prepare do
11
- Doorkeeper::AuthorizationsController.prepend Doorkeeper::OpenidConnect::AuthorizationsExtension
10
+ initializer "doorkeeper.openid_connect.controller_extensions" do |app|
11
+ app.autoloaders.main.on_load("Doorkeeper::AuthorizationsController") do |controller|
12
+ controller.prepend Doorkeeper::OpenidConnect::AuthorizationsExtension
13
+ end
12
14
 
13
15
  # Doorkeeper >= 6.0 serves its own RFC 8414 metadata document at
14
16
  # /.well-known/oauth-authorization-server; enrich it with the OpenID
15
17
  # Connect metadata (see MetadataExtension).
16
18
  if Doorkeeper::OpenidConnect.doorkeeper_metadata_endpoint?
17
- Doorkeeper::MetadataController.prepend Doorkeeper::OpenidConnect::MetadataExtension
19
+ app.autoloaders.main.on_load("Doorkeeper::MetadataController") do |controller|
20
+ controller.prepend Doorkeeper::OpenidConnect::MetadataExtension
21
+ end
18
22
  end
19
23
  end
20
24
  end
@@ -17,23 +17,13 @@ module Doorkeeper
17
17
  # itself gates the emission on redirectability and on the issuer
18
18
  # being configured. Versions predating #1849 simply ignore the
19
19
  # attribute, so no version guard is needed.
20
- error_response = if exception.type == :invalid_request
21
- ::Doorkeeper::OAuth::InvalidRequestResponse.new(
22
- name: exception.type,
23
- state: params[:state],
24
- redirect_uri: params[:redirect_uri],
25
- response_on_fragment: pre_auth.response_on_fragment?,
26
- issuer: Doorkeeper::OpenidConnect.doorkeeper_issuer,
27
- )
28
- else
29
- ::Doorkeeper::OAuth::ErrorResponse.new(
30
- name: exception.type,
31
- state: params[:state],
32
- redirect_uri: params[:redirect_uri],
33
- response_on_fragment: pre_auth.response_on_fragment?,
34
- issuer: Doorkeeper::OpenidConnect.doorkeeper_issuer,
35
- )
36
- end
20
+ error_response = oidc_error_response_class(exception).new(
21
+ name: exception.type,
22
+ state: params[:state],
23
+ redirect_uri: params[:redirect_uri],
24
+ response_on_fragment: pre_auth.response_on_fragment?,
25
+ issuer: Doorkeeper::OpenidConnect.doorkeeper_issuer,
26
+ )
37
27
 
38
28
  response.headers.merge!(error_response.headers)
39
29
 
@@ -45,6 +35,14 @@ module Doorkeeper
45
35
  @authorize_response = error_response
46
36
  redirect_or_render(@authorize_response)
47
37
  end
38
+
39
+ def oidc_error_response_class(exception)
40
+ if exception.type == :invalid_request
41
+ ::Doorkeeper::OAuth::InvalidRequestResponse
42
+ else
43
+ ::Doorkeeper::OAuth::ErrorResponse
44
+ end
45
+ end
48
46
  end
49
47
  end
50
48
  end
@@ -8,6 +8,38 @@ module Doorkeeper
8
8
  # §3.1.2.1): when the resource owner's last authentication is older
9
9
  # than `max_age` seconds, reauthentication is required.
10
10
  module MaxAge
11
+ # The shapes a `Time` takes once it has been serialized into the
12
+ # session:
13
+ #
14
+ # * `"2026-09-05T03:59:10.270Z"` — `ActiveSupport::JSON`, which is
15
+ # what the JSON cookie serializer emits
16
+ # * `"2026/09/05 03:59:10 +0000"` — the same encoder with
17
+ # `ActiveSupport::JSON::Encoding.use_standard_json_time_format`
18
+ # turned off
19
+ # * `"2026-09-05 03:59:27 UTC"` — `JSON.generate` on a bare `Time`
20
+ #
21
+ # The date separator is captured and backreferenced so the two forms
22
+ # cannot be mixed, and the zone suffix is optional so that a
23
+ # serializer which drops it still reads as a local time.
24
+ OIDC_SERIALIZED_AUTH_TIME = %r{
25
+ \A
26
+ (?<year>\d{4})(?<sep>[-/])(?<month>\d{2})\k<sep>(?<day>\d{2}) # date
27
+ [T\ ]
28
+ \d{2}:\d{2}:\d{2} # time
29
+ (?:\.\d+)? # optional fractional seconds
30
+ (?:\ ?(?:Z|UTC|[+-]\d{2}:?\d{2}))? # optional zone
31
+ \z
32
+ }x
33
+
34
+ # How far ahead of the provider's clock an auth_time may sit and
35
+ # still be trusted. An authentication cannot have happened in the
36
+ # future, but the servers that write auth_time and the one that
37
+ # checks it do not share a clock, and a user who authenticated a
38
+ # moment ago must not be sent to reauthenticate over ordinary skew —
39
+ # the fresh auth_time from that reauthentication would be ahead
40
+ # again, looping forever.
41
+ OIDC_AUTH_TIME_FUTURE_LEEWAY = 60
42
+
11
43
  private
12
44
 
13
45
  def handle_oidc_max_age_param!(owner)
@@ -56,12 +88,84 @@ module Doorkeeper
56
88
  def normalized_oidc_auth_time(owner)
57
89
  auth_time = resolve_oidc_auth_time(owner)
58
90
  return auth_time if !auth_time || auth_time.is_a?(Time) || auth_time.is_a?(DateTime)
91
+ return parse_oidc_auth_time_string(auth_time) if auth_time.is_a?(String)
59
92
 
60
93
  Time.zone.at(auth_time.to_i)
61
94
  end
62
95
 
96
+ # A `Time` written to the session comes back as a String once the
97
+ # session is serialized, which is the default since Rails 7.0
98
+ # (`config.load_defaults` selects the JSON cookie serializer). Running
99
+ # such a value through `to_i` yields the leading year — `"2026-09-05
100
+ # T03:59:10.270Z".to_i` is `2026` — placing auth_time in 1970 and
101
+ # making every `max_age` request look stale, so the RP and the
102
+ # provider bounce the user through reauthentication forever. Same
103
+ # class of bug as #248, which fixed the Integer case.
104
+ #
105
+ # A numeric String is an epoch. The grammar covers every *numeric*
106
+ # String `to_i` resolved — optional surrounding whitespace, an
107
+ # optional sign, an optional fractional part — and `to_f` rather than
108
+ # `to_i` keeps the sub-second part of `"1757150000.5"`. A signed
109
+ # value stays meaningful either way: `"+1757150000"` is the epoch it
110
+ # looks like, and a negative one resolves to a pre-1970 time, which
111
+ # is stale under any `max_age` and so requires reauthentication
112
+ # regardless.
113
+ #
114
+ # Anchoring it at both ends is the one deliberate narrowing here.
115
+ # `to_i` also truncated a String to its leading integer, so
116
+ # `"1788703912 seconds"` used to resolve and no longer does. Keeping
117
+ # that as a fallback would reinstate the bug above for every
118
+ # serialization this method does not recognise —
119
+ # `"20260905T035910Z".to_i` is `20260905`, i.e. 1970 and stale
120
+ # forever — while reading `"99999999999999 garbage"` as a year
121
+ # 3170843 timestamp, i.e. fresh. Truncation guesses wrong in both
122
+ # directions; a String that is not wholly a number is not an epoch.
123
+ #
124
+ # Anything else has to look like one of the
125
+ # serialized timestamps, which `Time.zone.parse` then reads. `parse`
126
+ # is not used on its own because it is far more permissive than any
127
+ # of those formats: it takes `"10 minutes"` for day 10 of the current
128
+ # month, and such a value landing within `max_age` of now would read
129
+ # as *fresh* and silently skip the reauthentication.
130
+ # `Time.zone.iso8601` is no alternative — it rejects the bare-`Time`
131
+ # form outright.
132
+ #
133
+ # Matching the shape is not enough on its own: `parse` rolls an
134
+ # impossible calendar date over instead of raising, so `"2026-02-31"`
135
+ # comes back as 2026-03-03 — a date that does not exist would read as
136
+ # fresh whenever its rollover lands within `max_age` of now. The day
137
+ # is therefore checked with `Date.valid_date?` before parsing.
138
+ #
139
+ # What is left over is rescued: an out-of-range hour, minute or
140
+ # second raises `ArgumentError`, and a numeric String too long to be
141
+ # a Float (`"9" * 400` overflows to `Float::INFINITY`) makes
142
+ # `Time.zone.at` raise `FloatDomainError`. A value of any of these
143
+ # shapes returns nil, which `oidc_auth_time_stale?` treats as "no
144
+ # known auth_time" and so requires reauthentication — a malformed
145
+ # callback value must fail closed, not 500.
146
+ def parse_oidc_auth_time_string(value)
147
+ return Time.zone.at(value.to_f) if value.match?(/\A\s*[+-]?\d+(?:\.\d+)?\s*\z/)
148
+
149
+ match = OIDC_SERIALIZED_AUTH_TIME.match(value)
150
+ return unless match
151
+ return unless Date.valid_date?(match[:year].to_i, match[:month].to_i, match[:day].to_i)
152
+
153
+ Time.zone.parse(value)
154
+ rescue ArgumentError, RangeError
155
+ nil
156
+ end
157
+
158
+ # An auth_time further in the future than clock skew explains is not
159
+ # a time the resource owner authenticated at, whatever its type — a
160
+ # `Time`, an Integer epoch and a parsed String all reach here — so it
161
+ # is treated like an unknown one and requires reauthentication.
162
+ # Left unchecked, its negative age is below any `max_age` and it
163
+ # would read as fresh indefinitely.
63
164
  def oidc_auth_time_stale?(auth_time, max_age)
64
- !auth_time || (Time.zone.now - auth_time) > max_age
165
+ return true unless auth_time
166
+
167
+ age = Time.zone.now - auth_time
168
+ age > max_age || age < -OIDC_AUTH_TIME_FUTURE_LEEWAY
65
169
  end
66
170
 
67
171
  # Resolve auth_time for max_age enforcement.
@@ -22,18 +22,12 @@ module Doorkeeper
22
22
 
23
23
  def apply_oidc_prompt!(prompt, prompt_values, owner)
24
24
  case prompt
25
- when "none"
26
- handle_oidc_prompt_none!(prompt_values, owner)
27
- when "login"
28
- handle_oidc_prompt_login!(owner)
29
- when "consent"
30
- handle_oidc_prompt_consent!(owner)
31
- when "select_account"
32
- select_account_for_oidc_resource_owner(owner)
33
- when "create"
34
- # NOTE: not supported, but does not raise an error.
35
- else
36
- raise Errors::InvalidRequest
25
+ when "none" then handle_oidc_prompt_none!(prompt_values, owner)
26
+ when "login" then handle_oidc_prompt_login!(owner)
27
+ when "consent" then handle_oidc_prompt_consent!(owner)
28
+ when "select_account" then select_account_for_oidc_resource_owner(owner)
29
+ when "create" then nil # NOTE: not supported, but does not raise an error.
30
+ else raise Errors::InvalidRequest
37
31
  end
38
32
  end
39
33
 
@@ -81,6 +75,11 @@ module Doorkeeper
81
75
 
82
76
  def return_without_oidc_prompt_param(prompt_value)
83
77
  return_to = URI.parse(request.path)
78
+ return_to.query = query_parameters_without_oidc_prompt(prompt_value).to_query
79
+ return_to.to_s
80
+ end
81
+
82
+ def query_parameters_without_oidc_prompt(prompt_value)
84
83
  # Work on a copy: `request.query_parameters` is memoized and shared, so
85
84
  # mutating it in place would corrupt the parameters seen by the rest of
86
85
  # the request (and any subsequent prompt value in the same loop).
@@ -95,8 +94,7 @@ module Doorkeeper
95
94
  else
96
95
  query.delete("prompt")
97
96
  end
98
- return_to.query = query.to_query
99
- return_to.to_s
97
+ query
100
98
  end
101
99
 
102
100
  def reauthenticate_oidc_resource_owner(owner)
@@ -35,14 +35,10 @@ module Doorkeeper
35
35
  relation = token_model.authorized_tokens_for(pre_auth.client.id, owner)
36
36
  batch_size = Doorkeeper.configuration.token_lookup_batch_size
37
37
 
38
- match_found = false
39
38
  token_model.find_access_token_in_batches(relation, batch_size: batch_size) do |batch|
40
- if batch.any? { |token| token.scopes.scopes?(pre_auth.scopes) }
41
- match_found = true
42
- break
43
- end
39
+ return true if batch.any? { |token| token.scopes.scopes?(pre_auth.scopes) }
44
40
  end
45
- match_found
41
+ false
46
42
  end
47
43
 
48
44
  # Force Doorkeeper's `render_success` onto the auto-issue path when a
@@ -9,10 +9,16 @@ module Doorkeeper
9
9
  # must never be silently dropped when blank.
10
10
  REQUIRED_CLAIMS = %i[iss sub aud exp iat].freeze
11
11
 
12
+ # The return type of `select_key`: the key material and the algorithm it
13
+ # signs with, kept together so `at_hash` can always be computed with the
14
+ # digest matching the algorithm in the token's actual JOSE header
15
+ # (OIDC Core §3.2.2.10).
16
+ SigningKey = Struct.new(:keypair, :kid, :algorithm, keyword_init: true)
17
+
12
18
  # `resource_owner` is exposed so callers can detect a token whose owner no
13
19
  # longer resolves (e.g. deleted after issuance) before serializing — the
14
20
  # `sub` claim would otherwise dereference a nil owner and raise.
15
- # `access_token` is the reader HybridIdTokenConcern relies on to compute
21
+ # `access_token` is the reader AtHashConcern relies on to compute
16
22
  # `at_hash`; a custom id_token_class must expose one as well.
17
23
  attr_reader :access_token, :nonce, :resource_owner
18
24
 
@@ -58,10 +64,31 @@ module Doorkeeper
58
64
  end
59
65
 
60
66
  def as_jws_token
67
+ key = selected_key
68
+
61
69
  ::JWT.encode(as_json,
62
- Doorkeeper::OpenidConnect.signing_key.keypair,
63
- Doorkeeper::OpenidConnect.signing_algorithm.to_s,
64
- { typ: "JWT", kid: Doorkeeper::OpenidConnect.signing_key.kid }).to_s
70
+ key.keypair,
71
+ key.algorithm.to_s,
72
+ { typ: "JWT", kid: key.kid }).to_s
73
+ end
74
+
75
+ # Override point for custom signing-key selection (per-client keys, key
76
+ # rotation, multi-tenant setups, …). Must return an object responding to
77
+ # `#keypair`, `#kid` and `#algorithm` — use `SigningKey` for convenience.
78
+ # Keys returned from here are not advertised automatically: a custom
79
+ # implementation is responsible for exposing any additional keys through
80
+ # its own JWKS handling so clients can validate the signature.
81
+ def select_key
82
+ # Resolved once: `signing_key` builds a fresh JWK per call and honors
83
+ # callable configuration, so reading `keypair` and `kid` from separate
84
+ # calls could pair values from two different keys.
85
+ jwk = Doorkeeper::OpenidConnect.signing_key
86
+
87
+ SigningKey.new(
88
+ keypair: jwk.keypair,
89
+ kid: jwk.kid,
90
+ algorithm: Doorkeeper::OpenidConnect.signing_algorithm.to_s,
91
+ )
65
92
  end
66
93
 
67
94
  # Public: the RFC 9207 `iss` authorization response parameter must be
@@ -78,6 +105,15 @@ module Doorkeeper
78
105
 
79
106
  private
80
107
 
108
+ # `select_key` resolved exactly once per token, mirroring the `issuer`
109
+ # memoization above: the signature (`as_jws_token`) and the `at_hash`
110
+ # digest (`AtHashConcern`) must agree on the algorithm, so a
111
+ # dynamic `select_key` implementation must not be re-invoked between
112
+ # the two.
113
+ def selected_key
114
+ @selected_key ||= select_key
115
+ end
116
+
81
117
  def subject
82
118
  Doorkeeper::OpenidConnect.configuration.subject.call(
83
119
  @resource_owner,
@@ -65,9 +65,9 @@ module Doorkeeper
65
65
  server_scopes = server.scopes
66
66
 
67
67
  if server_scopes.respond_to?(:allowed)
68
- server_scopes.allowed(requested_scopes).to_s
68
+ server_scopes.allowed(effective_requested_scopes).to_s
69
69
  else
70
- (server_scopes & requested_scopes).to_s
70
+ (server_scopes & effective_requested_scopes).to_s
71
71
  end
72
72
  end
73
73
 
@@ -121,12 +121,34 @@ module Doorkeeper
121
121
  ::Doorkeeper::OAuth::Scopes.from_string(@params[:scope].to_s)
122
122
  end
123
123
 
124
+ # RFC 7591 §2: when the request omits `scope`, the authorization
125
+ # server registers the client with its own default set. Doorkeeper
126
+ # has one — `default_scopes` — and registering with no scopes at all
127
+ # is emphatically not it: an application whose `scopes` column is
128
+ # blank is treated as unrestricted by Doorkeeper's ScopeChecker
129
+ # (`app_scopes.presence || server_scopes`), so an omitted `scope`
130
+ # would register a client entitled to every scope the server defines,
131
+ # while a client that honestly declared a narrow scope stays confined
132
+ # to it.
133
+ def effective_requested_scopes
134
+ requested_scopes.presence || server.default_scopes
135
+ end
136
+
137
+ # A registration must never persist an empty scope set, whichever way
138
+ # it got there: neither an explicit `scope` made up entirely of
139
+ # unsupported values, nor an omitted `scope` on a server that
140
+ # configures no default scopes.
124
141
  def validate_scope
125
- return true if requested_scopes.blank? || permitted_scopes.present?
142
+ return true if permitted_scopes.present?
126
143
 
127
144
  @error_description =
128
- "scope '#{requested_scopes}' contains no scopes supported by this server. " \
129
- "Supported scopes: #{server.scopes}"
145
+ if requested_scopes.present?
146
+ "scope '#{requested_scopes}' contains no scopes supported by this server. " \
147
+ "Supported scopes: #{server.scopes}"
148
+ else
149
+ "scope is required: this server registers no default scopes, so a client " \
150
+ "must request at least one of its supported scopes: #{server.scopes}"
151
+ end
130
152
  false
131
153
  end
132
154
 
@@ -3,13 +3,47 @@
3
3
  module Doorkeeper
4
4
  module OpenidConnect
5
5
  module AccessGrant
6
- def self.prepended(base)
7
- base.class_eval do
8
- has_one :openid_request,
9
- class_name: Doorkeeper::OpenidConnect.configuration.open_id_request_class,
10
- foreign_key: "access_grant_id",
11
- inverse_of: :access_grant,
12
- dependent: :delete
6
+ class << self
7
+ # Wiring the `openid_request` association needs
8
+ # `open_id_request_class`, so it can only run once
9
+ # `Doorkeeper::OpenidConnect.configure` has. The grant model is not
10
+ # guaranteed to load after that: Doorkeeper's own `initialize_models!`
11
+ # reaches for it from `Doorkeeper.configure` (immediately, when a
12
+ # railtie has already loaded `ActiveRecord::Base`), and any initializer
13
+ # sorting between `doorkeeper.rb` and `doorkeeper_openid_connect.rb`
14
+ # can name the model itself. Reading the configuration here would raise
15
+ # `MissingConfiguration` in both cases — with a message pointing at an
16
+ # initializer that does exist and simply has not run yet.
17
+ #
18
+ # So wire immediately when the configuration is there, and otherwise
19
+ # remember the host model and wire it from `configure`.
20
+ def prepended(base)
21
+ return pending_hosts << base unless OpenidConnect.configured?
22
+
23
+ wire_openid_request_association(base)
24
+ end
25
+
26
+ # Called from `Doorkeeper::OpenidConnect.configure` for the models that
27
+ # loaded before it.
28
+ def wire_pending_hosts
29
+ pending_hosts.each { |base| wire_openid_request_association(base) }
30
+ pending_hosts.clear
31
+ end
32
+
33
+ private
34
+
35
+ def pending_hosts
36
+ @pending_hosts ||= []
37
+ end
38
+
39
+ def wire_openid_request_association(base)
40
+ base.class_eval do
41
+ has_one :openid_request,
42
+ class_name: Doorkeeper::OpenidConnect.configuration.open_id_request_class,
43
+ foreign_key: "access_grant_id",
44
+ inverse_of: :access_grant,
45
+ dependent: :delete
46
+ end
13
47
  end
14
48
  end
15
49
  end
@@ -26,33 +26,38 @@ module Doorkeeper
26
26
 
27
27
  def generate_routes!(options)
28
28
  @mapping = Mapper.new.map(&@block)
29
- openid_connect = ::Doorkeeper::OpenidConnect.configuration
30
- prefix = route_helper_prefix(options)
31
29
 
32
30
  routes.scope options[:scope] || "oauth", as: "oauth" do
33
- map_route(:userinfo, :userinfo_routes)
34
- map_route(:discovery, :discovery_routes)
31
+ map_oauth_routes
32
+ end
35
33
 
36
- if openid_connect.dynamic_client_registration
37
- map_route(:dynamic_client_registration, :dynamic_client_registration_routes)
38
- end
34
+ routes.scope(**well_known_scope(options)) do
35
+ map_route(:discovery, :discovery_well_known_routes)
39
36
  end
37
+ end
40
38
 
41
- well_known_scope = { as: "oauth" }
39
+ private
40
+
41
+ def map_oauth_routes
42
+ map_route(:userinfo, :userinfo_routes)
43
+ map_route(:discovery, :discovery_routes)
44
+ return unless ::Doorkeeper::OpenidConnect.configuration.dynamic_client_registration
45
+
46
+ map_route(:dynamic_client_registration, :dynamic_client_registration_routes)
47
+ end
48
+
49
+ def well_known_scope(options)
50
+ prefix = route_helper_prefix(options)
51
+ scope = { as: "oauth" }
42
52
  # When the engine is mounted under a named scope (e.g.
43
53
  # `scope :users, as: :users`), Doorkeeper's and this engine's URL
44
54
  # helpers are generated with that prefix (`users_oauth_*`). Pass the
45
55
  # prefix down to the discovery controller via a route default so it can
46
56
  # resolve the correct namespaced helpers for the published endpoints.
47
- well_known_scope[:defaults] = { route_helper_prefix: prefix } if prefix.present?
48
-
49
- routes.scope(**well_known_scope) do
50
- map_route(:discovery, :discovery_well_known_routes)
51
- end
57
+ scope[:defaults] = { route_helper_prefix: prefix } if prefix.present?
58
+ scope
52
59
  end
53
60
 
54
- private
55
-
56
61
  def route_helper_prefix(options)
57
62
  name = options[:as]
58
63
  name.present? ? "#{name}_" : ""
@@ -3,9 +3,9 @@
3
3
  module Doorkeeper
4
4
  module OpenidConnect
5
5
  MAJOR = 2
6
- MINOR = 0
6
+ MINOR = 1
7
7
  TINY = 0
8
- PRE = "beta1"
8
+ PRE = nil
9
9
 
10
10
  # Full version number
11
11
  VERSION = [MAJOR, MINOR, TINY, PRE].compact.join(".")
@@ -17,11 +17,12 @@ require "jwt"
17
17
  # `ClaimsBuilder` while its class body runs.
18
18
  module Doorkeeper
19
19
  module OpenidConnect
20
+ autoload :AtHashConcern, "doorkeeper/openid_connect/at_hash_concern"
20
21
  autoload :ClaimsBuilder, "doorkeeper/openid_connect/claims_builder"
22
+ autoload :ClaimsHash, "doorkeeper/openid_connect/claims_hash"
21
23
  autoload :DiscoveryHelpersMixin, "doorkeeper/openid_connect/discovery_helpers_mixin"
22
24
  autoload :Errors, "doorkeeper/openid_connect/errors"
23
25
  autoload :GrantTypesSupportedMixin, "doorkeeper/openid_connect/grant_types_supported_mixin"
24
- autoload :HybridIdTokenConcern, "doorkeeper/openid_connect/hybrid_id_token_concern"
25
26
  autoload :IdToken, "doorkeeper/openid_connect/id_token"
26
27
  autoload :TokenEndpointAuthMethodsSupportedMixin,
27
28
  "doorkeeper/openid_connect/token_endpoint_auth_methods_supported_mixin"
@@ -45,8 +46,8 @@ module Doorkeeper
45
46
  # makes them safe to autoload from a namespace this gem does not own.
46
47
  #
47
48
  # `Request::IdToken` and `Request::IdTokenToken` reach these at request time;
48
- # for an application that never enables the OpenID Connect implicit or hybrid
49
- # flows, none of the four is ever loaded.
49
+ # for an application that never enables the OpenID Connect implicit flows,
50
+ # none of the four is ever loaded.
50
51
  module OAuth
51
52
  autoload :IdTokenRequest, "doorkeeper/oauth/id_token_request"
52
53
  autoload :IdTokenResponse, "doorkeeper/oauth/id_token_response"
@@ -298,14 +299,10 @@ module Doorkeeper
298
299
  return issuer unless issuer.respond_to?(:call)
299
300
 
300
301
  case issuer.arity
301
- when 0
302
- issuer.call
303
- when 1
304
- issuer.call(request || resource_owner)
305
- when 2
306
- issuer.call(resource_owner, application)
307
- else
308
- issuer.call(resource_owner, application, request)
302
+ when 0 then issuer.call
303
+ when 1 then issuer.call(request || resource_owner)
304
+ when 2 then issuer.call(resource_owner, application)
305
+ else issuer.call(resource_owner, application, request)
309
306
  end
310
307
  end
311
308
  private_class_method :call_issuer
@@ -59,9 +59,13 @@ Doorkeeper::OpenidConnect.configure do
59
59
  # piggy-backing on a fresh login from another device.
60
60
  #
61
61
  # The block is executed in the controller's scope and receives the
62
- # current `session` and `request`. Return value can be a `Time`,
63
- # `DateTime`, or anything responding to `to_i`. Return `nil` to force
64
- # reauthentication.
62
+ # current `session` and `request`. Return value can be a `Time`, a
63
+ # `DateTime`, epoch seconds (an Integer or a numeric String), or a
64
+ # serialized timestamp String such as `"2026-09-05T03:59:10.270Z"` — a
65
+ # `Time` stored in the session comes back as the latter once it
66
+ # round-trips through the JSON cookie serializer, which Rails 7.0+
67
+ # selects by default. A String of any other shape is read as an unknown
68
+ # auth_time and forces reauthentication, as does `nil`.
65
69
  #
66
70
  # auth_time_from_session do |session, _request|
67
71
  # # Example implementation: capture auth_time on the session at login,
@@ -208,12 +212,20 @@ Doorkeeper::OpenidConnect.configure do
208
212
  # different apps may need different claim sets) or if you need to transform data before returning
209
213
  # it to external clients.
210
214
  #
211
- # It is generally recommended to subclass the default models, but you can implement your own so
212
- # long as they respond to `#as_json` (and, for the ID Token, also to `#as_jws_token`, `#issuer`,
213
- # and an `#access_token` reader — the latter is used to compute the `at_hash` claim in the hybrid
214
- # `id_token token` response type), and have the same initializer. These methods may be private;
215
- # their presence is validated when the class is first used. Depending on the implementation, this
216
- # may or may not override the provided behavior of the `claims` block.
215
+ # Custom classes must inherit from Doorkeeper::OpenidConnect::IdToken /
216
+ # Doorkeeper::OpenidConnect::UserInfo (validated when the class is first used). The base classes
217
+ # carry the security-critical behavior — required-claim enforcement, the claim merge order that
218
+ # keeps the `claims` configuration block from overriding `sub`/`aud`/`exp`, nonce and `at_hash`
219
+ # handling — so a subclass only overrides what it actually needs. Typical override points:
220
+ #
221
+ # - `claims` — call `super.merge(...)` so the base claims are always present. A key you add
222
+ # that collides with a registered claim replaces it, so keep your keys distinct unless
223
+ # replacing it is what you mean
224
+ # - `audience` (ID Token) — the `aud` claim may be a string or an array per OIDC Core
225
+ # - `select_key` (ID Token) — return a Doorkeeper::OpenidConnect::IdToken::SigningKey to sign
226
+ # with a different key/algorithm (per-client keys, rotation, multi-tenancy). The `at_hash`
227
+ # digest follows the selected algorithm automatically; advertising any additional keys via
228
+ # JWKS is the application's responsibility.
217
229
  #
218
230
  # Refer to Doorkeeper::OpenidConnect::IdToken and Doorkeeper::OpenidConnect::UserInfo for more
219
231
  # information and implementation details.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: doorkeeper-openid_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.beta1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Dengler
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-08-20 00:00:00.000000000 Z
13
+ date: 2026-10-20 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: doorkeeper
@@ -46,20 +46,6 @@ dependencies:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
48
  version: '2.5'
49
- - !ruby/object:Gem::Dependency
50
- name: ostruct
51
- requirement: !ruby/object:Gem::Requirement
52
- requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- version: '0.5'
56
- type: :runtime
57
- prerelease: false
58
- version_requirements: !ruby/object:Gem::Requirement
59
- requirements:
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: '0.5'
63
49
  - !ruby/object:Gem::Dependency
64
50
  name: bigdecimal
65
51
  requirement: !ruby/object:Gem::Requirement
@@ -195,9 +181,11 @@ files:
195
181
  - lib/doorkeeper/oauth/id_token_token_request.rb
196
182
  - lib/doorkeeper/oauth/id_token_token_response.rb
197
183
  - lib/doorkeeper/openid_connect.rb
184
+ - lib/doorkeeper/openid_connect/at_hash_concern.rb
198
185
  - lib/doorkeeper/openid_connect/claims/claim.rb
199
186
  - lib/doorkeeper/openid_connect/claims/normal_claim.rb
200
187
  - lib/doorkeeper/openid_connect/claims_builder.rb
188
+ - lib/doorkeeper/openid_connect/claims_hash.rb
201
189
  - lib/doorkeeper/openid_connect/config.rb
202
190
  - lib/doorkeeper/openid_connect/discovery_helpers_mixin.rb
203
191
  - lib/doorkeeper/openid_connect/engine.rb
@@ -208,7 +196,6 @@ files:
208
196
  - lib/doorkeeper/openid_connect/helpers/controller/max_age.rb
209
197
  - lib/doorkeeper/openid_connect/helpers/controller/prompt.rb
210
198
  - lib/doorkeeper/openid_connect/helpers/controller/token_matching.rb
211
- - lib/doorkeeper/openid_connect/hybrid_id_token_concern.rb
212
199
  - lib/doorkeeper/openid_connect/id_token.rb
213
200
  - lib/doorkeeper/openid_connect/oauth/authorization/code.rb
214
201
  - lib/doorkeeper/openid_connect/oauth/authorization_code_request.rb
@@ -256,9 +243,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
256
243
  version: '3.2'
257
244
  required_rubygems_version: !ruby/object:Gem::Requirement
258
245
  requirements:
259
- - - ">"
246
+ - - ">="
260
247
  - !ruby/object:Gem::Version
261
- version: 1.3.1
248
+ version: '0'
262
249
  requirements: []
263
250
  rubygems_version: 3.1.6
264
251
  signing_key: