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 +4 -4
- data/CHANGELOG.md +62 -25
- data/README.md +7 -2
- data/lib/rodauth/features/oauth.rb +82 -41
- data/lib/rodauth/features/oauth_http_mac.rb +6 -10
- data/lib/rodauth/features/oauth_jwt.rb +101 -31
- data/lib/rodauth/features/oidc.rb +134 -34
- data/lib/rodauth/oauth/ttl_store.rb +1 -1
- data/lib/rodauth/oauth/version.rb +1 -1
- data/templates/authorize.str +1 -0
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7324d08b229d4bfdea92df95c769539570565e161d758a38d95bea50f78fda96
|
4
|
+
data.tar.gz: c421e4886baf39eb9ebe04e6919c6ab292d3c85dbc32bc78e5d8992c17a40816
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
*
|
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
|
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, :
|
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
|
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
|
-
|
480
|
-
|
481
|
-
|
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
|
-
|
486
|
-
authorization_required unless authorization_token
|
526
|
+
authorization_required unless authorization_token
|
487
527
|
|
488
|
-
|
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
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
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"] =
|
627
|
+
request["content-type"] = "application/x-www-form-urlencoded"
|
588
628
|
request["accept"] = json_response_content_type
|
589
|
-
request.
|
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(
|
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
|
-
|
908
|
-
|
909
|
-
|
910
|
-
|
911
|
-
|
912
|
-
|
913
|
-
|
914
|
-
|
915
|
-
|
916
|
-
|
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
|
-
|
962
|
+
response_params["state"] = param("state") if param_or_nil("state")
|
921
963
|
|
922
|
-
|
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
|
-
|
12
|
-
|
13
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
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"] !=
|
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 !=
|
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:
|
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(
|
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
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
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] =
|
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(
|
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
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
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[
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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 =
|
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
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
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
|
-
|
267
|
-
|
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 "`
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
357
|
-
|
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],
|
data/templates/authorize.str
CHANGED
@@ -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.
|
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:
|
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.
|
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.
|