rodauth-oauth 0.0.3 → 0.0.4

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: 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: