rodauth-oauth 1.3.2 → 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/README.md +17 -10
- data/doc/release_notes/1_4_0.md +57 -0
- data/doc/release_notes/1_5_0.md +20 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +28 -23
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/frontchannel_logout.html.erb +10 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +1 -1
- data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +37 -1
- data/lib/generators/rodauth/oauth/views_generator.rb +2 -2
- data/lib/rodauth/features/oauth_application_management.rb +1 -1
- data/lib/rodauth/features/oauth_assertion_base.rb +1 -1
- data/lib/rodauth/features/oauth_authorize_base.rb +1 -1
- data/lib/rodauth/features/oauth_base.rb +49 -38
- data/lib/rodauth/features/oauth_device_code_grant.rb +2 -2
- data/lib/rodauth/features/oauth_dpop.rb +410 -0
- data/lib/rodauth/features/oauth_dynamic_client_registration.rb +12 -2
- data/lib/rodauth/features/oauth_grant_management.rb +1 -1
- data/lib/rodauth/features/oauth_jwt.rb +12 -13
- data/lib/rodauth/features/oauth_jwt_base.rb +57 -34
- data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +1 -1
- data/lib/rodauth/features/oauth_jwt_jwks.rb +1 -1
- data/lib/rodauth/features/oauth_resource_indicators.rb +1 -1
- data/lib/rodauth/features/oauth_resource_server.rb +1 -1
- data/lib/rodauth/features/oauth_saml_bearer_grant.rb +79 -47
- data/lib/rodauth/features/oauth_tls_client_auth.rb +2 -4
- data/lib/rodauth/features/oauth_token_introspection.rb +3 -3
- data/lib/rodauth/features/oauth_token_revocation.rb +1 -1
- data/lib/rodauth/features/oidc.rb +32 -11
- data/lib/rodauth/features/oidc_backchannel_logout.rb +120 -0
- data/lib/rodauth/features/oidc_dynamic_client_registration.rb +25 -0
- data/lib/rodauth/features/oidc_frontchannel_logout.rb +134 -0
- data/lib/rodauth/features/oidc_logout_base.rb +76 -0
- data/lib/rodauth/features/oidc_rp_initiated_logout.rb +29 -6
- data/lib/rodauth/features/oidc_session_management.rb +91 -0
- data/lib/rodauth/oauth/database_extensions.rb +4 -0
- data/lib/rodauth/oauth/http_extensions.rb +1 -1
- data/lib/rodauth/oauth/ttl_store.rb +1 -1
- data/lib/rodauth/oauth/version.rb +1 -1
- data/locales/en.yml +19 -0
- data/locales/pt.yml +9 -0
- data/templates/authorize.str +1 -0
- data/templates/check_session.str +67 -0
- data/templates/frontchannel_logout.str +17 -0
- metadata +14 -2
@@ -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
|
@@ -379,6 +388,7 @@ module Rodauth
|
|
379
388
|
|
380
389
|
def convert_to_boolean(key, value)
|
381
390
|
case value
|
391
|
+
when true, false then value
|
382
392
|
when "true" then true
|
383
393
|
when "false" then false
|
384
394
|
else
|
@@ -9,7 +9,7 @@ module Rodauth
|
|
9
9
|
|
10
10
|
auth_value_method :oauth_jwt_access_tokens, true
|
11
11
|
|
12
|
-
|
12
|
+
auth_methods(:jwt_claims)
|
13
13
|
|
14
14
|
def require_oauth_authorization(*scopes)
|
15
15
|
return super unless oauth_jwt_access_tokens
|
@@ -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)
|
@@ -99,7 +98,7 @@ module Rodauth
|
|
99
98
|
end
|
100
99
|
|
101
100
|
def _generate_access_token(*)
|
102
|
-
|
101
|
+
super unless oauth_jwt_access_tokens
|
103
102
|
end
|
104
103
|
|
105
104
|
def jwt_claims(oauth_grant)
|
@@ -117,7 +116,7 @@ module Rodauth
|
|
117
116
|
# owner is involved, such as the client credentials grant, the value
|
118
117
|
# of "sub" SHOULD correspond to an identifier the authorization
|
119
118
|
# server uses to indicate the client application.
|
120
|
-
sub: jwt_subject(oauth_grant),
|
119
|
+
sub: jwt_subject(oauth_grant[oauth_grants_account_id_column]),
|
121
120
|
client_id: oauth_application[oauth_applications_client_id_column],
|
122
121
|
|
123
122
|
exp: issued_at + oauth_access_token_expires_in,
|
@@ -18,7 +18,7 @@ module Rodauth
|
|
18
18
|
|
19
19
|
auth_value_method :oauth_jwt_jwe_copyright, nil
|
20
20
|
|
21
|
-
|
21
|
+
auth_methods(
|
22
22
|
:jwt_encode,
|
23
23
|
:jwt_decode,
|
24
24
|
:jwt_decode_no_key,
|
@@ -63,12 +63,8 @@ module Rodauth
|
|
63
63
|
end
|
64
64
|
end
|
65
65
|
|
66
|
-
def jwt_subject(
|
67
|
-
|
68
|
-
|
69
|
-
return account_id.to_s if account_id
|
70
|
-
|
71
|
-
client_application[oauth_applications_client_id_column]
|
66
|
+
def jwt_subject(account_unique_id, client_application = oauth_application)
|
67
|
+
(account_unique_id || client_application[oauth_applications_client_id_column]).to_s
|
72
68
|
end
|
73
69
|
|
74
70
|
def resource_owner_params_from_jwt_claims(claims)
|
@@ -171,8 +167,13 @@ module Rodauth
|
|
171
167
|
jwk.thumbprint
|
172
168
|
end
|
173
169
|
|
170
|
+
def private_jwk?(jwk)
|
171
|
+
%w[d p q dp dq qi].any?(&jwk.method(:key?))
|
172
|
+
end
|
173
|
+
|
174
174
|
def jwt_encode(payload,
|
175
175
|
jwks: nil,
|
176
|
+
headers: {},
|
176
177
|
encryption_algorithm: oauth_jwt_jwe_keys.keys.dig(0, 0),
|
177
178
|
encryption_method: oauth_jwt_jwe_keys.keys.dig(0, 1),
|
178
179
|
jwe_key: oauth_jwt_jwe_keys[[encryption_algorithm,
|
@@ -186,8 +187,16 @@ module Rodauth
|
|
186
187
|
|
187
188
|
jwk = JSON::JWK.new(key || "")
|
188
189
|
|
190
|
+
# update headers
|
191
|
+
headers.each_key do |k|
|
192
|
+
if jwt.respond_to?(:"#{k}=")
|
193
|
+
jwt.send(:"#{k}=", headers[k])
|
194
|
+
headers.delete(k)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
jwt.header.merge(headers) unless headers.empty?
|
198
|
+
|
189
199
|
jwt = jwt.sign(jwk, signing_algorithm)
|
190
|
-
jwt.kid = jwk.thumbprint
|
191
200
|
|
192
201
|
return jwt.to_s unless encryption_algorithm && encryption_method
|
193
202
|
|
@@ -217,6 +226,7 @@ module Rodauth
|
|
217
226
|
verify_jti: true,
|
218
227
|
verify_iss: true,
|
219
228
|
verify_aud: true,
|
229
|
+
verify_headers: nil,
|
220
230
|
**
|
221
231
|
)
|
222
232
|
jws_key = jws_key.first if jws_key.is_a?(Array)
|
@@ -267,6 +277,8 @@ module Rodauth
|
|
267
277
|
return
|
268
278
|
end
|
269
279
|
|
280
|
+
return if verify_headers && !verify_headers.call(claims.header)
|
281
|
+
|
270
282
|
claims
|
271
283
|
rescue JSON::JWT::Exception
|
272
284
|
nil
|
@@ -328,9 +340,13 @@ module Rodauth
|
|
328
340
|
JWT::JWK::Thumbprint.new(jwk).generate
|
329
341
|
end
|
330
342
|
|
343
|
+
def private_jwk?(jwk)
|
344
|
+
jwk_import(jwk).private?
|
345
|
+
end
|
346
|
+
|
331
347
|
def jwt_encode(payload,
|
332
|
-
signing_algorithm: oauth_jwt_keys.keys.first,
|
333
|
-
|
348
|
+
signing_algorithm: oauth_jwt_keys.keys.first,
|
349
|
+
headers: {}, **)
|
334
350
|
|
335
351
|
key = oauth_jwt_keys[signing_algorithm] || _jwt_key
|
336
352
|
key = key.first if key.is_a?(Array)
|
@@ -389,7 +405,8 @@ module Rodauth
|
|
389
405
|
verify_claims: true,
|
390
406
|
verify_jti: true,
|
391
407
|
verify_iss: true,
|
392
|
-
verify_aud: true
|
408
|
+
verify_aud: true,
|
409
|
+
verify_headers: nil
|
393
410
|
)
|
394
411
|
jws_key = jws_key.first if jws_key.is_a?(Array)
|
395
412
|
|
@@ -416,32 +433,34 @@ module Rodauth
|
|
416
433
|
end
|
417
434
|
|
418
435
|
# decode jwt
|
419
|
-
claims = if is_authorization_server?
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
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
|
442
459
|
|
443
460
|
return if verify_claims && verify_aud && !verify_aud(claims["aud"], claims["client_id"])
|
444
461
|
|
462
|
+
return if verify_headers && !verify_headers.call(headers)
|
463
|
+
|
445
464
|
claims
|
446
465
|
rescue JWT::DecodeError, JWT::JWKError
|
447
466
|
nil
|
@@ -499,6 +518,10 @@ module Rodauth
|
|
499
518
|
def jwt_decode(_token, **)
|
500
519
|
raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
|
501
520
|
end
|
521
|
+
|
522
|
+
def private_jwk?(_jwk)
|
523
|
+
raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
|
524
|
+
end
|
502
525
|
# :nocov:
|
503
526
|
end
|
504
527
|
end
|
@@ -8,7 +8,7 @@ module Rodauth
|
|
8
8
|
|
9
9
|
auth_value_method :max_param_bytesize, nil if Rodauth::VERSION >= "2.26.0"
|
10
10
|
|
11
|
-
|
11
|
+
auth_methods(
|
12
12
|
:require_oauth_application_from_jwt_bearer_assertion_issuer,
|
13
13
|
:require_oauth_application_from_jwt_bearer_assertion_subject,
|
14
14
|
:account_from_jwt_bearer_assertion
|