rodauth-oauth 0.0.6 → 0.4.1
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 +167 -5
- data/README.md +43 -20
- data/lib/generators/roda/oauth/templates/db/migrate/create_rodauth_oauth.rb +8 -5
- data/lib/rodauth/features/oauth.rb +534 -409
- data/lib/rodauth/features/oauth_http_mac.rb +6 -10
- data/lib/rodauth/features/oauth_jwt.rb +115 -70
- data/lib/rodauth/features/oauth_saml.rb +104 -0
- data/lib/rodauth/features/oidc.rb +399 -0
- data/lib/rodauth/oauth/database_extensions.rb +73 -0
- data/lib/rodauth/oauth/ttl_store.rb +1 -1
- data/lib/rodauth/oauth/version.rb +1 -1
- data/templates/authorize.str +34 -0
- data/templates/client_secret_field.str +4 -0
- data/templates/description_field.str +4 -0
- data/templates/homepage_url_field.str +4 -0
- data/templates/name_field.str +4 -0
- data/templates/new_oauth_application.str +10 -0
- data/templates/oauth_application.str +11 -0
- data/templates/oauth_applications.str +14 -0
- data/templates/oauth_tokens.str +49 -0
- data/templates/redirect_uri_field.str +4 -0
- data/templates/scope_field.str +10 -0
- metadata +24 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c7c9cc026f547d781b05599d177237498390c8347791aaf5960e7447d2640b0b
|
4
|
+
data.tar.gz: 290ec103b22d394fbae7f153430605fa032b8baf6b6083e31ad8af8cd3d422b8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 64c22bd200ff9dcb5e8406ace5f4eb34625bcee5a381e52b6b7e960b614ec3941c0460997542d843ed4eaa843a85a7f2592a027c741d5380a7572edb974ca3a9
|
7
|
+
data.tar.gz: 0a6c93bc131d2fcb45e400173ced20096caa191e3ef73f4e67ab0fc12d5ead9b9f9e6867d163612299141b96b7691429cb5e5b263036887134396f244c3dd4f7
|
data/CHANGELOG.md
CHANGED
@@ -2,8 +2,160 @@
|
|
2
2
|
|
3
3
|
## master
|
4
4
|
|
5
|
+
### 0.4.1
|
6
|
+
|
7
|
+
### Improvements
|
8
|
+
|
9
|
+
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.
|
10
|
+
|
11
|
+
### Bugfixes
|
12
|
+
|
13
|
+
* An error ocurred 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.
|
14
|
+
|
15
|
+
### 0.4.0
|
16
|
+
|
17
|
+
### Features
|
18
|
+
|
19
|
+
* 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.
|
20
|
+
|
21
|
+
* 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.
|
22
|
+
|
23
|
+
|
24
|
+
### Improvements
|
25
|
+
|
26
|
+
* 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.
|
27
|
+
|
28
|
+
### Bugfixes
|
29
|
+
|
30
|
+
* 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;
|
31
|
+
* rails tests were silently not running in CI;
|
32
|
+
* 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;
|
33
|
+
|
34
|
+
### 0.3.0
|
35
|
+
|
36
|
+
#### Features
|
37
|
+
|
38
|
+
* `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.
|
39
|
+
|
40
|
+
#### Improvements
|
41
|
+
|
42
|
+
|
43
|
+
* 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).
|
44
|
+
|
45
|
+
* 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.
|
46
|
+
|
47
|
+
#### Bugfixes
|
48
|
+
|
49
|
+
* Default Templates now being packaged, as a way to provide a default experience to the OAuth journeys.
|
50
|
+
|
51
|
+
* fixing metadata urls when plugin loaded with a prefix path (@ianks)
|
52
|
+
|
53
|
+
* All date/time-based calculations, such as determining an expiration date, or checking if a token has expired, are now performed using database arithmetic operations, using sequel's `date_arithmetic` plugin. This will eliminate subtle bugs, such as when the database timezone is different than the application OS timezone.
|
54
|
+
|
55
|
+
* OIDC configuration endpoint is now stricter, eliminating JSON metadata inherited from the Oauth metadata endpoint. (@ianks)
|
56
|
+
|
57
|
+
#### Chore
|
58
|
+
|
59
|
+
Use `rodauth.convert_timestamp` in the templates, whenever dates are displayed.
|
60
|
+
|
61
|
+
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).
|
62
|
+
|
63
|
+
### 0.2.0
|
64
|
+
|
65
|
+
#### Features
|
66
|
+
|
67
|
+
##### SAML Assertion Grant Type
|
68
|
+
|
69
|
+
`rodauth-auth` now supports using a SAML Assertion to request for an Access token.In order to enable, you have to:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
plugin :rodauth do
|
73
|
+
enable :oauth_saml
|
74
|
+
end
|
75
|
+
```
|
76
|
+
|
77
|
+
For more info about integrating it, [check the wiki](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/SAML-Assertion-Access-Tokens).
|
78
|
+
|
79
|
+
##### Supporting rotating keys
|
80
|
+
|
81
|
+
At some point, you'll want to replace the pkeys and algorithm used to generate and verify the JWT access tokens, but you want to keep validating previously-distributed JWT tokens, at least until they expire. Now you can, via two new options, `oauth_jwt_legacy_public_key` and `oauth_jwt_legacy_algorithm`, which will be declared in the JWKs URI and used to verify access tokens.
|
82
|
+
|
83
|
+
|
84
|
+
##### Reuse access tokens
|
85
|
+
|
86
|
+
If the `oauth_reuse_access_token` is set, if there's already an existing valid access token, any new grant for the same application / account / scope will keep the same access token. This can be helpful in scenarios where one wants the same access token distributed across devices.
|
87
|
+
|
88
|
+
##### require_authorizable_account
|
89
|
+
|
90
|
+
The method used to verify access to the authorize flow is called `require_authorizable_account`. By default, it checks if a user is logged in by using rodauth's own `require_account`. This is the method you'd want to redefine in order to augment these requirements, i.e. request 2fa authentication.
|
91
|
+
|
92
|
+
#### Improvements
|
93
|
+
|
94
|
+
Expired and revoked access tokens end up generating a lot of garbage, which will have to be periodically cleaned up. You can mitigate this now by setting a uniqueness index for a group of columns, i.e. if you set a uniqueness index for the `oauth_application_id/account_id/scopes` column, `rodauth-oauth` will transparently reuse the same db entry to store the new access token. If setting some other type of uniqueness index, make sure to update the option `oauth_tokens_unique_columns` (the array of columns from the uniqueness index).
|
95
|
+
|
96
|
+
#### Bugfixes
|
97
|
+
|
98
|
+
Calling `before_*_route` callbacks appropriately.
|
99
|
+
|
100
|
+
Fixed some mishandling of HTTP headers when in in resource-server mode.
|
101
|
+
|
102
|
+
#### Chore
|
103
|
+
|
104
|
+
* 97.7% test coverage;
|
105
|
+
* `rodauth-oauth` CI tests run against sqlite, postgresql and mysql.
|
106
|
+
|
107
|
+
### 0.1.0
|
108
|
+
|
109
|
+
(31/7/2020)
|
110
|
+
|
111
|
+
#### Features
|
112
|
+
|
113
|
+
##### OpenID
|
114
|
+
|
115
|
+
`rodauth-oauth` now ships with support for [OpenID Connect](https://openid.net/connect/). In order to enable, you have to:
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
plugin :rodauth do
|
119
|
+
enable :oidc
|
120
|
+
end
|
121
|
+
```
|
122
|
+
|
123
|
+
For more info about integrating it, [check the wiki](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/wikis/home#openid-connect-since-v01).
|
124
|
+
|
125
|
+
It supports omniauth openID integrations out-of-the-box, [check the OpenID example, which integrates with omniauth_openid_connect](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/tree/master/examples).
|
126
|
+
|
127
|
+
#### Improvements
|
128
|
+
|
129
|
+
* JWT: `sub` claim now also handles "pairwise" subjects. For that, you have to set the `oauth_jwt_subject_type` option (`"public"` or `"pairwise"`) and `oauth_jwt_subject_secret` (will be used for salting the `sub` when the type is `"pairwise"`).
|
130
|
+
* JWT: `auth_time` claim is now supported; if your application uses the `rodauth` feature `:account_expiration`, it'll use the `last_account_login_at` method, otherwise you can set the `last_account_login_at` option:
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
last_account_login_at do
|
134
|
+
convert_timestamp(db[accounts_table].where(account_id_column => account_id).get(:that_column_where_you_keep_the_data))
|
135
|
+
end
|
136
|
+
```
|
137
|
+
* JWT: `iss` claim now defaults to `authorization_server_url` when not defined;
|
138
|
+
* JWT: `aud` claim now defaults to the token application's client ID (`client_id` claim was removed as a result);
|
139
|
+
|
140
|
+
|
141
|
+
|
142
|
+
#### Breaking Changes
|
143
|
+
|
144
|
+
`rodauth-oauth` URLs no longer have the `oauth-` prefix, so make sure you update your integrations accordingly, i.e. where you used to rely on `/oauth-authorize`, you'll have to use `/authorize`.
|
145
|
+
|
146
|
+
URI schemes for client applications redirect URIs have to be `https`. In order to override this, set the `oauth_valid_uri_schemes` to an array of your expected URI schemes.
|
147
|
+
|
148
|
+
|
149
|
+
#### Bugfixes
|
150
|
+
|
151
|
+
* Authorization request submission can receive the `scope` as an array of values now, instead of only dealing with receiving a white-space separated list.
|
152
|
+
* fixed trailing "/" in the "issuer" value in server metadata (`https://server.com/` -> `https://server.com`).
|
153
|
+
|
154
|
+
|
5
155
|
### 0.0.6
|
6
156
|
|
157
|
+
(6/7/2020)
|
158
|
+
|
7
159
|
#### Features
|
8
160
|
|
9
161
|
The `oauth_jwt` feature now supports JWT Secured Authorization Request (JAR) (see https://tools.ietf.org/html/draft-ietf-oauth-jwsreq-20). This means that client applications can send the authorization parameters inside a signed JWT. The client applications keeps the private key, while the authorization server **must** store a public key for the client application. For encrypted JWTs, the client application should use one of the public encryption keys exposed in the JWKs URI, to encrypt the JWT. Remember, **tokens must be signed then encrypted** (or just signed).
|
@@ -25,7 +177,9 @@ The `oauth_jwt` feature now supports JWT Secured Authorization Request (JAR) (se
|
|
25
177
|
Removed React Javascript from example applications.
|
26
178
|
|
27
179
|
|
28
|
-
### 0.0.5
|
180
|
+
### 0.0.5
|
181
|
+
|
182
|
+
(26/6/2020)
|
29
183
|
|
30
184
|
#### Features
|
31
185
|
|
@@ -62,7 +216,9 @@ It **requires** the authorization to implement the server metadata endpoint (`/.
|
|
62
216
|
* option `scopes_param` renamed to `scope_param`;
|
63
217
|
*
|
64
218
|
|
65
|
-
## 0.0.4
|
219
|
+
## 0.0.4
|
220
|
+
|
221
|
+
(13/6/2020)
|
66
222
|
|
67
223
|
### Features
|
68
224
|
|
@@ -99,7 +255,9 @@ The `oauth_jwt` feature now allows the usage of access tokens to authorize the g
|
|
99
255
|
|
100
256
|
* Fixed scope claim of JWT ("scopes" -> "scope");
|
101
257
|
|
102
|
-
## 0.0.3
|
258
|
+
## 0.0.3
|
259
|
+
|
260
|
+
(5/6/2020)
|
103
261
|
|
104
262
|
### Features
|
105
263
|
|
@@ -131,7 +289,9 @@ end
|
|
131
289
|
* renamed the existing `use_oauth_implicit_grant_type` to `use_oauth_implicit_grant_type?`;
|
132
290
|
* It's now usable as JSON API (small caveat: POST authorize will still redirect on success...);
|
133
291
|
|
134
|
-
## 0.0.2
|
292
|
+
## 0.0.2
|
293
|
+
|
294
|
+
(29/5/2020)
|
135
295
|
|
136
296
|
### Features
|
137
297
|
|
@@ -147,6 +307,8 @@ end
|
|
147
307
|
|
148
308
|
* usage of client secret for authorizing the generation of tokens, as the spec mandates (and refraining from them when doing PKCE).
|
149
309
|
|
150
|
-
## 0.0.1
|
310
|
+
## 0.0.1
|
311
|
+
|
312
|
+
(14/5/2020)
|
151
313
|
|
152
314
|
Initial implementation of the Oauth 2.0 framework, with an example app done using roda.
|
data/README.md
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
# Rodauth::Oauth
|
2
2
|
|
3
|
-
[](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/
|
4
|
-
[](https://gitlab.
|
3
|
+
[](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/pipelines?page=1&ref=master)
|
4
|
+
[](https://honeyryderchuck.gitlab.io/rodauth-oauth/coverage/#_AllFiles)
|
5
5
|
|
6
6
|
This is an extension to the `rodauth` gem which implements the [OAuth 2.0 framework](https://tools.ietf.org/html/rfc6749) for an authorization server.
|
7
7
|
|
8
8
|
## Features
|
9
9
|
|
10
|
-
This gem implements:
|
10
|
+
This gem implements the following RFCs and features of OAuth:
|
11
11
|
|
12
12
|
* [The OAuth 2.0 protocol framework](https://tools.ietf.org/html/rfc6749):
|
13
13
|
* [Authorization grant flow](https://tools.ietf.org/html/rfc6749#section-1.3);
|
@@ -21,9 +21,12 @@ This gem implements:
|
|
21
21
|
* Access Type (Token refresh online and offline);
|
22
22
|
* [MAC Authentication Scheme](https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-02);
|
23
23
|
* [JWT Acess Tokens](https://tools.ietf.org/html/draft-ietf-oauth-access-token-jwt-07);
|
24
|
+
* [SAML 2.0 Assertion Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-saml2-bearer-03);
|
24
25
|
* [JWT Secured Authorization Requests](https://tools.ietf.org/html/draft-ietf-oauth-jwsreq-20);
|
25
26
|
* OAuth application and token management dashboards;
|
26
27
|
|
28
|
+
It also implements the [OpenID Connect layer](https://openid.net/connect/) on top of the OAuth features it provides.
|
29
|
+
|
27
30
|
This gem supports also rails (through [rodauth-rails]((https://github.com/janko/rodauth-rails))).
|
28
31
|
|
29
32
|
|
@@ -43,6 +46,15 @@ Or install it yourself as:
|
|
43
46
|
|
44
47
|
$ gem install rodauth-oauth
|
45
48
|
|
49
|
+
|
50
|
+
## Resources
|
51
|
+
| | |
|
52
|
+
| ------------- | ----------------------------------------------------------- |
|
53
|
+
| Website | https://honeyryderchuck.gitlab.io/rodauth-oauth/ |
|
54
|
+
| Documentation | https://honeyryderchuck.gitlab.io/rodauth-oauth/rdoc/ |
|
55
|
+
| Wiki | https://gitlab.com/honeyryderchuck/rodauth-oauth/wikis/home |
|
56
|
+
| CI | https://gitlab.com/honeyryderchuck/rodauth-oauth/pipelines |
|
57
|
+
|
46
58
|
## Usage
|
47
59
|
|
48
60
|
This tutorial assumes you already read the documentation and know how to set up `rodauth`. After that, integrating `roda-auth` will look like:
|
@@ -86,7 +98,18 @@ route do |r|
|
|
86
98
|
end
|
87
99
|
```
|
88
100
|
|
89
|
-
|
101
|
+
|
102
|
+
For OpenID, it's very similar to the example above:
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
plugin :rodauth do
|
106
|
+
# enable it in the plugin
|
107
|
+
enable :login, :openid
|
108
|
+
oauth_application_default_scope %w[openid]
|
109
|
+
oauth_application_scopes %w[openid email profile]
|
110
|
+
end
|
111
|
+
```
|
112
|
+
|
90
113
|
|
91
114
|
### Example (TL;DR)
|
92
115
|
|
@@ -101,7 +124,7 @@ Generating tokens happens mostly server-to-server, so here's an example using:
|
|
101
124
|
|
102
125
|
```ruby
|
103
126
|
require "httpx"
|
104
|
-
response = HTTPX.post("https://auth_server/
|
127
|
+
response = HTTPX.post("https://auth_server/token",json: {
|
105
128
|
client_id: ENV["OAUTH_CLIENT_ID"],
|
106
129
|
client_secret: ENV["OAUTH_CLIENT_SECRET"],
|
107
130
|
grant_type: "authorization_code",
|
@@ -115,7 +138,7 @@ puts payload #=> {"access_token" => "awr23f3h8f9d2h89...", "refresh_token" => "2
|
|
115
138
|
##### cURL
|
116
139
|
|
117
140
|
```
|
118
|
-
> curl --data '{"client_id":"$OAUTH_CLIENT_ID","client_secret":"$OAUTH_CLIENT_SECRET","grant_type":"authorization_code","code":"oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as"}' https://auth_server/
|
141
|
+
> curl --data '{"client_id":"$OAUTH_CLIENT_ID","client_secret":"$OAUTH_CLIENT_SECRET","grant_type":"authorization_code","code":"oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as"}' https://auth_server/token
|
119
142
|
```
|
120
143
|
|
121
144
|
#### Refresh Token
|
@@ -126,7 +149,7 @@ Refreshing expired tokens also happens mostly server-to-server, here's an exampl
|
|
126
149
|
|
127
150
|
```ruby
|
128
151
|
require "httpx"
|
129
|
-
response = HTTPX.post("https://auth_server/
|
152
|
+
response = HTTPX.post("https://auth_server/token",json: {
|
130
153
|
client_id: ENV["OAUTH_CLIENT_ID"],
|
131
154
|
client_secret: ENV["OAUTH_CLIENT_SECRET"],
|
132
155
|
grant_type: "refresh_token",
|
@@ -140,7 +163,7 @@ puts payload #=> {"access_token" => "awr23f3h8f9d2h89...", "token_type" => "Bear
|
|
140
163
|
##### cURL
|
141
164
|
|
142
165
|
```
|
143
|
-
> curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","client_secret":"$OAUTH_CLIENT_SECRET","grant_type":"token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/
|
166
|
+
> curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","client_secret":"$OAUTH_CLIENT_SECRET","grant_type":"token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/token
|
144
167
|
```
|
145
168
|
|
146
169
|
#### Revoking tokens
|
@@ -151,7 +174,7 @@ Token revocation can be done both by the idenntity owner or the application owne
|
|
151
174
|
require "httpx"
|
152
175
|
httpx = HTTPX.plugin(:basic_authorization)
|
153
176
|
response = httpx.basic_authentication(ENV["CLIENT_ID"], ENV["CLIENT_SECRET"])
|
154
|
-
.post("https://auth_server/
|
177
|
+
.post("https://auth_server/revoke",json: {
|
155
178
|
token_type_hint: "access_token", # can also be "refresh:tokn"
|
156
179
|
token: "2r89hfef4j9f90d2j2390jf390g"
|
157
180
|
})
|
@@ -163,7 +186,7 @@ puts payload #=> {"access_token" => "awr23f3h8f9d2h89...", "token_type" => "Bear
|
|
163
186
|
##### cURL
|
164
187
|
|
165
188
|
```
|
166
|
-
> curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","token_type_hint":"access_token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/
|
189
|
+
> curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","token_type_hint":"access_token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/revoke
|
167
190
|
```
|
168
191
|
|
169
192
|
#### Token introspection
|
@@ -174,7 +197,7 @@ Token revocation can be used to determine the state of a token (whether active,
|
|
174
197
|
require "httpx"
|
175
198
|
httpx = HTTPX.plugin(:basic_authorization)
|
176
199
|
response = httpx.basic_authentication(ENV["CLIENT_ID"], ENV["CLIENT_SECRET"])
|
177
|
-
.post("https://auth_server/
|
200
|
+
.post("https://auth_server/introspect",json: {
|
178
201
|
token_type_hint: "access_token", # can also be "refresh:tokn"
|
179
202
|
token: "2r89hfef4j9f90d2j2390jf390g"
|
180
203
|
})
|
@@ -186,7 +209,7 @@ puts payload #=> {"active" => true, "scope" => "read write" ....
|
|
186
209
|
##### cURL
|
187
210
|
|
188
211
|
```
|
189
|
-
> curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","token_type_hint":"access_token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/
|
212
|
+
> curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","token_type_hint":"access_token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/revoke
|
190
213
|
```
|
191
214
|
|
192
215
|
### Authorization Server Metadata
|
@@ -243,10 +266,10 @@ The rodauth default setup expects the roda `render` plugin to be activated; by d
|
|
243
266
|
|
244
267
|
Once you set it up, by default, the following endpoints will be available:
|
245
268
|
|
246
|
-
* `GET /
|
247
|
-
* `POST /
|
248
|
-
* `POST /
|
249
|
-
* `POST /
|
269
|
+
* `GET /authorize`: Loads the OAuth authorization HTML form;
|
270
|
+
* `POST /authorize`: Responds to an OAuth authorization request, as [per the spec](https://tools.ietf.org/html/rfc6749#section-4);
|
271
|
+
* `POST /token`: Generates OAuth tokens as [per the spec](https://tools.ietf.org/html/rfc6749#section-4.4.2);
|
272
|
+
* `POST /revoke`: Revokes OAuth tokens as [per the spec](https://tools.ietf.org/html/rfc7009);
|
250
273
|
|
251
274
|
### OAuth applications
|
252
275
|
|
@@ -426,7 +449,7 @@ The "Proof Key for Code Exchange by OAuth Public Clients" (aka PKCE) flow, which
|
|
426
449
|
```ruby
|
427
450
|
# with httpx
|
428
451
|
require "httpx"
|
429
|
-
response = HTTPX.post("https://auth_server/
|
452
|
+
response = HTTPX.post("https://auth_server/token",json: {
|
430
453
|
client_id: ENV["OAUTH_CLIENT_ID"],
|
431
454
|
grant_type: "authorization_code",
|
432
455
|
code: "oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as",
|
@@ -477,7 +500,7 @@ Generating an access token will deliver the following fields:
|
|
477
500
|
```ruby
|
478
501
|
# with httpx
|
479
502
|
require "httpx"
|
480
|
-
response = httpx.post("https://auth_server/
|
503
|
+
response = httpx.post("https://auth_server/token",json: {
|
481
504
|
client_id: env["oauth_client_id"],
|
482
505
|
client_secret: env["oauth_client_secret"],
|
483
506
|
grant_type: "authorization_code",
|
@@ -576,7 +599,7 @@ which adds an extra layer of protection.
|
|
576
599
|
|
577
600
|
#### JWKS URI
|
578
601
|
|
579
|
-
A route is defined for getting the JWK Set in a JSON format; this is typically used by client applications, who need the JWK set to decode the JWT token. This URL is typically `https://oauth-server/
|
602
|
+
A route is defined for getting the JWK Set in a JSON format; this is typically used by client applications, who need the JWK set to decode the JWT token. This URL is typically `https://oauth-server/jwks`.
|
580
603
|
|
581
604
|
#### JWT Bearer as authorization grant
|
582
605
|
|
@@ -585,7 +608,7 @@ One can emit a new access token by using the bearer access token as grant. This
|
|
585
608
|
```ruby
|
586
609
|
# with httpx
|
587
610
|
require "httpx"
|
588
|
-
response = httpx.post("https://auth_server/
|
611
|
+
response = httpx.post("https://auth_server/token",json: {
|
589
612
|
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
590
613
|
assertion: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6IkV4YW1wbGUiLCJpYXQiOjE1OTIwMDk1MDEsImNsaWVudF9pZCI6IkNMSUVOVF9JRCIsImV4cCI6MTU5MjAxMzEwMSwiYXVkIjpudWxsLCJzY29wZSI6InVzZXIucmVhZCB1c2VyLndyaXRlIiwianRpIjoiOGM1NTVjMjdiOWRjNDdmOTcyNWRkYzBhMjk0NzA1ZTA4NzFkY2JlN2Q5ZTNlMmVkNGE1ZTBiOGZlNTZlYzcxMSJ9.AlxKRtE3ec0mtyBSDx4VseND4eC6cH5ubtv8gfYxxsc"
|
591
614
|
})
|
@@ -29,7 +29,8 @@ class CreateRodauthOAuth < ActiveRecord::Migration<%= migration_version %>
|
|
29
29
|
# uncomment to enable PKCE
|
30
30
|
# t.string :code_challenge
|
31
31
|
# t.string :code_challenge_method
|
32
|
-
|
32
|
+
# uncomment to use OIDC nonce
|
33
|
+
# t.string :nonce
|
33
34
|
t.index(%i[oauth_application_id code], unique: true)
|
34
35
|
end
|
35
36
|
|
@@ -42,18 +43,20 @@ class CreateRodauthOAuth < ActiveRecord::Migration<%= migration_version %>
|
|
42
43
|
t.foreign_key :oauth_tokens, column: :oauth_token_id
|
43
44
|
t.integer :oauth_application_id
|
44
45
|
t.foreign_key :oauth_applications, column: :oauth_application_id
|
45
|
-
t.string :token, null: false, token: true
|
46
|
+
t.string :token, null: false, token: true, unique: true
|
46
47
|
# uncomment if setting oauth_tokens_token_hash_column
|
47
48
|
# and delete the token column
|
48
|
-
# t.string :token_hash, token: true
|
49
|
-
t.string :refresh_token
|
49
|
+
# t.string :token_hash, token: true, unique: true
|
50
|
+
t.string :refresh_token, unique: true
|
50
51
|
# uncomment if setting oauth_tokens_refresh_token_hash_column
|
51
52
|
# and delete the refresh_token column
|
52
|
-
# t.string :refresh_token_hash, token: true
|
53
|
+
# t.string :refresh_token_hash, token: true, unique: true
|
53
54
|
t.datetime :expires_in, null: false
|
54
55
|
t.datetime :revoked_at
|
55
56
|
t.string :scopes, null: false
|
56
57
|
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
58
|
+
# uncomment to use OIDC nonce
|
59
|
+
# t.string :nonce
|
57
60
|
end
|
58
61
|
end
|
59
62
|
end
|
@@ -1,16 +1,21 @@
|
|
1
1
|
# frozen-string-literal: true
|
2
2
|
|
3
|
+
require "time"
|
3
4
|
require "base64"
|
4
5
|
require "securerandom"
|
5
6
|
require "net/http"
|
6
7
|
|
7
8
|
require "rodauth/oauth/ttl_store"
|
9
|
+
require "rodauth/oauth/database_extensions"
|
8
10
|
|
9
11
|
module Rodauth
|
10
12
|
Feature.define(:oauth) do
|
11
13
|
# RUBY EXTENSIONS
|
12
|
-
# :nocov:
|
13
14
|
unless Regexp.method_defined?(:match?)
|
15
|
+
# If you wonder why this is there: the oauth feature uses a refinement to enhance the
|
16
|
+
# Regexp class locally with #match? , but this is never tested, because ActiveSupport
|
17
|
+
# monkey-patches the same method... Please ActiveSupport, stop being so intrusive!
|
18
|
+
# :nocov:
|
14
19
|
module RegexpExtensions
|
15
20
|
refine(Regexp) do
|
16
21
|
def match?(*args)
|
@@ -19,6 +24,7 @@ module Rodauth
|
|
19
24
|
end
|
20
25
|
end
|
21
26
|
using(RegexpExtensions)
|
27
|
+
# :nocov:
|
22
28
|
end
|
23
29
|
|
24
30
|
unless String.method_defined?(:delete_suffix!)
|
@@ -37,13 +43,13 @@ module Rodauth
|
|
37
43
|
end
|
38
44
|
using(SuffixExtensions)
|
39
45
|
end
|
40
|
-
# :nocov:
|
41
46
|
|
42
47
|
SCOPES = %w[profile.read].freeze
|
43
48
|
|
49
|
+
SERVER_METADATA = OAuth::TtlStore.new
|
50
|
+
|
44
51
|
before "authorize"
|
45
52
|
after "authorize"
|
46
|
-
after "authorize_failure"
|
47
53
|
|
48
54
|
before "token"
|
49
55
|
|
@@ -55,15 +61,13 @@ module Rodauth
|
|
55
61
|
before "create_oauth_application"
|
56
62
|
after "create_oauth_application"
|
57
63
|
|
58
|
-
error_flash "OAuth Authorization invalid parameters", "oauth_grant_valid_parameters"
|
59
|
-
|
60
64
|
error_flash "Please authorize to continue", "require_authorization"
|
61
65
|
error_flash "There was an error registering your oauth application", "create_oauth_application"
|
62
66
|
notice_flash "Your oauth application has been registered", "create_oauth_application"
|
63
67
|
|
64
68
|
notice_flash "The oauth token has been revoked", "revoke_oauth_token"
|
65
69
|
|
66
|
-
view "
|
70
|
+
view "authorize", "Authorize", "authorize"
|
67
71
|
view "oauth_applications", "Oauth Applications", "oauth_applications"
|
68
72
|
view "oauth_application", "Oauth Application", "oauth_application"
|
69
73
|
view "new_oauth_application", "New Oauth Application", "new_oauth_application"
|
@@ -73,14 +77,16 @@ module Rodauth
|
|
73
77
|
|
74
78
|
auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes
|
75
79
|
auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes
|
80
|
+
auth_value_method :oauth_refresh_token_expires_in, 60 * 60 * 24 * 360 # 1 year
|
76
81
|
auth_value_method :use_oauth_implicit_grant_type?, false
|
77
82
|
auth_value_method :use_oauth_pkce?, true
|
78
83
|
auth_value_method :use_oauth_access_type?, true
|
79
84
|
|
80
85
|
auth_value_method :oauth_require_pkce, false
|
81
86
|
auth_value_method :oauth_pkce_challenge_method, "S256"
|
87
|
+
auth_value_method :oauth_response_mode, "query"
|
82
88
|
|
83
|
-
auth_value_method :oauth_valid_uri_schemes, %w[
|
89
|
+
auth_value_method :oauth_valid_uri_schemes, %w[https]
|
84
90
|
|
85
91
|
auth_value_method :oauth_scope_separator, " "
|
86
92
|
|
@@ -95,6 +101,7 @@ module Rodauth
|
|
95
101
|
button "Register", "oauth_application"
|
96
102
|
button "Authorize", "oauth_authorize"
|
97
103
|
button "Revoke", "oauth_token_revoke"
|
104
|
+
button "Back to Client Application", "oauth_authorize_post"
|
98
105
|
|
99
106
|
# OAuth Token
|
100
107
|
auth_value_method :oauth_tokens_path, "oauth-tokens"
|
@@ -113,6 +120,8 @@ module Rodauth
|
|
113
120
|
auth_value_method :oauth_tokens_token_hash_column, nil
|
114
121
|
auth_value_method :oauth_tokens_refresh_token_hash_column, nil
|
115
122
|
|
123
|
+
# Access Token reuse
|
124
|
+
auth_value_method :oauth_reuse_access_token, false
|
116
125
|
# OAuth Grants
|
117
126
|
auth_value_method :oauth_grants_table, :oauth_grants
|
118
127
|
auth_value_method :oauth_grants_id_column, :id
|
@@ -127,6 +136,7 @@ module Rodauth
|
|
127
136
|
|
128
137
|
auth_value_method :authorization_required_error_status, 401
|
129
138
|
auth_value_method :invalid_oauth_response_status, 400
|
139
|
+
auth_value_method :already_in_use_response_status, 409
|
130
140
|
|
131
141
|
# OAuth Applications
|
132
142
|
auth_value_method :oauth_applications_path, "oauth-applications"
|
@@ -144,13 +154,13 @@ module Rodauth
|
|
144
154
|
auth_value_method :"oauth_applications_#{column}_column", column
|
145
155
|
end
|
146
156
|
|
157
|
+
# Feature options
|
147
158
|
auth_value_method :oauth_application_default_scope, SCOPES.first
|
148
159
|
auth_value_method :oauth_application_scopes, SCOPES
|
149
160
|
auth_value_method :oauth_token_type, "bearer"
|
161
|
+
auth_value_method :oauth_refresh_token_protection_policy, "none" # can be: none, sender_constrained, rotation
|
150
162
|
|
151
|
-
auth_value_method :
|
152
|
-
auth_value_method :invalid_client, "Invalid client"
|
153
|
-
auth_value_method :unauthorized_client, "Unauthorized client"
|
163
|
+
auth_value_method :invalid_client_message, "Invalid client"
|
154
164
|
auth_value_method :invalid_grant_type_message, "Invalid grant type"
|
155
165
|
auth_value_method :invalid_grant_message, "Invalid grant"
|
156
166
|
auth_value_method :invalid_scope_message, "Invalid scope"
|
@@ -160,6 +170,8 @@ module Rodauth
|
|
160
170
|
|
161
171
|
auth_value_method :unique_error_message, "is already in use"
|
162
172
|
auth_value_method :null_error_message, "is not filled"
|
173
|
+
auth_value_method :already_in_use_message, "error generating unique token"
|
174
|
+
auth_value_method :already_in_use_error_code, "invalid_request"
|
163
175
|
|
164
176
|
# PKCE
|
165
177
|
auth_value_method :code_challenge_required_error_code, "invalid_request"
|
@@ -177,6 +189,8 @@ module Rodauth
|
|
177
189
|
# Only required to use if the plugin is to be used in a resource server
|
178
190
|
auth_value_method :is_authorization_server?, true
|
179
191
|
|
192
|
+
auth_value_method :oauth_unique_id_generation_retries, 3
|
193
|
+
|
180
194
|
auth_value_methods(
|
181
195
|
:fetch_access_token,
|
182
196
|
:oauth_unique_id_generator,
|
@@ -184,22 +198,231 @@ module Rodauth
|
|
184
198
|
:secret_hash,
|
185
199
|
:generate_token_hash,
|
186
200
|
:authorization_server_url,
|
187
|
-
:before_introspection_request
|
201
|
+
:before_introspection_request,
|
202
|
+
:require_authorizable_account,
|
203
|
+
:oauth_tokens_unique_columns
|
188
204
|
)
|
189
205
|
|
190
206
|
auth_value_methods(:only_json?)
|
191
207
|
|
192
208
|
auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i
|
193
209
|
|
194
|
-
|
210
|
+
# /token
|
211
|
+
route(:token) do |r|
|
212
|
+
next unless is_authorization_server?
|
213
|
+
|
214
|
+
before_token_route
|
215
|
+
require_oauth_application
|
216
|
+
|
217
|
+
r.post do
|
218
|
+
catch_error do
|
219
|
+
validate_oauth_token_params
|
220
|
+
|
221
|
+
oauth_token = nil
|
222
|
+
transaction do
|
223
|
+
before_token
|
224
|
+
oauth_token = create_oauth_token
|
225
|
+
end
|
226
|
+
|
227
|
+
json_response_success(json_access_token_payload(oauth_token))
|
228
|
+
end
|
229
|
+
|
230
|
+
throw_json_response_error(invalid_oauth_response_status, "invalid_request")
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# /introspect
|
235
|
+
route(:introspect) do |r|
|
236
|
+
next unless is_authorization_server?
|
237
|
+
|
238
|
+
before_introspect_route
|
239
|
+
|
240
|
+
r.post do
|
241
|
+
catch_error do
|
242
|
+
validate_oauth_introspect_params
|
243
|
+
|
244
|
+
before_introspect
|
245
|
+
oauth_token = case param("token_type_hint")
|
246
|
+
when "access_token"
|
247
|
+
oauth_token_by_token(param("token"))
|
248
|
+
when "refresh_token"
|
249
|
+
oauth_token_by_refresh_token(param("token"))
|
250
|
+
else
|
251
|
+
oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token"))
|
252
|
+
end
|
253
|
+
|
254
|
+
if oauth_application
|
255
|
+
redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application)
|
256
|
+
elsif oauth_token
|
257
|
+
@oauth_application = db[oauth_applications_table].where(oauth_applications_id_column =>
|
258
|
+
oauth_token[oauth_tokens_oauth_application_id_column]).first
|
259
|
+
end
|
260
|
+
|
261
|
+
json_response_success(json_token_introspect_payload(oauth_token))
|
262
|
+
end
|
263
|
+
|
264
|
+
throw_json_response_error(invalid_oauth_response_status, "invalid_request")
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# /revoke
|
269
|
+
route(:revoke) do |r|
|
270
|
+
next unless is_authorization_server?
|
271
|
+
|
272
|
+
before_revoke_route
|
273
|
+
require_oauth_application
|
274
|
+
|
275
|
+
r.post do
|
276
|
+
catch_error do
|
277
|
+
validate_oauth_revoke_params
|
278
|
+
|
279
|
+
oauth_token = nil
|
280
|
+
transaction do
|
281
|
+
before_revoke
|
282
|
+
oauth_token = revoke_oauth_token
|
283
|
+
after_revoke
|
284
|
+
end
|
285
|
+
|
286
|
+
if accepts_json?
|
287
|
+
json_response_success \
|
288
|
+
"token" => oauth_token[oauth_tokens_token_column],
|
289
|
+
"refresh_token" => oauth_token[oauth_tokens_refresh_token_column],
|
290
|
+
"revoked_at" => convert_timestamp(oauth_token[oauth_tokens_revoked_at_column])
|
291
|
+
else
|
292
|
+
set_notice_flash revoke_oauth_token_notice_flash
|
293
|
+
redirect request.referer || "/"
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
redirect_response_error("invalid_request", request.referer || "/")
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
# /authorize
|
302
|
+
route(:authorize) do |r|
|
303
|
+
next unless is_authorization_server?
|
304
|
+
|
305
|
+
before_authorize_route
|
306
|
+
require_authorizable_account
|
307
|
+
|
308
|
+
validate_oauth_grant_params
|
309
|
+
try_approval_prompt if use_oauth_access_type? && request.get?
|
310
|
+
|
311
|
+
r.get do
|
312
|
+
authorize_view
|
313
|
+
end
|
314
|
+
|
315
|
+
r.post do
|
316
|
+
redirect_url = URI.parse(redirect_uri)
|
317
|
+
|
318
|
+
params, mode = transaction do
|
319
|
+
before_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)
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def oauth_server_metadata(issuer = nil)
|
357
|
+
request.on(".well-known") do
|
358
|
+
request.on("oauth-authorization-server") do
|
359
|
+
request.get do
|
360
|
+
json_response_success(oauth_server_metadata_body(issuer), true)
|
361
|
+
end
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
# /oauth-applications routes
|
367
|
+
def oauth_applications
|
368
|
+
request.on(oauth_applications_path) do
|
369
|
+
require_account
|
370
|
+
|
371
|
+
request.get "new" do
|
372
|
+
new_oauth_application_view
|
373
|
+
end
|
374
|
+
|
375
|
+
request.on(oauth_applications_id_pattern) do |id|
|
376
|
+
oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first
|
377
|
+
next unless oauth_application
|
378
|
+
|
379
|
+
scope.instance_variable_set(:@oauth_application, oauth_application)
|
380
|
+
|
381
|
+
request.is do
|
382
|
+
request.get do
|
383
|
+
oauth_application_view
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
request.on(oauth_tokens_path) do
|
388
|
+
oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id)
|
389
|
+
scope.instance_variable_set(:@oauth_tokens, oauth_tokens)
|
390
|
+
request.get do
|
391
|
+
oauth_tokens_view
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
request.get do
|
397
|
+
scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table])
|
398
|
+
oauth_applications_view
|
399
|
+
end
|
400
|
+
|
401
|
+
request.post do
|
402
|
+
catch_error do
|
403
|
+
validate_oauth_application_params
|
404
|
+
|
405
|
+
transaction do
|
406
|
+
before_create_oauth_application
|
407
|
+
id = create_oauth_application
|
408
|
+
after_create_oauth_application
|
409
|
+
set_notice_flash create_oauth_application_notice_flash
|
410
|
+
redirect "#{request.path}/#{id}"
|
411
|
+
end
|
412
|
+
end
|
413
|
+
set_error_flash create_oauth_application_error_flash
|
414
|
+
new_oauth_application_view
|
415
|
+
end
|
416
|
+
end
|
417
|
+
end
|
195
418
|
|
196
419
|
def check_csrf?
|
197
420
|
case request.path
|
198
|
-
when
|
421
|
+
when token_path, introspect_path
|
199
422
|
false
|
200
|
-
when
|
423
|
+
when revoke_path
|
201
424
|
!json_request?
|
202
|
-
when
|
425
|
+
when authorize_path, %r{/#{oauth_applications_path}}
|
203
426
|
only_json? ? false : super
|
204
427
|
else
|
205
428
|
super
|
@@ -218,14 +441,12 @@ module Rodauth
|
|
218
441
|
end
|
219
442
|
|
220
443
|
unless method_defined?(:json_request?)
|
221
|
-
# :nocov:
|
222
444
|
# copied from the jwt feature
|
223
445
|
def json_request?
|
224
446
|
return @json_request if defined?(@json_request)
|
225
447
|
|
226
448
|
@json_request = request.content_type =~ json_request_regexp
|
227
449
|
end
|
228
|
-
# :nocov:
|
229
450
|
end
|
230
451
|
|
231
452
|
def initialize(scope)
|
@@ -233,7 +454,15 @@ module Rodauth
|
|
233
454
|
end
|
234
455
|
|
235
456
|
def scopes
|
236
|
-
|
457
|
+
scope = request.params["scope"]
|
458
|
+
case scope
|
459
|
+
when Array
|
460
|
+
scope
|
461
|
+
when String
|
462
|
+
scope.split(" ")
|
463
|
+
when nil
|
464
|
+
[oauth_application_default_scope]
|
465
|
+
end
|
237
466
|
end
|
238
467
|
|
239
468
|
def redirect_uri
|
@@ -260,12 +489,14 @@ module Rodauth
|
|
260
489
|
def fetch_access_token
|
261
490
|
value = request.env["HTTP_AUTHORIZATION"]
|
262
491
|
|
263
|
-
return unless value
|
492
|
+
return unless value && !value.empty?
|
264
493
|
|
265
494
|
scheme, token = value.split(" ", 2)
|
266
495
|
|
267
496
|
return unless scheme.downcase == oauth_token_type
|
268
497
|
|
498
|
+
return if token.nil? || token.empty?
|
499
|
+
|
269
500
|
token
|
270
501
|
end
|
271
502
|
|
@@ -277,101 +508,79 @@ module Rodauth
|
|
277
508
|
|
278
509
|
return unless bearer_token
|
279
510
|
|
280
|
-
|
281
|
-
|
282
|
-
|
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
|
283
523
|
end
|
284
524
|
|
285
525
|
def require_oauth_authorization(*scopes)
|
286
|
-
|
287
|
-
authorization_required unless authorization_token
|
526
|
+
authorization_required unless authorization_token
|
288
527
|
|
289
|
-
|
528
|
+
scopes << oauth_application_default_scope if scopes.empty?
|
290
529
|
|
530
|
+
token_scopes = if is_authorization_server?
|
291
531
|
authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
|
292
532
|
else
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
# where in resource server, NOT the authorization server.
|
300
|
-
payload = introspection_request("access_token", bearer_token)
|
301
|
-
|
302
|
-
authorization_required unless payload["active"]
|
303
|
-
|
304
|
-
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
|
305
539
|
end
|
306
540
|
|
307
541
|
authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
|
308
542
|
end
|
309
543
|
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
require_account
|
314
|
-
|
315
|
-
request.get "new" do
|
316
|
-
new_oauth_application_view
|
317
|
-
end
|
318
|
-
|
319
|
-
request.on(oauth_applications_id_pattern) do |id|
|
320
|
-
oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first
|
321
|
-
next unless oauth_application
|
322
|
-
|
323
|
-
scope.instance_variable_set(:@oauth_application, oauth_application)
|
544
|
+
def post_configure
|
545
|
+
super
|
546
|
+
self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
|
324
547
|
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
request.on(oauth_tokens_path) do
|
332
|
-
oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id)
|
333
|
-
scope.instance_variable_set(:@oauth_tokens, oauth_tokens)
|
334
|
-
request.get do
|
335
|
-
oauth_tokens_view
|
336
|
-
end
|
337
|
-
end
|
548
|
+
# Check whether we can reutilize db entries for the same account / application pair
|
549
|
+
one_oauth_token_per_account = begin
|
550
|
+
db.indexes(oauth_tokens_table).values.any? do |definition|
|
551
|
+
definition[:unique] &&
|
552
|
+
definition[:columns] == oauth_tokens_unique_columns
|
338
553
|
end
|
554
|
+
end
|
555
|
+
self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account }
|
556
|
+
end
|
339
557
|
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
end
|
558
|
+
def use_date_arithmetic?
|
559
|
+
true
|
560
|
+
end
|
344
561
|
|
345
|
-
|
346
|
-
catch_error do
|
347
|
-
validate_oauth_application_params
|
562
|
+
private
|
348
563
|
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
set_error_flash create_oauth_application_error_flash
|
358
|
-
new_oauth_application_view
|
359
|
-
end
|
564
|
+
def rescue_from_uniqueness_error(&block)
|
565
|
+
retries = oauth_unique_id_generation_retries
|
566
|
+
begin
|
567
|
+
transaction(savepoint: :only, &block)
|
568
|
+
rescue Sequel::UniqueConstraintViolation
|
569
|
+
redirect_response_error("already_in_use") if retries.zero?
|
570
|
+
retries -= 1
|
571
|
+
retry
|
360
572
|
end
|
361
573
|
end
|
362
574
|
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
end
|
575
|
+
# OAuth Token Unique/Reuse
|
576
|
+
def oauth_tokens_unique_columns
|
577
|
+
[
|
578
|
+
oauth_tokens_oauth_application_id_column,
|
579
|
+
oauth_tokens_account_id_column,
|
580
|
+
oauth_tokens_scopes_column
|
581
|
+
]
|
371
582
|
end
|
372
583
|
|
373
|
-
private
|
374
|
-
|
375
584
|
def authorization_server_url
|
376
585
|
base_url
|
377
586
|
end
|
@@ -394,10 +603,10 @@ module Rodauth
|
|
394
603
|
|
395
604
|
# time-to-live
|
396
605
|
ttl = if response.key?("cache-control")
|
397
|
-
cache_control = response["
|
398
|
-
cache_control[/max-age=(\d+)/, 1]
|
606
|
+
cache_control = response["cache-control"]
|
607
|
+
cache_control[/max-age=(\d+)/, 1].to_i
|
399
608
|
elsif response.key?("expires")
|
400
|
-
Time.
|
609
|
+
Time.parse(response["expires"]).to_i - Time.now.to_i
|
401
610
|
end
|
402
611
|
|
403
612
|
[JSON.parse(response.body, symbolize_names: true), ttl]
|
@@ -409,7 +618,7 @@ module Rodauth
|
|
409
618
|
http = Net::HTTP.new(auth_url.host, auth_url.port)
|
410
619
|
http.use_ssl = auth_url.scheme == "https"
|
411
620
|
|
412
|
-
request = Net::HTTP::Post.new(
|
621
|
+
request = Net::HTTP::Post.new(introspect_path)
|
413
622
|
request["content-type"] = json_response_content_type
|
414
623
|
request["accept"] = json_response_content_type
|
415
624
|
request.body = JSON.dump({ "token_type_hint" => token_type_hint, "token" => token })
|
@@ -465,7 +674,7 @@ module Rodauth
|
|
465
674
|
end
|
466
675
|
|
467
676
|
def oauth_unique_id_generator
|
468
|
-
SecureRandom.
|
677
|
+
SecureRandom.urlsafe_base64(32)
|
469
678
|
end
|
470
679
|
|
471
680
|
def generate_token_hash(token)
|
@@ -477,89 +686,106 @@ module Rodauth
|
|
477
686
|
end
|
478
687
|
|
479
688
|
unless method_defined?(:password_hash)
|
480
|
-
# :nocov:
|
481
689
|
# From login_requirements_base feature
|
482
|
-
if ENV["RACK_ENV"] == "test"
|
483
|
-
def password_hash_cost
|
484
|
-
BCrypt::Engine::MIN_COST
|
485
|
-
end
|
486
|
-
else
|
487
|
-
def password_hash_cost
|
488
|
-
BCrypt::Engine::DEFAULT_COST
|
489
|
-
end
|
490
|
-
end
|
491
690
|
|
492
691
|
def password_hash(password)
|
493
|
-
BCrypt::Password.create(password, cost:
|
692
|
+
BCrypt::Password.create(password, cost: BCrypt::Engine::DEFAULT_COST)
|
494
693
|
end
|
495
|
-
# :nocov:
|
496
694
|
end
|
497
695
|
|
498
696
|
def generate_oauth_token(params = {}, should_generate_refresh_token = true)
|
499
697
|
create_params = {
|
500
|
-
oauth_grants_expires_in_column =>
|
698
|
+
oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
|
501
699
|
}.merge(params)
|
502
700
|
|
503
|
-
|
504
|
-
|
701
|
+
rescue_from_uniqueness_error do
|
702
|
+
token = oauth_unique_id_generator
|
505
703
|
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
704
|
+
if oauth_tokens_token_hash_column
|
705
|
+
create_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
|
706
|
+
else
|
707
|
+
create_params[oauth_tokens_token_column] = token
|
708
|
+
end
|
511
709
|
|
512
|
-
|
513
|
-
|
710
|
+
refresh_token = nil
|
711
|
+
if should_generate_refresh_token
|
712
|
+
refresh_token = oauth_unique_id_generator
|
514
713
|
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
714
|
+
if oauth_tokens_refresh_token_hash_column
|
715
|
+
create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
|
716
|
+
else
|
717
|
+
create_params[oauth_tokens_refresh_token_column] = refresh_token
|
718
|
+
end
|
519
719
|
end
|
720
|
+
oauth_token = _generate_oauth_token(create_params)
|
721
|
+
oauth_token[oauth_tokens_token_column] = token
|
722
|
+
oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
|
723
|
+
oauth_token
|
520
724
|
end
|
521
|
-
oauth_token = _generate_oauth_token(create_params)
|
522
|
-
|
523
|
-
oauth_token[oauth_tokens_token_column] = token
|
524
|
-
oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
|
525
|
-
oauth_token
|
526
725
|
end
|
527
726
|
|
528
727
|
def _generate_oauth_token(params = {})
|
529
728
|
ds = db[oauth_tokens_table]
|
530
729
|
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
730
|
+
if __one_oauth_token_per_account
|
731
|
+
|
732
|
+
token = __insert_or_update_and_return__(
|
733
|
+
ds,
|
734
|
+
oauth_tokens_id_column,
|
735
|
+
oauth_tokens_unique_columns,
|
736
|
+
params,
|
737
|
+
Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP,
|
738
|
+
([oauth_tokens_token_column, oauth_tokens_refresh_token_column] if oauth_reuse_access_token)
|
739
|
+
)
|
740
|
+
|
741
|
+
# if the previous operation didn't return a row, it means that the conditions
|
742
|
+
# invalidated the update, and the existing token is still valid.
|
743
|
+
token || ds.where(
|
744
|
+
oauth_tokens_account_id_column => params[oauth_tokens_account_id_column],
|
745
|
+
oauth_tokens_oauth_application_id_column => params[oauth_tokens_oauth_application_id_column]
|
746
|
+
).first
|
747
|
+
else
|
748
|
+
if oauth_reuse_access_token
|
749
|
+
unique_conds = Hash[oauth_tokens_unique_columns.map { |column| [column, params[column]] }]
|
750
|
+
valid_token = ds.where(Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP)
|
751
|
+
.where(unique_conds).first
|
752
|
+
return valid_token if valid_token
|
537
753
|
end
|
538
|
-
|
539
|
-
retry
|
754
|
+
__insert_and_return__(ds, oauth_tokens_id_column, params)
|
540
755
|
end
|
541
756
|
end
|
542
757
|
|
543
|
-
def oauth_token_by_token(token
|
758
|
+
def oauth_token_by_token(token)
|
759
|
+
ds = db[oauth_tokens_table]
|
760
|
+
|
544
761
|
ds = if oauth_tokens_token_hash_column
|
545
|
-
|
762
|
+
ds.where(oauth_tokens_token_hash_column => generate_token_hash(token))
|
546
763
|
else
|
547
|
-
|
764
|
+
ds.where(oauth_tokens_token_column => token)
|
548
765
|
end
|
549
766
|
|
550
767
|
ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
|
551
768
|
.where(oauth_tokens_revoked_at_column => nil).first
|
552
769
|
end
|
553
770
|
|
554
|
-
def oauth_token_by_refresh_token(token,
|
771
|
+
def oauth_token_by_refresh_token(token, revoked: false)
|
772
|
+
ds = db[oauth_tokens_table]
|
773
|
+
#
|
774
|
+
# filter expired refresh tokens out.
|
775
|
+
# an expired refresh token is a token whose access token expired for a period longer than the
|
776
|
+
# refresh token expiration period.
|
777
|
+
#
|
778
|
+
ds = ds.where(Sequel.date_add(oauth_tokens_expires_in_column, seconds: oauth_refresh_token_expires_in) >= Sequel::CURRENT_TIMESTAMP)
|
779
|
+
|
555
780
|
ds = if oauth_tokens_refresh_token_hash_column
|
556
|
-
|
781
|
+
ds.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
|
557
782
|
else
|
558
|
-
|
783
|
+
ds.where(oauth_tokens_refresh_token_column => token)
|
559
784
|
end
|
560
785
|
|
561
|
-
ds.where(
|
562
|
-
|
786
|
+
ds = ds.where(oauth_tokens_revoked_at_column => nil) unless revoked
|
787
|
+
|
788
|
+
ds.first
|
563
789
|
end
|
564
790
|
|
565
791
|
def json_access_token_payload(oauth_token)
|
@@ -628,7 +854,6 @@ module Rodauth
|
|
628
854
|
# set client ID/secret pairs
|
629
855
|
|
630
856
|
create_params.merge! \
|
631
|
-
oauth_applications_client_id_column => oauth_unique_id_generator,
|
632
857
|
oauth_applications_client_secret_column => \
|
633
858
|
secret_hash(oauth_application_params[oauth_application_client_secret_param])
|
634
859
|
|
@@ -638,29 +863,14 @@ module Rodauth
|
|
638
863
|
oauth_application_default_scope
|
639
864
|
end
|
640
865
|
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
false
|
645
|
-
rescue Sequel::ConstraintViolation => e
|
646
|
-
e
|
647
|
-
end
|
648
|
-
|
649
|
-
if raised
|
650
|
-
field = raised.message[/\.(.*)$/, 1]
|
651
|
-
case raised
|
652
|
-
when Sequel::UniqueConstraintViolation
|
653
|
-
throw_error(field, unique_error_message)
|
654
|
-
when Sequel::NotNullConstraintViolation
|
655
|
-
throw_error(field, null_error_message)
|
656
|
-
end
|
866
|
+
rescue_from_uniqueness_error do
|
867
|
+
create_params[oauth_applications_client_id_column] = oauth_unique_id_generator
|
868
|
+
db[oauth_applications_table].insert(create_params)
|
657
869
|
end
|
658
|
-
|
659
|
-
!raised && id
|
660
870
|
end
|
661
871
|
|
662
872
|
# Authorize
|
663
|
-
def
|
873
|
+
def require_authorizable_account
|
664
874
|
require_account
|
665
875
|
end
|
666
876
|
|
@@ -673,6 +883,9 @@ module Rodauth
|
|
673
883
|
end
|
674
884
|
redirect_response_error("invalid_scope") unless check_valid_scopes?
|
675
885
|
|
886
|
+
if (response_mode = param_or_nil("response_mode")) && response_mode != "form_post"
|
887
|
+
redirect_response_error("invalid_request")
|
888
|
+
end
|
676
889
|
validate_pkce_challenge_params if use_oauth_pkce?
|
677
890
|
end
|
678
891
|
|
@@ -690,57 +903,79 @@ module Rodauth
|
|
690
903
|
).count.zero?
|
691
904
|
|
692
905
|
# if there's a previous oauth grant for the params combo, it means that this user has approved before.
|
693
|
-
|
694
906
|
request.env["REQUEST_METHOD"] = "POST"
|
695
907
|
end
|
696
908
|
|
697
|
-
def create_oauth_grant
|
698
|
-
create_params
|
909
|
+
def create_oauth_grant(create_params = {})
|
910
|
+
create_params.merge!(
|
699
911
|
oauth_grants_account_id_column => account_id,
|
700
912
|
oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
701
913
|
oauth_grants_redirect_uri_column => redirect_uri,
|
702
|
-
oauth_grants_expires_in_column =>
|
914
|
+
oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_grant_expires_in),
|
703
915
|
oauth_grants_scopes_column => scopes.join(oauth_scope_separator)
|
704
|
-
|
916
|
+
)
|
705
917
|
|
706
918
|
# Access Type flow
|
707
|
-
if use_oauth_access_type?
|
708
|
-
|
709
|
-
create_params[oauth_grants_access_type_column] = access_type
|
710
|
-
end
|
919
|
+
if use_oauth_access_type? && (access_type = param_or_nil("access_type"))
|
920
|
+
create_params[oauth_grants_access_type_column] = access_type
|
711
921
|
end
|
712
922
|
|
713
923
|
# PKCE flow
|
714
|
-
if use_oauth_pkce?
|
924
|
+
if use_oauth_pkce? && (code_challenge = param_or_nil("code_challenge"))
|
925
|
+
code_challenge_method = param_or_nil("code_challenge_method")
|
715
926
|
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
create_params[oauth_grants_code_challenge_column] = code_challenge
|
720
|
-
create_params[oauth_grants_code_challenge_method_column] = code_challenge_method
|
721
|
-
elsif oauth_require_pkce
|
722
|
-
redirect_response_error("code_challenge_required")
|
723
|
-
end
|
927
|
+
create_params[oauth_grants_code_challenge_column] = code_challenge
|
928
|
+
create_params[oauth_grants_code_challenge_method_column] = code_challenge_method
|
724
929
|
end
|
725
930
|
|
726
931
|
ds = db[oauth_grants_table]
|
727
932
|
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
ds.insert(create_params)
|
732
|
-
authorization_code
|
733
|
-
rescue Sequel::UniqueConstraintViolation
|
734
|
-
retry
|
933
|
+
rescue_from_uniqueness_error do
|
934
|
+
create_params[oauth_grants_code_column] = oauth_unique_id_generator
|
935
|
+
__insert_and_return__(ds, oauth_grants_id_column, create_params)
|
735
936
|
end
|
937
|
+
create_params[oauth_grants_code_column]
|
938
|
+
end
|
939
|
+
|
940
|
+
def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
|
941
|
+
case param("response_type")
|
942
|
+
when "token"
|
943
|
+
redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
|
944
|
+
|
945
|
+
response_mode ||= "fragment"
|
946
|
+
response_params.replace(_do_authorize_token)
|
947
|
+
when "code"
|
948
|
+
response_mode ||= "query"
|
949
|
+
response_params.replace(_do_authorize_code)
|
950
|
+
when "none"
|
951
|
+
response_mode ||= "none"
|
952
|
+
when "", nil
|
953
|
+
response_mode ||= oauth_response_mode
|
954
|
+
response_params.replace(_do_authorize_code)
|
955
|
+
end
|
956
|
+
|
957
|
+
response_params["state"] = param("state") if param_or_nil("state")
|
958
|
+
|
959
|
+
[response_params, response_mode]
|
736
960
|
end
|
737
961
|
|
738
|
-
|
962
|
+
def _do_authorize_code
|
963
|
+
{ "code" => create_oauth_grant }
|
964
|
+
end
|
739
965
|
|
740
|
-
def
|
741
|
-
|
966
|
+
def _do_authorize_token
|
967
|
+
create_params = {
|
968
|
+
oauth_tokens_account_id_column => account_id,
|
969
|
+
oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
970
|
+
oauth_tokens_scopes_column => scopes
|
971
|
+
}
|
972
|
+
oauth_token = generate_oauth_token(create_params, false)
|
973
|
+
|
974
|
+
json_access_token_payload(oauth_token)
|
742
975
|
end
|
743
976
|
|
977
|
+
# Access Tokens
|
978
|
+
|
744
979
|
def validate_oauth_token_params
|
745
980
|
unless (grant_type = param_or_nil("grant_type"))
|
746
981
|
redirect_response_error("invalid_request")
|
@@ -760,27 +995,56 @@ module Rodauth
|
|
760
995
|
def create_oauth_token
|
761
996
|
case param("grant_type")
|
762
997
|
when "authorization_code"
|
763
|
-
|
998
|
+
# fetch oauth grant
|
999
|
+
oauth_grant = db[oauth_grants_table].where(
|
1000
|
+
oauth_grants_code_column => param("code"),
|
1001
|
+
oauth_grants_redirect_uri_column => param("redirect_uri"),
|
1002
|
+
oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
1003
|
+
oauth_grants_revoked_at_column => nil
|
1004
|
+
).where(Sequel[oauth_grants_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
|
1005
|
+
.for_update
|
1006
|
+
.first
|
1007
|
+
|
1008
|
+
redirect_response_error("invalid_grant") unless oauth_grant
|
1009
|
+
|
1010
|
+
create_params = {
|
1011
|
+
oauth_tokens_account_id_column => oauth_grant[oauth_grants_account_id_column],
|
1012
|
+
oauth_tokens_oauth_application_id_column => oauth_grant[oauth_grants_oauth_application_id_column],
|
1013
|
+
oauth_tokens_oauth_grant_id_column => oauth_grant[oauth_grants_id_column],
|
1014
|
+
oauth_tokens_scopes_column => oauth_grant[oauth_grants_scopes_column]
|
1015
|
+
}
|
1016
|
+
create_oauth_token_from_authorization_code(oauth_grant, create_params)
|
764
1017
|
when "refresh_token"
|
765
|
-
|
766
|
-
|
767
|
-
|
1018
|
+
# fetch potentially revoked oauth token
|
1019
|
+
oauth_token = oauth_token_by_refresh_token(param("refresh_token"), revoked: true)
|
1020
|
+
|
1021
|
+
if !oauth_token
|
1022
|
+
redirect_response_error("invalid_grant")
|
1023
|
+
elsif oauth_token[oauth_tokens_revoked_at_column]
|
1024
|
+
if oauth_refresh_token_protection_policy == "rotation"
|
1025
|
+
# https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00#section-6.1
|
1026
|
+
#
|
1027
|
+
# If a refresh token is compromised and subsequently used by both the attacker and the legitimate
|
1028
|
+
# client, one of them will present an invalidated refresh token, which will inform the authorization
|
1029
|
+
# server of the breach. The authorization server cannot determine which party submitted the invalid
|
1030
|
+
# refresh token, but it will revoke the active refresh token. This stops the attack at the cost of
|
1031
|
+
# forcing the legitimate client to obtain a fresh authorization grant.
|
1032
|
+
|
1033
|
+
db[oauth_tokens_table].where(oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column])
|
1034
|
+
.update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
|
1035
|
+
end
|
1036
|
+
redirect_response_error("invalid_grant")
|
1037
|
+
end
|
1038
|
+
|
1039
|
+
update_params = {
|
1040
|
+
oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column],
|
1041
|
+
oauth_tokens_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
|
1042
|
+
}
|
1043
|
+
create_oauth_token_from_token(oauth_token, update_params)
|
768
1044
|
end
|
769
1045
|
end
|
770
1046
|
|
771
|
-
def create_oauth_token_from_authorization_code(
|
772
|
-
# fetch oauth grant
|
773
|
-
oauth_grant = db[oauth_grants_table].where(
|
774
|
-
oauth_grants_code_column => param("code"),
|
775
|
-
oauth_grants_redirect_uri_column => param("redirect_uri"),
|
776
|
-
oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
777
|
-
oauth_grants_revoked_at_column => nil
|
778
|
-
).where(Sequel[oauth_grants_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
|
779
|
-
.for_update
|
780
|
-
.first
|
781
|
-
|
782
|
-
redirect_response_error("invalid_grant") unless oauth_grant
|
783
|
-
|
1047
|
+
def create_oauth_token_from_authorization_code(oauth_grant, create_params)
|
784
1048
|
# PKCE
|
785
1049
|
if use_oauth_pkce?
|
786
1050
|
if oauth_grant[oauth_grants_code_challenge_column]
|
@@ -792,13 +1056,6 @@ module Rodauth
|
|
792
1056
|
end
|
793
1057
|
end
|
794
1058
|
|
795
|
-
create_params = {
|
796
|
-
oauth_tokens_account_id_column => oauth_grant[oauth_grants_account_id_column],
|
797
|
-
oauth_tokens_oauth_application_id_column => oauth_grant[oauth_grants_oauth_application_id_column],
|
798
|
-
oauth_tokens_oauth_grant_id_column => oauth_grant[oauth_grants_id_column],
|
799
|
-
oauth_tokens_scopes_column => oauth_grant[oauth_grants_scopes_column]
|
800
|
-
}
|
801
|
-
|
802
1059
|
# revoke oauth grant
|
803
1060
|
db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column])
|
804
1061
|
.update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
|
@@ -809,40 +1066,41 @@ module Rodauth
|
|
809
1066
|
generate_oauth_token(create_params, should_generate_refresh_token)
|
810
1067
|
end
|
811
1068
|
|
812
|
-
def create_oauth_token_from_token(
|
813
|
-
|
814
|
-
oauth_token = oauth_token_by_refresh_token(param("refresh_token"))
|
815
|
-
|
816
|
-
redirect_response_error("invalid_grant") unless oauth_token && token_from_application?(oauth_token, oauth_application)
|
1069
|
+
def create_oauth_token_from_token(oauth_token, update_params)
|
1070
|
+
redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application)
|
817
1071
|
|
818
|
-
|
1072
|
+
rescue_from_uniqueness_error do
|
1073
|
+
oauth_tokens_ds = db[oauth_tokens_table]
|
1074
|
+
token = oauth_unique_id_generator
|
819
1075
|
|
820
|
-
|
821
|
-
|
822
|
-
oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in
|
823
|
-
}
|
824
|
-
|
825
|
-
if oauth_tokens_token_hash_column
|
826
|
-
update_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
|
827
|
-
else
|
828
|
-
update_params[oauth_tokens_token_column] = token
|
829
|
-
end
|
830
|
-
|
831
|
-
ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
|
832
|
-
|
833
|
-
oauth_token = begin
|
834
|
-
if ds.supports_returning?(:update)
|
835
|
-
ds.returning.update(update_params).first
|
1076
|
+
if oauth_tokens_token_hash_column
|
1077
|
+
update_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
|
836
1078
|
else
|
837
|
-
|
838
|
-
ds.first
|
1079
|
+
update_params[oauth_tokens_token_column] = token
|
839
1080
|
end
|
840
|
-
rescue Sequel::UniqueConstraintViolation
|
841
|
-
retry
|
842
|
-
end
|
843
1081
|
|
844
|
-
|
845
|
-
|
1082
|
+
oauth_token = if oauth_refresh_token_protection_policy == "rotation"
|
1083
|
+
insert_params = {
|
1084
|
+
**update_params,
|
1085
|
+
oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column],
|
1086
|
+
oauth_tokens_scopes_column => oauth_token[oauth_tokens_scopes_column]
|
1087
|
+
}
|
1088
|
+
|
1089
|
+
# revoke the refresh token
|
1090
|
+
oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
|
1091
|
+
.update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
|
1092
|
+
|
1093
|
+
insert_params[oauth_tokens_oauth_token_id_column] = oauth_token[oauth_tokens_id_column]
|
1094
|
+
__insert_and_return__(oauth_tokens_ds, oauth_tokens_id_column, insert_params)
|
1095
|
+
else
|
1096
|
+
# includes none
|
1097
|
+
ds = oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
|
1098
|
+
__update_and_return__(ds, update_params)
|
1099
|
+
end
|
1100
|
+
|
1101
|
+
oauth_token[oauth_tokens_token_column] = token
|
1102
|
+
oauth_token
|
1103
|
+
end
|
846
1104
|
end
|
847
1105
|
|
848
1106
|
TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze
|
@@ -851,8 +1109,8 @@ module Rodauth
|
|
851
1109
|
|
852
1110
|
def validate_oauth_introspect_params
|
853
1111
|
# check if valid token hint type
|
854
|
-
if param_or_nil("token_type_hint")
|
855
|
-
redirect_response_error("unsupported_token_type")
|
1112
|
+
if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint"))
|
1113
|
+
redirect_response_error("unsupported_token_type")
|
856
1114
|
end
|
857
1115
|
|
858
1116
|
redirect_response_error("invalid_request") unless param_or_nil("token")
|
@@ -870,18 +1128,12 @@ module Rodauth
|
|
870
1128
|
}
|
871
1129
|
end
|
872
1130
|
|
873
|
-
def before_introspect; end
|
874
|
-
|
875
1131
|
# Token revocation
|
876
1132
|
|
877
|
-
def before_revoke
|
878
|
-
require_oauth_application
|
879
|
-
end
|
880
|
-
|
881
1133
|
def validate_oauth_revoke_params
|
882
1134
|
# check if valid token hint type
|
883
|
-
if param_or_nil("token_type_hint")
|
884
|
-
redirect_response_error("unsupported_token_type")
|
1135
|
+
if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint"))
|
1136
|
+
redirect_response_error("unsupported_token_type")
|
885
1137
|
end
|
886
1138
|
|
887
1139
|
redirect_response_error("invalid_request") unless param_or_nil("token")
|
@@ -898,23 +1150,13 @@ module Rodauth
|
|
898
1150
|
|
899
1151
|
redirect_response_error("invalid_request") unless oauth_token
|
900
1152
|
|
901
|
-
|
902
|
-
redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application)
|
903
|
-
else
|
904
|
-
@oauth_application = db[oauth_applications_table].where(oauth_applications_id_column =>
|
905
|
-
oauth_token[oauth_tokens_oauth_application_id_column]).first
|
906
|
-
end
|
1153
|
+
redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application)
|
907
1154
|
|
908
1155
|
update_params = { oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP }
|
909
1156
|
|
910
1157
|
ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
|
911
1158
|
|
912
|
-
oauth_token =
|
913
|
-
ds.returning.update(update_params).first
|
914
|
-
else
|
915
|
-
ds.update(update_params)
|
916
|
-
ds.first
|
917
|
-
end
|
1159
|
+
oauth_token = __update_and_return__(ds, update_params)
|
918
1160
|
|
919
1161
|
oauth_token[oauth_tokens_token_column] = token
|
920
1162
|
oauth_token
|
@@ -932,7 +1174,13 @@ module Rodauth
|
|
932
1174
|
|
933
1175
|
def redirect_response_error(error_code, redirect_url = redirect_uri || request.referer || default_redirect)
|
934
1176
|
if accepts_json?
|
935
|
-
|
1177
|
+
status_code = if respond_to?(:"#{error_code}_response_status")
|
1178
|
+
send(:"#{error_code}_response_status")
|
1179
|
+
else
|
1180
|
+
invalid_oauth_response_status
|
1181
|
+
end
|
1182
|
+
|
1183
|
+
throw_json_response_error(status_code, error_code)
|
936
1184
|
else
|
937
1185
|
redirect_url = URI.parse(redirect_url)
|
938
1186
|
query_params = []
|
@@ -954,9 +1202,17 @@ module Rodauth
|
|
954
1202
|
end
|
955
1203
|
end
|
956
1204
|
|
957
|
-
def json_response_success(body)
|
1205
|
+
def json_response_success(body, cache = false)
|
958
1206
|
response.status = 200
|
959
1207
|
response["Content-Type"] ||= json_response_content_type
|
1208
|
+
if cache
|
1209
|
+
# defaulting to 1-day for everyone, for now at least
|
1210
|
+
max_age = 60 * 60 * 24
|
1211
|
+
response["Cache-Control"] = "private, max-age=#{max_age}"
|
1212
|
+
else
|
1213
|
+
response["Cache-Control"] = "no-store"
|
1214
|
+
response["Pragma"] = "no-cache"
|
1215
|
+
end
|
960
1216
|
json_payload = _json_response_body(body)
|
961
1217
|
response.write(json_payload)
|
962
1218
|
request.halt
|
@@ -979,7 +1235,6 @@ module Rodauth
|
|
979
1235
|
end
|
980
1236
|
|
981
1237
|
unless method_defined?(:_json_response_body)
|
982
|
-
# :nocov:
|
983
1238
|
def _json_response_body(hash)
|
984
1239
|
if request.respond_to?(:convert_to_json)
|
985
1240
|
request.send(:convert_to_json, hash)
|
@@ -987,7 +1242,6 @@ module Rodauth
|
|
987
1242
|
JSON.dump(hash)
|
988
1243
|
end
|
989
1244
|
end
|
990
|
-
# :nocov:
|
991
1245
|
end
|
992
1246
|
|
993
1247
|
def authorization_required
|
@@ -995,7 +1249,7 @@ module Rodauth
|
|
995
1249
|
throw_json_response_error(authorization_required_error_status, "invalid_client")
|
996
1250
|
else
|
997
1251
|
set_redirect_error_flash(require_authorization_error_flash)
|
998
|
-
redirect(
|
1252
|
+
redirect(authorize_path)
|
999
1253
|
end
|
1000
1254
|
end
|
1001
1255
|
|
@@ -1075,10 +1329,10 @@ module Rodauth
|
|
1075
1329
|
|
1076
1330
|
def oauth_server_metadata_body(path)
|
1077
1331
|
issuer = base_url
|
1078
|
-
issuer += "/#{path}" if
|
1332
|
+
issuer += "/#{path}" if path
|
1079
1333
|
|
1080
1334
|
responses_supported = %w[code]
|
1081
|
-
response_modes_supported = %w[query]
|
1335
|
+
response_modes_supported = %w[query form_post]
|
1082
1336
|
grant_types_supported = %w[authorization_code]
|
1083
1337
|
|
1084
1338
|
if use_oauth_implicit_grant_type?
|
@@ -1086,11 +1340,12 @@ module Rodauth
|
|
1086
1340
|
response_modes_supported << "fragment"
|
1087
1341
|
grant_types_supported << "implicit"
|
1088
1342
|
end
|
1343
|
+
|
1089
1344
|
{
|
1090
1345
|
issuer: issuer,
|
1091
|
-
authorization_endpoint:
|
1092
|
-
token_endpoint:
|
1093
|
-
registration_endpoint:
|
1346
|
+
authorization_endpoint: authorize_url,
|
1347
|
+
token_endpoint: token_url,
|
1348
|
+
registration_endpoint: route_url(oauth_applications_path),
|
1094
1349
|
scopes_supported: oauth_application_scopes,
|
1095
1350
|
response_types_supported: responses_supported,
|
1096
1351
|
response_modes_supported: response_modes_supported,
|
@@ -1100,142 +1355,12 @@ module Rodauth
|
|
1100
1355
|
ui_locales_supported: oauth_metadata_ui_locales_supported,
|
1101
1356
|
op_policy_uri: oauth_metadata_op_policy_uri,
|
1102
1357
|
op_tos_uri: oauth_metadata_op_tos_uri,
|
1103
|
-
revocation_endpoint:
|
1358
|
+
revocation_endpoint: revoke_url,
|
1104
1359
|
revocation_endpoint_auth_methods_supported: nil, # because it's client_secret_basic
|
1105
|
-
introspection_endpoint:
|
1360
|
+
introspection_endpoint: introspect_url,
|
1106
1361
|
introspection_endpoint_auth_methods_supported: %w[client_secret_basic],
|
1107
1362
|
code_challenge_methods_supported: (use_oauth_pkce? ? oauth_pkce_challenge_method : nil)
|
1108
1363
|
}
|
1109
1364
|
end
|
1110
|
-
|
1111
|
-
# /oauth-token
|
1112
|
-
route(:oauth_token) do |r|
|
1113
|
-
before_token
|
1114
|
-
|
1115
|
-
r.post do
|
1116
|
-
catch_error do
|
1117
|
-
validate_oauth_token_params
|
1118
|
-
|
1119
|
-
oauth_token = nil
|
1120
|
-
transaction do
|
1121
|
-
oauth_token = create_oauth_token
|
1122
|
-
end
|
1123
|
-
|
1124
|
-
json_response_success(json_access_token_payload(oauth_token))
|
1125
|
-
end
|
1126
|
-
|
1127
|
-
throw_json_response_error(invalid_oauth_response_status, "invalid_request")
|
1128
|
-
end
|
1129
|
-
end
|
1130
|
-
|
1131
|
-
# /oauth-introspect
|
1132
|
-
route(:oauth_introspect) do |r|
|
1133
|
-
before_introspect
|
1134
|
-
|
1135
|
-
r.post do
|
1136
|
-
catch_error do
|
1137
|
-
validate_oauth_introspect_params
|
1138
|
-
|
1139
|
-
oauth_token = case param("token_type_hint")
|
1140
|
-
when "access_token"
|
1141
|
-
oauth_token_by_token(param("token"))
|
1142
|
-
when "refresh_token"
|
1143
|
-
oauth_token_by_refresh_token(param("token"))
|
1144
|
-
else
|
1145
|
-
oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token"))
|
1146
|
-
end
|
1147
|
-
|
1148
|
-
if oauth_application
|
1149
|
-
redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application)
|
1150
|
-
elsif oauth_token
|
1151
|
-
@oauth_application = db[oauth_applications_table].where(oauth_applications_id_column =>
|
1152
|
-
oauth_token[oauth_tokens_oauth_application_id_column]).first
|
1153
|
-
end
|
1154
|
-
|
1155
|
-
json_response_success(json_token_introspect_payload(oauth_token))
|
1156
|
-
end
|
1157
|
-
|
1158
|
-
throw_json_response_error(invalid_oauth_response_status, "invalid_request")
|
1159
|
-
end
|
1160
|
-
end
|
1161
|
-
|
1162
|
-
# /oauth-revoke
|
1163
|
-
route(:oauth_revoke) do |r|
|
1164
|
-
before_revoke
|
1165
|
-
|
1166
|
-
r.post do
|
1167
|
-
catch_error do
|
1168
|
-
validate_oauth_revoke_params
|
1169
|
-
|
1170
|
-
oauth_token = nil
|
1171
|
-
transaction do
|
1172
|
-
oauth_token = revoke_oauth_token
|
1173
|
-
after_revoke
|
1174
|
-
end
|
1175
|
-
|
1176
|
-
if accepts_json?
|
1177
|
-
json_response_success \
|
1178
|
-
"token" => oauth_token[oauth_tokens_token_column],
|
1179
|
-
"refresh_token" => oauth_token[oauth_tokens_refresh_token_column],
|
1180
|
-
"revoked_at" => oauth_token[oauth_tokens_revoked_at_column]
|
1181
|
-
else
|
1182
|
-
set_notice_flash revoke_oauth_token_notice_flash
|
1183
|
-
redirect request.referer || "/"
|
1184
|
-
end
|
1185
|
-
end
|
1186
|
-
|
1187
|
-
redirect_response_error("invalid_request", request.referer || "/")
|
1188
|
-
end
|
1189
|
-
end
|
1190
|
-
|
1191
|
-
# /oauth-authorize
|
1192
|
-
route(:oauth_authorize) do |r|
|
1193
|
-
require_account
|
1194
|
-
validate_oauth_grant_params
|
1195
|
-
try_approval_prompt if use_oauth_access_type? && request.get?
|
1196
|
-
|
1197
|
-
before_authorize
|
1198
|
-
|
1199
|
-
r.get do
|
1200
|
-
authorize_view
|
1201
|
-
end
|
1202
|
-
|
1203
|
-
r.post do
|
1204
|
-
code = nil
|
1205
|
-
query_params = []
|
1206
|
-
fragment_params = []
|
1207
|
-
|
1208
|
-
transaction do
|
1209
|
-
case param("response_type")
|
1210
|
-
when "token"
|
1211
|
-
redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
|
1212
|
-
|
1213
|
-
create_params = {
|
1214
|
-
oauth_tokens_account_id_column => account_id,
|
1215
|
-
oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
1216
|
-
oauth_tokens_scopes_column => scopes
|
1217
|
-
}
|
1218
|
-
oauth_token = generate_oauth_token(create_params, false)
|
1219
|
-
|
1220
|
-
token_payload = json_access_token_payload(oauth_token)
|
1221
|
-
fragment_params.replace(token_payload.map { |k, v| "#{k}=#{v}" })
|
1222
|
-
when "code", "", nil
|
1223
|
-
code = create_oauth_grant
|
1224
|
-
query_params << "code=#{code}"
|
1225
|
-
else
|
1226
|
-
redirect_response_error("invalid_request")
|
1227
|
-
end
|
1228
|
-
after_authorize
|
1229
|
-
end
|
1230
|
-
|
1231
|
-
redirect_url = URI.parse(redirect_uri)
|
1232
|
-
query_params << "state=#{param('state')}" if param_or_nil("state")
|
1233
|
-
query_params << redirect_url.query if redirect_url.query
|
1234
|
-
redirect_url.query = query_params.join("&") unless query_params.empty?
|
1235
|
-
redirect_url.fragment = fragment_params.join("&") unless fragment_params.empty?
|
1236
|
-
|
1237
|
-
redirect(redirect_url.to_s)
|
1238
|
-
end
|
1239
|
-
end
|
1240
1365
|
end
|
1241
1366
|
end
|