rodauth-oauth 0.3.0 → 0.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: 24f3563d467a065dc119cbe53c0f14c8f28654d6512de0b7fd09a99e3f4aabdb
4
- data.tar.gz: 8abc6dc62b463885f7198cafec57fe707f99885e67754be1d7cefbbefa31a834
3
+ metadata.gz: 7324d08b229d4bfdea92df95c769539570565e161d758a38d95bea50f78fda96
4
+ data.tar.gz: c421e4886baf39eb9ebe04e6919c6ab292d3c85dbc32bc78e5d8992c17a40816
5
5
  SHA512:
6
- metadata.gz: 9fd172c9930f1cf88239a8f5ba7d5c93dc9c92b05a601c6f909b5ad12e4319ce0aa093831b93dad93542fdda0cdc1694b001ca78922239e80a2370472373b9a5
7
- data.tar.gz: 26e8e9c619425213f2cd5f21e7710f9561c0b0395bd8928656584c88586f4197bf80a765d9a90baea604ef9fdaff5c27f4b8447adf3fa617463e26b7b0a08470
6
+ metadata.gz: 4ba7e8a7975260618034285b77d4489416fe368c500b31516e7dd69d89e75863e18bebdb4868a72caa61fa2ed93541f1da6ea1c9c9d4cf15072bf4008209a1a9
7
+ data.tar.gz: 87e9f031183ede6af4f338f60ff5ae5f7a8581412068abb484b5312de4a6962fcaa68cb95eae8aab7c755adc4e1c0ada6e3e19b009ece35d4902cc84d1ab1e01
data/CHANGELOG.md CHANGED
@@ -2,18 +2,69 @@
2
2
 
3
3
  ## master
4
4
 
5
- ### 0.3.0
5
+ ### 0.5.0 (08/02/2021)
6
+
7
+ #### RP-Initiated Logout
8
+
9
+ The `:oidc` plugin can now do [RP-Initiated Logout](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/RP-Initiated-Logout). It's disabled by default, so read the docs to learn how to enable it.
10
+
11
+ #### Security
12
+
13
+ The `:oauth_jwt` (and by association, `:oidc`) plugin(s) verifies the claims of used JWT tokens. This is a **very important security fix**, as without it, there is no protection against replay attacks and other types of misuse of the JWT token.
14
+
15
+ A new auth method, `generate_jti(claims)`, was [added to the list of oauth_jwt plugin options](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/JWT-Access-Tokens#rodauth-options). By default, it'll hash the `aud` and `iat` claims together, but you can overwrite how this is done.
16
+
17
+ ### 0.4.3 (09/12/2020)
18
+
19
+ * Introspection requests made to an Authorization Server in "resource server" mode are not correctly encoding the body using the "application/x-www-form-urlencoded" format.
20
+
21
+ ### 0.4.2 (24/11/2020)
22
+
23
+ #### Bugfixes
24
+
25
+ * database extensions were being run in resource server mode, when it's not expected that the oauth db tables are around.
26
+
27
+ ### 0.4.1 (24/11/2020)
28
+
29
+ #### Improvements
30
+
31
+ When in "Resource Server" mode, calling `rodauth.authorization_token` will now return an hash of the JSON payload that the Authorization Server responds, and which was already previously used to authorize access to protected resources.
32
+
33
+ #### Bugfixes
34
+
35
+ * An error occurred if the client passed an empty authorization header (`Authorization: ` or `Authorization: Bearer `), causing an unexpected error; It now responds with the proper `401 Unauthorized` status code.
36
+
37
+ ### 0.4.0 (13/11/2020)
6
38
 
7
39
  #### Features
8
40
 
9
- * `oauth_refresh_token_protection_policy` is a new option, which can be used to set a protection policy around usage of refresh tokens. By default it's `none`, for backwards-compatibility. However, when set to `rotation`, refresh tokens will be "use-once", i.e. a token refresh request will generate a new refresh token. Also, refresh token requests doen with already-used refresh tokens will be interpreted as a security breach, i.e. all tokens linked to the compromised refresh token will be revoked.
41
+ * A new method, `get_additional_param(account, claim)`, is now exposed; this method will be called whenever non-OIDC scopes are requested in the emission of the ID token.
42
+
43
+ * The `form_post` response is now supported, either by passing the `response_mode=form_post` request param in the authorization URL, or by setting `oauth_response_mode "form_post"` option. This improves the overall security of an Authorization server even more, as authorization codes are sent to client applications via a POST request to the redirect URI.
44
+
45
+
46
+ #### Improvements
47
+
48
+ * For the OIDC `address` scope, proper claims are now emitted as per the standard, i.e. the "formatted", "street_address", "locality", "region", "postal_code", "country". These will be the ones referenced in the `get_oidc_param` method.
49
+
50
+ #### Bugfixes
51
+
52
+ * The rails templates were missing declarations from a few params, which made some of the flows (the PKCE for example) not work out-of-the box;
53
+ * rails tests were silently not running in CI;
54
+ * The CI suite was revamped, so that all Oauth tests would be run under rails as well. All versions from rails equal or above 5.0 are now targeted;
55
+
56
+ ### 0.3.0 (8/10/2020)
57
+
58
+ #### Features
59
+
60
+ * `oauth_refresh_token_protection_policy` is a new option, which can be used to set a protection policy around usage of refresh tokens. By default it's `none`, for backwards-compatibility. However, when set to `rotation`, refresh tokens will be "use-once", i.e. a token refresh request will generate a new refresh token. Also, refresh token requests performed with already-used refresh tokens will be interpreted as a security breach, i.e. all tokens linked to the compromised refresh token will be revoked.
10
61
 
11
62
  #### Improvements
12
63
 
13
64
 
14
65
  * Support for the OIDC authorize [`prompt` parameter](https://openid.net/specs/openid-connect-core-1_0.html) (sectionn 3.1.2.1). It supports the `none`, `login` and `consent` out-of-the-box, while providing support for `select-account` when paired with [rodauth-select-account, a rodauth feature to handle multiple accounts in the same session](https://gitlab.com/honeyryderchuck/rodauth-select-account).
15
66
 
16
- * Refresh Tokens are now expired. The refresh token expiration period is governed by the `oauth_refresh_token_expires_in` option (default: 1 year), and is the period for which a refresh token can be used after its respective token expired.
67
+ * Refresh Tokens are now expirable. The refresh token expiration period is governed by the `oauth_refresh_token_expires_in` option (default: 1 year), and is the period for which a refresh token can be used after its respective access token expired.
17
68
 
18
69
  #### Bugfixes
19
70
 
@@ -31,7 +82,7 @@ Use `rodauth.convert_timestamp` in the templates, whenever dates are displayed.
31
82
 
32
83
  Set HTTP Cache headers for metadata responses, such as `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration`, so they can be stored at the edge. The cache will be valid for 1 day (this value isn't set by an option yet).
33
84
 
34
- ### 0.2.0
85
+ ### 0.2.0 (9/9/2020)
35
86
 
36
87
  #### Features
37
88
 
@@ -75,9 +126,7 @@ Fixed some mishandling of HTTP headers when in in resource-server mode.
75
126
  * 97.7% test coverage;
76
127
  * `rodauth-oauth` CI tests run against sqlite, postgresql and mysql.
77
128
 
78
- ### 0.1.0
79
-
80
- (31/7/2020)
129
+ ### 0.1.0 (31/7/2020)
81
130
 
82
131
  #### Features
83
132
 
@@ -123,9 +172,7 @@ URI schemes for client applications redirect URIs have to be `https`. In order t
123
172
  * fixed trailing "/" in the "issuer" value in server metadata (`https://server.com/` -> `https://server.com`).
124
173
 
125
174
 
126
- ### 0.0.6
127
-
128
- (6/7/2020)
175
+ ### 0.0.6 (6/7/2020)
129
176
 
130
177
  #### Features
131
178
 
@@ -148,9 +195,7 @@ The `oauth_jwt` feature now supports JWT Secured Authorization Request (JAR) (se
148
195
  Removed React Javascript from example applications.
149
196
 
150
197
 
151
- ### 0.0.5
152
-
153
- (26/6/2020)
198
+ ### 0.0.5 (26/6/2020)
154
199
 
155
200
  #### Features
156
201
 
@@ -187,9 +232,7 @@ It **requires** the authorization to implement the server metadata endpoint (`/.
187
232
  * option `scopes_param` renamed to `scope_param`;
188
233
  *
189
234
 
190
- ## 0.0.4
191
-
192
- (13/6/2020)
235
+ ## 0.0.4 (13/6/2020)
193
236
 
194
237
  ### Features
195
238
 
@@ -226,9 +269,7 @@ The `oauth_jwt` feature now allows the usage of access tokens to authorize the g
226
269
 
227
270
  * Fixed scope claim of JWT ("scopes" -> "scope");
228
271
 
229
- ## 0.0.3
230
-
231
- (5/6/2020)
272
+ ## 0.0.3 (5/6/2020)
232
273
 
233
274
  ### Features
234
275
 
@@ -260,9 +301,7 @@ end
260
301
  * renamed the existing `use_oauth_implicit_grant_type` to `use_oauth_implicit_grant_type?`;
261
302
  * It's now usable as JSON API (small caveat: POST authorize will still redirect on success...);
262
303
 
263
- ## 0.0.2
264
-
265
- (29/5/2020)
304
+ ## 0.0.2 (29/5/2020)
266
305
 
267
306
  ### Features
268
307
 
@@ -278,8 +317,6 @@ end
278
317
 
279
318
  * usage of client secret for authorizing the generation of tokens, as the spec mandates (and refraining from them when doing PKCE).
280
319
 
281
- ## 0.0.1
282
-
283
- (14/5/2020)
320
+ ## 0.0.1 (14/5/2020)
284
321
 
285
322
  Initial implementation of the Oauth 2.0 framework, with an example app done using roda.
data/README.md CHANGED
@@ -25,7 +25,12 @@ This gem implements the following RFCs and features of OAuth:
25
25
  * [JWT Secured Authorization Requests](https://tools.ietf.org/html/draft-ietf-oauth-jwsreq-20);
26
26
  * OAuth application and token management dashboards;
27
27
 
28
- It also implements the [OpenID Connect layer](https://openid.net/connect/) on top of the OAuth features it provides.
28
+ It also implements the [OpenID Connect layer](https://openid.net/connect/) on top of the OAuth features it provides, including:
29
+
30
+ * [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html);
31
+ * [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0-29.html);
32
+ * [OpenID Multiple Response Types](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html);
33
+ * [RP Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html);
29
34
 
30
35
  This gem supports also rails (through [rodauth-rails]((https://github.com/janko/rodauth-rails))).
31
36
 
@@ -104,7 +109,7 @@ For OpenID, it's very similar to the example above:
104
109
  ```ruby
105
110
  plugin :rodauth do
106
111
  # enable it in the plugin
107
- enable :login, :openid
112
+ enable :login, :oidc
108
113
  oauth_application_default_scope %w[openid]
109
114
  oauth_application_scopes %w[openid email profile]
110
115
  end
@@ -84,6 +84,7 @@ module Rodauth
84
84
 
85
85
  auth_value_method :oauth_require_pkce, false
86
86
  auth_value_method :oauth_pkce_challenge_method, "S256"
87
+ auth_value_method :oauth_response_mode, "query"
87
88
 
88
89
  auth_value_method :oauth_valid_uri_schemes, %w[https]
89
90
 
@@ -100,6 +101,7 @@ module Rodauth
100
101
  button "Register", "oauth_application"
101
102
  button "Authorize", "oauth_authorize"
102
103
  button "Revoke", "oauth_token_revoke"
104
+ button "Back to Client Application", "oauth_authorize_post"
103
105
 
104
106
  # OAuth Token
105
107
  auth_value_method :oauth_tokens_path, "oauth-tokens"
@@ -313,11 +315,41 @@ module Rodauth
313
315
  r.post do
314
316
  redirect_url = URI.parse(redirect_uri)
315
317
 
316
- transaction do
318
+ params, mode = transaction do
317
319
  before_authorize
318
- do_authorize(redirect_url)
320
+ do_authorize
321
+ end
322
+
323
+ case mode
324
+ when "query"
325
+ params = params.map { |k, v| "#{k}=#{v}" }
326
+ params << redirect_url.query if redirect_url.query
327
+ redirect_url.query = params.join("&")
328
+ redirect(redirect_url.to_s)
329
+ when "fragment"
330
+ params = params.map { |k, v| "#{k}=#{v}" }
331
+ params << redirect_url.query if redirect_url.query
332
+ redirect_url.fragment = params.join("&")
333
+ redirect(redirect_url.to_s)
334
+ when "form_post"
335
+ scope.view layout: false, inline: <<-FORM
336
+ <html>
337
+ <head><title>Authorized</title></head>
338
+ <body onload="javascript:document.forms[0].submit()">
339
+ <form method="post" action="#{redirect_uri}">
340
+ #{
341
+ params.map do |name, value|
342
+ "<input type=\"hidden\" name=\"#{name}\" value=\"#{scope.h(value)}\" />"
343
+ end.join
344
+ }
345
+ <input type="submit" class="btn btn-outline-primary" value="#{scope.h(oauth_authorize_post_button)}"/>
346
+ </form>
347
+ </body>
348
+ </html>
349
+ FORM
350
+ when "none"
351
+ redirect(redirect_url.to_s)
319
352
  end
320
- redirect(redirect_url.to_s)
321
353
  end
322
354
  end
323
355
 
@@ -457,13 +489,13 @@ module Rodauth
457
489
  def fetch_access_token
458
490
  value = request.env["HTTP_AUTHORIZATION"]
459
491
 
460
- return unless value
492
+ return unless value && !value.empty?
461
493
 
462
494
  scheme, token = value.split(" ", 2)
463
495
 
464
496
  return unless scheme.downcase == oauth_token_type
465
497
 
466
- return if token.empty?
498
+ return if token.nil? || token.empty?
467
499
 
468
500
  token
469
501
  end
@@ -476,31 +508,34 @@ module Rodauth
476
508
 
477
509
  return unless bearer_token
478
510
 
479
- # check if token has not expired
480
- # check if token has been revoked
481
- @authorization_token = oauth_token_by_token(bearer_token)
511
+ @authorization_token = if is_authorization_server?
512
+ # check if token has not expired
513
+ # check if token has been revoked
514
+ oauth_token_by_token(bearer_token)
515
+ else
516
+ # where in resource server, NOT the authorization server.
517
+ payload = introspection_request("access_token", bearer_token)
518
+
519
+ return unless payload["active"]
520
+
521
+ payload
522
+ end
482
523
  end
483
524
 
484
525
  def require_oauth_authorization(*scopes)
485
- token_scopes = if is_authorization_server?
486
- authorization_required unless authorization_token
526
+ authorization_required unless authorization_token
487
527
 
488
- scopes << oauth_application_default_scope if scopes.empty?
528
+ scopes << oauth_application_default_scope if scopes.empty?
489
529
 
530
+ token_scopes = if is_authorization_server?
490
531
  authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
491
532
  else
492
- bearer_token = fetch_access_token
493
-
494
- authorization_required unless bearer_token
495
-
496
- scopes << oauth_application_default_scope if scopes.empty?
497
-
498
- # where in resource server, NOT the authorization server.
499
- payload = introspection_request("access_token", bearer_token)
500
-
501
- authorization_required unless payload["active"]
502
-
503
- payload["scope"].split(oauth_scope_separator)
533
+ aux_scopes = authorization_token["scope"]
534
+ if aux_scopes
535
+ aux_scopes.split(oauth_scope_separator)
536
+ else
537
+ []
538
+ end
504
539
  end
505
540
 
506
541
  authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
@@ -508,6 +543,11 @@ module Rodauth
508
543
 
509
544
  def post_configure
510
545
  super
546
+
547
+ # all of the extensions below involve DB changes. Resource server mode doesn't use
548
+ # database functions for OAuth though.
549
+ return unless is_authorization_server?
550
+
511
551
  self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
512
552
 
513
553
  # Check whether we can reutilize db entries for the same account / application pair
@@ -584,9 +624,9 @@ module Rodauth
584
624
  http.use_ssl = auth_url.scheme == "https"
585
625
 
586
626
  request = Net::HTTP::Post.new(introspect_path)
587
- request["content-type"] = json_response_content_type
627
+ request["content-type"] = "application/x-www-form-urlencoded"
588
628
  request["accept"] = json_response_content_type
589
- request.body = JSON.dump({ "token_type_hint" => token_type_hint, "token" => token })
629
+ request.set_form_data({ "token_type_hint" => token_type_hint, "token" => token })
590
630
 
591
631
  before_introspection_request(request)
592
632
  response = http.request(request)
@@ -848,6 +888,9 @@ module Rodauth
848
888
  end
849
889
  redirect_response_error("invalid_scope") unless check_valid_scopes?
850
890
 
891
+ if (response_mode = param_or_nil("response_mode")) && response_mode != "form_post"
892
+ redirect_response_error("invalid_request")
893
+ end
851
894
  validate_pkce_challenge_params if use_oauth_pkce?
852
895
  end
853
896
 
@@ -899,28 +942,26 @@ module Rodauth
899
942
  create_params[oauth_grants_code_column]
900
943
  end
901
944
 
902
- def do_authorize(redirect_url, query_params = [], fragment_params = [])
945
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
903
946
  case param("response_type")
904
947
  when "token"
905
948
  redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
906
949
 
907
- fragment_params.replace(_do_authorize_token.map { |k, v| "#{k}=#{v}" })
908
- when "code", "", nil
909
- query_params.replace(_do_authorize_code.map { |k, v| "#{k}=#{v}" })
910
- end
911
-
912
- if param_or_nil("state")
913
- if !fragment_params.empty?
914
- fragment_params << "state=#{param('state')}"
915
- else
916
- query_params << "state=#{param('state')}"
917
- end
950
+ response_mode ||= "fragment"
951
+ response_params.replace(_do_authorize_token)
952
+ when "code"
953
+ response_mode ||= "query"
954
+ response_params.replace(_do_authorize_code)
955
+ when "none"
956
+ response_mode ||= "none"
957
+ when "", nil
958
+ response_mode ||= oauth_response_mode
959
+ response_params.replace(_do_authorize_code)
918
960
  end
919
961
 
920
- query_params << redirect_url.query if redirect_url.query
962
+ response_params["state"] = param("state") if param_or_nil("state")
921
963
 
922
- redirect_url.query = query_params.join("&") unless query_params.empty?
923
- redirect_url.fragment = fragment_params.join("&") unless fragment_params.empty?
964
+ [response_params, response_mode]
924
965
  end
925
966
 
926
967
  def _do_authorize_code
@@ -1296,7 +1337,7 @@ module Rodauth
1296
1337
  issuer += "/#{path}" if path
1297
1338
 
1298
1339
  responses_supported = %w[code]
1299
- response_modes_supported = %w[query]
1340
+ response_modes_supported = %w[query form_post]
1300
1341
  grant_types_supported = %w[authorization_code]
1301
1342
 
1302
1343
  if use_oauth_implicit_grant_type?
@@ -8,20 +8,16 @@ module Rodauth
8
8
  def delete_suffix(suffix)
9
9
  suffix = suffix.to_s
10
10
  len = suffix.length
11
- if len.positive? && index(suffix, -len)
12
- self[0...-len]
13
- else
14
- dup
15
- end
11
+ return dup unless len.positive? && index(suffix, -len)
12
+
13
+ self[0...-len]
16
14
  end
17
15
 
18
16
  def delete_prefix(prefix)
19
17
  prefix = prefix.to_s
20
- if rindex(prefix, 0)
21
- self[prefix.length..-1]
22
- else
23
- dup
24
- end
18
+ return dup unless rindex(prefix, 0)
19
+
20
+ self[prefix.length..-1]
25
21
  end
26
22
  end
27
23
  end
@@ -8,6 +8,8 @@ module Rodauth
8
8
 
9
9
  JWKS = OAuth::TtlStore.new
10
10
 
11
+ # Recommended to have hmac_secret as well
12
+
11
13
  auth_value_method :oauth_jwt_subject_type, "public" # public, pairwise
12
14
  auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
13
15
 
@@ -38,7 +40,8 @@ module Rodauth
38
40
  :jwt_encode,
39
41
  :jwt_decode,
40
42
  :jwks_set,
41
- :last_account_login_at
43
+ :last_account_login_at,
44
+ :generate_jti
42
45
  )
43
46
 
44
47
  route(:jwks) do |r|
@@ -67,6 +70,10 @@ module Rodauth
67
70
  end
68
71
  end
69
72
 
73
+ def issuer
74
+ @issuer ||= oauth_jwt_token_issuer || authorization_server_url
75
+ end
76
+
70
77
  def authorization_token
71
78
  return @authorization_token if defined?(@authorization_token)
72
79
 
@@ -79,7 +86,7 @@ module Rodauth
79
86
 
80
87
  return unless jwt_token
81
88
 
82
- return if jwt_token["iss"] != (oauth_jwt_token_issuer || authorization_server_url) ||
89
+ return if jwt_token["iss"] != issuer ||
83
90
  (oauth_jwt_audience && jwt_token["aud"] != oauth_jwt_audience) ||
84
91
  !jwt_token["sub"]
85
92
 
@@ -105,7 +112,7 @@ module Rodauth
105
112
  redirect_response_error("invalid_request_object")
106
113
  end
107
114
 
108
- claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg])
115
+ claims = jwt_decode(request_object, jws_key: jwk_import(jws_jwk), jws_algorithm: jwk[:alg], verify_jti: false)
109
116
 
110
117
  redirect_response_error("invalid_request_object") unless claims
111
118
 
@@ -118,7 +125,7 @@ module Rodauth
118
125
  claims.delete("iss")
119
126
  audience = claims.delete("aud")
120
127
 
121
- redirect_response_error("invalid_request_object") if audience && audience != authorization_server_url
128
+ redirect_response_error("invalid_request_object") if audience && audience != issuer
122
129
 
123
130
  claims.each do |k, v|
124
131
  request.params[k.to_s] = v
@@ -209,7 +216,7 @@ module Rodauth
209
216
  issued_at = Time.now.to_i
210
217
 
211
218
  claims = {
212
- iss: (oauth_jwt_token_issuer || authorization_server_url), # issuer
219
+ iss: issuer, # issuer
213
220
  iat: issued_at, # issued at
214
221
  #
215
222
  # sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
@@ -317,6 +324,23 @@ module Rodauth
317
324
  end
318
325
  end
319
326
 
327
+ def generate_jti(payload)
328
+ # Use the key and iat to create a unique key per request to prevent replay attacks
329
+ jti_raw = [
330
+ payload[:aud] || payload["aud"],
331
+ payload[:iat] || payload["iat"]
332
+ ].join(":").to_s
333
+ Digest::SHA256.hexdigest(jti_raw)
334
+ end
335
+
336
+ def verify_jti(jti, claims)
337
+ generate_jti(claims) == jti
338
+ end
339
+
340
+ def verify_aud(aud, claims)
341
+ aud == (oauth_jwt_audience || claims["client_id"])
342
+ end
343
+
320
344
  if defined?(JSON::JWT)
321
345
 
322
346
  def jwk_import(data)
@@ -325,6 +349,7 @@ module Rodauth
325
349
 
326
350
  # json-jwt
327
351
  def jwt_encode(payload)
352
+ payload[:jti] = generate_jti(payload)
328
353
  jwt = JSON::JWT.new(payload)
329
354
  jwk = JSON::JWK.new(_jwt_key)
330
355
 
@@ -340,18 +365,34 @@ module Rodauth
340
365
  jwt.to_s
341
366
  end
342
367
 
343
- def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, **)
368
+ def jwt_decode(
369
+ token,
370
+ jws_key: oauth_jwt_public_key || _jwt_key,
371
+ verify_claims: true,
372
+ verify_jti: true,
373
+ **
374
+ )
344
375
  token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
345
376
 
346
- if is_authorization_server?
347
- if oauth_jwt_legacy_public_key
348
- JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks_set }))
349
- elsif jws_key
350
- JSON::JWT.decode(token, jws_key)
351
- end
352
- elsif (jwks = auth_server_jwks_set)
353
- JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
377
+ claims = if is_authorization_server?
378
+ if oauth_jwt_legacy_public_key
379
+ JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks_set }))
380
+ elsif jws_key
381
+ JSON::JWT.decode(token, jws_key)
382
+ end
383
+ elsif (jwks = auth_server_jwks_set)
384
+ JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
385
+ end
386
+
387
+ if verify_claims && !(claims[:iss] == issuer &&
388
+ verify_aud(claims[:aud], claims) &&
389
+ (!claims[:iat] || Time.at(claims[:iat]) > (Time.now - oauth_token_expires_in)) &&
390
+ (!claims[:exp] || Time.at(claims[:exp]) > Time.now) &&
391
+ (!verify_jti || verify_jti(claims[:jti], claims)))
392
+ return
354
393
  end
394
+
395
+ claims
355
396
  rescue JSON::JWT::Exception
356
397
  nil
357
398
  end
@@ -384,12 +425,8 @@ module Rodauth
384
425
  key = jwk.keypair
385
426
  end
386
427
 
387
- # Use the key and iat to create a unique key per request to prevent replay attacks
388
- jti_raw = [key, payload[:iat]].join(":").to_s
389
- jti = Digest::SHA256.hexdigest(jti_raw)
390
-
391
428
  # @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
392
- payload[:jti] = jti
429
+ payload[:jti] = generate_jti(payload)
393
430
  token = JWT.encode(payload, key, oauth_jwt_algorithm, headers)
394
431
 
395
432
  if oauth_jwt_jwe_key
@@ -405,21 +442,54 @@ module Rodauth
405
442
  token
406
443
  end
407
444
 
408
- def jwt_decode(token, jws_key: oauth_jwt_public_key || _jwt_key, jws_algorithm: oauth_jwt_algorithm)
445
+ def jwt_decode(
446
+ token,
447
+ jws_key: oauth_jwt_public_key || _jwt_key,
448
+ jws_algorithm: oauth_jwt_algorithm,
449
+ verify_claims: true,
450
+ verify_jti: true
451
+ )
409
452
  # decrypt jwe
410
453
  token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
454
+
455
+ # verifying the JWT implies verifying:
456
+ #
457
+ # issuer: check that server generated the token
458
+ # aud: check the audience field (client is who he says he is)
459
+ # iat: check that the token didn't expire
460
+ #
461
+ # subject can't be verified automatically without having access to the account id,
462
+ # which we don't because that's the whole point.
463
+ #
464
+ verify_claims_params = if verify_claims
465
+ {
466
+ verify_iss: true,
467
+ iss: issuer,
468
+ # can't use stock aud verification, as it's dependent on the client application id
469
+ verify_aud: false,
470
+ verify_jti: (verify_jti ? method(:verify_jti) : false),
471
+ verify_iat: true
472
+ }
473
+ else
474
+ {}
475
+ end
476
+
411
477
  # decode jwt
412
- if is_authorization_server?
413
- if oauth_jwt_legacy_public_key
414
- algorithms = jwks_set.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
415
- JWT.decode(token, nil, true, jwks: { keys: jwks_set }, algorithms: algorithms).first
416
- elsif jws_key
417
- JWT.decode(token, jws_key, true, algorithms: [jws_algorithm]).first
418
- end
419
- elsif (jwks = auth_server_jwks_set)
420
- algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
421
- JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms).first
422
- end
478
+ claims = if is_authorization_server?
479
+ if oauth_jwt_legacy_public_key
480
+ algorithms = jwks_set.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
481
+ JWT.decode(token, nil, true, jwks: { keys: jwks_set }, algorithms: algorithms, **verify_claims_params).first
482
+ elsif jws_key
483
+ JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params).first
484
+ end
485
+ elsif (jwks = auth_server_jwks_set)
486
+ algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
487
+ JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params).first
488
+ end
489
+
490
+ return if verify_claims && !verify_aud(claims["aud"], claims)
491
+
492
+ claims
423
493
  rescue JWT::DecodeError, JWT::JWKError
424
494
  nil
425
495
  end
@@ -2,17 +2,19 @@
2
2
 
3
3
  module Rodauth
4
4
  Feature.define(:oidc) do
5
+ # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
5
6
  OIDC_SCOPES_MAP = {
6
7
  "profile" => %i[name family_name given_name middle_name nickname preferred_username
7
8
  profile picture website gender birthdate zoneinfo locale updated_at].freeze,
8
9
  "email" => %i[email email_verified].freeze,
9
- "address" => %i[address].freeze,
10
+ "address" => %i[formatted street_address locality region postal_code country].freeze,
10
11
  "phone" => %i[phone_number phone_number_verified].freeze
11
12
  }.freeze
12
13
 
13
14
  VALID_METADATA_KEYS = %i[
14
15
  issuer
15
16
  authorization_endpoint
17
+ end_session_endpoint
16
18
  token_endpoint
17
19
  userinfo_endpoint
18
20
  jwks_uri
@@ -74,7 +76,11 @@ module Rodauth
74
76
  auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
75
77
  auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
76
78
 
77
- auth_value_methods(:get_oidc_param)
79
+ # logout
80
+ auth_value_method :oauth_applications_post_logout_redirect_uri_column, :post_logout_redirect_uri
81
+ auth_value_method :use_rp_initiated_logout?, false
82
+
83
+ auth_value_methods(:get_oidc_param, :get_additional_param)
78
84
 
79
85
  # /userinfo
80
86
  route(:userinfo) do |r|
@@ -107,10 +113,81 @@ module Rodauth
107
113
  end
108
114
  end
109
115
 
110
- def openid_configuration(issuer = nil)
116
+ # /oidc-logout
117
+ route(:oidc_logout) do |r|
118
+ next unless use_rp_initiated_logout?
119
+
120
+ before_oidc_logout_route
121
+ require_authorizable_account
122
+
123
+ # OpenID Providers MUST support the use of the HTTP GET and POST methods
124
+ r.on method: %i[get post] do
125
+ catch_error do
126
+ validate_oidc_logout_params
127
+
128
+ #
129
+ # why this is done:
130
+ #
131
+ # we need to decode the id token in order to get the application, because, if the
132
+ # signing key is application-specific, we don't know how to verify the signature
133
+ # beforehand. Hence, we have to do it twice: decode-and-do-not-verify, initialize
134
+ # the @oauth_application, and then decode-and-verify.
135
+ #
136
+ oauth_token = jwt_decode(param("id_token_hint"), verify_claims: false)
137
+ oauth_application_id = oauth_token["client_id"]
138
+
139
+ # check whether ID token belongs to currently logged-in user
140
+ redirect_response_error("invalid_request") unless oauth_token["sub"] == jwt_subject(
141
+ oauth_tokens_account_id_column => account_id,
142
+ oauth_tokens_oauth_application_id_column => oauth_application_id
143
+ )
144
+
145
+ # When an id_token_hint parameter is present, the OP MUST validate that it was the issuer of the ID Token.
146
+ redirect_response_error("invalid_request") unless oauth_token && oauth_token["iss"] == issuer
147
+
148
+ # now let's logout from IdP
149
+ transaction do
150
+ before_logout
151
+ logout
152
+ after_logout
153
+ end
154
+
155
+ if (post_logout_redirect_uri = param_or_nil("post_logout_redirect_uri"))
156
+ catch(:default_logout_redirect) do
157
+ oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => oauth_token["client_id"]).first
158
+
159
+ throw(:default_logout_redirect) unless oauth_application
160
+
161
+ post_logout_redirect_uris = oauth_application[oauth_applications_post_logout_redirect_uri_column].split(" ")
162
+
163
+ throw(:default_logout_redirect) unless post_logout_redirect_uris.include?(post_logout_redirect_uri)
164
+
165
+ if (state = param_or_nil("state"))
166
+ post_logout_redirect_uri = URI(post_logout_redirect_uri)
167
+ params = ["state=#{state}"]
168
+ params << post_logout_redirect_uri.query if post_logout_redirect_uri.query
169
+ post_logout_redirect_uri.query = params.join("&")
170
+ post_logout_redirect_uri = post_logout_redirect_uri.to_s
171
+ end
172
+
173
+ redirect(post_logout_redirect_uri)
174
+ end
175
+
176
+ end
177
+
178
+ # regular logout procedure
179
+ set_notice_flash(logout_notice_flash)
180
+ redirect(logout_redirect)
181
+ end
182
+
183
+ redirect_response_error("invalid_request")
184
+ end
185
+ end
186
+
187
+ def openid_configuration(alt_issuer = nil)
111
188
  request.on(".well-known/openid-configuration") do
112
189
  request.get do
113
- json_response_success(openid_configuration_body(issuer), cache: true)
190
+ json_response_success(openid_configuration_body(alt_issuer), cache: true)
114
191
  end
115
192
  end
116
193
  end
@@ -245,8 +322,11 @@ module Rodauth
245
322
  oauth_token[:id_token] = jwt_encode(id_token_claims)
246
323
  end
247
324
 
325
+ # aka fill_with_standard_claims
248
326
  def fill_with_account_claims(claims, account, scopes)
249
- scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
327
+ scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
328
+ next if scope == "openid"
329
+
250
330
  oidc, param = scope.split(".", 2)
251
331
 
252
332
  by_oidc[oidc] ||= []
@@ -254,21 +334,33 @@ module Rodauth
254
334
  by_oidc[oidc] << param.to_sym if param
255
335
  end
256
336
 
257
- oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
258
-
259
- return if oidc_scopes.empty?
337
+ oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
260
338
 
261
- if respond_to?(:get_oidc_param)
262
- oidc_scopes.each do |scope|
263
- params = scopes_by_oidc[scope]
264
- params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
339
+ unless oidc_scopes.empty?
340
+ if respond_to?(:get_oidc_param)
341
+ oidc_scopes.each do |scope|
342
+ scope_claims = claims
343
+ params = scopes_by_claim[scope]
344
+ params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
265
345
 
266
- params.each do |param|
267
- claims[param] = __send__(:get_oidc_param, account, param)
346
+ scope_claims = (claims["address"] = {}) if scope == "address"
347
+ params.each do |param|
348
+ scope_claims[param] = __send__(:get_oidc_param, account, param)
349
+ end
268
350
  end
351
+ else
352
+ warn "`get_oidc_param(account, claim)` must be implemented to use oidc scopes."
353
+ end
354
+ end
355
+
356
+ return if additional_scopes.empty?
357
+
358
+ if respond_to?(:get_additional_param)
359
+ additional_scopes.each do |scope|
360
+ claims[scope] = __send__(:get_additional_param, account, scope.to_sym)
269
361
  end
270
362
  else
271
- warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
363
+ warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes."
272
364
  end
273
365
  end
274
366
 
@@ -290,33 +382,27 @@ module Rodauth
290
382
  end
291
383
  end
292
384
 
293
- def do_authorize(redirect_url, query_params = [], fragment_params = [])
385
+ def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
294
386
  return super unless use_oauth_implicit_grant_type?
295
387
 
296
388
  case param("response_type")
297
389
  when "id_token"
298
- fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
390
+ response_params.replace(_do_authorize_id_token)
299
391
  when "code token"
300
392
  redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
301
393
 
302
- params = _do_authorize_code.merge(_do_authorize_token)
303
-
304
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
394
+ response_params.replace(_do_authorize_code.merge(_do_authorize_token))
305
395
  when "code id_token"
306
- params = _do_authorize_code.merge(_do_authorize_id_token)
307
-
308
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
396
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token))
309
397
  when "id_token token"
310
- params = _do_authorize_id_token.merge(_do_authorize_token)
311
-
312
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
398
+ response_params.replace(_do_authorize_id_token.merge(_do_authorize_token))
313
399
  when "code id_token token"
314
- params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
315
400
 
316
- fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
401
+ response_params.replace(_do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token))
317
402
  end
403
+ response_mode ||= "fragment" unless response_params.empty?
318
404
 
319
- super(redirect_url, query_params, fragment_params)
405
+ super(response_params, response_mode)
320
406
  end
321
407
 
322
408
  def _do_authorize_id_token
@@ -332,6 +418,18 @@ module Rodauth
332
418
  params
333
419
  end
334
420
 
421
+ # Logout
422
+
423
+ def validate_oidc_logout_params
424
+ redirect_response_error("invalid_request") unless param_or_nil("id_token_hint")
425
+ # check if valid token hint type
426
+ return unless (redirect_uri = param_or_nil("post_logout_redirect_uri"))
427
+
428
+ return if check_valid_uri?(redirect_uri)
429
+
430
+ redirect_response_error("invalid_request")
431
+ end
432
+
335
433
  # Metadata
336
434
 
337
435
  def openid_configuration_body(path)
@@ -351,13 +449,15 @@ module Rodauth
351
449
 
352
450
  scope_claims.unshift("auth_time") if last_account_login_at
353
451
 
452
+ response_types_supported = metadata[:response_types_supported]
453
+ if use_oauth_implicit_grant_type?
454
+ response_types_supported += ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"]
455
+ end
456
+
354
457
  metadata.merge(
355
458
  userinfo_endpoint: userinfo_url,
356
- response_types_supported: metadata[:response_types_supported] +
357
- ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"],
358
- response_modes_supported: %w[query fragment],
359
- grant_types_supported: %w[authorization_code implicit],
360
-
459
+ end_session_endpoint: (oidc_logout_url if use_rp_initiated_logout?),
460
+ response_types_supported: response_types_supported,
361
461
  subject_types_supported: [oauth_jwt_subject_type],
362
462
 
363
463
  id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
@@ -13,7 +13,7 @@ class Rodauth::OAuth::TtlStore
13
13
 
14
14
  def initialize
15
15
  @store_mutex = Mutex.new
16
- @store = Hash.new {}
16
+ @store = {}
17
17
  end
18
18
 
19
19
  def [](key)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -20,6 +20,7 @@
20
20
 
21
21
  #{"<input type=\"hidden\" name=\"access_type\" value=\"#{rodauth.param("access_type")}\"/>" if rodauth.param_or_nil("access_type")}
22
22
  #{"<input type=\"hidden\" name=\"response_type\" value=\"#{rodauth.param("response_type")}\"/>" if rodauth.param_or_nil("response_type")}
23
+ #{"<input type=\"hidden\" name=\"response_mode\" value=\"#{rodauth.param("response_mode")}\"/>" if rodauth.param_or_nil("response_mode")}
23
24
  #{"<input type=\"hidden\" name=\"state\" value=\"#{rodauth.param("state")}\"/>" if rodauth.param_or_nil("state")}
24
25
  #{"<input type=\"hidden\" name=\"nonce\" value=\"#{rodauth.param("nonce")}\"/>" if rodauth.param_or_nil("nonce")}
25
26
  #{"<input type=\"hidden\" name=\"redirect_uri\" value=\"#{rodauth.redirect_uri}\"/>" if rodauth.param_or_nil("redirect_uri")}
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: 0.3.0
4
+ version: 0.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: 2020-10-08 00:00:00.000000000 Z
11
+ date: 2021-02-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Implementation of the OAuth 2.0 protocol on top of rodauth.
14
14
  email:
@@ -71,7 +71,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
71
  - !ruby/object:Gem::Version
72
72
  version: '0'
73
73
  requirements: []
74
- rubygems_version: 3.1.2
74
+ rubygems_version: 3.2.3
75
75
  signing_key:
76
76
  specification_version: 4
77
77
  summary: Implementation of the OAuth 2.0 protocol on top of rodauth.