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 +4 -4
- data/CHANGELOG.md +25 -0
- data/app/controllers/doorkeeper/openid_connect/discovery_controller.rb +5 -0
- data/app/controllers/doorkeeper/openid_connect/dynamic_client_registration_controller.rb +13 -4
- data/lib/doorkeeper/oauth/id_token_token_request.rb +1 -1
- data/lib/doorkeeper/openid_connect/{hybrid_id_token_concern.rb → at_hash_concern.rb} +15 -6
- data/lib/doorkeeper/openid_connect/claims_builder.rb +12 -6
- data/lib/doorkeeper/openid_connect/claims_hash.rb +85 -0
- data/lib/doorkeeper/openid_connect/config.rb +41 -37
- data/lib/doorkeeper/openid_connect/engine.rb +7 -3
- data/lib/doorkeeper/openid_connect/helpers/controller/error_response.rb +15 -17
- data/lib/doorkeeper/openid_connect/helpers/controller/max_age.rb +105 -1
- data/lib/doorkeeper/openid_connect/helpers/controller/prompt.rb +12 -14
- data/lib/doorkeeper/openid_connect/helpers/controller/token_matching.rb +2 -6
- data/lib/doorkeeper/openid_connect/id_token.rb +40 -4
- data/lib/doorkeeper/openid_connect/oauth/dynamic_registration_request.rb +27 -5
- data/lib/doorkeeper/openid_connect/orm/active_record/access_grant.rb +41 -7
- data/lib/doorkeeper/openid_connect/rails/routes.rb +20 -15
- data/lib/doorkeeper/openid_connect/version.rb +2 -2
- data/lib/doorkeeper/openid_connect.rb +8 -11
- data/lib/generators/doorkeeper/openid_connect/templates/initializer.rb +21 -9
- metadata +6 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8eba70212cad992603afa10fe0f3ae1db4461c03938897418e8659f6a95c64bb
|
|
4
|
+
data.tar.gz: 74cc66dd6b502f6897428d36173f8faa0b68293dd456b99d7d80343714b2bb54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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::
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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
|
-
#
|
|
151
|
-
#
|
|
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,
|
|
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,
|
|
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
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
validate_model_inheritance!(kind, class_name, model, base_model)
|
|
203
|
+
@validated_models[kind] = model
|
|
204
|
+
end
|
|
198
205
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
11
|
-
Doorkeeper::AuthorizationsController
|
|
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
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
when "
|
|
28
|
-
|
|
29
|
-
when "
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
{ typ: "JWT", kid:
|
|
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(
|
|
68
|
+
server_scopes.allowed(effective_requested_scopes).to_s
|
|
69
69
|
else
|
|
70
|
-
(server_scopes &
|
|
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
|
|
142
|
+
return true if permitted_scopes.present?
|
|
126
143
|
|
|
127
144
|
@error_description =
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
34
|
-
|
|
31
|
+
map_oauth_routes
|
|
32
|
+
end
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}_" : ""
|
|
@@ -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
|
|
49
|
-
#
|
|
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
|
-
|
|
303
|
-
when
|
|
304
|
-
|
|
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`,
|
|
64
|
-
#
|
|
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
|
-
#
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
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.
|
|
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-
|
|
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:
|
|
248
|
+
version: '0'
|
|
262
249
|
requirements: []
|
|
263
250
|
rubygems_version: 3.1.6
|
|
264
251
|
signing_key:
|