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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -10
  3. data/doc/release_notes/1_4_0.md +57 -0
  4. data/doc/release_notes/1_5_0.md +20 -0
  5. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +28 -23
  6. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/frontchannel_logout.html.erb +10 -0
  7. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +1 -1
  8. data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +37 -1
  9. data/lib/generators/rodauth/oauth/views_generator.rb +2 -2
  10. data/lib/rodauth/features/oauth_application_management.rb +1 -1
  11. data/lib/rodauth/features/oauth_assertion_base.rb +1 -1
  12. data/lib/rodauth/features/oauth_authorize_base.rb +1 -1
  13. data/lib/rodauth/features/oauth_base.rb +49 -38
  14. data/lib/rodauth/features/oauth_device_code_grant.rb +2 -2
  15. data/lib/rodauth/features/oauth_dpop.rb +410 -0
  16. data/lib/rodauth/features/oauth_dynamic_client_registration.rb +12 -2
  17. data/lib/rodauth/features/oauth_grant_management.rb +1 -1
  18. data/lib/rodauth/features/oauth_jwt.rb +12 -13
  19. data/lib/rodauth/features/oauth_jwt_base.rb +57 -34
  20. data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +1 -1
  21. data/lib/rodauth/features/oauth_jwt_jwks.rb +1 -1
  22. data/lib/rodauth/features/oauth_resource_indicators.rb +1 -1
  23. data/lib/rodauth/features/oauth_resource_server.rb +1 -1
  24. data/lib/rodauth/features/oauth_saml_bearer_grant.rb +79 -47
  25. data/lib/rodauth/features/oauth_tls_client_auth.rb +2 -4
  26. data/lib/rodauth/features/oauth_token_introspection.rb +3 -3
  27. data/lib/rodauth/features/oauth_token_revocation.rb +1 -1
  28. data/lib/rodauth/features/oidc.rb +32 -11
  29. data/lib/rodauth/features/oidc_backchannel_logout.rb +120 -0
  30. data/lib/rodauth/features/oidc_dynamic_client_registration.rb +25 -0
  31. data/lib/rodauth/features/oidc_frontchannel_logout.rb +134 -0
  32. data/lib/rodauth/features/oidc_logout_base.rb +76 -0
  33. data/lib/rodauth/features/oidc_rp_initiated_logout.rb +29 -6
  34. data/lib/rodauth/features/oidc_session_management.rb +91 -0
  35. data/lib/rodauth/oauth/database_extensions.rb +4 -0
  36. data/lib/rodauth/oauth/http_extensions.rb +1 -1
  37. data/lib/rodauth/oauth/ttl_store.rb +1 -1
  38. data/lib/rodauth/oauth/version.rb +1 -1
  39. data/locales/en.yml +19 -0
  40. data/locales/pt.yml +9 -0
  41. data/templates/authorize.str +1 -0
  42. data/templates/check_session.str +67 -0
  43. data/templates/frontchannel_logout.str +17 -0
  44. 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 = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1]))
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"] = "client_secret_basic"
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
@@ -21,7 +21,7 @@ module Rodauth
21
21
  auth_value_method :oauth_grants_id_pattern, Integer
22
22
  auth_value_method :oauth_grants_per_page, 20
23
23
 
24
- auth_value_methods(
24
+ auth_methods(
25
25
  :oauth_grant_path
26
26
  )
27
27
 
@@ -9,7 +9,7 @@ module Rodauth
9
9
 
10
10
  auth_value_method :oauth_jwt_access_tokens, true
11
11
 
12
- auth_value_methods(:jwt_claims)
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 = begin
54
- access_token = fetch_access_token
53
+ @authorization_token = decode_access_token
54
+ end
55
55
 
56
- return unless access_token
56
+ def decode_access_token(access_token = fetch_access_token)
57
+ return unless access_token
57
58
 
58
- jwt_claims = jwt_decode(access_token)
59
+ jwt_claims = jwt_decode(access_token)
59
60
 
60
- return unless jwt_claims
61
+ return unless jwt_claims
61
62
 
62
- return unless jwt_claims["sub"]
63
+ return unless jwt_claims["sub"]
63
64
 
64
- return unless jwt_claims["aud"]
65
+ return unless jwt_claims["aud"]
65
66
 
66
- jwt_claims
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
- return super unless oauth_jwt_access_tokens
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
- auth_value_methods(
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(oauth_grant, client_application = oauth_application)
67
- account_id = oauth_grant[oauth_grants_account_id_column]
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
- headers = {}
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
- if jwks
421
- jwks = jwks[:keys] if jwks.is_a?(Hash)
422
-
423
- # JWKs may be set up without a KID, when there's a single one
424
- if jwks.size == 1 && !jwks[0][:kid]
425
- key = jwks[0]
426
- algo = key[:alg]
427
- key = JWT::JWK.import(key).keypair
428
- JWT.decode(token, key, true, algorithms: [algo], **verify_claims_params).first
429
- else
430
- algorithms = jws_algorithm ? [jws_algorithm] : jwks.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
431
- JWT.decode(token, nil, true, algorithms: algorithms, jwks: { keys: jwks }, **verify_claims_params).first
432
- end
433
- elsif jws_key
434
- JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params).first
435
- else
436
- JWT.decode(token, jws_key, false, **verify_claims_params).first
437
- end
438
- elsif (jwks = auth_server_jwks_set)
439
- algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
440
- JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params).first
441
- end
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
- auth_value_methods(
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