rodauth-oauth 0.4.3 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -6
- data/README.md +7 -2
- data/lib/rodauth/features/oauth_jwt.rb +101 -31
- data/lib/rodauth/features/oidc.rb +91 -2
- data/lib/rodauth/oauth/version.rb +1 -1
- 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,40 +2,52 @@
|
|
2
2
|
|
3
3
|
## master
|
4
4
|
|
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
|
+
|
5
17
|
### 0.4.3 (09/12/2020)
|
6
18
|
|
7
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.
|
8
20
|
|
9
21
|
### 0.4.2 (24/11/2020)
|
10
22
|
|
11
|
-
|
23
|
+
#### Bugfixes
|
12
24
|
|
13
25
|
* database extensions were being run in resource server mode, when it's not expected that the oauth db tables are around.
|
14
26
|
|
15
27
|
### 0.4.1 (24/11/2020)
|
16
28
|
|
17
|
-
|
29
|
+
#### Improvements
|
18
30
|
|
19
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.
|
20
32
|
|
21
|
-
|
33
|
+
#### Bugfixes
|
22
34
|
|
23
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.
|
24
36
|
|
25
37
|
### 0.4.0 (13/11/2020)
|
26
38
|
|
27
|
-
|
39
|
+
#### Features
|
28
40
|
|
29
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.
|
30
42
|
|
31
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.
|
32
44
|
|
33
45
|
|
34
|
-
|
46
|
+
#### Improvements
|
35
47
|
|
36
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.
|
37
49
|
|
38
|
-
|
50
|
+
#### Bugfixes
|
39
51
|
|
40
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;
|
41
53
|
* rails tests were silently not running in CI;
|
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
|
@@ -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
|
@@ -14,6 +14,7 @@ module Rodauth
|
|
14
14
|
VALID_METADATA_KEYS = %i[
|
15
15
|
issuer
|
16
16
|
authorization_endpoint
|
17
|
+
end_session_endpoint
|
17
18
|
token_endpoint
|
18
19
|
userinfo_endpoint
|
19
20
|
jwks_uri
|
@@ -75,6 +76,10 @@ module Rodauth
|
|
75
76
|
auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
|
76
77
|
auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
|
77
78
|
|
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
|
+
|
78
83
|
auth_value_methods(:get_oidc_param, :get_additional_param)
|
79
84
|
|
80
85
|
# /userinfo
|
@@ -108,10 +113,81 @@ module Rodauth
|
|
108
113
|
end
|
109
114
|
end
|
110
115
|
|
111
|
-
|
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)
|
112
188
|
request.on(".well-known/openid-configuration") do
|
113
189
|
request.get do
|
114
|
-
json_response_success(openid_configuration_body(
|
190
|
+
json_response_success(openid_configuration_body(alt_issuer), cache: true)
|
115
191
|
end
|
116
192
|
end
|
117
193
|
end
|
@@ -342,6 +418,18 @@ module Rodauth
|
|
342
418
|
params
|
343
419
|
end
|
344
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
|
+
|
345
433
|
# Metadata
|
346
434
|
|
347
435
|
def openid_configuration_body(path)
|
@@ -368,6 +456,7 @@ module Rodauth
|
|
368
456
|
|
369
457
|
metadata.merge(
|
370
458
|
userinfo_endpoint: userinfo_url,
|
459
|
+
end_session_endpoint: (oidc_logout_url if use_rp_initiated_logout?),
|
371
460
|
response_types_supported: response_types_supported,
|
372
461
|
subject_types_supported: [oauth_jwt_subject_type],
|
373
462
|
|
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.
|