rodauth-oauth 1.4.0 → 1.5.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/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
|