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 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