rodauth-oauth 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec27cf0fcac2e93b6ba08d76a4fa3e165c55722864d87fb78e4f0fc70a15c759
4
- data.tar.gz: 535f0b61b987f38179de2d7011b55d8b9b6e12bdf1293c9da7534c063277c2f3
3
+ metadata.gz: 22808f6f421ce935e5f69301b1f0edc37a286baedf0c4a4039d29b3ff678bf40
4
+ data.tar.gz: 64305ba438e3035309ca428d4820f00f7b169ca219882575262ee8242a800f50
5
5
  SHA512:
6
- metadata.gz: d1179a1e5db37dbfdc9932a6ec74fb3fba37c7c863c0ec643f9b1c11fee8ef8b8bf71d0705bb9629135f517dbb3d49dd5e7609014ddc3aae28ad4bd0f45981a1
7
- data.tar.gz: ebc711bc991746fbfa780f81a91c92508c55eca65e3e316a1fc4b23b6e9141f0d97a8871ce12527fdeaf71234d97ddfa0c712aca870c65f73ab89a11fe40839e
6
+ metadata.gz: c6302e1e592bf760b9a1a1ac4adc6e9189545f58818553ec85fda0f86299531dcea1e1b59cba65aaa62d607e2cf541fb18ee2a9c2161ce96678b5b2812326a0b
7
+ data.tar.gz: 689a8e88d89c8fe74b665c65138ff8372cee05d0783e473e8e124b9a9f21d9806bb397d185edf9d0c87ef21cfcd2d81bfa16ab0aaa3da64b9b28d6e5e2e24ab3
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.0.4 (13/6/2020)
6
+
7
+ ### Features
8
+
9
+ #### Token introspection
10
+
11
+ `rodauth-oauth` now ships with an introspection endpoint (`/oauth-introspect`).
12
+
13
+ #### Authorization Server Metadata
14
+
15
+ `rodauth-oauth` now allows to define an authorization metadata endpoint, which has to be defined at the route of the router:
16
+
17
+ ```ruby
18
+ route do |r|
19
+ r.rodauth
20
+ rodauth.oauth_server_metadata
21
+ ...
22
+ ```
23
+
24
+ #### JWKs URI
25
+
26
+ the `oauth_jwt` feature now ships with an endpoint, `/oauth-jwks`, where client applications can retrieve the JWK set to verify generated tokens.
27
+
28
+ #### JWT access tokens as authorization grants
29
+
30
+ The `oauth_jwt` feature now allows the usage of access tokens to authorize the generation of new tokens, [as per the RFC](https://tools.ietf.org/html/rfc7523#section-4);
31
+
32
+ ### Improvements
33
+
34
+ * using `client_secret_basic` authorization where client id/secret params were allowed (i.e. in the token and revoke endpoints, for example);
35
+ * improved JWK usage for both supported jwt libraries;
36
+ * marked `fetch_access_token` as auth_value_method, thereby allowing users to fetch the access token from other sources than the "Authorization" header (i.e. form body, query params, etc...)
37
+
38
+ ### Bugfixes
39
+
40
+ * Fixed scope claim of JWT ("scopes" -> "scope");
41
+
5
42
  ## 0.0.3 (5/6/2020)
6
43
 
7
44
  ### Features
data/README.md CHANGED
@@ -15,6 +15,8 @@ This gem implements:
15
15
  * [Access Token refresh](https://tools.ietf.org/html/rfc6749#section-1.5);
16
16
  * [Implicit grant (off by default)[https://tools.ietf.org/html/rfc6749#section-4.2];
17
17
  * [Token revocation](https://tools.ietf.org/html/rfc7009);
18
+ * [Token introspection](https://tools.ietf.org/html/rfc7662);
19
+ * [Authorization Server Metadata](https://tools.ietf.org/html/rfc8414);
18
20
  * [PKCE](https://tools.ietf.org/html/rfc7636);
19
21
  * Access Type (Token refresh online and offline);
20
22
  * [MAC Authentication Scheme](https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-02);
@@ -146,10 +148,9 @@ Token revocation can be done both by the idenntity owner or the application owne
146
148
 
147
149
  ```ruby
148
150
  require "httpx"
149
- httpx = HTTPX.plugin(:authorization)
150
- response = httpx.with(headers: { "X-your-auth-scheme" => ENV["SERVER_KEY"] })
151
+ httpx = HTTPX.plugin(:basic_authorization)
152
+ response = httpx.basic_authentication(ENV["CLIENT_ID"], ENV["CLIENT_SECRET"])
151
153
  .post("https://auth_server/oauth-revoke",json: {
152
- client_id: ENV["OAUTH_CLIENT_ID"],
153
154
  token_type_hint: "access_token", # can also be "refresh:tokn"
154
155
  token: "2r89hfef4j9f90d2j2390jf390g"
155
156
  })
@@ -164,6 +165,55 @@ puts payload #=> {"access_token" => "awr23f3h8f9d2h89...", "token_type" => "Bear
164
165
  > curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","token_type_hint":"access_token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/oauth-revoke
165
166
  ```
166
167
 
168
+ #### Token introspection
169
+
170
+ Token revocation can be used to determine the state of a token (whether active, what's the scope...) . Here's an example using server-to-server:
171
+
172
+ ```ruby
173
+ require "httpx"
174
+ httpx = HTTPX.plugin(:basic_authorization)
175
+ response = httpx.basic_authentication(ENV["CLIENT_ID"], ENV["CLIENT_SECRET"])
176
+ .post("https://auth_server/oauth-introspect",json: {
177
+ token_type_hint: "access_token", # can also be "refresh:tokn"
178
+ token: "2r89hfef4j9f90d2j2390jf390g"
179
+ })
180
+ response.raise_for_status
181
+ payload = JSON.parse(response.to_s)
182
+ puts payload #=> {"active" => true, "scope" => "read write" ....
183
+ ```
184
+
185
+ ##### cURL
186
+
187
+ ```
188
+ > curl -H "X-your-auth-scheme: $SERVER_KEY" --data '{"client_id":"$OAUTH_CLIENT_ID","token_type_hint":"access_token","token":"2r89hfef4j9f90d2j2390jf390g"}' https://auth_server/oauth-revoke
189
+ ```
190
+
191
+ ### Authorization Server Metadata
192
+
193
+ The Authorization Server Metadata endpoint can be used by clients to obtain the information needed to interact with an
194
+ OAuth 2.0 authorization server, i.e. know which endpoint is used to authorize clients.
195
+
196
+ Because this endpoint **must be https://AUTHSERVER/.well-known/oauth-authorization-server**, you'll have to define it at the root-level of your app:
197
+
198
+ ```ruby
199
+ plugin :rodauth do
200
+ # enable it in the plugin
201
+ enable :login, :oauth
202
+ oauth_application_default_scope %w[profile.read]
203
+ oauth_application_scopes %w[profile.read profile.write]
204
+ end
205
+
206
+ # then, inside roda
207
+
208
+ route do |r|
209
+ r.rodauth
210
+ # server metadata endpoint
211
+ rodauth.oauth_server_metadata
212
+
213
+ # now, your oauth and app code...
214
+
215
+ ```
216
+
167
217
  ### Database migrations
168
218
 
169
219
  You have to generate database tables for Oauth applications, grants and tokens. In order for you to hit the ground running, [here's a set of migrations (using `sequel`) to generate the needed tables](https://gitlab.com/honeyryderchuck/rodauth-oauth/-/tree/master/test/migrate) (omit the first 2 if you already have account tables).
@@ -427,13 +477,13 @@ Generating an access token will deliver the following fields:
427
477
  # with httpx
428
478
  require "httpx"
429
479
  response = httpx.post("https://auth_server/oauth-token",json: {
430
- client_id: ENV["OAUTH_CLIENT_ID"],
431
- client_secret: ENV["OAUTH_CLIENT_SECRET"],
480
+ client_id: env["oauth_client_id"],
481
+ client_secret: env["oauth_client_secret"],
432
482
  grant_type: "authorization_code",
433
483
  code: "oiweicnewdh32fhoi3hf3ihfo2ih3f2o3as"
434
484
  })
435
485
  response.raise_for_status
436
- payload = JSON.parse(response.to_s)
486
+ payload = json.parse(response.to_s)
437
487
  puts payload #=> {
438
488
  # "access_token" => ....
439
489
  # "mac_key" => ....
@@ -469,7 +519,6 @@ This will, by default, use the OAuth application as HMAC signature and "HS256" a
469
519
  ```ruby
470
520
  enable :oauth_jwt
471
521
  oauth_jwt_secret "SECRET"
472
- # or oauth_jwt_secret_path "path/to/file"
473
522
  oauth_jwt_algorithm "HS512"
474
523
  ```
475
524
 
@@ -486,7 +535,7 @@ rsa_public = rsa_private.public_key
486
535
  plugin :rodauth do
487
536
  enable :oauth_jwt
488
537
  oauth_jwt_key rsa_private
489
- oauth_jwt_decoding_secret rsa_public
538
+ oauth_jwt_public_key rsa_public
490
539
  oauth_jwt_algorithm "RS256"
491
540
  end
492
541
  ```
@@ -496,10 +545,14 @@ end
496
545
  One can further encode the JWT token using JSON Web Keys. Here's how you could enable the feature:
497
546
 
498
547
  ```ruby
548
+ rsa_private = OpenSSL::PKey::RSA.generate 2048
549
+ rsa_public = rsa_private.public_key
550
+
499
551
  plugin :rodauth do
500
552
  enable :oauth_jwt
501
- oauth_jwt_jwk_key 2048 # can be key size or the encoded RSA key, which is the only one supported now.
502
- # oauth_jwt_jwk_key "path/to/rsa.pem" if you prefer
553
+ oauth_jwt_jwk_key rsa_private
554
+ oauth_jwt_jwk_public_key rsa_public
555
+ oauth_jwt_jwk_algorithm "RS256"
503
556
  end
504
557
  ```
505
558
 
@@ -520,6 +573,26 @@ end
520
573
 
521
574
  which adds an extra layer of protection.
522
575
 
576
+ #### JWKS URI
577
+
578
+ 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/oauth-jwks`.
579
+
580
+ #### JWT Bearer as authorization grant
581
+
582
+ One can emit a new access token by using the bearer access token as grant. This can be done emitting a request similar to this:
583
+
584
+ ```ruby
585
+ # with httpx
586
+ require "httpx"
587
+ response = httpx.post("https://auth_server/oauth-token",json: {
588
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
589
+ assertion: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6IkV4YW1wbGUiLCJpYXQiOjE1OTIwMDk1MDEsImNsaWVudF9pZCI6IkNMSUVOVF9JRCIsImV4cCI6MTU5MjAxMzEwMSwiYXVkIjpudWxsLCJzY29wZSI6InVzZXIucmVhZCB1c2VyLndyaXRlIiwianRpIjoiOGM1NTVjMjdiOWRjNDdmOTcyNWRkYzBhMjk0NzA1ZTA4NzFkY2JlN2Q5ZTNlMmVkNGE1ZTBiOGZlNTZlYzcxMSJ9.AlxKRtE3ec0mtyBSDx4VseND4eC6cH5ubtv8gfYxxsc"
590
+ })
591
+ response.raise_for_status
592
+ payload = json.parse(response.to_s)
593
+ puts payload #=> {
594
+ # "access_token" => "ey....
595
+ ```
523
596
 
524
597
  #### DB Schema
525
598
 
@@ -1,5 +1,7 @@
1
1
  # frozen-string-literal: true
2
2
 
3
+ require "base64"
4
+
3
5
  module Rodauth
4
6
  Feature.define(:oauth) do
5
7
  # RUBY EXTENSIONS
@@ -38,11 +40,12 @@ module Rodauth
38
40
  after "authorize_failure"
39
41
 
40
42
  before "token"
41
- after "token"
42
43
 
43
44
  before "revoke"
44
45
  after "revoke"
45
46
 
47
+ before "introspect"
48
+
46
49
  before "create_oauth_application"
47
50
  after "create_oauth_application"
48
51
 
@@ -143,7 +146,7 @@ module Rodauth
143
146
 
144
147
  auth_value_method :oauth_application_default_scope, SCOPES.first
145
148
  auth_value_method :oauth_application_scopes, SCOPES
146
- auth_value_method :oauth_token_type, "Bearer"
149
+ auth_value_method :oauth_token_type, "bearer"
147
150
 
148
151
  auth_value_method :invalid_request, "Request is missing a required parameter"
149
152
  auth_value_method :invalid_client, "Invalid client"
@@ -164,7 +167,14 @@ module Rodauth
164
167
  auth_value_method :unsupported_transform_algorithm_error_code, "invalid_request"
165
168
  auth_value_method :unsupported_transform_algorithm_message, "transform algorithm not supported"
166
169
 
170
+ # METADATA
171
+ auth_value_method :oauth_metadata_service_documentation, nil
172
+ auth_value_method :oauth_metadata_ui_locales_supported, nil
173
+ auth_value_method :oauth_metadata_op_policy_uri, nil
174
+ auth_value_method :oauth_metadata_op_tos_uri, nil
175
+
167
176
  auth_value_methods(
177
+ :fetch_access_token,
168
178
  :oauth_unique_id_generator,
169
179
  :secret_matches?,
170
180
  :secret_hash
@@ -190,7 +200,7 @@ module Rodauth
190
200
 
191
201
  def check_csrf?
192
202
  case request.path
193
- when oauth_token_path
203
+ when oauth_token_path, oauth_introspect_path
194
204
  false
195
205
  when oauth_revoke_path
196
206
  !json_request?
@@ -221,8 +231,6 @@ module Rodauth
221
231
  end
222
232
  end
223
233
 
224
- attr_reader :oauth_application
225
-
226
234
  def initialize(scope)
227
235
  @scope = scope
228
236
  end
@@ -267,23 +275,25 @@ module Rodauth
267
275
  end
268
276
  end
269
277
 
270
- def authorization_token
271
- return @authorization_token if defined?(@authorization_token)
278
+ def fetch_access_token
279
+ value = request.env["HTTP_AUTHORIZATION"]
272
280
 
273
- @authorization_token = begin
274
- value = request.get_header("HTTP_AUTHORIZATION").to_s
281
+ return unless value
275
282
 
276
- scheme, token = value.split(" ", 2)
283
+ scheme, token = value.split(" ", 2)
277
284
 
278
- return unless scheme == "Bearer"
285
+ return unless scheme.downcase == oauth_token_type
279
286
 
280
- # check if there is a token
281
- # check if token has not expired
282
- # check if token has been revoked
283
- oauth_token_by_token(token).where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
284
- .where(oauth_tokens_revoked_at_column => nil)
285
- .first
286
- end
287
+ token
288
+ end
289
+
290
+ def authorization_token
291
+ return @authorization_token if defined?(@authorization_token)
292
+
293
+ # check if there is a token
294
+ # check if token has not expired
295
+ # check if token has been revoked
296
+ @authorization_token = oauth_token_by_token(fetch_access_token)
287
297
  end
288
298
 
289
299
  def require_oauth_authorization(*scopes)
@@ -344,8 +354,44 @@ module Rodauth
344
354
  end
345
355
  end
346
356
 
357
+ def oauth_server_metadata(issuer = nil)
358
+ request.on(".well-known") do
359
+ request.on("oauth-authorization-server") do
360
+ request.get do
361
+ json_response_success(oauth_server_metadata_body(issuer))
362
+ end
363
+ end
364
+ end
365
+ end
366
+
347
367
  private
348
368
 
369
+ # to be used internally. Same semantics as require account, must:
370
+ # fetch an authorization basic header
371
+ # parse client id and secret
372
+ #
373
+ def require_oauth_application
374
+ # get client credenntials
375
+ client_id = client_secret = nil
376
+
377
+ # client_secret_basic
378
+ if (token = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Basic (.*)\Z/, 1]))
379
+ client_id, client_secret = Base64.decode64(token).split(/:/, 2)
380
+ else
381
+ client_id = param_or_nil(client_id_param)
382
+ client_secret = param_or_nil(client_secret_param)
383
+ end
384
+
385
+ authorization_required unless client_id
386
+
387
+ @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
388
+
389
+ # skip if using pkce
390
+ return if @oauth_application && use_oauth_pkce? && param_or_nil(code_verifier_param)
391
+
392
+ authorization_required unless @oauth_application && secret_matches?(@oauth_application, client_secret)
393
+ end
394
+
349
395
  def secret_matches?(oauth_application, secret)
350
396
  BCrypt::Password.new(oauth_application[oauth_applications_client_secret_column]) == secret
351
397
  end
@@ -362,6 +408,10 @@ module Rodauth
362
408
  Base64.urlsafe_encode64(Digest::SHA256.digest(token))
363
409
  end
364
410
 
411
+ def token_from_application?(oauth_token, oauth_application)
412
+ oauth_token[oauth_tokens_oauth_application_id_column] == oauth_application[oauth_applications_id_column]
413
+ end
414
+
365
415
  unless method_defined?(:password_hash)
366
416
  # From login_requirements_base feature
367
417
  if ENV["RACK_ENV"] == "test"
@@ -426,31 +476,35 @@ module Rodauth
426
476
  end
427
477
  end
428
478
 
429
- def oauth_token_by_token(token)
430
- if oauth_tokens_token_hash_column
431
- db[oauth_tokens_table].where(oauth_tokens_token_hash_column => generate_token_hash(token))
432
- else
433
- db[oauth_tokens_table].where(oauth_tokens_token_column => token)
434
- end
479
+ def oauth_token_by_token(token, dataset = db[oauth_tokens_table])
480
+ ds = if oauth_tokens_token_hash_column
481
+ dataset.where(oauth_tokens_token_hash_column => generate_token_hash(token))
482
+ else
483
+ dataset.where(oauth_tokens_token_column => token)
484
+ end
485
+
486
+ ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
487
+ .where(oauth_tokens_revoked_at_column => nil).first
435
488
  end
436
489
 
437
- def oauth_token_by_refresh_token(token)
438
- if oauth_tokens_refresh_token_hash_column
439
- db[oauth_tokens_table].where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
440
- else
441
- db[oauth_tokens_table].where(oauth_tokens_refresh_token_column => token)
442
- end
490
+ def oauth_token_by_refresh_token(token, dataset = db[oauth_tokens_table])
491
+ ds = if oauth_tokens_refresh_token_hash_column
492
+ dataset.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
493
+ else
494
+ dataset.where(oauth_tokens_refresh_token_column => token)
495
+ end
496
+
497
+ ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
498
+ .where(oauth_tokens_revoked_at_column => nil).first
443
499
  end
444
500
 
445
501
  def json_access_token_payload(oauth_token)
446
502
  payload = {
447
503
  "access_token" => oauth_token[oauth_tokens_token_column],
448
- "token_type" => oauth_token_type.downcase,
504
+ "token_type" => oauth_token_type,
449
505
  "expires_in" => oauth_token_expires_in
450
506
  }
451
- if oauth_token[oauth_tokens_refresh_token_column]
452
- payload["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column]
453
- end
507
+ payload["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] if oauth_token[oauth_tokens_refresh_token_column]
454
508
  payload
455
509
  end
456
510
 
@@ -472,9 +526,7 @@ module Rodauth
472
526
  if key == oauth_application_homepage_url_param ||
473
527
  key == oauth_application_redirect_uri_param
474
528
 
475
- unless URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(value)
476
- set_field_error(key, invalid_url_message)
477
- end
529
+ set_field_error(key, invalid_url_message) unless URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(value)
478
530
 
479
531
  elsif key == oauth_application_scopes_param
480
532
 
@@ -617,13 +669,11 @@ module Rodauth
617
669
 
618
670
  # Access Tokens
619
671
 
620
- def validate_oauth_token_params
621
- redirect_response_error("invalid_request") unless param_or_nil(client_id_param)
622
-
623
- unless param_or_nil(client_secret_param)
624
- redirect_response_error("invalid_request") unless param_or_nil(code_verifier_param)
625
- end
672
+ def before_token
673
+ require_oauth_application
674
+ end
626
675
 
676
+ def validate_oauth_token_params
627
677
  unless (grant_type = param_or_nil(grant_type_param))
628
678
  redirect_response_error("invalid_request")
629
679
  end
@@ -640,16 +690,6 @@ module Rodauth
640
690
  end
641
691
 
642
692
  def create_oauth_token
643
- oauth_application = db[oauth_applications_table].where(
644
- oauth_applications_client_id_column => param(client_id_param)
645
- ).first
646
-
647
- redirect_response_error("invalid_request") unless oauth_application
648
-
649
- if (client_secret = param_or_nil(client_secret_param))
650
- redirect_response_error("invalid_request") unless secret_matches?(oauth_application, client_secret)
651
- end
652
-
653
693
  case param(grant_type_param)
654
694
  when "authorization_code"
655
695
  create_oauth_token_from_authorization_code(oauth_application)
@@ -678,9 +718,7 @@ module Rodauth
678
718
  if oauth_grant[oauth_grants_code_challenge_column]
679
719
  code_verifier = param_or_nil(code_verifier_param)
680
720
 
681
- unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier)
682
- redirect_response_error("invalid_request")
683
- end
721
+ redirect_response_error("invalid_request") unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier)
684
722
  elsif oauth_require_pkce
685
723
  redirect_response_error("code_challenge_required")
686
724
  end
@@ -705,11 +743,9 @@ module Rodauth
705
743
 
706
744
  def create_oauth_token_from_token(oauth_application)
707
745
  # fetch oauth token
708
- oauth_token = oauth_token_by_refresh_token(param(refresh_token_param)).where(
709
- oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column]
710
- ).where(oauth_grants_revoked_at_column => nil).for_update.first
746
+ oauth_token = oauth_token_by_refresh_token(param(refresh_token_param))
711
747
 
712
- redirect_response_error("invalid_grant") unless oauth_token
748
+ redirect_response_error("invalid_grant") unless oauth_token && token_from_application?(oauth_token, oauth_application)
713
749
 
714
750
  token = oauth_unique_id_generator
715
751
 
@@ -741,42 +777,57 @@ module Rodauth
741
777
  oauth_token
742
778
  end
743
779
 
780
+ TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze
781
+
782
+ # Token introspect
783
+
784
+ def validate_oauth_introspect_params
785
+ # check if valid token hint type
786
+ if token_type_hint
787
+ redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(token_type_hint)
788
+ end
789
+
790
+ redirect_response_error("invalid_request") unless param_or_nil(token_param)
791
+ end
792
+
793
+ def json_token_introspect_payload(token)
794
+ return { active: false } unless token
795
+
796
+ {
797
+ active: true,
798
+ scope: token[oauth_tokens_scopes_column].gsub(",", " "),
799
+ client_id: oauth_application[oauth_applications_client_id_column],
800
+ # username
801
+ token_type: oauth_token_type
802
+ }
803
+ end
804
+
805
+ def before_introspect
806
+ require_oauth_application
807
+ end
808
+
744
809
  # Token revocation
745
810
 
746
811
  def before_revoke
747
- require_account
812
+ require_oauth_application
748
813
  end
749
814
 
750
- TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze
751
-
752
815
  def validate_oauth_revoke_params
753
816
  # check if valid token hint type
754
817
  redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(token_type_hint)
755
818
 
756
- redirect_response_error("invalid_request") unless param(token_param)
819
+ redirect_response_error("invalid_request") unless param_or_nil(token_param)
757
820
  end
758
821
 
759
822
  def revoke_oauth_token
760
- ds = case token_type_hint
761
- when "access_token"
762
- oauth_token_by_token(token)
763
- when "refresh_token"
764
- oauth_token_by_refresh_token(token)
765
- end
766
- # one can only revoke tokens which haven't been revoked before, and which are
767
- # either our tokens, or tokens from applications we own.
768
- oauth_token = ds.where(oauth_tokens_revoked_at_column => nil)
769
- .where(
770
- Sequel.or(
771
- oauth_tokens_account_id_column => account_id,
772
- oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where(
773
- oauth_applications_client_id_column => param(client_id_param),
774
- oauth_applications_account_id_column => account_id
775
- ).select(oauth_applications_id_column)
776
- )
777
- ).for_update.first
778
-
779
- redirect_response_error("invalid_request") unless oauth_token
823
+ oauth_token = case token_type_hint
824
+ when "access_token"
825
+ oauth_token_by_token(token)
826
+ when "refresh_token"
827
+ oauth_token_by_refresh_token(token)
828
+ end
829
+
830
+ redirect_response_error("invalid_request") unless oauth_token && token_from_application?(oauth_token, oauth_application)
780
831
 
781
832
  update_params = { oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP }
782
833
 
@@ -827,6 +878,14 @@ module Rodauth
827
878
  end
828
879
  end
829
880
 
881
+ def json_response_success(body)
882
+ response.status = 200
883
+ response["Content-Type"] ||= json_response_content_type
884
+ json_payload = _json_response_body(body)
885
+ response.write(json_payload)
886
+ request.halt
887
+ end
888
+
830
889
  def throw_json_response_error(status, error_code)
831
890
  set_response_error_status(status)
832
891
  code = if respond_to?(:"#{error_code}_error_code")
@@ -838,7 +897,7 @@ module Rodauth
838
897
  payload["error_description"] = send(:"#{error_code}_message") if respond_to?(:"#{error_code}_message")
839
898
  json_payload = _json_response_body(payload)
840
899
  response["Content-Type"] ||= json_response_content_type
841
- response["WWW-Authenticate"] = oauth_token_type if status == 401
900
+ response["WWW-Authenticate"] = oauth_token_type.upcase if status == 401
842
901
  response.write(json_payload)
843
902
  request.halt
844
903
  end
@@ -930,6 +989,43 @@ module Rodauth
930
989
  end
931
990
  end
932
991
 
992
+ # Server metadata
993
+
994
+ def oauth_server_metadata_body(path)
995
+ issuer = base_url
996
+ issuer += "/#{path}" if issuer
997
+
998
+ responses_supported = %w[code]
999
+ response_modes_supported = %w[query]
1000
+ grant_types_supported = %w[authorization_code]
1001
+
1002
+ if use_oauth_implicit_grant_type?
1003
+ responses_supported << "token"
1004
+ response_modes_supported << "fragment"
1005
+ grant_types_supported << "implicit"
1006
+ end
1007
+ {
1008
+ issuer: issuer,
1009
+ authorization_endpoint: oauth_authorize_url,
1010
+ token_endpoint: oauth_token_url,
1011
+ registration_endpoint: "#{base_url}/#{oauth_applications_path}",
1012
+ scopes_supported: oauth_application_scopes,
1013
+ response_types_supported: responses_supported,
1014
+ response_modes_supported: response_modes_supported,
1015
+ grant_types_supported: grant_types_supported,
1016
+ token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
1017
+ service_documentation: oauth_metadata_service_documentation,
1018
+ ui_locales_supported: oauth_metadata_ui_locales_supported,
1019
+ op_policy_uri: oauth_metadata_op_policy_uri,
1020
+ op_tos_uri: oauth_metadata_op_tos_uri,
1021
+ revocation_endpoint: oauth_revoke_url,
1022
+ revocation_endpoint_auth_methods_supported: nil, # because it's client_secret_basic
1023
+ introspection_endpoint: oauth_introspect_url,
1024
+ introspection_endpoint_auth_methods_supported: %w[client_secret_basic],
1025
+ code_challenge_methods_supported: (use_oauth_pkce? ? oauth_pkce_challenge_method : nil)
1026
+ }
1027
+ end
1028
+
933
1029
  # /oauth-token
934
1030
  route(:oauth_token) do |r|
935
1031
  before_token
@@ -941,14 +1037,35 @@ module Rodauth
941
1037
  oauth_token = nil
942
1038
  transaction do
943
1039
  oauth_token = create_oauth_token
944
- after_token
945
1040
  end
946
1041
 
947
- response.status = 200
948
- response["Content-Type"] ||= json_response_content_type
949
- json_payload = _json_response_body(json_access_token_payload(oauth_token))
950
- response.write(json_payload)
951
- request.halt
1042
+ json_response_success(json_access_token_payload(oauth_token))
1043
+ end
1044
+
1045
+ throw_json_response_error(invalid_oauth_response_status, "invalid_request")
1046
+ end
1047
+ end
1048
+
1049
+ # /oauth-introspect
1050
+ route(:oauth_introspect) do |r|
1051
+ before_introspect
1052
+
1053
+ r.post do
1054
+ catch_error do
1055
+ validate_oauth_introspect_params
1056
+
1057
+ oauth_token = case param(token_type_hint_param)
1058
+ when "access_token"
1059
+ oauth_token_by_token(param(token_param))
1060
+ when "refresh_token"
1061
+ oauth_token_by_refresh_token(param(token_param))
1062
+ else
1063
+ oauth_token_by_token(param(token_param)) || oauth_token_by_refresh_token(param(token_param))
1064
+ end
1065
+
1066
+ redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application)
1067
+
1068
+ json_response_success(json_token_introspect_payload(oauth_token))
952
1069
  end
953
1070
 
954
1071
  throw_json_response_error(invalid_oauth_response_status, "invalid_request")
@@ -957,7 +1074,6 @@ module Rodauth
957
1074
 
958
1075
  # /oauth-revoke
959
1076
  route(:oauth_revoke) do |r|
960
- require_account
961
1077
  before_revoke
962
1078
 
963
1079
  # access-token
@@ -972,16 +1088,10 @@ module Rodauth
972
1088
  end
973
1089
 
974
1090
  if accepts_json?
975
- response.status = 200
976
- response["Content-Type"] ||= json_response_content_type
977
- json_response = {
1091
+ json_response_success \
978
1092
  "token" => oauth_token[oauth_tokens_token_column],
979
1093
  "refresh_token" => oauth_token[oauth_tokens_refresh_token_column],
980
1094
  "revoked_at" => oauth_token[oauth_tokens_revoked_at_column]
981
- }
982
- json_payload = _json_response_body(json_response)
983
- response.write(json_payload)
984
- request.halt
985
1095
  else
986
1096
  set_notice_flash revoke_oauth_token_notice_flash
987
1097
  redirect request.referer || "/"
@@ -47,9 +47,6 @@ module Rodauth
47
47
  mac_attributes = parse_mac_authorization_header_props(token)
48
48
 
49
49
  oauth_token = oauth_token_by_token(mac_attributes["id"])
50
- .where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
51
- .where(oauth_tokens_revoked_at_column => nil)
52
- .first
53
50
 
54
51
  return unless oauth_token && mac_signature_matches?(oauth_token, mac_attributes)
55
52
 
@@ -4,6 +4,9 @@ module Rodauth
4
4
  Feature.define(:oauth_jwt) do
5
5
  depends :oauth
6
6
 
7
+ auth_value_method :grant_type_param, "grant_type"
8
+ auth_value_method :assertion_param, "assertion"
9
+
7
10
  auth_value_method :oauth_jwt_token_issuer, "Example"
8
11
 
9
12
  auth_value_method :oauth_jwt_key, nil
@@ -23,9 +26,9 @@ module Rodauth
23
26
  auth_value_method :oauth_jwt_audience, nil
24
27
 
25
28
  auth_value_methods(
26
- :generate_jti,
27
29
  :jwt_encode,
28
- :jwt_decode
30
+ :jwt_decode,
31
+ :jwks_set
29
32
  )
30
33
 
31
34
  def require_oauth_authorization(*scopes)
@@ -33,7 +36,7 @@ module Rodauth
33
36
 
34
37
  scopes << oauth_application_default_scope if scopes.empty?
35
38
 
36
- token_scopes = authorization_token["scopes"].split(",")
39
+ token_scopes = authorization_token["scope"].split(" ")
37
40
 
38
41
  authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
39
42
  end
@@ -43,17 +46,54 @@ module Rodauth
43
46
  def authorization_token
44
47
  return @authorization_token if defined?(@authorization_token)
45
48
 
46
- @authorization_token = begin
47
- value = request.get_header("HTTP_AUTHORIZATION").to_s
49
+ @authorization_token = jwt_decode(fetch_access_token)
50
+ end
51
+
52
+ # /token
48
53
 
49
- scheme, token = value.split(/ +/, 2)
54
+ def before_token
55
+ # requset authentication optional for assertions
56
+ return if param(grant_type_param) == "urn:ietf:params:oauth:grant-type:jwt-bearer"
50
57
 
51
- return unless scheme == "Bearer"
58
+ super
59
+ end
52
60
 
53
- jwt_decode(token)
61
+ def validate_oauth_token_params
62
+ if param(grant_type_param) == "urn:ietf:params:oauth:grant-type:jwt-bearer"
63
+ redirect_response_error("invalid_client") unless param_or_nil(assertion_param)
64
+ else
65
+ super
54
66
  end
55
67
  end
56
68
 
69
+ def create_oauth_token
70
+ if param(grant_type_param) == "urn:ietf:params:oauth:grant-type:jwt-bearer"
71
+ create_oauth_token_from_assertion
72
+ else
73
+ super
74
+ end
75
+ end
76
+
77
+ def create_oauth_token_from_assertion
78
+ claims = jwt_decode(param(assertion_param))
79
+
80
+ redirect_response_error("invalid_grant") unless claims
81
+
82
+ @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
83
+
84
+ account = account_ds(claims["sub"]).first
85
+
86
+ redirect_response_error("invalid_client") unless oauth_application && account
87
+
88
+ create_params = {
89
+ oauth_tokens_account_id_column => claims["sub"],
90
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
91
+ oauth_tokens_scopes_column => claims["scope"]
92
+ }
93
+
94
+ generate_oauth_token(create_params, false)
95
+ end
96
+
57
97
  def generate_oauth_token(params = {}, should_generate_refresh_token = true)
58
98
  create_params = {
59
99
  oauth_grants_expires_in_column => Time.now + oauth_token_expires_in
@@ -86,14 +126,14 @@ module Rodauth
86
126
  # owner is involved, such as the client credentials grant, the value
87
127
  # of "sub" SHOULD correspond to an identifier the authorization
88
128
  # server uses to indicate the client application.
89
- client_id: oauth_token[oauth_tokens_oauth_application_id_column],
129
+ client_id: oauth_application[oauth_applications_client_id_column],
90
130
 
91
131
  exp: issued_at + oauth_token_expires_in,
92
132
  aud: oauth_jwt_audience,
93
133
 
94
134
  # one of the points of using jwt is avoiding database lookups, so we put here all relevant
95
135
  # token data.
96
- scopes: oauth_token[oauth_tokens_scopes_column]
136
+ scope: oauth_token[oauth_tokens_scopes_column].gsub(",", " ")
97
137
  }
98
138
 
99
139
  token = jwt_encode(payload)
@@ -102,6 +142,45 @@ module Rodauth
102
142
  oauth_token
103
143
  end
104
144
 
145
+ def oauth_token_by_token(token, *)
146
+ jwt_decode(token)
147
+ end
148
+
149
+ def json_token_introspect_payload(oauth_token)
150
+ return { active: false } unless oauth_token
151
+
152
+ return super unless oauth_token["sub"] # naive check on whether it's a jwt token
153
+
154
+ {
155
+ active: true,
156
+ scope: oauth_token["scope"],
157
+ client_id: oauth_token["client_id"],
158
+ # username
159
+ token_type: "access_token",
160
+ exp: oauth_token["exp"],
161
+ iat: oauth_token["iat"],
162
+ nbf: oauth_token["nbf"],
163
+ sub: oauth_token["sub"],
164
+ aud: oauth_token["aud"],
165
+ iss: oauth_token["iss"],
166
+ jti: oauth_token["jti"]
167
+ }
168
+ end
169
+
170
+ def oauth_server_metadata_body(path)
171
+ metadata = super
172
+ metadata.merge! \
173
+ jwks_uri: oauth_jwks_url,
174
+ token_endpoint_auth_signing_alg_values_supported: [oauth_jwt_algorithm]
175
+ metadata
176
+ end
177
+
178
+ def token_from_application?(oauth_token, oauth_application)
179
+ return super unless oauth_token["sub"] # naive check on whether it's a jwt token
180
+
181
+ oauth_token["client_id"] == oauth_application[oauth_applications_client_id_column]
182
+ end
183
+
105
184
  def _jwt_key
106
185
  @_jwt_key ||= oauth_jwt_key || oauth_application[oauth_applications_client_secret_column]
107
186
  end
@@ -130,16 +209,26 @@ module Rodauth
130
209
  end
131
210
 
132
211
  def jwt_decode(token)
212
+ return @jwt_token if defined?(@jwt_token)
213
+
133
214
  token = JSON::JWT.decode(token, oauth_jwt_jwe_key).plain_text if oauth_jwt_jwe_key
134
- if oauth_jwt_jwk_key
135
- jwk = JSON::JWK.new(oauth_jwt_jwk_key)
136
- JSON::JWT.decode(token, jwk)
137
- else
138
- JSON::JWT.decode(token, oauth_jwt_public_key || _jwt_key)
139
- end
215
+
216
+ @jwt_token = if oauth_jwt_jwk_key
217
+ jwk = JSON::JWK.new(oauth_jwt_jwk_public_key || oauth_jwt_jwk_key)
218
+ JSON::JWT.decode(token, jwk)
219
+ else
220
+ JSON::JWT.decode(token, oauth_jwt_public_key || _jwt_key)
221
+ end
140
222
  rescue JSON::JWT::Exception
141
223
  nil
142
224
  end
225
+
226
+ def jwks_set
227
+ [
228
+ (JSON::JWK.new(oauth_jwt_jwk_public_key).merge(use: "sig", alg: oauth_jwt_jwk_algorithm) if oauth_jwt_jwk_public_key),
229
+ (JSON::JWK.new(oauth_jwt_jwe_public_key).merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
230
+ ].compact
231
+ end
143
232
  # :nocov:
144
233
  elsif defined?(JWT)
145
234
 
@@ -163,7 +252,7 @@ module Rodauth
163
252
 
164
253
  # Use the key and iat to create a unique key per request to prevent replay attacks
165
254
  jti_raw = [key, payload[:iat]].join(":").to_s
166
- jti = Digest::MD5.hexdigest(jti_raw)
255
+ jti = Digest::SHA256.hexdigest(jti_raw)
167
256
 
168
257
  # @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
169
258
  payload[:jti] = jti
@@ -183,6 +272,8 @@ module Rodauth
183
272
  end
184
273
 
185
274
  def jwt_decode(token)
275
+ return @jwt_token if defined?(@jwt_token)
276
+
186
277
  # decrypt jwe
187
278
  token = JWE.decrypt(token, oauth_jwt_jwe_key) if oauth_jwt_jwe_key
188
279
 
@@ -190,7 +281,7 @@ module Rodauth
190
281
  headers = { algorithms: [oauth_jwt_algorithm] }
191
282
 
192
283
  key = if oauth_jwt_jwk_key
193
- jwk_key = JWT::JWK.new(oauth_jwt_jwk_key)
284
+ jwk_key = JWT::JWK.new(oauth_jwt_jwk_public_key || oauth_jwt_jwk_key)
194
285
  # JWK
195
286
  # The jwk loader would fetch the set of JWKs from a trusted source
196
287
  jwk_loader = lambda do |options|
@@ -207,12 +298,18 @@ module Rodauth
207
298
  # worst case scenario, the key is the application key
208
299
  oauth_jwt_public_key || _jwt_key
209
300
  end
210
- token, = JWT.decode(token, key, true, headers)
211
- token
301
+ @jwt_token, = JWT.decode(token, key, true, headers)
302
+ @jwt_token
212
303
  rescue JWT::DecodeError
213
304
  nil
214
305
  end
215
306
 
307
+ def jwks_set
308
+ [
309
+ (JWT::JWK.new(oauth_jwt_jwk_public_key).export.merge(use: "sig", alg: oauth_jwt_jwk_algorithm) if oauth_jwt_jwk_public_key),
310
+ (JWT::JWK.new(oauth_jwt_jwe_public_key).export.merge(use: "enc", alg: oauth_jwt_jwe_algorithm) if oauth_jwt_jwe_public_key)
311
+ ].compact
312
+ end
216
313
  else
217
314
  # :nocov:
218
315
  def jwt_encode(_token)
@@ -222,7 +319,17 @@ module Rodauth
222
319
  def jwt_decode(_token)
223
320
  raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
224
321
  end
322
+
323
+ def jwks_set
324
+ raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
325
+ end
225
326
  # :nocov:
226
327
  end
328
+
329
+ route(:oauth_jwks) do |r|
330
+ r.get do
331
+ json_response_success(jwks_set)
332
+ end
333
+ end
227
334
  end
228
335
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.0.3"
5
+ VERSION = "0.0.4"
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.3
4
+ version: 0.0.4
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-06-05 00:00:00.000000000 Z
11
+ date: 2020-06-13 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: