descope 1.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yaml +54 -0
- data/.gitignore +59 -0
- data/.release-please-manifest.json +3 -0
- data/.rubocop.yml +10 -0
- data/.rubocop_todo.yml +10 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +90 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +204 -0
- data/LICENSE +21 -0
- data/README.md +1171 -0
- data/Rakefile +31 -0
- data/descope.gemspec +34 -0
- data/examples/ruby/Gemfile +4 -0
- data/examples/ruby/Gemfile.lock +41 -0
- data/examples/ruby/access_key_app.rb +45 -0
- data/examples/ruby/enchantedlink_app.rb +65 -0
- data/examples/ruby/magiclink_app.rb +81 -0
- data/examples/ruby/management/Gemfile +5 -0
- data/examples/ruby/management/Gemfile.lock +38 -0
- data/examples/ruby/management/access_key_app.rb +71 -0
- data/examples/ruby/management/audit_app.rb +25 -0
- data/examples/ruby/management/authz_app.rb +135 -0
- data/examples/ruby/management/authz_files.json +229 -0
- data/examples/ruby/management/flow_app.rb +57 -0
- data/examples/ruby/management/permission_app.rb +56 -0
- data/examples/ruby/management/role_app.rb +58 -0
- data/examples/ruby/management/tenant_app.rb +60 -0
- data/examples/ruby/management/user_app.rb +60 -0
- data/examples/ruby/oauth_app.rb +39 -0
- data/examples/ruby/otp_app.rb +50 -0
- data/examples/ruby/password_app.rb +76 -0
- data/examples/ruby/saml_app.rb +38 -0
- data/examples/ruby-on-rails-api/descope/.dockerignore +37 -0
- data/examples/ruby-on-rails-api/descope/.gitattributes +9 -0
- data/examples/ruby-on-rails-api/descope/.gitignore +40 -0
- data/examples/ruby-on-rails-api/descope/.node-version +1 -0
- data/examples/ruby-on-rails-api/descope/.ruby-version +1 -0
- data/examples/ruby-on-rails-api/descope/Dockerfile +75 -0
- data/examples/ruby-on-rails-api/descope/Gemfile +67 -0
- data/examples/ruby-on-rails-api/descope/Gemfile.lock +284 -0
- data/examples/ruby-on-rails-api/descope/Procfile.dev +3 -0
- data/examples/ruby-on-rails-api/descope/README.md +54 -0
- data/examples/ruby-on-rails-api/descope/Rakefile +6 -0
- data/examples/ruby-on-rails-api/descope/app/assets/builds/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/app/assets/config/manifest.js +3 -0
- data/examples/ruby-on-rails-api/descope/app/assets/images/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/app/assets/images/descope.jpeg +0 -0
- data/examples/ruby-on-rails-api/descope/app/assets/images/favicon.ico +0 -0
- data/examples/ruby-on-rails-api/descope/app/assets/images/logo192.png +0 -0
- data/examples/ruby-on-rails-api/descope/app/assets/images/logo512.png +0 -0
- data/examples/ruby-on-rails-api/descope/app/assets/stylesheets/application.bootstrap.scss +67 -0
- data/examples/ruby-on-rails-api/descope/app/channels/application_cable/channel.rb +4 -0
- data/examples/ruby-on-rails-api/descope/app/channels/application_cable/connection.rb +4 -0
- data/examples/ruby-on-rails-api/descope/app/controllers/application_controller.rb +2 -0
- data/examples/ruby-on-rails-api/descope/app/controllers/concerns/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/app/controllers/homepage_controller.rb +4 -0
- data/examples/ruby-on-rails-api/descope/app/controllers/session_controller.rb +66 -0
- data/examples/ruby-on-rails-api/descope/app/helpers/application_helper.rb +2 -0
- data/examples/ruby-on-rails-api/descope/app/helpers/homepage_helper.rb +2 -0
- data/examples/ruby-on-rails-api/descope/app/helpers/session_helper.rb +2 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/App.css +53 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/application.js +5 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/App.jsx +4 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/Dashboard.jsx +60 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/Home.jsx +27 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/Login.jsx +45 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/Profile.jsx +81 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/index.html +11 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/components/index.jsx +24 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/controllers/application.js +9 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/controllers/index.js +5 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/reportWebVitals.js +13 -0
- data/examples/ruby-on-rails-api/descope/app/javascript/routes/index.jsx +17 -0
- data/examples/ruby-on-rails-api/descope/app/jobs/application_job.rb +7 -0
- data/examples/ruby-on-rails-api/descope/app/mailers/application_mailer.rb +4 -0
- data/examples/ruby-on-rails-api/descope/app/models/application_record.rb +3 -0
- data/examples/ruby-on-rails-api/descope/app/models/concerns/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/app/views/homepage/index.html.erb +2 -0
- data/examples/ruby-on-rails-api/descope/app/views/layouts/application.html.erb +16 -0
- data/examples/ruby-on-rails-api/descope/app/views/layouts/mailer.html.erb +13 -0
- data/examples/ruby-on-rails-api/descope/app/views/layouts/mailer.text.erb +1 -0
- data/examples/ruby-on-rails-api/descope/app/views/session/index.html.erb +2 -0
- data/examples/ruby-on-rails-api/descope/bin/bundle +109 -0
- data/examples/ruby-on-rails-api/descope/bin/dev +11 -0
- data/examples/ruby-on-rails-api/descope/bin/docker-entrypoint +8 -0
- data/examples/ruby-on-rails-api/descope/bin/rails +4 -0
- data/examples/ruby-on-rails-api/descope/bin/rake +4 -0
- data/examples/ruby-on-rails-api/descope/bin/setup +36 -0
- data/examples/ruby-on-rails-api/descope/build.js +30 -0
- data/examples/ruby-on-rails-api/descope/config/application.rb +42 -0
- data/examples/ruby-on-rails-api/descope/config/boot.rb +4 -0
- data/examples/ruby-on-rails-api/descope/config/cable.yml +10 -0
- data/examples/ruby-on-rails-api/descope/config/config.yml +9 -0
- data/examples/ruby-on-rails-api/descope/config/credentials.yml.enc +1 -0
- data/examples/ruby-on-rails-api/descope/config/database.yml +25 -0
- data/examples/ruby-on-rails-api/descope/config/environment.rb +5 -0
- data/examples/ruby-on-rails-api/descope/config/environments/development.rb +76 -0
- data/examples/ruby-on-rails-api/descope/config/environments/production.rb +97 -0
- data/examples/ruby-on-rails-api/descope/config/environments/test.rb +64 -0
- data/examples/ruby-on-rails-api/descope/config/initializers/assets.rb +13 -0
- data/examples/ruby-on-rails-api/descope/config/initializers/content_security_policy.rb +25 -0
- data/examples/ruby-on-rails-api/descope/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/ruby-on-rails-api/descope/config/initializers/inflections.rb +16 -0
- data/examples/ruby-on-rails-api/descope/config/initializers/load_config.rb +12 -0
- data/examples/ruby-on-rails-api/descope/config/initializers/permissions_policy.rb +13 -0
- data/examples/ruby-on-rails-api/descope/config/locales/en.yml +31 -0
- data/examples/ruby-on-rails-api/descope/config/puma.rb +35 -0
- data/examples/ruby-on-rails-api/descope/config/routes.rb +18 -0
- data/examples/ruby-on-rails-api/descope/config/storage.yml +34 -0
- data/examples/ruby-on-rails-api/descope/config.ru +6 -0
- data/examples/ruby-on-rails-api/descope/db/seeds.rb +9 -0
- data/examples/ruby-on-rails-api/descope/lib/assets/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/lib/tasks/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/log/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/package-lock.json +19680 -0
- data/examples/ruby-on-rails-api/descope/package.json +51 -0
- data/examples/ruby-on-rails-api/descope/public/404.html +67 -0
- data/examples/ruby-on-rails-api/descope/public/422.html +67 -0
- data/examples/ruby-on-rails-api/descope/public/500.html +66 -0
- data/examples/ruby-on-rails-api/descope/public/apple-touch-icon-precomposed.png +0 -0
- data/examples/ruby-on-rails-api/descope/public/apple-touch-icon.png +0 -0
- data/examples/ruby-on-rails-api/descope/public/favicon.ico +0 -0
- data/examples/ruby-on-rails-api/descope/public/robots.txt +1 -0
- data/examples/ruby-on-rails-api/descope/storage/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/tmp/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/tmp/pids/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/tmp/storage/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/vendor/.keep +0 -0
- data/examples/ruby-on-rails-api/descope/yarn.lock +10780 -0
- data/lib/descope/api/v1/auth/enchantedlink.rb +156 -0
- data/lib/descope/api/v1/auth/magiclink.rb +170 -0
- data/lib/descope/api/v1/auth/oauth.rb +72 -0
- data/lib/descope/api/v1/auth/otp.rb +186 -0
- data/lib/descope/api/v1/auth/password.rb +100 -0
- data/lib/descope/api/v1/auth/saml.rb +48 -0
- data/lib/descope/api/v1/auth/totp.rb +72 -0
- data/lib/descope/api/v1/auth.rb +452 -0
- data/lib/descope/api/v1/management/access_key.rb +81 -0
- data/lib/descope/api/v1/management/audit.rb +82 -0
- data/lib/descope/api/v1/management/authz.rb +165 -0
- data/lib/descope/api/v1/management/common.rb +147 -0
- data/lib/descope/api/v1/management/flow.rb +55 -0
- data/lib/descope/api/v1/management/password.rb +58 -0
- data/lib/descope/api/v1/management/permission.rb +48 -0
- data/lib/descope/api/v1/management/project.rb +53 -0
- data/lib/descope/api/v1/management/role.rb +48 -0
- data/lib/descope/api/v1/management/scim.rb +206 -0
- data/lib/descope/api/v1/management/sso_settings.rb +153 -0
- data/lib/descope/api/v1/management/tenant.rb +71 -0
- data/lib/descope/api/v1/management/user.rb +619 -0
- data/lib/descope/api/v1/management.rb +38 -0
- data/lib/descope/api/v1/session.rb +84 -0
- data/lib/descope/api/v1.rb +13 -0
- data/lib/descope/client.rb +6 -0
- data/lib/descope/exception.rb +50 -0
- data/lib/descope/mixins/common.rb +129 -0
- data/lib/descope/mixins/headers.rb +15 -0
- data/lib/descope/mixins/http.rb +133 -0
- data/lib/descope/mixins/initializer.rb +80 -0
- data/lib/descope/mixins/logging.rb +30 -0
- data/lib/descope/mixins/validation.rb +79 -0
- data/lib/descope/mixins.rb +22 -0
- data/lib/descope/version.rb +7 -0
- data/lib/descope.rb +9 -0
- data/lib/descope_client.rb +5 -0
- data/release-please-config.json +18 -0
- data/renovate.json +6 -0
- data/spec/factories/user.rb +16 -0
- data/spec/lib.descope/api/v1/auth/enchantedlink_spec.rb +159 -0
- data/spec/lib.descope/api/v1/auth/magiclink_spec.rb +282 -0
- data/spec/lib.descope/api/v1/auth/oauth_spec.rb +117 -0
- data/spec/lib.descope/api/v1/auth/otp_spec.rb +285 -0
- data/spec/lib.descope/api/v1/auth/password_spec.rb +124 -0
- data/spec/lib.descope/api/v1/auth/saml_spec.rb +55 -0
- data/spec/lib.descope/api/v1/auth/totp_spec.rb +70 -0
- data/spec/lib.descope/api/v1/auth_spec.rb +372 -0
- data/spec/lib.descope/api/v1/management/access_key_spec.rb +118 -0
- data/spec/lib.descope/api/v1/management/audit_spec.rb +78 -0
- data/spec/lib.descope/api/v1/management/authz_spec.rb +336 -0
- data/spec/lib.descope/api/v1/management/flow_spec.rb +78 -0
- data/spec/lib.descope/api/v1/management/password_spec.rb +25 -0
- data/spec/lib.descope/api/v1/management/permission_spec.rb +81 -0
- data/spec/lib.descope/api/v1/management/project_spec.rb +63 -0
- data/spec/lib.descope/api/v1/management/role_spec.rb +85 -0
- data/spec/lib.descope/api/v1/management/scim_spec.rb +312 -0
- data/spec/lib.descope/api/v1/management/sso_settings_spec.rb +172 -0
- data/spec/lib.descope/api/v1/management/tenant_spec.rb +141 -0
- data/spec/lib.descope/api/v1/management/user_spec.rb +667 -0
- data/spec/lib.descope/api/v1/session_spec.rb +117 -0
- data/spec/lib.descope/client_spec.rb +40 -0
- data/spec/spec_helper.rb +72 -0
- data/spec/support/client_config.rb +14 -0
- data/spec/support/dummy_class.rb +36 -0
- data/spec/support/utils.rb +32 -0
- metadata +420 -0
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cgi'
|
4
|
+
|
5
|
+
module Descope
|
6
|
+
module Api
|
7
|
+
module V1
|
8
|
+
module Auth
|
9
|
+
# Holds all the password API calls
|
10
|
+
module SAML
|
11
|
+
include Descope::Mixins::Validation
|
12
|
+
include Descope::Mixins::Common::EndpointsV1
|
13
|
+
include Descope::Mixins::Common::EndpointsV2
|
14
|
+
|
15
|
+
# rubocop:disable Metrics/AbcSize
|
16
|
+
def saml_sign_in(tenant: nil, redirect_url: nil, prompt: nil, stepup: false,
|
17
|
+
mfa: false, custom_claims: {}, sso_app_id: nil)
|
18
|
+
validate_tenant(tenant)
|
19
|
+
validate_redirect_url(redirect_url)
|
20
|
+
uri = compose_saml_signin_url(tenant, redirect_url, prompt)
|
21
|
+
|
22
|
+
request_params = {}
|
23
|
+
request_params[:stepup] = stepup
|
24
|
+
request_params[:mfa] = mfa
|
25
|
+
request_params[:customClaims] = custom_claims
|
26
|
+
request_params[:ssoAppId] = sso_app_id unless sso_app_id.nil?
|
27
|
+
|
28
|
+
post(uri, request_params)
|
29
|
+
end
|
30
|
+
|
31
|
+
def saml_exchange_token(code = nil)
|
32
|
+
exchange_token(SAML_EXCHANGE_TOKEN_PATH, code)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def compose_saml_signin_url(tenant, redirect_url, prompt)
|
38
|
+
uri = AUTH_SAML_START_PATH
|
39
|
+
uri += "?tenant=#{CGI.escape(tenant)}" unless tenant.nil?
|
40
|
+
uri += "&redirectUrl=#{CGI.escape(redirect_url)}" unless redirect_url.nil?
|
41
|
+
uri += "&prompt=#{CGI.escape(prompt)}" unless prompt.nil?
|
42
|
+
uri
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Descope
|
4
|
+
module Api
|
5
|
+
module V1
|
6
|
+
module Auth
|
7
|
+
# Holds all the password API calls
|
8
|
+
module TOTP
|
9
|
+
include Descope::Mixins::Validation
|
10
|
+
include Descope::Mixins::Common::EndpointsV1
|
11
|
+
include Descope::Mixins::Common::EndpointsV2
|
12
|
+
|
13
|
+
def totp_sign_in_code(login_id: nil, login_options: nil, code: nil)
|
14
|
+
# Sign in by verifying the validity of a TOTP code entered by an end user.
|
15
|
+
validate_login_id(login_id)
|
16
|
+
validate_code(code)
|
17
|
+
uri = VERIFY_TOTP_PATH
|
18
|
+
body = totp_compose_signin_body(login_id, code, login_options)
|
19
|
+
res = post(uri, body, {}, nil)
|
20
|
+
generate_jwt_response(response_body: res, refresh_cookie: res.fetch('refreshJwt', {}))
|
21
|
+
end
|
22
|
+
|
23
|
+
def totp_sign_up(login_id: nil, user: nil, sso_app_id: nil)
|
24
|
+
# Sign up (create) a new user using their email or phone number.
|
25
|
+
# (optional) Include additional user metadata that you wish to save.
|
26
|
+
user ||= {}
|
27
|
+
validate_login_id(login_id)
|
28
|
+
|
29
|
+
request_params = {
|
30
|
+
loginId: login_id
|
31
|
+
}
|
32
|
+
request_params[:user] = user_compose_update_body(**user) unless user.empty?
|
33
|
+
request_params[:ssoAppId] = sso_app_id unless sso_app_id.nil?
|
34
|
+
post(SIGN_UP_AUTH_TOTP_PATH, request_params)
|
35
|
+
end
|
36
|
+
|
37
|
+
def totp_add_update_key(login_id: nil, refresh_token: nil)
|
38
|
+
# Add or update TOTP key for existing end userUpdate the email address of an end user,
|
39
|
+
# after verifying the authenticity of the end user using OTP.
|
40
|
+
validate_login_id(login_id)
|
41
|
+
post(UPDATE_TOTP_PATH, { loginId: login_id }, {}, refresh_token)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# rubocop:disable Metrics/MethodLength
|
47
|
+
def totp_compose_signin_body(login_id, code, login_options)
|
48
|
+
login_options ||= {}
|
49
|
+
unless login_options.is_a?(Hash)
|
50
|
+
raise Descope::ArgumentException.new(
|
51
|
+
'Unable to read login_option, not a Hash',
|
52
|
+
code: 400
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
body = {
|
57
|
+
loginId: login_id,
|
58
|
+
code:,
|
59
|
+
loginOptions: {}
|
60
|
+
}
|
61
|
+
body[:loginOptions][:stepup] = login_options.fetch(:stepup, false)
|
62
|
+
body[:loginOptions][:mfa] = login_options.fetch(:mfa, false)
|
63
|
+
body[:loginOptions][:customClaims] = login_options.fetch(:custom_claims, {})
|
64
|
+
body[:loginOptions][:ssoAppId] = login_options.fetch(:sso_app_id, nil)
|
65
|
+
|
66
|
+
body
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,452 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'descope/mixins/common'
|
4
|
+
require 'descope/api/v1/auth/password'
|
5
|
+
require 'descope/api/v1/auth/enchantedlink'
|
6
|
+
require 'descope/api/v1/auth/magiclink'
|
7
|
+
require 'descope/api/v1/auth/oauth'
|
8
|
+
require 'descope/api/v1/auth/otp'
|
9
|
+
require 'descope/api/v1/auth/saml'
|
10
|
+
require 'descope/api/v1/auth/totp'
|
11
|
+
|
12
|
+
module Descope
|
13
|
+
module Api
|
14
|
+
module V1
|
15
|
+
# Holds all the management API calls
|
16
|
+
module Auth
|
17
|
+
include Descope::Mixins::Common
|
18
|
+
include Descope::Mixins::Common::EndpointsV1
|
19
|
+
include Descope::Mixins::Common::EndpointsV2
|
20
|
+
include Descope::Api::V1::Auth::Password
|
21
|
+
include Descope::Api::V1::Auth::EnchantedLink
|
22
|
+
include Descope::Api::V1::Auth::MagicLink
|
23
|
+
include Descope::Api::V1::Auth::OAuth
|
24
|
+
include Descope::Api::V1::Auth::OTP
|
25
|
+
include Descope::Api::V1::Auth::SAML
|
26
|
+
include Descope::Api::V1::Auth::TOTP
|
27
|
+
|
28
|
+
|
29
|
+
ALGORITHM_KEY = 'alg'
|
30
|
+
|
31
|
+
def generate_jwt_response(response_body: nil, refresh_cookie: nil, audience: nil)
|
32
|
+
if response_body.nil? || response_body.empty?
|
33
|
+
raise AuthException.new('Unable to generate jwt response. Response body is empty', code: 500)
|
34
|
+
end
|
35
|
+
|
36
|
+
jwt_response = generate_auth_info(response_body, refresh_cookie, true, audience)
|
37
|
+
@logger.debug "jwt_response: #{jwt_response}"
|
38
|
+
jwt_response['user'] = response_body.key?('user') ? response_body['user'] : {}
|
39
|
+
jwt_response['firstSeen'] = response_body.key?('firstSeen') ? response_body['firstSeen'] : true
|
40
|
+
|
41
|
+
jwt_response
|
42
|
+
end
|
43
|
+
|
44
|
+
def exchange_access_key(access_key = nil)
|
45
|
+
post(EXCHANGE_AUTH_ACCESS_KEY_PATH, {}, {}, access_key)
|
46
|
+
end
|
47
|
+
|
48
|
+
def select_tenant(tenant_id: nil, refresh_token: nil)
|
49
|
+
validate_refresh_token_not_nil(refresh_token)
|
50
|
+
res = post(SELECT_TENANT_PATH, { tenantId: tenant_id }, {}, refresh_token)
|
51
|
+
@logger.debug "select_tenant response: #{res}"
|
52
|
+
generate_jwt_response(
|
53
|
+
response_body: res,
|
54
|
+
refresh_cookie: res['refreshJwt']
|
55
|
+
)
|
56
|
+
end
|
57
|
+
|
58
|
+
def validate_permissions(jwt_response: nil, permissions: nil)
|
59
|
+
# Validate that a jwt_response has been granted the specified permissions.
|
60
|
+
# For a multi-tenant environment use validate_tenant_permissions function
|
61
|
+
validate_tenant_permissions(jwt_response:, permissions:)
|
62
|
+
end
|
63
|
+
|
64
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
|
65
|
+
def validate_tenant_permissions(jwt_response: nil, tenant: nil, permissions: nil)
|
66
|
+
# Validate that a jwt_response has been granted the specified permissions on the specified tenant.
|
67
|
+
# For a multi-tenant environment use validate_tenant_permissions function
|
68
|
+
if permissions.is_a?(String)
|
69
|
+
permissions = [permissions]
|
70
|
+
else
|
71
|
+
permissions ||= []
|
72
|
+
end
|
73
|
+
|
74
|
+
unless jwt_response.is_a?(Hash)
|
75
|
+
raise Descope::ArgumentException.new(
|
76
|
+
'Invalid JWT response hash', code: 400
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
return false unless jwt_response
|
81
|
+
|
82
|
+
granted_permissions = if tenant.nil? || tenant.to_s.empty?
|
83
|
+
jwt_response.fetch('permissions', [])
|
84
|
+
else
|
85
|
+
# ensure that the tenant is associated with the jwt_response
|
86
|
+
@logger.debug "tenant associated jwt: #{jwt_response['tenants']&.key?(tenant)}"
|
87
|
+
return false unless jwt_response['tenants'].key?(tenant)
|
88
|
+
|
89
|
+
# dig is a method in Ruby for safely navigating nested data structures like hashes
|
90
|
+
# and arrays. It allows you to access deeply nested values without worrying about
|
91
|
+
# raising an error if a middle value is nil.
|
92
|
+
tenant_permission = jwt_response.dig('tenants', tenant, 'permissions') || []
|
93
|
+
tenant_permission = [] if tenant_permission.nil?
|
94
|
+
if tenant_permission.is_a?(String)
|
95
|
+
@logger.debug "tenant_permission string: #{tenant_permission}"
|
96
|
+
[tenant_permission]
|
97
|
+
else
|
98
|
+
@logger.debug "tenant_permission array: #{tenant_permission}"
|
99
|
+
tenant_permission
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Validate all permissions are granted
|
104
|
+
permissions.all? do |permission|
|
105
|
+
granted_permissions.include?(permission)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def validate_roles(jwt_response: nil, roles: nil)
|
110
|
+
# Validate that a jwt_response has been granted the specified roles.
|
111
|
+
# For a multi-tenant environment use validate_tenant_roles function
|
112
|
+
validate_tenant_roles(jwt_response:, tenant: '', roles:)
|
113
|
+
end
|
114
|
+
|
115
|
+
def validate_tenant_roles(jwt_response: nil, tenant: nil, roles: nil)
|
116
|
+
# Validate that a jwt_response has been granted the specified roles on the specified tenant.
|
117
|
+
# For a multi-tenant environment use validate_tenant_roles function
|
118
|
+
@logger.debug "Validate_tenant_roles: #{jwt_response}, #{tenant}, #{roles}"
|
119
|
+
if roles.is_a?(String)
|
120
|
+
roles = [roles]
|
121
|
+
else
|
122
|
+
roles ||= []
|
123
|
+
end
|
124
|
+
|
125
|
+
unless jwt_response.is_a?(Hash)
|
126
|
+
raise Descope::ArgumentException.new(
|
127
|
+
'Invalid JWT response hash', code: 400
|
128
|
+
)
|
129
|
+
end
|
130
|
+
|
131
|
+
return false unless jwt_response
|
132
|
+
|
133
|
+
granted_roles = if tenant.nil? || tenant.to_s.empty?
|
134
|
+
jwt_response.fetch('roles', [])
|
135
|
+
else
|
136
|
+
# ensure that the tenant is associated with the jwt_response
|
137
|
+
return false unless jwt_response['tenants'].key?(tenant)
|
138
|
+
|
139
|
+
# dig is a method in Ruby for safely navigating nested data structures like hashes
|
140
|
+
# and arrays. It allows you to access deeply nested values without worrying about
|
141
|
+
# raising an error if a middle value is nil.
|
142
|
+
tenant_roles = jwt_response.dig('tenants', tenant, 'roles') || []
|
143
|
+
tenant_roles = [] if tenant_roles.nil?
|
144
|
+
if tenant_roles.is_a?(String)
|
145
|
+
[tenant_roles]
|
146
|
+
else
|
147
|
+
tenant_roles
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
@logger.debug "granted_roles: #{granted_roles}"
|
152
|
+
# Validate all roles are granted
|
153
|
+
roles.all? do |role|
|
154
|
+
@logger.debug "granted_roles.include?(#{role}): #{granted_roles.include?(role)}"
|
155
|
+
granted_roles.include?(role)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def validate_token(token, _audience = nil)
|
160
|
+
@logger.debug "validating token: #{token}"
|
161
|
+
raise AuthException.new('Token validation received empty token', code: 500) if token.nil? || token.to_s.empty?
|
162
|
+
|
163
|
+
unverified_header = jwt_get_unverified_header(token)
|
164
|
+
@logger.debug "unverified_header: #{unverified_header}"
|
165
|
+
alg_header = unverified_header[ALGORITHM_KEY]
|
166
|
+
@logger.debug "alg_header: #{alg_header}"
|
167
|
+
|
168
|
+
if alg_header.nil? || alg_header == 'none'
|
169
|
+
raise AuthException.new('Token header is missing property: alg', code: 500)
|
170
|
+
end
|
171
|
+
|
172
|
+
kid = unverified_header['kid']
|
173
|
+
@logger.debug "kid: #{kid}"
|
174
|
+
raise AuthException.new('Token header is missing property: kid', code: 500) if kid.nil?
|
175
|
+
|
176
|
+
found_key = nil
|
177
|
+
@mlock.synchronize do
|
178
|
+
if @public_keys.nil? || @public_keys == {} || @public_keys.to_s.empty? || @public_keys[kid].nil?
|
179
|
+
@logger.debug 'fetching public keys'
|
180
|
+
# fetch keys from /v2/keys and set them in @public_keys
|
181
|
+
fetch_public_keys
|
182
|
+
end
|
183
|
+
|
184
|
+
found_key = @public_keys[kid]
|
185
|
+
@logger.debug "found_key: #{found_key}"
|
186
|
+
raise AuthException.new('Unable to validate public key. Public key not found.', code: 500) if found_key.nil?
|
187
|
+
end
|
188
|
+
|
189
|
+
# save reference to the found key
|
190
|
+
# (as another thread can change the self.public_keys hash)
|
191
|
+
@logger.debug 'checking if alg_header matches alg_from_key'
|
192
|
+
alg_from_key = found_key[1]
|
193
|
+
if alg_header != alg_from_key
|
194
|
+
raise AuthException.new(
|
195
|
+
'Algorithm signature in JWT header does not match the algorithm signature in the Public key.',
|
196
|
+
code: 500
|
197
|
+
)
|
198
|
+
end
|
199
|
+
|
200
|
+
begin
|
201
|
+
@logger.debug 'decoding token'
|
202
|
+
claims = JWT.decode(
|
203
|
+
token,
|
204
|
+
found_key[0].public_key,
|
205
|
+
true,
|
206
|
+
{ algorithm: alg_header, exp_leeway: @jwt_validation_leeway }
|
207
|
+
)[0] # the payload is the first index in the decoded array
|
208
|
+
rescue JWT::ExpiredSignature => e
|
209
|
+
raise AuthException.new(
|
210
|
+
"Received Invalid token times error due to time glitch (between machines) during jwt validation, try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default: #{e.message}", code: 500
|
211
|
+
)
|
212
|
+
end
|
213
|
+
claims['jwt'] = token
|
214
|
+
@logger.debug "claims: #{claims}"
|
215
|
+
claims
|
216
|
+
end
|
217
|
+
|
218
|
+
private
|
219
|
+
|
220
|
+
def generate_auth_info(response_body, refresh_token, user_jwt, audience = nil)
|
221
|
+
@logger.debug "generating auth info: #{response_body}, #{refresh_token}, #{user_jwt}, #{audience}"
|
222
|
+
jwt_response = {}
|
223
|
+
|
224
|
+
# validate the session token if sessionJwt is not empty
|
225
|
+
st_jwt = response_body.fetch('sessionJwt', '')
|
226
|
+
if st_jwt
|
227
|
+
jwt_response[SESSION_TOKEN_NAME] = validate_token(st_jwt, audience)
|
228
|
+
end
|
229
|
+
|
230
|
+
# validate refresh token if refresh_token was passed or if refreshJwt is not empty
|
231
|
+
rt_jwt = response_body.fetch('refreshJwt', '')
|
232
|
+
|
233
|
+
if refresh_token
|
234
|
+
jwt_response[REFRESH_SESSION_TOKEN_NAME] = validate_token(refresh_token, audience)
|
235
|
+
elsif rt_jwt
|
236
|
+
jwt_response[REFRESH_SESSION_TOKEN_NAME] = validate_token(rt_jwt, audience)
|
237
|
+
end
|
238
|
+
|
239
|
+
jwt_response = adjust_properties(jwt_response, user_jwt)
|
240
|
+
|
241
|
+
if user_jwt
|
242
|
+
jwt_response[COOKIE_DATA_NAME] = {
|
243
|
+
exp: response_body.fetch('cookieExpiration', 0),
|
244
|
+
maxAge: response_body.fetch('cookieMaxAge', 0),
|
245
|
+
domain: response_body.fetch('cookieDomain', ''),
|
246
|
+
path: response_body.fetch('cookiePath', '/')
|
247
|
+
}
|
248
|
+
end
|
249
|
+
|
250
|
+
jwt_response
|
251
|
+
end
|
252
|
+
|
253
|
+
def adjust_properties(jwt_response, user_jwt)
|
254
|
+
# Save permissions, roles and tenants info from Session token or from refresh token on the json top level
|
255
|
+
if jwt_response[SESSION_TOKEN_NAME]
|
256
|
+
jwt_response['permissions'] = jwt_response[SESSION_TOKEN_NAME].fetch('permissions', [])
|
257
|
+
jwt_response['roles'] = jwt_response[SESSION_TOKEN_NAME].fetch('roles', [])
|
258
|
+
jwt_response['tenants'] = jwt_response[SESSION_TOKEN_NAME].fetch('tenants', {})
|
259
|
+
elsif jwt_response[REFRESH_SESSION_TOKEN_NAME]
|
260
|
+
jwt_response['permissions'] = jwt_response[REFRESH_SESSION_TOKEN_NAME].fetch('permissions', [])
|
261
|
+
jwt_response['roles'] = jwt_response[REFRESH_SESSION_TOKEN_NAME].fetch('roles', [])
|
262
|
+
jwt_response['tenants'] = jwt_response[REFRESH_SESSION_TOKEN_NAME].fetch('tenants', {})
|
263
|
+
else
|
264
|
+
jwt_response['permissions'] = jwt_response.fetch('permissions', [])
|
265
|
+
jwt_response['roles'] = jwt_response.fetch('roles', [])
|
266
|
+
jwt_response['tenants'] = jwt_response.fetch('tenants', {})
|
267
|
+
end
|
268
|
+
|
269
|
+
# Save the projectID also in the dict top level
|
270
|
+
issuer =
|
271
|
+
jwt_response.fetch(SESSION_TOKEN_NAME, {}).fetch('iss', nil) ||
|
272
|
+
jwt_response.fetch(REFRESH_SESSION_TOKEN_NAME, {}).fetch('iss', nil) ||
|
273
|
+
jwt_response.fetch('iss', '')
|
274
|
+
|
275
|
+
jwt_response['projectId'] = issuer.split('/').last # support both url issuer and project ID issuer
|
276
|
+
|
277
|
+
sub =
|
278
|
+
jwt_response.fetch(SESSION_TOKEN_NAME, {}).fetch('iss', nil) ||
|
279
|
+
jwt_response.fetch(REFRESH_SESSION_TOKEN_NAME, {}).fetch('iss', nil) ||
|
280
|
+
jwt_response.fetch('sub', '')
|
281
|
+
|
282
|
+
if user_jwt
|
283
|
+
jwt_response['userId'] = sub # Save the userID also in the dict top level
|
284
|
+
else
|
285
|
+
jwt_response['keyId'] = sub # Save the AccessKeyID also in the dict top level
|
286
|
+
end
|
287
|
+
|
288
|
+
jwt_response
|
289
|
+
end
|
290
|
+
|
291
|
+
def jwt_get_unverified_header(token)
|
292
|
+
begin
|
293
|
+
decode_response = JWT.decode(token, nil, false)
|
294
|
+
rescue JWT::DecodeError => e
|
295
|
+
raise AuthException.new("Unable to parse token. #{e.message}", code: 500)
|
296
|
+
end
|
297
|
+
|
298
|
+
# The JWT.decode method returns an array where
|
299
|
+
# the first element is the payload and the second element is the header.
|
300
|
+
decode_response[1]
|
301
|
+
end
|
302
|
+
|
303
|
+
def fetch_public_keys
|
304
|
+
response = token_validation_key(@project_id)
|
305
|
+
unless response.is_a?(Hash) && response.key?('keys')
|
306
|
+
raise AuthException.new("Unable to fetch public keys. #{response}", code: 500)
|
307
|
+
end
|
308
|
+
|
309
|
+
jwkeys_wrapper = response
|
310
|
+
jwkeys = jwkeys_wrapper['keys']
|
311
|
+
@public_keys = {}
|
312
|
+
|
313
|
+
jwkeys.each do |key|
|
314
|
+
loaded_kid, pub_key, alg = validate_and_load_public_key(key)
|
315
|
+
@public_keys[loaded_kid] = [pub_key, alg]
|
316
|
+
rescue AuthException
|
317
|
+
nil
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
322
|
+
def validate_and_load_public_key(public_key)
|
323
|
+
unless public_key.is_a?(String) || public_key.is_a?(Hash)
|
324
|
+
raise AuthException.new(
|
325
|
+
'Unable to load public key. Invalid public key error: (unknown type)',
|
326
|
+
code: 500
|
327
|
+
)
|
328
|
+
end
|
329
|
+
|
330
|
+
if public_key.is_a? String
|
331
|
+
begin
|
332
|
+
public_key = JSON.parse(public_key)
|
333
|
+
rescue JSON::ParserError => e
|
334
|
+
raise AuthException.new(
|
335
|
+
"Unable to parse public key json, error: #{e.message}",
|
336
|
+
code: 500
|
337
|
+
)
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
alg = public_key[ALGORITHM_KEY]
|
342
|
+
if alg.nil?
|
343
|
+
raise AuthException.new(
|
344
|
+
'Unable to load public key. Missing property: alg',
|
345
|
+
code: 500
|
346
|
+
)
|
347
|
+
end
|
348
|
+
|
349
|
+
kid = public_key['kid']
|
350
|
+
if kid.nil?
|
351
|
+
raise AuthException.new(
|
352
|
+
'Unable to load public key. Missing property: kid',
|
353
|
+
code: 500
|
354
|
+
)
|
355
|
+
end
|
356
|
+
|
357
|
+
begin
|
358
|
+
# Load and validate public key
|
359
|
+
[kid, JWT::JWK.new(public_key), alg]
|
360
|
+
rescue JWT::JWKError => e
|
361
|
+
raise AuthException.new(
|
362
|
+
"Unable to load public key #{e.message}",
|
363
|
+
code: 500
|
364
|
+
)
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
def validate_refresh_token_provided(login_options, refresh_token)
|
369
|
+
refresh_required = !login_options.nil? && (login_options[:mfa] || login_options[:stepup])
|
370
|
+
refresh_missing = refresh_token.nil? || refresh_token.to_s.empty?
|
371
|
+
|
372
|
+
return unless refresh_required && refresh_missing
|
373
|
+
|
374
|
+
raise AuthException.new(
|
375
|
+
'Missing refresh token for stepup/mfa',
|
376
|
+
code: 400
|
377
|
+
)
|
378
|
+
end
|
379
|
+
|
380
|
+
def compose_url(base, method)
|
381
|
+
suffix = get_method_string(method)
|
382
|
+
unless suffix
|
383
|
+
raise AuthException.new(
|
384
|
+
"Unable to compose url. Unknown delivery method: #{method}",
|
385
|
+
code: 500
|
386
|
+
)
|
387
|
+
end
|
388
|
+
"#{base}/#{suffix}"
|
389
|
+
end
|
390
|
+
|
391
|
+
def get_login_id_by_method(method: nil, user: {})
|
392
|
+
login_id = {
|
393
|
+
DeliveryMethod::WHATSAPP => ['whatsapp', user.fetch(:phone, '')],
|
394
|
+
DeliveryMethod::SMS => ['phone', user.fetch(:phone, '')],
|
395
|
+
DeliveryMethod::EMAIL => ['email', user.fetch(:email, '')]
|
396
|
+
}[method]
|
397
|
+
|
398
|
+
raise AuthException.new("Unknown delivery method: #{method}", code: 400) if login_id.nil?
|
399
|
+
|
400
|
+
login_id
|
401
|
+
end
|
402
|
+
|
403
|
+
def adjust_and_verify_delivery_method(method, login_id, user)
|
404
|
+
return false if login_id.nil?
|
405
|
+
|
406
|
+
return false unless user.is_a?(Hash)
|
407
|
+
|
408
|
+
case method
|
409
|
+
when DeliveryMethod::EMAIL
|
410
|
+
user[:email] ||= login_id
|
411
|
+
begin
|
412
|
+
validate_email(user[:email])
|
413
|
+
return true
|
414
|
+
rescue AuthException
|
415
|
+
return false
|
416
|
+
end
|
417
|
+
when DeliveryMethod::SMS
|
418
|
+
user[:phone] ||= login_id
|
419
|
+
return false unless /^#{PHONE_REGEX}$/.match(user[:phone])
|
420
|
+
when DeliveryMethod::WHATSAPP
|
421
|
+
user[:phone] ||= login_id
|
422
|
+
return false unless /^#{PHONE_REGEX}$/.match(user[:phone])
|
423
|
+
else
|
424
|
+
return false
|
425
|
+
end
|
426
|
+
|
427
|
+
true
|
428
|
+
end
|
429
|
+
|
430
|
+
def extract_masked_address(response, method)
|
431
|
+
if [DeliveryMethod::SMS, DeliveryMethod::WHATSAPP].include?(method)
|
432
|
+
response['maskedPhone']
|
433
|
+
elsif method == DeliveryMethod::EMAIL
|
434
|
+
response['maskedEmail']
|
435
|
+
else
|
436
|
+
''
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
def exchange_token(uri, code)
|
441
|
+
raise Descope::ArgumentException.new("Code can't be empty", code: 400) if code.nil? || code.empty?
|
442
|
+
|
443
|
+
res = post(uri, { code: })
|
444
|
+
generate_jwt_response(
|
445
|
+
response_body: res,
|
446
|
+
refresh_cookie: res['refreshJwt']
|
447
|
+
)
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|
451
|
+
end
|
452
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Descope
|
4
|
+
module Api
|
5
|
+
module V1
|
6
|
+
module Management
|
7
|
+
# Management API calls
|
8
|
+
module AccessKey
|
9
|
+
include Descope::Mixins::Validation
|
10
|
+
include Descope::Api::V1::Management::Common
|
11
|
+
|
12
|
+
def create_access_key(name: nil, expire_time: nil, role_names: nil, key_tenants: nil)
|
13
|
+
# Create a new access key.'
|
14
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/CreateAccessKey/
|
15
|
+
|
16
|
+
role_names ||= []
|
17
|
+
key_tenants ||= []
|
18
|
+
validate_tenants(key_tenants)
|
19
|
+
post(ACCESS_KEY_CREATE_PATH, access_key_compose_create_body(name, expire_time, role_names, key_tenants))
|
20
|
+
end
|
21
|
+
|
22
|
+
def access_key_compose_create_body(name, expire_time, role_names, key_tenants)
|
23
|
+
{
|
24
|
+
name:,
|
25
|
+
expireTime: expire_time,
|
26
|
+
roleNames: role_names,
|
27
|
+
keyTenants: associated_tenants_to_hash_array(key_tenants)
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def load_access_key(id)
|
32
|
+
# Load an access key.'
|
33
|
+
# @param id [string] The access key id.
|
34
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/LoadAccessKey/
|
35
|
+
|
36
|
+
get(ACCESS_KEY_LOAD_PATH, { id: })
|
37
|
+
end
|
38
|
+
|
39
|
+
def search_all_access_keys(tenant_ids = nil)
|
40
|
+
# Search all access keys.'
|
41
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/SearchAccessKeys/
|
42
|
+
request_params = {
|
43
|
+
tenantIds: tenant_ids
|
44
|
+
}
|
45
|
+
post(ACCESS_KEYS_SEARCH_PATH, request_params)
|
46
|
+
end
|
47
|
+
|
48
|
+
def update_access_key(id: nil, name: nil)
|
49
|
+
# Update an existing access key name
|
50
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/UpdateAccessKey/
|
51
|
+
request_params = {
|
52
|
+
id:,
|
53
|
+
name:
|
54
|
+
}
|
55
|
+
post(ACCESS_KEY_UPDATE_PATH, request_params)
|
56
|
+
end
|
57
|
+
|
58
|
+
def deactivate_access_key(id)
|
59
|
+
# Deactivate an existing access key. IMPORTANT: This deactivated key will not be usable from this stage.
|
60
|
+
# It will, however, persist, and can be activated again if needed.
|
61
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/DeactivateAccessKey/
|
62
|
+
post(ACCESS_KEY_DEACTIVATE_PATH, { id: })
|
63
|
+
end
|
64
|
+
|
65
|
+
def activate_access_key(id)
|
66
|
+
# Activate an existing access key. IMPORTANT: Only deactivated keys can be activated again,
|
67
|
+
# and become usable once more. New access keys are active by default.
|
68
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/ActivateAccessKey/
|
69
|
+
post(ACCESS_KEY_ACTIVATE_PATH, { id: })
|
70
|
+
end
|
71
|
+
|
72
|
+
def delete_access_key(id)
|
73
|
+
# Delete an existing access key. IMPORTANT: This action is irreversible. Use carefully.
|
74
|
+
# @see https://docs.descope.com/api/openapi/accesskeymanagement/operation/DeleteAccessKey/
|
75
|
+
post(ACCESS_KEY_DELETE_PATH, { id: })
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|