rodauth-oauth 1.4.0 → 1.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/doc/release_notes/1_4_0.md +10 -2
- data/doc/release_notes/1_5_0.md +20 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +5 -0
- data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +17 -1
- data/lib/rodauth/features/oauth_base.rb +18 -8
- data/lib/rodauth/features/oauth_dpop.rb +410 -0
- data/lib/rodauth/features/oauth_dynamic_client_registration.rb +11 -2
- data/lib/rodauth/features/oauth_jwt.rb +9 -10
- data/lib/rodauth/features/oauth_jwt_base.rb +42 -24
- data/lib/rodauth/features/oauth_resource_indicators.rb +1 -1
- data/lib/rodauth/features/oauth_tls_client_auth.rb +1 -3
- data/lib/rodauth/features/oauth_token_introspection.rb +2 -2
- data/lib/rodauth/features/oidc.rb +5 -3
- data/lib/rodauth/features/oidc_session_management.rb +3 -1
- data/lib/rodauth/oauth/database_extensions.rb +4 -0
- data/lib/rodauth/oauth/ttl_store.rb +1 -1
- data/lib/rodauth/oauth/version.rb +1 -1
- data/locales/en.yml +11 -1
- data/templates/authorize.str +1 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 40ff3b3b3de0595eae98f218aec2f8e876f18329061c537e91e5841ab35e67dc
|
4
|
+
data.tar.gz: 19692e86d66400a9e7227f655bdc6375e38554cb52b97ee3a5e22cceab582168
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cd6efdeda012c25d83949e7f0ea2aa043238851f95bf1217b8156786f7376d39792caed37a2af68fc7777ed5a892fa2f9e5185efe1f8a3e7168df07de84b954d
|
7
|
+
data.tar.gz: 0e435eea239f81ff16db08b187ce7bb06ba3d25359603906e48610e38912162b35c79c3d8b7fbfa74df2c56f52bb653d61b999da76c1cc8d445f2077b876c578
|
data/doc/release_notes/1_4_0.md
CHANGED
@@ -29,6 +29,15 @@ If you're using `oidc`, the dependency on `account_expiration` has been replaced
|
|
29
29
|
|
30
30
|
If you're migrating, it's recommended that you keep depending on `account_expiration` during the transition, add `active_sessions` tables as per [rodauth specs](https://github.com/jeremyevans/rodauth/blob/master/spec/migrate/001_tables.rb#L150), and run them alongside one another for the max period ID tokens should be valid, after which you can remove `account_expiration` and its tables.
|
31
31
|
|
32
|
+
Some `auth_value_methods` were changed to `auth_methods` everywhere where it made sense. If you were overriding them, you'll have to wrap them in a block:
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
# in 1.3.2
|
36
|
+
oauth_jwt_issuer "http://myissuer.com"
|
37
|
+
# in 1.4.0
|
38
|
+
oauth_jwt_issuer { "http://myissuer.com" }
|
39
|
+
```
|
40
|
+
|
32
41
|
## Improvements
|
33
42
|
|
34
43
|
### OAuth SAML Bearer Grant per oauth application settings
|
@@ -45,5 +54,4 @@ The `oauth_saml_bearer_grant` feature requires a new table/resource, SAML settin
|
|
45
54
|
|
46
55
|
## Chore
|
47
56
|
|
48
|
-
*
|
49
|
-
* `oauth_tls_client_auth` is not dependent on the `oauth_jwt` feature, and can therefore be used with non-JWT access tokens, at least with the features which do not require it.
|
57
|
+
* `oauth_tls_client_auth` is not dependent on the `oauth_jwt` feature, and can therefore be used with non-JWT access tokens, at least with the features which do not require it.
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# 1.5.0
|
2
|
+
|
3
|
+
## Highlights
|
4
|
+
|
5
|
+
### OAuth DPoP Support
|
6
|
+
|
7
|
+
`rodauth-oauth` supports Demonstrating Proof-of-Possession at the Application Layer (also known as DPoP), via the `oauth_dpop` feature. This provides a mechanism to bind access tokens to a particular client based on public key cryptography.
|
8
|
+
|
9
|
+
More info about the feature [in the docs](https://gitlab.com/os85/rodauth-oauth/-/wikis/DPoP).
|
10
|
+
|
11
|
+
## Improvements
|
12
|
+
|
13
|
+
All features managing cookies are now able to set configure them as "session cookies" (i.e. removed on browser shutdown) by setting the expiration interval auth method to `nil`. This ncludes:
|
14
|
+
|
15
|
+
* `oauth_prompt_login_interval` (from the `oidc` feature)
|
16
|
+
* `oauth_oidc_user_agent_state_cookie_expires_in` (from the `oidc_session_management` feature)
|
17
|
+
|
18
|
+
## Bugfixes
|
19
|
+
|
20
|
+
* when using the `oauth_token_instrospection` feature, the `token_type` has been fixed to show "Bearer" (instead of "access_token").
|
@@ -79,6 +79,11 @@
|
|
79
79
|
<%= hidden_field_tag :registration, rodauth.param_or_nil("registration") %>
|
80
80
|
<% end %>
|
81
81
|
<% end %>
|
82
|
+
<% if rodauth.features.include?(:oidc) %>
|
83
|
+
<% if rodauth.param_or_nil("dpop_jkt") %>
|
84
|
+
<%= hidden_field_tag :dpop_jkt, rodauth.param_or_nil("dpop_jkt") %>
|
85
|
+
<% end %>
|
86
|
+
<% end %>
|
82
87
|
</div>
|
83
88
|
<p class="text-center">
|
84
89
|
<%= submit_tag rodauth.oauth_authorize_button, class: "btn btn-outline-primary" %>
|
@@ -49,6 +49,9 @@ class CreateRodauthOauth < ActiveRecord::Migration<%= migration_version %>
|
|
49
49
|
t.boolean :require_signed_request_object, null: true
|
50
50
|
t.boolean :require_pushed_authorization_requests, null: false, default: false
|
51
51
|
|
52
|
+
# :oauth_dpop
|
53
|
+
t.string :dpop_bound_access_tokens, null: true
|
54
|
+
|
52
55
|
# :oauth_tls_client_auth
|
53
56
|
t.string :tls_client_auth_subject_dn, null: true
|
54
57
|
t.string :tls_client_auth_san_dns, null: true
|
@@ -86,6 +89,9 @@ class CreateRodauthOauth < ActiveRecord::Migration<%= migration_version %>
|
|
86
89
|
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP(6)" }
|
87
90
|
t.string :access_type, null: false, default: "offline"
|
88
91
|
|
92
|
+
# :oauth_dpop enabled
|
93
|
+
t.string :dpop_jwk, null: true
|
94
|
+
|
89
95
|
# :oauth_pkce enabled
|
90
96
|
t.string :code_challenge
|
91
97
|
t.string :code_challenge_method
|
@@ -105,15 +111,20 @@ class CreateRodauthOauth < ActiveRecord::Migration<%= migration_version %>
|
|
105
111
|
t.string :acr
|
106
112
|
t.string :claims_locales
|
107
113
|
t.string :claims
|
114
|
+
|
115
|
+
# :oauth_dpop enabled
|
116
|
+
t.string :dpop_jkt
|
108
117
|
end
|
109
118
|
|
110
119
|
create_table :oauth_pushed_requests do |t|
|
111
120
|
t.bigint :oauth_application_id
|
112
121
|
t.foreign_key :oauth_applications, column: :oauth_application_id
|
113
122
|
t.string :code, null: false, index: { unique: true }
|
123
|
+
t.index %i[oauth_application_id code], unique: true
|
114
124
|
t.string :params, null: false
|
115
125
|
t.datetime :expires_in, null: false
|
116
|
-
|
126
|
+
# :oauth_dpop
|
127
|
+
t.string :dpop_jkt
|
117
128
|
end
|
118
129
|
|
119
130
|
create_table :oauth_saml_settings do |t|
|
@@ -127,5 +138,10 @@ class CreateRodauthOauth < ActiveRecord::Migration<%= migration_version %>
|
|
127
138
|
t.string :audience, null: true
|
128
139
|
t.string :issuer, null: false, unique: true
|
129
140
|
end
|
141
|
+
|
142
|
+
create_table :oauth_dpop_proofs, primary_key: :jti do |t|
|
143
|
+
t.string :jti, null: false
|
144
|
+
t.datetime :first_use, null: false, default: -> { "CURRENT_TIMESTAMP(6)" }
|
145
|
+
end
|
130
146
|
end
|
131
147
|
end
|
@@ -237,16 +237,22 @@ module Rodauth
|
|
237
237
|
return
|
238
238
|
end
|
239
239
|
else
|
240
|
-
|
240
|
+
token = fetch_access_token_from_authorization_header
|
241
|
+
end
|
241
242
|
|
242
|
-
|
243
|
+
return if token.nil? || token.empty?
|
243
244
|
|
244
|
-
|
245
|
+
token
|
246
|
+
end
|
245
247
|
|
246
|
-
|
247
|
-
|
248
|
+
def fetch_access_token_from_authorization_header(token_type = oauth_token_type)
|
249
|
+
value = request.env["HTTP_AUTHORIZATION"]
|
248
250
|
|
249
|
-
return
|
251
|
+
return unless value && !value.empty?
|
252
|
+
|
253
|
+
scheme, token = value.split(" ", 2)
|
254
|
+
|
255
|
+
return unless scheme.downcase == token_type
|
250
256
|
|
251
257
|
token
|
252
258
|
end
|
@@ -353,7 +359,7 @@ module Rodauth
|
|
353
359
|
# parse client id and secret
|
354
360
|
#
|
355
361
|
def require_oauth_application
|
356
|
-
@oauth_application = if (token = (
|
362
|
+
@oauth_application = if (token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Basic (.*)\Z/, 1])
|
357
363
|
# client_secret_basic
|
358
364
|
require_oauth_application_from_client_secret_basic(token)
|
359
365
|
elsif (client_id = param_or_nil("client_id"))
|
@@ -819,10 +825,14 @@ module Rodauth
|
|
819
825
|
payload = response_error_params(error_code, message)
|
820
826
|
json_payload = _json_response_body(payload)
|
821
827
|
response["Content-Type"] ||= json_response_content_type
|
822
|
-
response["WWW-Authenticate"] =
|
828
|
+
response["WWW-Authenticate"] = www_authenticate_header(payload) if status == 401
|
823
829
|
return_response(json_payload)
|
824
830
|
end
|
825
831
|
|
832
|
+
def www_authenticate_header(*)
|
833
|
+
oauth_token_type.capitalize
|
834
|
+
end
|
835
|
+
|
826
836
|
def _json_response_body(hash)
|
827
837
|
return super if features.include?(:json)
|
828
838
|
|
@@ -0,0 +1,410 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rodauth/oauth"
|
4
|
+
require "logger"
|
5
|
+
|
6
|
+
module Rodauth
|
7
|
+
Feature.define(:oauth_dpop, :OauthDpop) do
|
8
|
+
depends :oauth_jwt, :oauth_authorize_base
|
9
|
+
|
10
|
+
auth_value_method :oauth_invalid_token_error_response_status, 401
|
11
|
+
auth_value_method :oauth_multiple_auth_methods_response_status, 401
|
12
|
+
auth_value_method :oauth_access_token_dpop_bound_response_status, 401
|
13
|
+
|
14
|
+
translatable_method :oauth_invalid_dpop_proof_message, "Invalid DPoP proof"
|
15
|
+
translatable_method :oauth_multiple_auth_methods_message, "Multiple methods used to include access token"
|
16
|
+
auth_value_method :oauth_multiple_dpop_proofs_error_code, "invalid_request"
|
17
|
+
translatable_method :oauth_multiple_dpop_proofs_message, "Multiple DPoP proofs used"
|
18
|
+
auth_value_method :oauth_invalid_dpop_jkt_error_code, "invalid_dpop_proof"
|
19
|
+
translatable_method :oauth_invalid_dpop_jkt_message, "Invalid DPoP JKT"
|
20
|
+
auth_value_method :oauth_invalid_dpop_jti_error_code, "invalid_dpop_proof"
|
21
|
+
translatable_method :oauth_invalid_dpop_jti_message, "Invalid DPoP jti"
|
22
|
+
auth_value_method :oauth_invalid_dpop_htm_error_code, "invalid_dpop_proof"
|
23
|
+
translatable_method :oauth_invalid_dpop_htm_message, "Invalid DPoP htm"
|
24
|
+
auth_value_method :oauth_invalid_dpop_htu_error_code, "invalid_dpop_proof"
|
25
|
+
translatable_method :oauth_invalid_dpop_htu_message, "Invalid DPoP htu"
|
26
|
+
translatable_method :oauth_access_token_dpop_bound_message, "DPoP bound access token requires DPoP proof"
|
27
|
+
|
28
|
+
translatable_method :oauth_use_dpop_nonce_message, "DPoP nonce is required"
|
29
|
+
|
30
|
+
auth_value_method :oauth_dpop_proof_expires_in, 60 * 5 # 5 minutes
|
31
|
+
auth_value_method :oauth_dpop_bound_access_tokens, false
|
32
|
+
auth_value_method :oauth_dpop_use_nonce, false
|
33
|
+
auth_value_method :oauth_dpop_nonce_expires_in, 5 # 5 seconds
|
34
|
+
auth_value_method :oauth_dpop_signing_alg_values_supported,
|
35
|
+
%w[
|
36
|
+
RS256
|
37
|
+
RS384
|
38
|
+
RS512
|
39
|
+
PS256
|
40
|
+
PS384
|
41
|
+
PS512
|
42
|
+
ES256
|
43
|
+
ES384
|
44
|
+
ES512
|
45
|
+
ES256K
|
46
|
+
]
|
47
|
+
|
48
|
+
auth_value_method :oauth_applications_dpop_bound_access_tokens_column, :dpop_bound_access_tokens
|
49
|
+
auth_value_method :oauth_grants_dpop_jkt_column, :dpop_jkt
|
50
|
+
auth_value_method :oauth_pushed_authorization_requests_dpop_jkt_column, :dpop_jkt
|
51
|
+
|
52
|
+
auth_value_method :oauth_dpop_proofs_table, :oauth_dpop_proofs
|
53
|
+
auth_value_method :oauth_dpop_proofs_jti_column, :jti
|
54
|
+
auth_value_method :oauth_dpop_proofs_first_use_column, :first_use
|
55
|
+
|
56
|
+
auth_methods(:validate_dpop_proof_usage)
|
57
|
+
|
58
|
+
def require_oauth_authorization(*scopes)
|
59
|
+
@dpop_access_token = fetch_access_token_from_authorization_header("dpop")
|
60
|
+
|
61
|
+
unless @dpop_access_token
|
62
|
+
authorization_required if oauth_dpop_bound_access_tokens
|
63
|
+
|
64
|
+
# Specifically, such a protected resource MUST reject a DPoP-bound access token received as a bearer token
|
65
|
+
redirect_response_error("access_token_dpop_bound") if authorization_token && authorization_token.dig("cnf", "jkt")
|
66
|
+
|
67
|
+
return super
|
68
|
+
end
|
69
|
+
|
70
|
+
dpop = fetch_dpop_token
|
71
|
+
|
72
|
+
dpop_claims = validate_dpop_token(dpop)
|
73
|
+
|
74
|
+
# 4.3.12
|
75
|
+
validate_ath(dpop_claims, @dpop_access_token)
|
76
|
+
|
77
|
+
@authorization_token = decode_access_token(@dpop_access_token)
|
78
|
+
|
79
|
+
# 4.3.12 - confirm that the public key to which the access token is bound matches the public key from the DPoP proof.
|
80
|
+
jkt = authorization_token.dig("cnf", "jkt")
|
81
|
+
|
82
|
+
redirect_response_error("invalid_dpop_jkt") if oauth_dpop_bound_access_tokens && !jkt
|
83
|
+
|
84
|
+
redirect_response_error("invalid_dpop_jkt") unless jkt == @dpop_thumbprint
|
85
|
+
|
86
|
+
super
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def validate_token_params
|
92
|
+
dpop = fetch_dpop_token
|
93
|
+
|
94
|
+
unless dpop
|
95
|
+
authorization_required if dpop_bound_access_tokens_required?
|
96
|
+
|
97
|
+
return super
|
98
|
+
end
|
99
|
+
|
100
|
+
validate_dpop_token(dpop)
|
101
|
+
|
102
|
+
super
|
103
|
+
end
|
104
|
+
|
105
|
+
def validate_par_params
|
106
|
+
super
|
107
|
+
|
108
|
+
return unless (dpop = fetch_dpop_token)
|
109
|
+
|
110
|
+
validate_dpop_token(dpop)
|
111
|
+
|
112
|
+
if (dpop_jkt = param_or_nil("dpop_jkt"))
|
113
|
+
redirect_response_error("invalid_request") if dpop_jkt != @dpop_thumbprint
|
114
|
+
else
|
115
|
+
request.params["dpop_jkt"] = @dpop_thumbprint
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def validate_dpop_token(dpop)
|
120
|
+
# 4.3.2
|
121
|
+
@dpop_claims = dpop_decode(dpop)
|
122
|
+
redirect_response_error("invalid_dpop_proof") unless @dpop_claims
|
123
|
+
|
124
|
+
validate_dpop_jwt_claims(@dpop_claims)
|
125
|
+
|
126
|
+
# 4.3.10
|
127
|
+
validate_nonce(@dpop_claims)
|
128
|
+
|
129
|
+
# 11.1
|
130
|
+
# To prevent multiple uses of the same DPoP proof, servers can store, in the
|
131
|
+
# context of the target URI, the jti value of each DPoP proof for the time window
|
132
|
+
# in which the respective DPoP proof JWT would be accepted.
|
133
|
+
validate_dpop_proof_usage(@dpop_claims)
|
134
|
+
|
135
|
+
@dpop_claims
|
136
|
+
end
|
137
|
+
|
138
|
+
def validate_dpop_proof_usage(claims)
|
139
|
+
jti = claims["jti"]
|
140
|
+
|
141
|
+
dpop_proof = __insert_or_do_nothing_and_return__(
|
142
|
+
db[oauth_dpop_proofs_table],
|
143
|
+
oauth_dpop_proofs_jti_column,
|
144
|
+
[oauth_dpop_proofs_jti_column],
|
145
|
+
oauth_dpop_proofs_jti_column => Digest::SHA256.hexdigest(jti),
|
146
|
+
oauth_dpop_proofs_first_use_column => Sequel::CURRENT_TIMESTAMP
|
147
|
+
)
|
148
|
+
|
149
|
+
return unless (Time.now - dpop_proof[oauth_dpop_proofs_first_use_column]) > oauth_dpop_proof_expires_in
|
150
|
+
|
151
|
+
redirect_response_error("invalid_dpop_proof")
|
152
|
+
end
|
153
|
+
|
154
|
+
def dpop_decode(dpop)
|
155
|
+
# decode first without verifying!
|
156
|
+
_, headers = jwt_decode_no_key(dpop)
|
157
|
+
|
158
|
+
redirect_response_error("invalid_dpop_proof") unless verify_dpop_jwt_headers(headers)
|
159
|
+
|
160
|
+
dpop_jwk = headers["jwk"]
|
161
|
+
|
162
|
+
jwt_decode(
|
163
|
+
dpop,
|
164
|
+
jws_key: jwk_key(dpop_jwk),
|
165
|
+
jws_algorithm: headers["alg"],
|
166
|
+
verify_iss: false,
|
167
|
+
verify_aud: false,
|
168
|
+
verify_jti: false
|
169
|
+
)
|
170
|
+
end
|
171
|
+
|
172
|
+
def verify_dpop_jwt_headers(headers)
|
173
|
+
# 4.3.4 - A field with the value dpop+jwt
|
174
|
+
return false unless headers["typ"] == "dpop+jwt"
|
175
|
+
|
176
|
+
# 4.3.5 - It MUST NOT be none or an identifier for a symmetric algorithm
|
177
|
+
alg = headers["alg"]
|
178
|
+
return false unless alg && oauth_dpop_signing_alg_values_supported.include?(alg)
|
179
|
+
|
180
|
+
dpop_jwk = headers["jwk"]
|
181
|
+
|
182
|
+
return false unless dpop_jwk
|
183
|
+
|
184
|
+
# 4.3.7 - It MUST NOT contain a private key.
|
185
|
+
return false if private_jwk?(dpop_jwk)
|
186
|
+
|
187
|
+
# store thumbprint for future assertions
|
188
|
+
@dpop_thumbprint = jwk_thumbprint(dpop_jwk)
|
189
|
+
|
190
|
+
true
|
191
|
+
end
|
192
|
+
|
193
|
+
def validate_dpop_jwt_claims(claims)
|
194
|
+
jti = claims["jti"]
|
195
|
+
|
196
|
+
unless jti && jti == Digest::SHA256.hexdigest("#{request.request_method}:#{request.url}:#{claims['iat']}")
|
197
|
+
redirect_response_error("invalid_dpop_jti")
|
198
|
+
end
|
199
|
+
|
200
|
+
htm = claims["htm"]
|
201
|
+
|
202
|
+
# 4.3.8 - Check if htm matches the request method
|
203
|
+
redirect_response_error("invalid_dpop_htm") unless htm && htm == request.request_method
|
204
|
+
|
205
|
+
htu = claims["htu"]
|
206
|
+
|
207
|
+
# 4.3.9 - Check if htu matches the request URL
|
208
|
+
redirect_response_error("invalid_dpop_htu") unless htu && htu == request.url
|
209
|
+
end
|
210
|
+
|
211
|
+
def validate_ath(claims, access_token)
|
212
|
+
# When the DPoP proof is used in conjunction with the presentation of an access token in protected resource access
|
213
|
+
# the DPoP proof MUST also contain the following claim
|
214
|
+
ath = claims["ath"]
|
215
|
+
|
216
|
+
redirect_response_error("invalid_token") unless ath
|
217
|
+
|
218
|
+
# The value MUST be the result of a base64url encoding of the SHA-256 hash of the ASCII encoding of
|
219
|
+
# the associated access token's value.
|
220
|
+
redirect_response_error("invalid_token") unless ath == Base64.urlsafe_encode64(Digest::SHA256.digest(access_token), padding: false)
|
221
|
+
end
|
222
|
+
|
223
|
+
def validate_nonce(claims)
|
224
|
+
nonce = claims["nonce"]
|
225
|
+
|
226
|
+
unless nonce
|
227
|
+
dpop_nonce_required(claims) if dpop_use_nonce?
|
228
|
+
|
229
|
+
return
|
230
|
+
end
|
231
|
+
|
232
|
+
dpop_nonce_required(claims) unless valid_dpop_nonce?(nonce)
|
233
|
+
end
|
234
|
+
|
235
|
+
def jwt_claims(oauth_grant)
|
236
|
+
claims = super
|
237
|
+
if @dpop_thumbprint
|
238
|
+
# the authorization server associates the issued access token with the
|
239
|
+
# public key from the DPoP proof
|
240
|
+
claims[:cnf] = { jkt: @dpop_thumbprint }
|
241
|
+
end
|
242
|
+
claims
|
243
|
+
end
|
244
|
+
|
245
|
+
def generate_token(grant_params = {}, should_generate_refresh_token = true)
|
246
|
+
# When an authorization server supporting DPoP issues a refresh token to a public client
|
247
|
+
# that presents a valid DPoP proof at the token endpoint, the refresh token MUST be bound to the respective public key.
|
248
|
+
grant_params[oauth_grants_dpop_jkt_column] = @dpop_thumbprint if @dpop_thumbprint
|
249
|
+
super
|
250
|
+
end
|
251
|
+
|
252
|
+
def valid_oauth_grant_ds(grant_params = nil)
|
253
|
+
ds = super
|
254
|
+
|
255
|
+
ds = ds.where(oauth_grants_dpop_jkt_column => nil)
|
256
|
+
ds = ds.or(oauth_grants_dpop_jkt_column => @dpop_thumbprint) if @dpop_thumbprint
|
257
|
+
ds
|
258
|
+
end
|
259
|
+
|
260
|
+
def oauth_grant_by_refresh_token_ds(_token, revoked: false)
|
261
|
+
ds = super
|
262
|
+
# The binding MUST be validated when the refresh token is later presented to get new access tokens.
|
263
|
+
ds = ds.where(oauth_grants_dpop_jkt_column => nil)
|
264
|
+
ds = ds.or(oauth_grants_dpop_jkt_column => @dpop_thumbprint) if @dpop_thumbprint
|
265
|
+
ds
|
266
|
+
end
|
267
|
+
|
268
|
+
def oauth_grant_by_token_ds(_token)
|
269
|
+
ds = super
|
270
|
+
# The binding MUST be validated when the refresh token is later presented to get new access tokens.
|
271
|
+
ds = ds.where(oauth_grants_dpop_jkt_column => nil)
|
272
|
+
ds = ds.or(oauth_grants_dpop_jkt_column => @dpop_thumbprint) if @dpop_thumbprint
|
273
|
+
ds
|
274
|
+
end
|
275
|
+
|
276
|
+
def create_oauth_grant(create_params = {})
|
277
|
+
# 10. Authorization Code Binding to DPoP Key
|
278
|
+
# Binding the authorization code issued to the client's proof-of-possession key can enable end-to-end
|
279
|
+
# binding of the entire authorization flow.
|
280
|
+
if (dpop_jkt = param_or_nil("dpop_jkt"))
|
281
|
+
create_params[oauth_grants_dpop_jkt_column] = dpop_jkt
|
282
|
+
end
|
283
|
+
|
284
|
+
super
|
285
|
+
end
|
286
|
+
|
287
|
+
def json_access_token_payload(oauth_grant)
|
288
|
+
payload = super
|
289
|
+
# 5. A token_type of DPoP MUST be included in the access token response to
|
290
|
+
# signal to the client that the access token was bound to its DPoP key
|
291
|
+
payload["token_type"] = "DPoP" if @dpop_claims
|
292
|
+
payload
|
293
|
+
end
|
294
|
+
|
295
|
+
def fetch_dpop_token
|
296
|
+
dpop = request.env["HTTP_DPOP"]
|
297
|
+
|
298
|
+
return if dpop.nil? || dpop.empty?
|
299
|
+
|
300
|
+
# 4.3.1 - There is not more than one DPoP HTTP request header field.
|
301
|
+
redirect_response_error("multiple_dpop_proofs") if dpop.split(";").size > 1
|
302
|
+
|
303
|
+
dpop
|
304
|
+
end
|
305
|
+
|
306
|
+
def dpop_bound_access_tokens_required?
|
307
|
+
oauth_dpop_bound_access_tokens || (oauth_application && oauth_application[oauth_applications_dpop_bound_access_tokens_column])
|
308
|
+
end
|
309
|
+
|
310
|
+
def dpop_use_nonce?
|
311
|
+
oauth_dpop_use_nonce || (oauth_application && oauth_application[oauth_applications_dpop_bound_access_tokens_column])
|
312
|
+
end
|
313
|
+
|
314
|
+
def valid_dpop_proof_required(error_code = "invalid_dpop_proof")
|
315
|
+
if @dpop_access_token
|
316
|
+
# protected resource access
|
317
|
+
throw_json_response_error(401, error_code)
|
318
|
+
else
|
319
|
+
redirect_response_error(error_code)
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
def dpop_nonce_required(dpop_claims)
|
324
|
+
response["DPoP-Nonce"] = generate_dpop_nonce(dpop_claims)
|
325
|
+
|
326
|
+
if @dpop_access_token
|
327
|
+
# protected resource access
|
328
|
+
throw_json_response_error(401, "use_dpop_nonce")
|
329
|
+
else
|
330
|
+
redirect_response_error("use_dpop_nonce")
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def www_authenticate_header(payload)
|
335
|
+
header = if dpop_bound_access_tokens_required?
|
336
|
+
"DPoP"
|
337
|
+
else
|
338
|
+
"#{super}, DPoP"
|
339
|
+
end
|
340
|
+
|
341
|
+
error_code = payload["error"]
|
342
|
+
|
343
|
+
unless error_code == "invalid_client"
|
344
|
+
header = "#{header} error=\"#{error_code}\""
|
345
|
+
|
346
|
+
if (desc = payload["error_description"])
|
347
|
+
header = "#{header} error_description=\"#{desc}\""
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
351
|
+
algs = oauth_dpop_signing_alg_values_supported.join(" ")
|
352
|
+
|
353
|
+
"#{header} algs=\"#{algs}\""
|
354
|
+
end
|
355
|
+
|
356
|
+
# Nonce
|
357
|
+
|
358
|
+
def generate_dpop_nonce(dpop_claims)
|
359
|
+
issued_at = Time.now.to_i
|
360
|
+
|
361
|
+
aud = "#{dpop_claims['htm']}:#{dpop_claims['htu']}"
|
362
|
+
|
363
|
+
nonce_claims = {
|
364
|
+
iss: oauth_jwt_issuer,
|
365
|
+
iat: issued_at,
|
366
|
+
exp: issued_at + oauth_dpop_nonce_expires_in,
|
367
|
+
aud: aud
|
368
|
+
}
|
369
|
+
|
370
|
+
jwt_encode(nonce_claims)
|
371
|
+
end
|
372
|
+
|
373
|
+
def valid_dpop_nonce?(nonce)
|
374
|
+
nonce_claims = jwt_decode(nonce, verify_aud: false, verify_jti: false)
|
375
|
+
|
376
|
+
return false unless nonce_claims
|
377
|
+
|
378
|
+
jti = nonce_claims["jti"]
|
379
|
+
|
380
|
+
return false unless jti
|
381
|
+
|
382
|
+
return false unless jti == Digest::SHA256.hexdigest("#{request.request_method}:#{request.url}:#{nonce_claims['iat']}")
|
383
|
+
|
384
|
+
return false unless nonce_claims.key?("aud")
|
385
|
+
|
386
|
+
htm, htu = nonce_claims["aud"].split(":", 2)
|
387
|
+
|
388
|
+
htm == request.request_method && htu == request.url
|
389
|
+
end
|
390
|
+
|
391
|
+
def json_token_introspect_payload(grant_or_claims)
|
392
|
+
claims = super
|
393
|
+
|
394
|
+
return claims unless grant_or_claims
|
395
|
+
|
396
|
+
if (jkt = grant_or_claims.dig("cnf", "jkt"))
|
397
|
+
(claims[:cnf] ||= {})[:jkt] = jkt
|
398
|
+
claims[:token_type] = "DPoP"
|
399
|
+
end
|
400
|
+
|
401
|
+
claims
|
402
|
+
end
|
403
|
+
|
404
|
+
def oauth_server_metadata_body(*)
|
405
|
+
super.tap do |data|
|
406
|
+
data[:dpop_signing_alg_values_supported] = oauth_dpop_signing_alg_values_supported
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
end
|
@@ -18,7 +18,7 @@ module Rodauth
|
|
18
18
|
request.on(registration_client_uri_route) do
|
19
19
|
# CLIENT REGISTRATION URI
|
20
20
|
request.on(String) do |client_id|
|
21
|
-
(token = (
|
21
|
+
(token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1])
|
22
22
|
|
23
23
|
next unless token
|
24
24
|
|
@@ -200,6 +200,14 @@ module Rodauth
|
|
200
200
|
when "client_name"
|
201
201
|
register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(value)) unless value.is_a?(String)
|
202
202
|
key = oauth_applications_name_column
|
203
|
+
when "dpop_bound_access_tokens"
|
204
|
+
unless respond_to?(:oauth_applications_dpop_bound_access_tokens_column)
|
205
|
+
register_throw_json_response_error("invalid_client_metadata",
|
206
|
+
register_invalid_param_message(key))
|
207
|
+
end
|
208
|
+
request_params[key] = value = convert_to_boolean(key, value)
|
209
|
+
|
210
|
+
key = oauth_applications_dpop_bound_access_tokens_column
|
203
211
|
when "require_signed_request_object"
|
204
212
|
unless respond_to?(:oauth_applications_require_signed_request_object_column)
|
205
213
|
register_throw_json_response_error("invalid_client_metadata",
|
@@ -291,7 +299,8 @@ module Rodauth
|
|
291
299
|
create_params[oauth_applications_token_endpoint_auth_method_column] ||= begin
|
292
300
|
# If unspecified or omitted, the default is "client_secret_basic", denoting the HTTP Basic
|
293
301
|
# authentication scheme as specified in Section 2.3.1 of OAuth 2.0.
|
294
|
-
return_params["token_endpoint_auth_method"] =
|
302
|
+
return_params["token_endpoint_auth_method"] =
|
303
|
+
"client_secret_basic"
|
295
304
|
"client_secret_basic"
|
296
305
|
end
|
297
306
|
end
|
@@ -50,23 +50,22 @@ module Rodauth
|
|
50
50
|
|
51
51
|
return @authorization_token if defined?(@authorization_token)
|
52
52
|
|
53
|
-
@authorization_token =
|
54
|
-
|
53
|
+
@authorization_token = decode_access_token
|
54
|
+
end
|
55
55
|
|
56
|
-
|
56
|
+
def decode_access_token(access_token = fetch_access_token)
|
57
|
+
return unless access_token
|
57
58
|
|
58
|
-
|
59
|
+
jwt_claims = jwt_decode(access_token)
|
59
60
|
|
60
|
-
|
61
|
+
return unless jwt_claims
|
61
62
|
|
62
|
-
|
63
|
+
return unless jwt_claims["sub"]
|
63
64
|
|
64
|
-
|
65
|
+
return unless jwt_claims["aud"]
|
65
66
|
|
66
|
-
|
67
|
-
end
|
67
|
+
jwt_claims
|
68
68
|
end
|
69
|
-
|
70
69
|
# /token
|
71
70
|
|
72
71
|
def create_token_from_token(_grant, update_params)
|
@@ -167,6 +167,10 @@ module Rodauth
|
|
167
167
|
jwk.thumbprint
|
168
168
|
end
|
169
169
|
|
170
|
+
def private_jwk?(jwk)
|
171
|
+
%w[d p q dp dq qi].any?(&jwk.method(:key?))
|
172
|
+
end
|
173
|
+
|
170
174
|
def jwt_encode(payload,
|
171
175
|
jwks: nil,
|
172
176
|
headers: {},
|
@@ -222,6 +226,7 @@ module Rodauth
|
|
222
226
|
verify_jti: true,
|
223
227
|
verify_iss: true,
|
224
228
|
verify_aud: true,
|
229
|
+
verify_headers: nil,
|
225
230
|
**
|
226
231
|
)
|
227
232
|
jws_key = jws_key.first if jws_key.is_a?(Array)
|
@@ -272,6 +277,8 @@ module Rodauth
|
|
272
277
|
return
|
273
278
|
end
|
274
279
|
|
280
|
+
return if verify_headers && !verify_headers.call(claims.header)
|
281
|
+
|
275
282
|
claims
|
276
283
|
rescue JSON::JWT::Exception
|
277
284
|
nil
|
@@ -333,6 +340,10 @@ module Rodauth
|
|
333
340
|
JWT::JWK::Thumbprint.new(jwk).generate
|
334
341
|
end
|
335
342
|
|
343
|
+
def private_jwk?(jwk)
|
344
|
+
jwk_import(jwk).private?
|
345
|
+
end
|
346
|
+
|
336
347
|
def jwt_encode(payload,
|
337
348
|
signing_algorithm: oauth_jwt_keys.keys.first,
|
338
349
|
headers: {}, **)
|
@@ -394,7 +405,8 @@ module Rodauth
|
|
394
405
|
verify_claims: true,
|
395
406
|
verify_jti: true,
|
396
407
|
verify_iss: true,
|
397
|
-
verify_aud: true
|
408
|
+
verify_aud: true,
|
409
|
+
verify_headers: nil
|
398
410
|
)
|
399
411
|
jws_key = jws_key.first if jws_key.is_a?(Array)
|
400
412
|
|
@@ -421,32 +433,34 @@ module Rodauth
|
|
421
433
|
end
|
422
434
|
|
423
435
|
# decode jwt
|
424
|
-
claims = if is_authorization_server?
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
436
|
+
claims, headers = if is_authorization_server?
|
437
|
+
if jwks
|
438
|
+
jwks = jwks[:keys] if jwks.is_a?(Hash)
|
439
|
+
|
440
|
+
# JWKs may be set up without a KID, when there's a single one
|
441
|
+
if jwks.size == 1 && !jwks[0][:kid]
|
442
|
+
key = jwks[0]
|
443
|
+
algo = key[:alg]
|
444
|
+
key = JWT::JWK.import(key).keypair
|
445
|
+
JWT.decode(token, key, true, algorithms: [algo], **verify_claims_params)
|
446
|
+
else
|
447
|
+
algorithms = jws_algorithm ? [jws_algorithm] : jwks.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
|
448
|
+
JWT.decode(token, nil, true, algorithms: algorithms, jwks: { keys: jwks }, **verify_claims_params)
|
449
|
+
end
|
450
|
+
elsif jws_key
|
451
|
+
JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params)
|
452
|
+
else
|
453
|
+
JWT.decode(token, jws_key, false, **verify_claims_params)
|
454
|
+
end
|
455
|
+
elsif (jwks = auth_server_jwks_set)
|
456
|
+
algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
|
457
|
+
JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params)
|
458
|
+
end
|
447
459
|
|
448
460
|
return if verify_claims && verify_aud && !verify_aud(claims["aud"], claims["client_id"])
|
449
461
|
|
462
|
+
return if verify_headers && !verify_headers.call(headers)
|
463
|
+
|
450
464
|
claims
|
451
465
|
rescue JWT::DecodeError, JWT::JWKError
|
452
466
|
nil
|
@@ -504,6 +518,10 @@ module Rodauth
|
|
504
518
|
def jwt_decode(_token, **)
|
505
519
|
raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
|
506
520
|
end
|
521
|
+
|
522
|
+
def private_jwk?(_jwk)
|
523
|
+
raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
|
524
|
+
end
|
507
525
|
# :nocov:
|
508
526
|
end
|
509
527
|
end
|
@@ -112,9 +112,7 @@ module Rodauth
|
|
112
112
|
|
113
113
|
return claims unless grant_or_claims && grant_or_claims[oauth_grants_certificate_thumbprint_column]
|
114
114
|
|
115
|
-
claims[:cnf] =
|
116
|
-
"x5t#S256" => grant_or_claims[oauth_grants_certificate_thumbprint_column]
|
117
|
-
}
|
115
|
+
(claims[:cnf] ||= {})["x5t#S256"] = grant_or_claims[oauth_grants_certificate_thumbprint_column]
|
118
116
|
|
119
117
|
claims
|
120
118
|
end
|
@@ -66,7 +66,7 @@ module Rodauth
|
|
66
66
|
scope: grant_or_claims["scope"],
|
67
67
|
client_id: grant_or_claims["client_id"],
|
68
68
|
username: resource_owner_identifier(grant_or_claims),
|
69
|
-
token_type:
|
69
|
+
token_type: oauth_token_type.capitalize,
|
70
70
|
exp: grant_or_claims["exp"],
|
71
71
|
iat: grant_or_claims["iat"],
|
72
72
|
nbf: grant_or_claims["nbf"],
|
@@ -99,7 +99,7 @@ module Rodauth
|
|
99
99
|
private
|
100
100
|
|
101
101
|
def require_oauth_application_for_introspect
|
102
|
-
(token = (
|
102
|
+
(token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1])
|
103
103
|
|
104
104
|
return require_oauth_application unless token
|
105
105
|
|
@@ -302,10 +302,10 @@ module Rodauth
|
|
302
302
|
# The value is a JSON object listing the requested Claims.
|
303
303
|
claims = JSON.parse(claims)
|
304
304
|
|
305
|
-
claims.
|
305
|
+
claims.each_value do |individual_claims|
|
306
306
|
redirect_response_error("invalid_request") unless individual_claims.is_a?(Hash)
|
307
307
|
|
308
|
-
individual_claims.
|
308
|
+
individual_claims.each_value do |claim|
|
309
309
|
redirect_response_error("invalid_request") unless claim.nil? || individual_claims.is_a?(Hash)
|
310
310
|
end
|
311
311
|
end
|
@@ -419,7 +419,9 @@ module Rodauth
|
|
419
419
|
|
420
420
|
login_cookie_opts = Hash[oauth_prompt_login_cookie_options]
|
421
421
|
login_cookie_opts[:value] = "login"
|
422
|
-
|
422
|
+
if oauth_prompt_login_interval
|
423
|
+
login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
|
424
|
+
end
|
423
425
|
::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts)
|
424
426
|
|
425
427
|
redirect require_login_redirect
|
@@ -35,8 +35,10 @@ module Rodauth
|
|
35
35
|
|
36
36
|
user_agent_state_cookie_opts = Hash[oauth_oidc_user_agent_state_cookie_options]
|
37
37
|
user_agent_state_cookie_opts[:value] = oauth_unique_id_generator
|
38
|
-
user_agent_state_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_oidc_user_agent_state_cookie_expires_in)
|
39
38
|
user_agent_state_cookie_opts[:secure] = true
|
39
|
+
if oauth_oidc_user_agent_state_cookie_expires_in
|
40
|
+
user_agent_state_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_oidc_user_agent_state_cookie_expires_in)
|
41
|
+
end
|
40
42
|
::Rack::Utils.set_cookie_header!(response.headers, oauth_oidc_user_agent_state_cookie_key, user_agent_state_cookie_opts)
|
41
43
|
end
|
42
44
|
|
@@ -14,6 +14,10 @@ module Rodauth
|
|
14
14
|
else
|
15
15
|
def __insert_and_return__(dataset, pkey, params)
|
16
16
|
id = dataset.insert(params)
|
17
|
+
if params.key?(pkey)
|
18
|
+
# mysql returns 0 when the primary key is a varchar.
|
19
|
+
id = params[pkey]
|
20
|
+
end
|
17
21
|
dataset.where(pkey => id).first
|
18
22
|
end
|
19
23
|
end
|
@@ -35,7 +35,7 @@ class Rodauth::OAuth::TtlStore
|
|
35
35
|
# at the same time, this ensures the first one wins.
|
36
36
|
return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
|
37
37
|
|
38
|
-
@store[key] = { payload: payload, ttl:
|
38
|
+
@store[key] = { payload: payload, ttl: ttl || (now + DEFAULT_TTL) }
|
39
39
|
end
|
40
40
|
@store[key][:payload]
|
41
41
|
end
|
data/locales/en.yml
CHANGED
@@ -76,4 +76,14 @@ en:
|
|
76
76
|
oauth_saml_assertion_not_base64_message: "SAML assertion must be in base64 format"
|
77
77
|
oauth_saml_assertion_single_issuer_message: "SAML assertion must have a single issuer"
|
78
78
|
oauth_saml_settings_not_found_message: "No SAML settings found for issuer"
|
79
|
-
oauth_invalid_id_token_hint_message: "Invalid ID token hint"
|
79
|
+
oauth_invalid_id_token_hint_message: "Invalid ID token hint"
|
80
|
+
translatable_method :oauth_saml_settings_not_found_message: "No SAML settings found for issuer"
|
81
|
+
oauth_invalid_dpop_proof_message: "Invalid DPoP proof"
|
82
|
+
oauth_multiple_dpop_proofs_message: "Multiple DPoP proofs used"
|
83
|
+
oauth_invalid_dpop_jkt_message: "Invalid DPoP JKT"
|
84
|
+
oauth_invalid_dpop_jti_message: "Invalid DPoP jti"
|
85
|
+
oauth_invalid_dpop_htm_message: "Invalid DPoP htm"
|
86
|
+
oauth_invalid_dpop_htu_message: "Invalid DPoP htu"
|
87
|
+
oauth_use_dpop_nonce_message: "DPoP nonce is required"
|
88
|
+
oauth_access_token_dpop_bound_message: "DPoP bound access token requires DPoP proof"
|
89
|
+
oauth_multiple_auth_methods_message: "Multiple methods used to include access token"
|
data/templates/authorize.str
CHANGED
@@ -96,6 +96,7 @@
|
|
96
96
|
end.join
|
97
97
|
end
|
98
98
|
}
|
99
|
+
#{"<input type=\"hidden\" name=\"dpop_jkt\" value=\"#{h(rodauth.param("dpop_jkt"))}\"/>" if rodauth.features.include?(:oauth_dpop) && rodauth.param_or_nil("dpop_jkt")}
|
99
100
|
</div>
|
100
101
|
<p class="text-center">
|
101
102
|
<input type="submit" class="btn btn-outline-primary" value="#{h(rodauth.oauth_authorize_button)}"/>
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rodauth-oauth
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tiago Cardoso
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-02-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rodauth
|
@@ -73,6 +73,7 @@ extra_rdoc_files:
|
|
73
73
|
- doc/release_notes/1_3_1.md
|
74
74
|
- doc/release_notes/1_3_2.md
|
75
75
|
- doc/release_notes/1_4_0.md
|
76
|
+
- doc/release_notes/1_5_0.md
|
76
77
|
files:
|
77
78
|
- CHANGELOG.md
|
78
79
|
- LICENSE.txt
|
@@ -117,6 +118,7 @@ files:
|
|
117
118
|
- doc/release_notes/1_3_1.md
|
118
119
|
- doc/release_notes/1_3_2.md
|
119
120
|
- doc/release_notes/1_4_0.md
|
121
|
+
- doc/release_notes/1_5_0.md
|
120
122
|
- lib/generators/rodauth/oauth/install_generator.rb
|
121
123
|
- lib/generators/rodauth/oauth/templates/app/models/oauth_application.rb
|
122
124
|
- lib/generators/rodauth/oauth/templates/app/models/oauth_grant.rb
|
@@ -139,6 +141,7 @@ files:
|
|
139
141
|
- lib/rodauth/features/oauth_base.rb
|
140
142
|
- lib/rodauth/features/oauth_client_credentials_grant.rb
|
141
143
|
- lib/rodauth/features/oauth_device_code_grant.rb
|
144
|
+
- lib/rodauth/features/oauth_dpop.rb
|
142
145
|
- lib/rodauth/features/oauth_dynamic_client_registration.rb
|
143
146
|
- lib/rodauth/features/oauth_grant_management.rb
|
144
147
|
- lib/rodauth/features/oauth_implicit_grant.rb
|