doorkeeper-openid_connect 2.0.0 → 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: a6ac229d2a87bb6712986b26a47a87ef8758b581b1fa99ddd2475d0a860f9e65
4
- data.tar.gz: 27300a20c1ad92391d2ecd2bbe8a635ccb97b490505f8d0c9752c09f8738d0ba
3
+ metadata.gz: 8eba70212cad992603afa10fe0f3ae1db4461c03938897418e8659f6a95c64bb
4
+ data.tar.gz: 74cc66dd6b502f6897428d36173f8faa0b68293dd456b99d7d80343714b2bb54
5
5
  SHA512:
6
- metadata.gz: b1bc13a50ca14f08f218341c00ff2ab15d9b04cf1bc4d25b92d9d8617cb7a432acf86df5ee69bebec6b3e576956734db840ecec63453f5572f848c6e35976416
7
- data.tar.gz: ce360e5a9ad631ff29da07b288e0fd74523293c4227d1a9f9bd9583a3e41539225d8a61e70ab3e26aa43ffe51ec9fb061328f0be6e7089f02639739f8f6c21e3
6
+ metadata.gz: 751bf66f9a2a58009f787a13c50d9a22a9f7b76117c8fbf1062e22896237ef7d8b66778e4834d59092fa7cdc623f48850b4e61eb796ba36f035418f3d4a39512
7
+ data.tar.gz: 1df8e6e7c4600a43e81833f5dcc3a210d99d4003a7f8a5bcde2ae53b8df662ced2147e7961d022b13a20607692f9147c44c7eee31704fd2ef11f289b0dd1ddac
data/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ 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
+
7
15
  ## v2.0.0 (2026-09-22)
8
16
 
9
17
  >[!IMPORTANT]
@@ -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
@@ -33,7 +31,7 @@ module Doorkeeper
33
31
  private_class_method :claim_applies?
34
32
 
35
33
  def initialize(&block)
36
- @claims = OpenStruct.new
34
+ @claims = ClaimsHash.new
37
35
  instance_eval(&block)
38
36
  end
39
37
 
@@ -42,13 +40,14 @@ module Doorkeeper
42
40
  end
43
41
 
44
42
  def normal_claim(name, response: [:user_info], scope: nil, &block)
45
- @claims[name] =
43
+ claim =
46
44
  Claims::NormalClaim.new(
47
45
  name: name,
48
46
  response: response,
49
47
  scope: scope,
50
48
  generator: block,
51
49
  )
50
+ @claims[claim.name] = claim
52
51
  end
53
52
  alias claim normal_claim
54
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
 
@@ -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
@@ -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.
@@ -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
@@ -3,7 +3,7 @@
3
3
  module Doorkeeper
4
4
  module OpenidConnect
5
5
  MAJOR = 2
6
- MINOR = 0
6
+ MINOR = 1
7
7
  TINY = 0
8
8
  PRE = nil
9
9
 
@@ -19,6 +19,7 @@ module Doorkeeper
19
19
  module OpenidConnect
20
20
  autoload :AtHashConcern, "doorkeeper/openid_connect/at_hash_concern"
21
21
  autoload :ClaimsBuilder, "doorkeeper/openid_connect/claims_builder"
22
+ autoload :ClaimsHash, "doorkeeper/openid_connect/claims_hash"
22
23
  autoload :DiscoveryHelpersMixin, "doorkeeper/openid_connect/discovery_helpers_mixin"
23
24
  autoload :Errors, "doorkeeper/openid_connect/errors"
24
25
  autoload :GrantTypesSupportedMixin, "doorkeeper/openid_connect/grant_types_supported_mixin"
@@ -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,
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
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-09-22 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
@@ -199,6 +185,7 @@ files:
199
185
  - lib/doorkeeper/openid_connect/claims/claim.rb
200
186
  - lib/doorkeeper/openid_connect/claims/normal_claim.rb
201
187
  - lib/doorkeeper/openid_connect/claims_builder.rb
188
+ - lib/doorkeeper/openid_connect/claims_hash.rb
202
189
  - lib/doorkeeper/openid_connect/config.rb
203
190
  - lib/doorkeeper/openid_connect/discovery_helpers_mixin.rb
204
191
  - lib/doorkeeper/openid_connect/engine.rb