rodauth-oauth 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cea0f9a4896e57d535c2ef73eff0c1a9e6b4e25f4d53740c65171b8c098a8ac1
4
- data.tar.gz: 96f78feb4157d6f940700fe658b7817b95bd79b61a6f02b9cc530947512a8ff0
3
+ metadata.gz: 21325edd12e5fc3ded38294dccf9200497df7bf2ecf72e5e90b5faa0f44c76b6
4
+ data.tar.gz: 5bd3e791aa77e4702763207fd60640db7c42e3c05d764cff5e56fab17df982b8
5
5
  SHA512:
6
- metadata.gz: 3b2bc30f8793a0ae0ef8a48b46fcd896494d6a941e57e66481d8b8197424c5a6da80761237e26b4eb70acf73ae933be40484d7ca06cf5191790ee83b62eb7411
7
- data.tar.gz: 7a1cb3061c3eb9c271b04c35e970306a96018a40408c63c5fd5c815d62f3b4bfdf8c84f32c6947015c8a99a810a8cc904a1f6e10ca3f9eba842ec8575f975a11
6
+ metadata.gz: 65614673a89008e8a23fd2f3e14617ea6b66eccc5dfad930f6d0205f591077dfb9132ec9927d3a8dce5e313b9310d024aee9761c5a3e9cc0d9cffe4b3e089851
7
+ data.tar.gz: 97b00f5c89429b79afe5403e2a6ee792611d6f121f6c6ae72582c15c4654daeebeddb3e7348c539e22e7664e886e00feb67df257ed308544bbfe3a4cb53b194e
@@ -1,5 +1,23 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## master
4
+
5
+ ## 0.0.2
6
+
7
+ ### Features
8
+
9
+ * Implementation of PKCE by OAuth Public Clients (https://tools.ietf.org/html/rfc7636);
10
+ * Implementation of grants using "access_type" and "approval_prompt" ([similar to what Google OAuth 2.0 API does](https://wiki.scn.sap.com/wiki/display/Security/Access+Google+APIs+using+the+OAuth+2.0+Client+API));
11
+
12
+ ### Improvements
13
+
14
+ * Store token/refresh token hashes in the database, instead of the "plain" tokens;
15
+ * Client secret hashed by default, and provided by the application owner;
16
+
17
+ ### Fix
18
+
19
+ * usage of client secret for authorizing the generation of tokens, as the spec mandates (and refraining from them when doing PKCE).
20
+
3
21
  ## 0.0.1
4
22
 
5
23
  Initial implementation of the Oauth 2.0 framework, with an example app done using roda.
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![pipeline status](https://gitlab.com/honeyryderchuck/rodauth-oauth/badges/master/pipeline.svg)](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/commits/master)
4
4
  [![coverage report](https://gitlab.com/honeyryderchuck/rodauth-oauth/badges/master/coverage.svg)](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/commits/master)
5
5
 
6
- This is an extension to the `rodauth` gem which adds support for the [OAuth 2.0 protocol](https://tools.ietf.org/html/rfc6749).
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
 
@@ -15,6 +15,7 @@ This gem implements:
15
15
  * [Access Token refresh](https://tools.ietf.org/html/rfc6749#section-1.5);
16
16
  * [Token revocation](https://tools.ietf.org/html/rfc7009);
17
17
  * [Implicit grant (off by default)[https://tools.ietf.org/html/rfc6749#section-4.2];
18
+ * [PKCE](https://tools.ietf.org/html/rfc7636);
18
19
  * Access Type (Token refresh online and offline);
19
20
  * OAuth application and token management dashboards;
20
21
 
@@ -96,10 +97,9 @@ Generating tokens happens mostly server-to-server, so here's an example using:
96
97
 
97
98
  ```ruby
98
99
  require "httpx"
99
- httpx = HTTPX.plugin(:authorization)
100
- response = httpx.with(headers: { "X-your-auth-scheme" => ENV["SERVER_KEY"] })
101
- .post("https://auth_server/oauth-token",json: {
100
+ response = HTTPX.post("https://auth_server/oauth-token",json: {
102
101
  client_id: ENV["OAUTH_CLIENT_ID"],
102
+ client_secret: ENV["OAUTH_CLIENT_SECRET"],
103
103
  grant_type: "authorization_code",
104
104
  code: "oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as"
105
105
  })
@@ -111,7 +111,7 @@ puts payload #=> {"token" => "awr23f3h8f9d2h89...", "refresh_token" => "23fkop3k
111
111
  ##### cURL
112
112
 
113
113
  ```
114
- > curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","grant_type":"authorization_code","code":"oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as"}' https://auth_server/oauth-token
114
+ > curl --data '{"client_id":"$OAUTH_CLIENT_ID","client_secret":"$OAUTH_CLIENT_SECRET","grant_type":"authorization_code","code":"oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as"}' https://auth_server/oauth-token
115
115
  ```
116
116
 
117
117
  #### Refresh Token
@@ -122,10 +122,9 @@ Refreshing expired tokens also happens mostly server-to-server, here's an exampl
122
122
 
123
123
  ```ruby
124
124
  require "httpx"
125
- httpx = HTTPX.plugin(:authorization)
126
- response = httpx.with(headers: { "X-your-auth-scheme" => ENV["SERVER_KEY"] })
127
- .post("https://auth_server/oauth-token",json: {
125
+ response = HTTPX.post("https://auth_server/oauth-token",json: {
128
126
  client_id: ENV["OAUTH_CLIENT_ID"],
127
+ client_secret: ENV["OAUTH_CLIENT_SECRET"],
129
128
  grant_type: "refresh_token",
130
129
  token: "2r89hfef4j9f90d2j2390jf390g"
131
130
  })
@@ -137,7 +136,7 @@ puts payload #=> {"token" => "awr23f3h8f9d2h89...", "token_type" => "Bearer" ...
137
136
  ##### cURL
138
137
 
139
138
  ```
140
- > curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","grant_type":"token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/oauth-token
139
+ > 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/oauth-token
141
140
  ```
142
141
 
143
142
  #### Revoking tokens
@@ -291,14 +290,59 @@ end
291
290
 
292
291
  In this section, the non-standard features are going to be described in more detail.
293
292
 
293
+ ### Token / Secrets Hashing
294
+
295
+ Although not human-friendly as passwords, for security reasons, you might not want to store access (and refresh) tokens in the database. If that is the case, You'll have to add the respective hash columns in the table:
296
+
297
+ ```ruby
298
+ # in migration
299
+ String :token_hash, null: false, token: true
300
+ String :refresh_token_hash, token, true
301
+ # and you DO NOT NEED the token and refresh_token columns anymore!
302
+ ```
303
+
304
+ And declare them in the plugin:
305
+
306
+ ```ruby
307
+ plugin :rodauth do
308
+ enable :oauth
309
+ oauth_tokens_token_hash_column :token_hash
310
+ oauth_tokens_token_hash_column :refresh_token_hash
311
+ ```
312
+
313
+ #### Client Secret
314
+
315
+ By default, it's expected that the "client secret" property from an OAuth application is only known by the owner, and only the hash is stored in the database; this way, the authorization server doesn't know what the client secret is, only the application owner. The provided [OAuth Applications Extensions](#oauth-applications) application form contains a "Client Secret" input field for this reason.
316
+
317
+ However, this extension is optional, and you might want to generate the secrets and store them as is. In that case, you'll have to re-define some options:
318
+
319
+ ```ruby
320
+ plugin :rodauth do
321
+ enable :oauth
322
+ secret_matches? ->(application, secret){ application[:client_secret] == secret }
323
+ end
324
+ ```
325
+
294
326
  ### Access Type (default: "offline")
295
327
 
296
328
  The "access_type" feature allows the authorization server to emit access tokens with no associated refresh token. This means that users with expired access tokens will have to go through the OAuth flow everytime they need a new one.
297
329
 
298
330
  In order to enable this option, add "access_type=online" to the query params section of the authorization url.
299
331
 
300
- **Note**: this feature does not yet support the "approval_prompt" feature.
332
+ #### Approval Prompt
333
+
334
+ When using "online grants", one can use an extra query param in the URL, "approval_prompt", which when set to "auto", will skip the authorization form (on the other hand, if one wants to force the authorization form for all grants, then you can set it to "force", or don't set it at all, as it's the default).
335
+
336
+ This will only work **if there was a previous successful online grant** for the same application, scopes and redirect URI.
337
+
338
+ #### DB schema
339
+
340
+ the "oauth_grants" table will have to include the "access_type row":
301
341
 
342
+ ```ruby
343
+ # in migration
344
+ String :access_type, null: false, default: "offline"
345
+ ```
302
346
 
303
347
  ### Implicit Grant (default: disabled)
304
348
 
@@ -315,6 +359,44 @@ end
315
359
 
316
360
  And add "response_type=token" to the query params section of the authorization url.
317
361
 
362
+ ### PKCE
363
+
364
+ The "Proof Key for Code Exchange by OAuth Public Clients" (aka PKCE) flow, which is **particularly recommended for OAuth integration in mobile apps**, is transparently supported by `rodauth-oauth`, by adding the `code_challenge_method=S256&code_challenge=$YOUR_CODE_CHALLENGE` query params to the authorization url. Once you do that, you'll have to pass the `code_verifier` when generating a token:
365
+
366
+ ```ruby
367
+ # with httpx
368
+ require "httpx"
369
+ httpx = HTTPX.plugin(:authorization)
370
+ response = httpx.with(headers: { "X-your-auth-scheme" => ENV["SERVER_KEY"] })
371
+ .post("https://auth_server/oauth-token",json: {
372
+ client_id: ENV["OAUTH_CLIENT_ID"],
373
+ grant_type: "authorization_code",
374
+ code: "oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as",
375
+ code_verifier: your_code_verifier_here
376
+ })
377
+ response.raise_for_status
378
+ payload = JSON.parse(response.to_s)
379
+ puts payload #=> {"token" =
380
+ ```
381
+
382
+ By default, the pkce integration sets "S256" as the default challenge method. If you value security, you **should not use plain**. However, if you really need to, you can set it in the `rodauth` plugin:
383
+
384
+ ```ruby
385
+ plugin :rodauth do
386
+ enable :oauth
387
+ oauth_pkce_challenge_method "plain"
388
+ end
389
+ ```
390
+
391
+ Although PKCE flow is supported out-of-the-box, it's not enforced by default. If you want to, you can force it, thereby forcing clients to generate a challenge:
392
+
393
+ ```ruby
394
+ plugin :rodauth do
395
+ enable :oauth
396
+ oauth_require_pkce true
397
+ end
398
+ ```
399
+
318
400
  ## Ruby support policy
319
401
 
320
402
  The minimum Ruby version required to run `rodauth-oauth` is 2.3 . Besides that, it should support all rubies that rodauth and roda support.
@@ -24,6 +24,12 @@ class CreateRodauthOAuth < ActiveRecord::Migration<%= migration_version %>
24
24
  t.datetime :revoked_at
25
25
  t.string :scopes, null: false
26
26
  t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
27
+ # for using access_types
28
+ t.string :access_type, null: false, default: "offline"
29
+ # uncomment to enable PKCE
30
+ # t.string :code_challenge
31
+ # t.string :code_challenge_method
32
+
27
33
  t.index(%i[oauth_application_id code], unique: true)
28
34
  end
29
35
 
@@ -37,7 +43,13 @@ class CreateRodauthOAuth < ActiveRecord::Migration<%= migration_version %>
37
43
  t.integer :oauth_application_id
38
44
  t.foreign_key :oauth_applications, column: :oauth_application_id
39
45
  t.string :token, null: false, token: true
46
+ # uncomment if setting oauth_tokens_token_hash_column
47
+ # and delete the token column
48
+ # t.string :token_hash, token: true
40
49
  t.string :refresh_token
50
+ # uncomment if setting oauth_tokens_refresh_token_hash_column
51
+ # and delete the refresh_token column
52
+ # t.string :refresh_token_hash, token: true
41
53
  t.datetime :expires_in, null: false
42
54
  t.datetime :revoked_at
43
55
  t.string :scopes, null: false
@@ -14,6 +14,23 @@ module Rodauth
14
14
  using(RegexpExtensions)
15
15
  end
16
16
 
17
+ unless String.method_defined?(:delete_suffix!)
18
+ module SuffixExtensions
19
+ refine(String) do
20
+ def delete_suffix!(suffix)
21
+ suffix = suffix.to_s
22
+ chomp! if frozen?
23
+ len = suffix.length
24
+ return unless len.positive? && index(suffix, -len)
25
+
26
+ self[-len..-1] = ""
27
+ self
28
+ end
29
+ end
30
+ end
31
+ using(SuffixExtensions)
32
+ end
33
+
17
34
  SCOPES = %w[profile.read].freeze
18
35
 
19
36
  depends :login
@@ -51,22 +68,26 @@ module Rodauth
51
68
  auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes
52
69
  auth_value_method :use_oauth_implicit_grant_type, false
53
70
 
71
+ auth_value_method :oauth_require_pkce, false
72
+ auth_value_method :oauth_pkce_challenge_method, "S256"
73
+
54
74
  # URL PARAMS
55
75
 
56
76
  # Authorize / token
57
77
  %w[
58
- grant_type code refresh_token client_id scope
78
+ grant_type code refresh_token client_id client_secret scope
59
79
  state redirect_uri scopes token_type_hint token
60
- access_type response_type
80
+ access_type approval_prompt response_type
81
+ code_challenge code_challenge_method code_verifier
61
82
  ].each do |param|
62
83
  auth_value_method :"#{param}_param", param
63
84
  end
64
85
 
65
86
  # Application
66
- APPLICATION_REQUIRED_PARAMS = %w[name description scopes homepage_url redirect_uri].freeze
87
+ APPLICATION_REQUIRED_PARAMS = %w[name description scopes homepage_url redirect_uri client_secret].freeze
67
88
  auth_value_method :oauth_application_required_params, APPLICATION_REQUIRED_PARAMS
68
89
 
69
- (APPLICATION_REQUIRED_PARAMS + %w[client_id client_secret]).each do |param|
90
+ (APPLICATION_REQUIRED_PARAMS + %w[client_id]).each do |param|
70
91
  auth_value_method :"oauth_application_#{param}_param", param
71
92
  end
72
93
 
@@ -83,6 +104,10 @@ module Rodauth
83
104
  auth_value_method :"oauth_tokens_#{column}_column", column
84
105
  end
85
106
 
107
+ # Oauth Token Hash
108
+ auth_value_method :oauth_tokens_token_hash_column, nil
109
+ auth_value_method :oauth_tokens_refresh_token_hash_column, nil
110
+
86
111
  # OAuth Grants
87
112
  auth_value_method :oauth_grants_table, :oauth_grants
88
113
  auth_value_method :oauth_grants_id_column, :id
@@ -90,6 +115,7 @@ module Rodauth
90
115
  account_id oauth_application_id
91
116
  redirect_uri code scopes access_type
92
117
  expires_in revoked_at
118
+ code_challenge code_challenge_method
93
119
  ].each do |column|
94
120
  auth_value_method :"oauth_grants_#{column}_column", column
95
121
  end
@@ -130,8 +156,16 @@ module Rodauth
130
156
  auth_value_method :unique_error_message, "is already in use"
131
157
  auth_value_method :null_error_message, "is not filled"
132
158
 
159
+ # PKCE
160
+ auth_value_method :code_challenge_required_error_code, "invalid_request"
161
+ auth_value_method :code_challenge_required_message, "code challenge required"
162
+ auth_value_method :unsupported_transform_algorithm_error_code, "invalid_request"
163
+ auth_value_method :unsupported_transform_algorithm_message, "transform algorithm not supported"
164
+
133
165
  auth_value_methods(
134
- :oauth_unique_id_generator
166
+ :oauth_unique_id_generator,
167
+ :secret_matches?,
168
+ :secret_hash
135
169
  )
136
170
 
137
171
  redirect(:oauth_application) do |id|
@@ -178,51 +212,31 @@ module Rodauth
178
212
  end
179
213
 
180
214
  def state
181
- state = param(state_param)
182
-
183
- return unless state && !state.empty?
184
-
185
- state
215
+ param_or_nil(state_param)
186
216
  end
187
217
 
188
218
  def scopes
189
- scopes = param(scopes_param)
190
-
191
- return [oauth_application_default_scope] unless scopes && !scopes.empty?
192
-
193
- scopes.split(" ")
219
+ (param_or_nil(scopes_param) || oauth_application_default_scope).split(" ")
194
220
  end
195
221
 
196
222
  def client_id
197
- client_id = param(client_id_param)
198
-
199
- return unless client_id && !client_id.empty?
223
+ param_or_nil(client_id_param)
224
+ end
200
225
 
201
- client_id
226
+ def client_secret
227
+ param_or_nil(client_secret_param)
202
228
  end
203
229
 
204
230
  def redirect_uri
205
- redirect_uri = param(redirect_uri_param)
206
-
207
- return oauth_application[oauth_applications_redirect_uri_column] unless redirect_uri && !redirect_uri.empty?
208
-
209
- redirect_uri
231
+ param_or_nil(redirect_uri_param) || oauth_application[oauth_applications_redirect_uri_column]
210
232
  end
211
233
 
212
234
  def token_type_hint
213
- token_type_hint = param(token_type_hint_param)
214
-
215
- return "access_token" unless token_type_hint && !token_type_hint.empty?
216
-
217
- token_type_hint
235
+ param_or_nil(token_type_hint_param) || "access_token"
218
236
  end
219
237
 
220
238
  def token
221
- token = param(token_param)
222
-
223
- return unless token && !token.empty?
224
-
225
- token
239
+ param_or_nil(token_param)
226
240
  end
227
241
 
228
242
  def oauth_application
@@ -250,10 +264,9 @@ module Rodauth
250
264
  # check if there is a token
251
265
  # check if token has not expired
252
266
  # check if token has been revoked
253
- db[oauth_tokens_table].where(oauth_tokens_token_column => token)
254
- .where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
255
- .where(oauth_tokens_revoked_at_column => nil)
256
- .first
267
+ oauth_token_by_token(token).where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
268
+ .where(oauth_tokens_revoked_at_column => nil)
269
+ .first
257
270
  end
258
271
  end
259
272
 
@@ -317,8 +330,100 @@ module Rodauth
317
330
 
318
331
  private
319
332
 
333
+ def secret_matches?(oauth_application, secret)
334
+ BCrypt::Password.new(oauth_application[oauth_applications_client_secret_column]) == secret
335
+ end
336
+
337
+ def secret_hash(secret)
338
+ password_hash(secret)
339
+ end
340
+
320
341
  def oauth_unique_id_generator
321
- SecureRandom.uuid
342
+ SecureRandom.hex(32)
343
+ end
344
+
345
+ def generate_token_hash(token)
346
+ Base64.urlsafe_encode64(Digest::SHA256.digest(token))
347
+ end
348
+
349
+ unless method_defined?(:password_hash)
350
+ # From login_requirements_base feature
351
+ if ENV["RACK_ENV"] == "test"
352
+ def password_hash_cost
353
+ BCrypt::Engine::MIN_COST
354
+ end
355
+ else
356
+ # :nocov:
357
+ def password_hash_cost
358
+ BCrypt::Engine::DEFAULT_COST
359
+ end
360
+ # :nocov:
361
+ end
362
+
363
+ def password_hash(password)
364
+ BCrypt::Password.create(password, cost: password_hash_cost)
365
+ end
366
+ end
367
+
368
+ def generate_oauth_token(params = {}, should_generate_refresh_token = true)
369
+ create_params = {
370
+ oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
371
+ }.merge(params)
372
+
373
+ token = oauth_unique_id_generator
374
+ refresh_token = nil
375
+
376
+ if oauth_tokens_token_hash_column
377
+ create_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
378
+ else
379
+ create_params[oauth_tokens_token_column] = token
380
+ end
381
+
382
+ if should_generate_refresh_token
383
+ refresh_token = oauth_unique_id_generator
384
+
385
+ if oauth_tokens_refresh_token_hash_column
386
+ create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
387
+ else
388
+ create_params[oauth_tokens_refresh_token_column] = refresh_token
389
+ end
390
+ end
391
+ oauth_token = _generate_oauth_token(create_params)
392
+
393
+ oauth_token[oauth_tokens_token_column] = token
394
+ oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
395
+ oauth_token
396
+ end
397
+
398
+ def _generate_oauth_token(params = {})
399
+ ds = db[oauth_tokens_table]
400
+
401
+ begin
402
+ if ds.supports_returning?(:insert)
403
+ ds.returning.insert(params)
404
+ else
405
+ id = ds.insert(params)
406
+ ds.where(oauth_tokens_id_column => id).first
407
+ end
408
+ rescue Sequel::UniqueConstraintViolation
409
+ retry
410
+ end
411
+ end
412
+
413
+ def oauth_token_by_token(token)
414
+ if oauth_tokens_token_hash_column
415
+ db[oauth_tokens_table].where(oauth_tokens_token_hash_column => generate_token_hash(token))
416
+ else
417
+ db[oauth_tokens_table].where(oauth_tokens_token_column => token)
418
+ end
419
+ end
420
+
421
+ def oauth_token_by_refresh_token(token)
422
+ if oauth_tokens_refresh_token_hash_column
423
+ db[oauth_tokens_table].where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
424
+ else
425
+ db[oauth_tokens_table].where(oauth_tokens_refresh_token_column => token)
426
+ end
322
427
  end
323
428
 
324
429
  # Oauth Application
@@ -363,9 +468,11 @@ module Rodauth
363
468
  }
364
469
 
365
470
  # set client ID/secret pairs
471
+
366
472
  create_params.merge! \
367
473
  oauth_applications_client_id_column => oauth_unique_id_generator,
368
- oauth_applications_client_secret_column => oauth_unique_id_generator
474
+ oauth_applications_client_secret_column => \
475
+ secret_hash(oauth_application_params[oauth_application_client_secret_param])
369
476
 
370
477
  create_params[oauth_applications_scopes_column] = if create_params[oauth_applications_scopes_column]
371
478
  create_params[oauth_applications_scopes_column].join(",")
@@ -404,10 +511,31 @@ module Rodauth
404
511
  # Authorize
405
512
 
406
513
  def validate_oauth_grant_params
407
- unless oauth_application && check_valid_redirect_uri? && check_valid_access_type?
514
+ unless oauth_application && check_valid_redirect_uri? && check_valid_access_type? &&
515
+ check_valid_approval_prompt? && check_valid_response_type?
408
516
  redirect_response_error("invalid_request")
409
517
  end
410
518
  redirect_response_error("invalid_scope") unless check_valid_scopes?
519
+
520
+ validate_pkce_challenge_params
521
+ end
522
+
523
+ def try_approval_prompt
524
+ approval_prompt = param_or_nil(approval_prompt_param)
525
+
526
+ return unless approval_prompt && approval_prompt == "auto"
527
+
528
+ return if db[oauth_grants_table].where(
529
+ oauth_grants_account_id_column => account_id,
530
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
531
+ oauth_grants_redirect_uri_column => redirect_uri,
532
+ oauth_grants_scopes_column => scopes.join(","),
533
+ oauth_grants_access_type_column => "online"
534
+ ).count.zero?
535
+
536
+ # if there's a previous oauth grant for the params combo, it means that this user has approved before.
537
+
538
+ request.env["REQUEST_METHOD"] = "POST"
411
539
  end
412
540
 
413
541
  def create_oauth_grant
@@ -420,10 +548,20 @@ module Rodauth
420
548
  oauth_grants_scopes_column => scopes.join(",")
421
549
  }
422
550
 
423
- unless (access_type = param("access_type")).empty?
551
+ if (access_type = param_or_nil(access_type_param))
424
552
  create_params[oauth_grants_access_type_column] = access_type
425
553
  end
426
554
 
555
+ # PKCE flow
556
+ if (code_challenge = param_or_nil(code_challenge_param))
557
+ code_challenge_method = param_or_nil(code_challenge_method_param)
558
+
559
+ create_params[oauth_grants_code_challenge_column] = code_challenge
560
+ create_params[oauth_grants_code_challenge_method_column] = code_challenge_method
561
+ elsif oauth_require_pkce
562
+ redirect_response_error("code_challenge_required")
563
+ end
564
+
427
565
  ds = db[oauth_grants_table]
428
566
 
429
567
  begin
@@ -441,60 +579,63 @@ module Rodauth
441
579
  # Access Tokens
442
580
 
443
581
  def validate_oauth_token_params
444
- redirect_response_error("invalid_request") unless param(client_id_param)
582
+ redirect_response_error("invalid_request") unless param_or_nil(client_id_param)
445
583
 
446
- unless (grant_type = param(grant_type_param))
584
+ unless param_or_nil(client_secret_param)
585
+ redirect_response_error("invalid_request") unless param_or_nil(code_verifier_param)
586
+ end
587
+
588
+ unless (grant_type = param_or_nil(grant_type_param))
447
589
  redirect_response_error("invalid_request")
448
590
  end
449
591
 
450
592
  case grant_type
451
593
  when "authorization_code"
452
- redirect_response_error("invalid_request") unless param(code_param)
594
+ redirect_response_error("invalid_request") unless param_or_nil(code_param)
453
595
 
454
596
  when "refresh_token"
455
- redirect_response_error("invalid_request") unless param(refresh_token_param)
597
+ redirect_response_error("invalid_request") unless param_or_nil(refresh_token_param)
456
598
  else
457
599
  redirect_response_error("invalid_request")
458
600
  end
459
601
  end
460
602
 
461
- def generate_oauth_token(params = {})
462
- create_params = {
463
- oauth_grants_expires_in_column => Time.now + oauth_token_expires_in,
464
- oauth_tokens_token_column => oauth_unique_id_generator
465
- }.merge(params)
603
+ def create_oauth_token
604
+ oauth_application = db[oauth_applications_table].where(
605
+ oauth_applications_client_id_column => param(client_id_param)
606
+ ).first
466
607
 
467
- ds = db[oauth_tokens_table]
608
+ redirect_response_error("invalid_request") unless oauth_application
468
609
 
469
- begin
470
- if ds.supports_returning?(:insert)
471
- ds.returning.insert(create_params)
472
- else
473
- id = ds.insert(create_params)
474
- ds.where(oauth_tokens_id_column => id).first
475
- end
476
- rescue Sequel::UniqueConstraintViolation
477
- retry
610
+ if (client_secret = param_or_nil(client_secret_param))
611
+ redirect_response_error("invalid_request") unless secret_matches?(oauth_application, client_secret)
478
612
  end
479
- end
480
613
 
481
- def create_oauth_token
482
614
  case param(grant_type_param)
483
615
  when "authorization_code"
616
+
484
617
  # fetch oauth grant
485
618
  oauth_grant = db[oauth_grants_table].where(
486
619
  oauth_grants_code_column => param(code_param),
487
620
  oauth_grants_redirect_uri_column => param(redirect_uri_param),
488
- oauth_grants_oauth_application_id_column => db[oauth_applications_table].where(
489
- oauth_applications_client_id_column => param(client_id_param),
490
- oauth_applications_account_id_column => oauth_applications_account_id_column
491
- ).select(oauth_applications_id_column)
621
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
622
+ oauth_grants_revoked_at_column => nil
492
623
  ).where(Sequel[oauth_grants_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
493
- .where(oauth_grants_revoked_at_column => nil)
494
624
  .first
495
625
 
496
626
  redirect_response_error("invalid_grant") unless oauth_grant
497
627
 
628
+ # PKCE
629
+ if oauth_grant[oauth_grants_code_challenge_column]
630
+ code_verifier = param_or_nil(code_verifier_param)
631
+
632
+ unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier)
633
+ redirect_response_error("invalid_request")
634
+ end
635
+ elsif oauth_require_pkce
636
+ redirect_response_error("code_challenge_required")
637
+ end
638
+
498
639
  create_params = {
499
640
  oauth_tokens_account_id_column => oauth_grant[oauth_grants_account_id_column],
500
641
  oauth_tokens_oauth_application_id_column => oauth_grant[oauth_grants_oauth_application_id_column],
@@ -502,43 +643,48 @@ module Rodauth
502
643
  oauth_tokens_scopes_column => oauth_grant[oauth_grants_scopes_column]
503
644
  }
504
645
 
505
- if oauth_grant[oauth_grants_access_type_column] == "offline"
506
- create_params[oauth_tokens_refresh_token_column] = oauth_unique_id_generator
507
- end
508
646
  # revoke oauth grant
509
647
  db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column])
510
648
  .update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
511
649
 
512
- generate_oauth_token(create_params)
650
+ generate_oauth_token(create_params, oauth_grant[oauth_grants_access_type_column] == "offline")
651
+
513
652
  when "refresh_token"
514
- # fetch oauth grant
515
- oauth_token = db[oauth_tokens_table].where(
516
- oauth_tokens_refresh_token_column => param(refresh_token_param),
517
- oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where(
518
- oauth_applications_client_id_column => param(client_id_param),
519
- oauth_applications_account_id_column => account_id
520
- ).select(oauth_applications_id_column)
653
+ # fetch oauth token
654
+ oauth_token = oauth_token_by_refresh_token(param(refresh_token_param)).where(
655
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column]
521
656
  ).where(oauth_grants_revoked_at_column => nil).first
522
657
 
523
658
  redirect_response_error("invalid_grant") unless oauth_token
524
659
 
660
+ token = oauth_unique_id_generator
661
+
525
662
  update_params = {
526
663
  oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column],
527
- oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in,
528
- oauth_tokens_token_column => oauth_unique_id_generator
664
+ oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in
529
665
  }
530
666
 
667
+ if oauth_tokens_token_hash_column
668
+ update_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
669
+ else
670
+ update_params[oauth_tokens_token_column] = token
671
+ end
672
+
531
673
  ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
532
- begin
674
+
675
+ oauth_token = begin
533
676
  if ds.supports_returning?(:update)
534
677
  ds.returning.update(update_params)
535
678
  else
536
679
  ds.update(update_params)
537
680
  ds.first
538
681
  end
539
- rescue Sequel::UniqueConstraintViolation
540
- retry
682
+ rescue Sequel::UniqueConstraintViolation
683
+ retry
541
684
  end
685
+
686
+ oauth_token[oauth_tokens_token_column] = token
687
+ oauth_token
542
688
  else
543
689
  redirect_response_error("invalid_grant")
544
690
  end
@@ -556,39 +702,40 @@ module Rodauth
556
702
  end
557
703
 
558
704
  def revoke_oauth_token
559
- # one can only revoke tokens which haven't been revoked before, and which are
560
- # either our tokens, or tokens from applications we own.
561
- ds = db[oauth_tokens_table]
562
- .where(oauth_tokens_revoked_at_column => nil)
563
- .where(
564
- Sequel.or(
565
- oauth_tokens_account_id_column => account_id,
566
- oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where(
567
- oauth_applications_client_id_column => param(client_id_param),
568
- oauth_applications_account_id_column => account_id
569
- ).select(oauth_applications_id_column)
570
- )
571
- )
572
705
  ds = case token_type_hint
573
706
  when "access_token"
574
- ds.where(oauth_tokens_token_column => token)
707
+ oauth_token_by_token(token)
575
708
  when "refresh_token"
576
- ds.where(oauth_tokens_refresh_token_column => token)
709
+ oauth_token_by_refresh_token(token)
577
710
  end
711
+ # one can only revoke tokens which haven't been revoked before, and which are
712
+ # either our tokens, or tokens from applications we own.
713
+ oauth_token = ds.where(oauth_tokens_revoked_at_column => nil)
714
+ .where(
715
+ Sequel.or(
716
+ oauth_tokens_account_id_column => account_id,
717
+ oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where(
718
+ oauth_applications_client_id_column => param(client_id_param),
719
+ oauth_applications_account_id_column => account_id
720
+ ).select(oauth_applications_id_column)
721
+ )
722
+ ).first
578
723
 
579
- oauth_token = ds.first
580
724
  redirect_response_error("invalid_request") unless oauth_token
581
725
 
582
726
  update_params = { oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP }
583
727
 
584
728
  ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
585
729
 
586
- if ds.supports_returning?(:update)
587
- ds.returning.update(update_params)
588
- else
589
- ds.update(update_params)
590
- ds.first
591
- end
730
+ oauth_token = if ds.supports_returning?(:update)
731
+ ds.returning.update(update_params)
732
+ else
733
+ ds.update(update_params)
734
+ ds.first
735
+ end
736
+
737
+ oauth_token[oauth_tokens_token_column] = token
738
+ oauth_token
592
739
 
593
740
  # If the particular
594
741
  # token is a refresh token and the authorization server supports the
@@ -606,11 +753,19 @@ module Rodauth
606
753
  throw_json_response_error(invalid_oauth_response_status, error_code)
607
754
  else
608
755
  redirect_url = URI.parse(redirect_url)
609
- query_params = ["error=#{error_code}"]
756
+ query_params = []
757
+
758
+ query_params << if respond_to?(:"#{error_code}_error_code")
759
+ "error=#{send(:"#{error_code}_error_code")}"
760
+ else
761
+ "error=#{error_code}"
762
+ end
763
+
610
764
  if respond_to?(:"#{error_code}_message")
611
765
  message = send(:"#{error_code}_message")
612
766
  query_params << ["error_description=#{CGI.escape(message)}"]
613
767
  end
768
+
614
769
  query_params << redirect_url.query if redirect_url.query
615
770
  redirect_url.query = query_params.join("&")
616
771
  redirect(redirect_url.to_s)
@@ -619,19 +774,30 @@ module Rodauth
619
774
 
620
775
  def throw_json_response_error(status, error_code)
621
776
  set_response_error_status(status)
622
- payload = { "error" => error_code }
777
+ code = if respond_to?(:"#{error_code}_error_code")
778
+ send(:"#{error_code}_error_code")
779
+ else
780
+ error_code
781
+ end
782
+ payload = { "error" => code }
623
783
  payload["error_description"] = send(:"#{error_code}_message") if respond_to?(:"#{error_code}_message")
624
- json_payload = if request.respond_to?(:convert_to_json)
625
- request.send(:convert_to_json, payload)
626
- else
627
- JSON.dump(payload)
628
- end
784
+ json_payload = _json_response_body(payload)
629
785
  response["Content-Type"] ||= json_response_content_type
630
786
  response["WWW-Authenticate"] = "Bearer" if status == 401
631
787
  response.write(json_payload)
632
788
  request.halt
633
789
  end
634
790
 
791
+ unless method_defined?(:_json_response_body)
792
+ def _json_response_body(hash)
793
+ if request.respond_to?(:convert_to_json)
794
+ request.send(:convert_to_json, hash)
795
+ else
796
+ JSON.dump(hash)
797
+ end
798
+ end
799
+ end
800
+
635
801
  def authorization_required
636
802
  if json_request?
637
803
  throw_json_response_error(authorization_required_error_status, "invalid_client")
@@ -654,25 +820,59 @@ module Rodauth
654
820
  ACCESS_TYPES = %w[offline online].freeze
655
821
 
656
822
  def check_valid_access_type?
657
- access_type = param("access_type")
658
- access_type.empty? || ACCESS_TYPES.include?(access_type)
823
+ access_type = param_or_nil(access_type_param)
824
+ !access_type || ACCESS_TYPES.include?(access_type)
825
+ end
826
+
827
+ APPROVAL_PROMPTS = %w[force auto].freeze
828
+
829
+ def check_valid_approval_prompt?
830
+ approval_prompt = param_or_nil(approval_prompt_param)
831
+ !approval_prompt || APPROVAL_PROMPTS.include?(approval_prompt)
659
832
  end
660
833
 
661
834
  def check_valid_response_type?
662
- response_type = param("response_type")
835
+ response_type = param_or_nil(response_type_param)
663
836
 
664
- return true if response_type.empty? || response_type == "code"
837
+ return true if response_type.nil? || response_type == "code"
665
838
 
666
839
  return use_oauth_implicit_grant_type if response_type == "token"
667
840
 
668
841
  false
669
842
  end
670
843
 
844
+ # PKCE
845
+
846
+ def validate_pkce_challenge_params
847
+ if param_or_nil(code_challenge_param)
848
+
849
+ challenge_method = param_or_nil(code_challenge_method_param)
850
+ redirect_response_error("code_challenge_required") unless oauth_pkce_challenge_method == challenge_method
851
+ else
852
+ return unless oauth_require_pkce
853
+
854
+ redirect_response_error("code_challenge_required")
855
+ end
856
+ end
857
+
858
+ def check_valid_grant_challenge?(grant, verifier)
859
+ challenge = grant[oauth_grants_code_challenge_column]
860
+
861
+ case grant[oauth_grants_code_challenge_method_column]
862
+ when "plain"
863
+ challenge == verifier
864
+ when "S256"
865
+ generated_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier))
866
+ generated_challenge.delete_suffix!("=") while generated_challenge.end_with?("=")
867
+
868
+ challenge == generated_challenge
869
+ else
870
+ redirect_response_error("unsupported_transform_algorithm")
871
+ end
872
+ end
873
+
671
874
  # /oauth-token
672
875
  route(:oauth_token) do |r|
673
- throw_json_response_error(authorization_required_error_status, "invalid_client") unless logged_in?
674
-
675
- # access-token
676
876
  r.post do
677
877
  catch_error do
678
878
  validate_oauth_token_params
@@ -687,18 +887,14 @@ module Rodauth
687
887
  response.status = 200
688
888
  response["Content-Type"] ||= json_response_content_type
689
889
  json_response = {
690
- "token" => oauth_token[:token],
890
+ "token" => oauth_token[oauth_tokens_token_column],
691
891
  "token_type" => oauth_token_type,
692
892
  "expires_in" => oauth_token_expires_in
693
893
  }
694
894
 
695
- json_response["refresh_token"] = oauth_token[:refresh_token] if oauth_token[:refresh_token]
895
+ json_response["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] if oauth_token[:refresh_token]
696
896
 
697
- json_payload = if request.respond_to?(:convert_to_json)
698
- request.send(:convert_to_json, json_response)
699
- else
700
- JSON.dump(json_response)
701
- end
897
+ json_payload = _json_response_body(json_response)
702
898
  response.write(json_payload)
703
899
  request.halt
704
900
  end
@@ -727,15 +923,11 @@ module Rodauth
727
923
  response.status = 200
728
924
  response["Content-Type"] ||= json_response_content_type
729
925
  json_response = {
730
- "token" => oauth_token[:token],
731
- "refresh_token" => oauth_token[:refresh_token],
732
- "revoked_at" => oauth_token[:revoked_at]
926
+ "token" => oauth_token[oauth_tokens_token_column],
927
+ "refresh_token" => oauth_token[oauth_tokens_refresh_token_column],
928
+ "revoked_at" => oauth_token[oauth_tokens_revoked_at_column]
733
929
  }
734
- json_payload = if request.respond_to?(:convert_to_json)
735
- request.send(:convert_to_json, json_response)
736
- else
737
- JSON.dump(json_response)
738
- end
930
+ json_payload = _json_response_body(json_response)
739
931
  response.write(json_payload)
740
932
  request.halt
741
933
  else
@@ -751,15 +943,14 @@ module Rodauth
751
943
  # /oauth-authorize
752
944
  route(:oauth_authorize) do |r|
753
945
  require_account
946
+ validate_oauth_grant_params
947
+ try_approval_prompt if request.get?
754
948
 
755
949
  r.get do
756
- validate_oauth_grant_params
757
950
  authorize_view
758
951
  end
759
952
 
760
953
  r.post do
761
- validate_oauth_grant_params
762
-
763
954
  code = nil
764
955
  query_params = []
765
956
  fragment_params = []
@@ -775,9 +966,9 @@ module Rodauth
775
966
  oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
776
967
  oauth_tokens_scopes_column => scopes
777
968
  }
778
- oauth_token = generate_oauth_token(create_params)
969
+ oauth_token = generate_oauth_token(create_params, false)
779
970
 
780
- fragment_params << ["access_token=#{oauth_token[:token]}"]
971
+ fragment_params << ["access_token=#{oauth_token[oauth_tokens_token_column]}"]
781
972
  fragment_params << ["token_type=#{oauth_token_type}"]
782
973
  fragment_params << ["expires_in=#{oauth_token_expires_in}"]
783
974
  when "code", "", nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.0.1"
5
+ VERSION = "0.0.2"
6
6
  end
7
7
  end
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.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tiago Cardoso
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-05-27 00:00:00.000000000 Z
11
+ date: 2020-05-29 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: