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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87a675b4d44ba1003c451dc25b22c0b9fec5f346e2d03ef114d19f77ffd768da
4
- data.tar.gz: 6921bd4b1bef1c88124bc323e293d9218959f94c985b9ed4c7e4b4452d474d55
3
+ metadata.gz: 40ff3b3b3de0595eae98f218aec2f8e876f18329061c537e91e5841ab35e67dc
4
+ data.tar.gz: 19692e86d66400a9e7227f655bdc6375e38554cb52b97ee3a5e22cceab582168
5
5
  SHA512:
6
- metadata.gz: 4969a6eba906a0b180c325528c780c122b841ce7cc59c844d510a0dee8791de41cebb294f0f11d6bd23e09644732bf4b32b72e9b8ac918f0c53249fdcf4e6fd0
7
- data.tar.gz: 4fae6adcc4b8ac567160182c52dc1c8d8f6fe34d0875f617b5284777fb64f0f5e7c4430ec9e769d78a8774e04d1c755bbabc4be6757d916b07fa5d14dc2b7eb4
6
+ metadata.gz: cd6efdeda012c25d83949e7f0ea2aa043238851f95bf1217b8156786f7376d39792caed37a2af68fc7777ed5a892fa2f9e5185efe1f8a3e7168df07de84b954d
7
+ data.tar.gz: 0e435eea239f81ff16db08b187ce7bb06ba3d25359603906e48610e38912162b35c79c3d8b7fbfa74df2c56f52bb653d61b999da76c1cc8d445f2077b876c578
@@ -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
- * Using `auth_methods` everywhere where `auth_value_methods` was used and didn't make sense.
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
- t.index %i[oauth_application_id code], unique: true
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
- value = request.env["HTTP_AUTHORIZATION"]
240
+ token = fetch_access_token_from_authorization_header
241
+ end
241
242
 
242
- return unless value && !value.empty?
243
+ return if token.nil? || token.empty?
243
244
 
244
- scheme, token = value.split(" ", 2)
245
+ token
246
+ end
245
247
 
246
- return unless scheme.downcase == oauth_token_type
247
- end
248
+ def fetch_access_token_from_authorization_header(token_type = oauth_token_type)
249
+ value = request.env["HTTP_AUTHORIZATION"]
248
250
 
249
- return if token.nil? || token.empty?
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 = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Basic (.*)\Z/, 1]))
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"] = oauth_token_type.upcase if status == 401
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 = ((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
@@ -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)
@@ -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
- if jwks
426
- jwks = jwks[:keys] if jwks.is_a?(Hash)
427
-
428
- # JWKs may be set up without a KID, when there's a single one
429
- if jwks.size == 1 && !jwks[0][:kid]
430
- key = jwks[0]
431
- algo = key[:alg]
432
- key = JWT::JWK.import(key).keypair
433
- JWT.decode(token, key, true, algorithms: [algo], **verify_claims_params).first
434
- else
435
- algorithms = jws_algorithm ? [jws_algorithm] : jwks.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
436
- JWT.decode(token, nil, true, algorithms: algorithms, jwks: { keys: jwks }, **verify_claims_params).first
437
- end
438
- elsif jws_key
439
- JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params).first
440
- else
441
- JWT.decode(token, jws_key, false, **verify_claims_params).first
442
- end
443
- elsif (jwks = auth_server_jwks_set)
444
- algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
445
- JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params).first
446
- 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
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
@@ -116,7 +116,7 @@ module Rodauth
116
116
 
117
117
  module IndicatorIntrospection
118
118
  def json_token_introspect_payload(grant)
119
- return super unless grant[oauth_grants_id_column]
119
+ return super unless grant && grant[oauth_grants_id_column]
120
120
 
121
121
  payload = super
122
122
 
@@ -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: "access_token",
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 = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1]))
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.each do |_, individual_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.each do |_, claim|
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
- login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
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: (ttl || (now + DEFAULT_TTL)) }
38
+ @store[key] = { payload: payload, ttl: ttl || (now + DEFAULT_TTL) }
39
39
  end
40
40
  @store[key][:payload]
41
41
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "1.4.0"
5
+ VERSION = "1.5.0"
6
6
  end
7
7
  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"
@@ -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.0
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: 2023-11-08 00:00:00.000000000 Z
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