doorkeeper-openid_connect 2.0.0.beta1 → 2.0.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: a6ac229d2a87bb6712986b26a47a87ef8758b581b1fa99ddd2475d0a860f9e65
4
+ data.tar.gz: 27300a20c1ad92391d2ecd2bbe8a635ccb97b490505f8d0c9752c09f8738d0ba
5
5
  SHA512:
6
- metadata.gz: f349f5d093f30c5ed07a77349cc63d0651047185587c149172ec5d38eb29bd4644a74f3b10fd54674f43beda2885f58b7b400094e19afda4d24daadd56dc1e8d
7
- data.tar.gz: 73e1e48f7fa439a9f7df513db74ba0a5df969a19a32abdf9c1b8bda34db08356abde81b3b3fdf425a0bbc46c8f258b669d1368cf25d067f187405e2f7cc8efd0
6
+ metadata.gz: b1bc13a50ca14f08f218341c00ff2ab15d9b04cf1bc4d25b92d9d8617cb7a432acf86df5ee69bebec6b3e576956734db840ecec63453f5572f848c6e35976416
7
+ data.tar.gz: ce360e5a9ad631ff29da07b288e0fd74523293c4227d1a9f9bd9583a3e41539225d8a61e70ab3e26aa43ffe51ec9fb061328f0be6e7089f02639739f8f6c21e3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ Upgrading? [Migration from Old Versions](https://github.com/doorkeeper-gem/doork
4
4
 
5
5
  - Add entry here
6
6
 
7
+ ## v2.0.0 (2026-09-22)
8
+
9
+ >[!IMPORTANT]
10
+ >
11
+ >- **Breaking (class overrides):** a configured `id_token_class` / `user_info_class` must now inherit from `Doorkeeper::OpenidConnect::IdToken` / `UserInfo` ([#364])
12
+ >- **Breaking (settings removed):** the deprecated `jws_private_key` / `jws_public_key` initializer settings are gone ([#387])
13
+ >- **Breaking (constant renamed):** `Doorkeeper::OpenidConnect::HybridIdTokenConcern` is now `AtHashConcern`, with no alias for the old name ([#399])
14
+ >
15
+ >**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.
16
+ >
17
+ >[Migration from Old Versions](https://github.com/doorkeeper-gem/doorkeeper-openid_connect/wiki/Migration-from-Old-Versions) walks through each one.
18
+
19
+ - **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.
20
+ - [#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`
21
+ - [#387] **Breaking:** Remove the deprecated `jws_private_key` and `jws_public_key` initializer settings
22
+ - [#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
23
+
7
24
  ## v2.0.0.beta1 (2026-08-20)
8
25
 
9
26
  >[!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
@@ -18,13 +18,20 @@ module Doorkeeper
18
18
  end
19
19
 
20
20
  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)
21
+ if claim_applies?(claim, access_token, response)
23
22
  [name, claim.generator.call(resource_owner, access_token.scopes, access_token)]
24
23
  end
25
24
  end.compact.to_h
26
25
  end
27
26
 
27
+ # A claim is included when the token carries one of its scopes and it is
28
+ # configured for this response type (:id_token / :user_info).
29
+ def self.claim_applies?(claim, access_token, response)
30
+ claim.scopes.any? { |scope| access_token.scopes.exists?(scope) } &&
31
+ claim.response.include?(response)
32
+ end
33
+ private_class_method :claim_applies?
34
+
28
35
  def initialize(&block)
29
36
  @claims = OpenStruct.new
30
37
  instance_eval(&block)
@@ -68,15 +68,6 @@ module Doorkeeper
68
68
  def build
69
69
  @config
70
70
  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
71
  end
81
72
 
82
73
  mattr_reader(:builder_class) { Config::Builder }
@@ -140,15 +131,19 @@ module Doorkeeper
140
131
  option :open_id_request_class, default: "Doorkeeper::OpenidConnect::Request"
141
132
 
142
133
  # 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.
134
+ # Must inherit from `Doorkeeper::OpenidConnect::IdToken`, which carries the
135
+ # security-critical invariants (required-claim enforcement, the merge order that keeps the
136
+ # `claims` configuration block from overriding `sub`/`aud`/`exp`, nonce and `at_hash`
137
+ # handling), so a subclass only overrides what it actually needs typically `claims`
138
+ # (call `super.merge(...)` so the registered claims are always present; a key you add that
139
+ # collides with one of them does replace it, so keep your keys distinct unless replacing it
140
+ # is what you mean), `audience`, or `select_key` for custom signing-key selection.
147
141
  option :id_token_class, default: "Doorkeeper::OpenidConnect::IdToken"
148
142
 
149
143
  # 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.
144
+ # Must inherit from `Doorkeeper::OpenidConnect::UserInfo`; a subclass typically only
145
+ # overrides `claims` (call `super.merge(...)`, and leave `sub` out of the merged hash unless
146
+ # replacing the canonical subject identifier is what you mean).
152
147
  option :user_info_class, default: "Doorkeeper::OpenidConnect::UserInfo"
153
148
 
154
149
  # Doorkeeper OpenID Request model class.
@@ -160,26 +155,32 @@ module Doorkeeper
160
155
  end
161
156
 
162
157
  def id_token_model
163
- resolve_validated_model(:id_token, id_token_class, %i[as_json as_jws_token issuer access_token])
158
+ resolve_validated_model(:id_token, id_token_class, IdToken)
164
159
  end
165
160
 
166
161
  def user_info_model
167
- resolve_validated_model(:user_info, user_info_class, %i[as_json])
162
+ resolve_validated_model(:user_info, user_info_class, UserInfo)
168
163
  end
169
164
 
170
165
  private
171
166
 
172
167
  # 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)
168
+ # and validates that it inherits from the corresponding default. The
169
+ # ancestry check replaced the earlier method-presence list: presence
170
+ # could be satisfied by any class (ActiveSupport defines `as_json` on
171
+ # `Object`), while inheritance also carries the security-critical
172
+ # behavior required-claim enforcement, the claim merge order, nonce
173
+ # and `at_hash` handling that a from-scratch implementation would
174
+ # have to reproduce. Both resolution and validation happen lazily at
175
+ # first use rather than inside `Doorkeeper::OpenidConnect.configure`:
176
+ # constantizing an app-defined class while initializers run breaks
177
+ # zeitwerk on Rails 7+, because reloadable constants must not be
178
+ # referenced during boot — the same reason `open_id_request_model`
179
+ # constantizes lazily. The class is also deliberately not memoized, so
180
+ # code reloading in development never hands back a stale class; only
181
+ # the validation result is cached, keyed on the resolved class so a
182
+ # reloaded class is re-validated.
183
+ def resolve_validated_model(kind, class_name, base_model)
183
184
  # `safe_constantize` (unlike a bare `constantize` rescue) only reports
184
185
  # nil when the configured constant itself is missing; a NameError
185
186
  # raised while loading the class body still surfaces as-is.
@@ -192,18 +193,15 @@ module Doorkeeper
192
193
  @validated_models ||= {}
193
194
  return model if @validated_models[kind] == model
194
195
 
195
- missing_methods = required_methods.reject do |method|
196
- model.method_defined?(method) || model.private_method_defined?(method)
197
- end
196
+ validate_model_inheritance!(kind, class_name, model, base_model)
197
+ @validated_models[kind] = model
198
+ end
198
199
 
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
200
+ def validate_model_inheritance!(kind, class_name, model, base_model)
201
+ return if model.is_a?(Class) && model <= base_model
204
202
 
205
- @validated_models[kind] = model
206
- model
203
+ raise Errors::InvalidConfiguration,
204
+ "The configured #{kind}_class (#{class_name}) must inherit from #{base_model.name}"
207
205
  end
208
206
  end
209
207
  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
@@ -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
 
@@ -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}_" : ""
@@ -5,7 +5,7 @@ module Doorkeeper
5
5
  MAJOR = 2
6
6
  MINOR = 0
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,11 @@ 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"
21
22
  autoload :DiscoveryHelpersMixin, "doorkeeper/openid_connect/discovery_helpers_mixin"
22
23
  autoload :Errors, "doorkeeper/openid_connect/errors"
23
24
  autoload :GrantTypesSupportedMixin, "doorkeeper/openid_connect/grant_types_supported_mixin"
24
- autoload :HybridIdTokenConcern, "doorkeeper/openid_connect/hybrid_id_token_concern"
25
25
  autoload :IdToken, "doorkeeper/openid_connect/id_token"
26
26
  autoload :TokenEndpointAuthMethodsSupportedMixin,
27
27
  "doorkeeper/openid_connect/token_endpoint_auth_methods_supported_mixin"
@@ -45,8 +45,8 @@ module Doorkeeper
45
45
  # makes them safe to autoload from a namespace this gem does not own.
46
46
  #
47
47
  # `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.
48
+ # for an application that never enables the OpenID Connect implicit flows,
49
+ # none of the four is ever loaded.
50
50
  module OAuth
51
51
  autoload :IdTokenRequest, "doorkeeper/oauth/id_token_request"
52
52
  autoload :IdTokenResponse, "doorkeeper/oauth/id_token_response"
@@ -298,14 +298,10 @@ module Doorkeeper
298
298
  return issuer unless issuer.respond_to?(:call)
299
299
 
300
300
  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)
301
+ when 0 then issuer.call
302
+ when 1 then issuer.call(request || resource_owner)
303
+ when 2 then issuer.call(resource_owner, application)
304
+ else issuer.call(resource_owner, application, request)
309
305
  end
310
306
  end
311
307
  private_class_method :call_issuer
@@ -208,12 +208,20 @@ Doorkeeper::OpenidConnect.configure do
208
208
  # different apps may need different claim sets) or if you need to transform data before returning
209
209
  # it to external clients.
210
210
  #
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` readerthe 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.
211
+ # Custom classes must inherit from Doorkeeper::OpenidConnect::IdToken /
212
+ # Doorkeeper::OpenidConnect::UserInfo (validated when the class is first used). The base classes
213
+ # carry the security-critical behaviorrequired-claim enforcement, the claim merge order that
214
+ # keeps the `claims` configuration block from overriding `sub`/`aud`/`exp`, nonce and `at_hash`
215
+ # handling so a subclass only overrides what it actually needs. Typical override points:
216
+ #
217
+ # - `claims` — call `super.merge(...)` so the base claims are always present. A key you add
218
+ # that collides with a registered claim replaces it, so keep your keys distinct unless
219
+ # replacing it is what you mean
220
+ # - `audience` (ID Token) — the `aud` claim may be a string or an array per OIDC Core
221
+ # - `select_key` (ID Token) — return a Doorkeeper::OpenidConnect::IdToken::SigningKey to sign
222
+ # with a different key/algorithm (per-client keys, rotation, multi-tenancy). The `at_hash`
223
+ # digest follows the selected algorithm automatically; advertising any additional keys via
224
+ # JWKS is the application's responsibility.
217
225
  #
218
226
  # Refer to Doorkeeper::OpenidConnect::IdToken and Doorkeeper::OpenidConnect::UserInfo for more
219
227
  # 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.0.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-09-22 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: doorkeeper
@@ -195,6 +195,7 @@ files:
195
195
  - lib/doorkeeper/oauth/id_token_token_request.rb
196
196
  - lib/doorkeeper/oauth/id_token_token_response.rb
197
197
  - lib/doorkeeper/openid_connect.rb
198
+ - lib/doorkeeper/openid_connect/at_hash_concern.rb
198
199
  - lib/doorkeeper/openid_connect/claims/claim.rb
199
200
  - lib/doorkeeper/openid_connect/claims/normal_claim.rb
200
201
  - lib/doorkeeper/openid_connect/claims_builder.rb
@@ -208,7 +209,6 @@ files:
208
209
  - lib/doorkeeper/openid_connect/helpers/controller/max_age.rb
209
210
  - lib/doorkeeper/openid_connect/helpers/controller/prompt.rb
210
211
  - lib/doorkeeper/openid_connect/helpers/controller/token_matching.rb
211
- - lib/doorkeeper/openid_connect/hybrid_id_token_concern.rb
212
212
  - lib/doorkeeper/openid_connect/id_token.rb
213
213
  - lib/doorkeeper/openid_connect/oauth/authorization/code.rb
214
214
  - lib/doorkeeper/openid_connect/oauth/authorization_code_request.rb
@@ -256,9 +256,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
256
256
  version: '3.2'
257
257
  required_rubygems_version: !ruby/object:Gem::Requirement
258
258
  requirements:
259
- - - ">"
259
+ - - ">="
260
260
  - !ruby/object:Gem::Version
261
- version: 1.3.1
261
+ version: '0'
262
262
  requirements: []
263
263
  rubygems_version: 3.1.6
264
264
  signing_key: