descope 1.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.
Files changed (197) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yaml +54 -0
  3. data/.gitignore +59 -0
  4. data/.release-please-manifest.json +3 -0
  5. data/.rubocop.yml +10 -0
  6. data/.rubocop_todo.yml +10 -0
  7. data/.ruby-version +1 -0
  8. data/CHANGELOG.md +90 -0
  9. data/Gemfile +22 -0
  10. data/Gemfile.lock +204 -0
  11. data/LICENSE +21 -0
  12. data/README.md +1171 -0
  13. data/Rakefile +31 -0
  14. data/descope.gemspec +34 -0
  15. data/examples/ruby/Gemfile +4 -0
  16. data/examples/ruby/Gemfile.lock +41 -0
  17. data/examples/ruby/access_key_app.rb +45 -0
  18. data/examples/ruby/enchantedlink_app.rb +65 -0
  19. data/examples/ruby/magiclink_app.rb +81 -0
  20. data/examples/ruby/management/Gemfile +5 -0
  21. data/examples/ruby/management/Gemfile.lock +38 -0
  22. data/examples/ruby/management/access_key_app.rb +71 -0
  23. data/examples/ruby/management/audit_app.rb +25 -0
  24. data/examples/ruby/management/authz_app.rb +135 -0
  25. data/examples/ruby/management/authz_files.json +229 -0
  26. data/examples/ruby/management/flow_app.rb +57 -0
  27. data/examples/ruby/management/permission_app.rb +56 -0
  28. data/examples/ruby/management/role_app.rb +58 -0
  29. data/examples/ruby/management/tenant_app.rb +60 -0
  30. data/examples/ruby/management/user_app.rb +60 -0
  31. data/examples/ruby/oauth_app.rb +39 -0
  32. data/examples/ruby/otp_app.rb +50 -0
  33. data/examples/ruby/password_app.rb +76 -0
  34. data/examples/ruby/saml_app.rb +38 -0
  35. data/examples/ruby-on-rails-api/descope/.dockerignore +37 -0
  36. data/examples/ruby-on-rails-api/descope/.gitattributes +9 -0
  37. data/examples/ruby-on-rails-api/descope/.gitignore +40 -0
  38. data/examples/ruby-on-rails-api/descope/.node-version +1 -0
  39. data/examples/ruby-on-rails-api/descope/.ruby-version +1 -0
  40. data/examples/ruby-on-rails-api/descope/Dockerfile +75 -0
  41. data/examples/ruby-on-rails-api/descope/Gemfile +67 -0
  42. data/examples/ruby-on-rails-api/descope/Gemfile.lock +284 -0
  43. data/examples/ruby-on-rails-api/descope/Procfile.dev +3 -0
  44. data/examples/ruby-on-rails-api/descope/README.md +54 -0
  45. data/examples/ruby-on-rails-api/descope/Rakefile +6 -0
  46. data/examples/ruby-on-rails-api/descope/app/assets/builds/.keep +0 -0
  47. data/examples/ruby-on-rails-api/descope/app/assets/config/manifest.js +3 -0
  48. data/examples/ruby-on-rails-api/descope/app/assets/images/.keep +0 -0
  49. data/examples/ruby-on-rails-api/descope/app/assets/images/descope.jpeg +0 -0
  50. data/examples/ruby-on-rails-api/descope/app/assets/images/favicon.ico +0 -0
  51. data/examples/ruby-on-rails-api/descope/app/assets/images/logo192.png +0 -0
  52. data/examples/ruby-on-rails-api/descope/app/assets/images/logo512.png +0 -0
  53. data/examples/ruby-on-rails-api/descope/app/assets/stylesheets/application.bootstrap.scss +67 -0
  54. data/examples/ruby-on-rails-api/descope/app/channels/application_cable/channel.rb +4 -0
  55. data/examples/ruby-on-rails-api/descope/app/channels/application_cable/connection.rb +4 -0
  56. data/examples/ruby-on-rails-api/descope/app/controllers/application_controller.rb +2 -0
  57. data/examples/ruby-on-rails-api/descope/app/controllers/concerns/.keep +0 -0
  58. data/examples/ruby-on-rails-api/descope/app/controllers/homepage_controller.rb +4 -0
  59. data/examples/ruby-on-rails-api/descope/app/controllers/session_controller.rb +66 -0
  60. data/examples/ruby-on-rails-api/descope/app/helpers/application_helper.rb +2 -0
  61. data/examples/ruby-on-rails-api/descope/app/helpers/homepage_helper.rb +2 -0
  62. data/examples/ruby-on-rails-api/descope/app/helpers/session_helper.rb +2 -0
  63. data/examples/ruby-on-rails-api/descope/app/javascript/App.css +53 -0
  64. data/examples/ruby-on-rails-api/descope/app/javascript/application.js +5 -0
  65. data/examples/ruby-on-rails-api/descope/app/javascript/components/App.jsx +4 -0
  66. data/examples/ruby-on-rails-api/descope/app/javascript/components/Dashboard.jsx +60 -0
  67. data/examples/ruby-on-rails-api/descope/app/javascript/components/Home.jsx +27 -0
  68. data/examples/ruby-on-rails-api/descope/app/javascript/components/Login.jsx +45 -0
  69. data/examples/ruby-on-rails-api/descope/app/javascript/components/Profile.jsx +81 -0
  70. data/examples/ruby-on-rails-api/descope/app/javascript/components/index.html +11 -0
  71. data/examples/ruby-on-rails-api/descope/app/javascript/components/index.jsx +24 -0
  72. data/examples/ruby-on-rails-api/descope/app/javascript/controllers/application.js +9 -0
  73. data/examples/ruby-on-rails-api/descope/app/javascript/controllers/index.js +5 -0
  74. data/examples/ruby-on-rails-api/descope/app/javascript/reportWebVitals.js +13 -0
  75. data/examples/ruby-on-rails-api/descope/app/javascript/routes/index.jsx +17 -0
  76. data/examples/ruby-on-rails-api/descope/app/jobs/application_job.rb +7 -0
  77. data/examples/ruby-on-rails-api/descope/app/mailers/application_mailer.rb +4 -0
  78. data/examples/ruby-on-rails-api/descope/app/models/application_record.rb +3 -0
  79. data/examples/ruby-on-rails-api/descope/app/models/concerns/.keep +0 -0
  80. data/examples/ruby-on-rails-api/descope/app/views/homepage/index.html.erb +2 -0
  81. data/examples/ruby-on-rails-api/descope/app/views/layouts/application.html.erb +16 -0
  82. data/examples/ruby-on-rails-api/descope/app/views/layouts/mailer.html.erb +13 -0
  83. data/examples/ruby-on-rails-api/descope/app/views/layouts/mailer.text.erb +1 -0
  84. data/examples/ruby-on-rails-api/descope/app/views/session/index.html.erb +2 -0
  85. data/examples/ruby-on-rails-api/descope/bin/bundle +109 -0
  86. data/examples/ruby-on-rails-api/descope/bin/dev +11 -0
  87. data/examples/ruby-on-rails-api/descope/bin/docker-entrypoint +8 -0
  88. data/examples/ruby-on-rails-api/descope/bin/rails +4 -0
  89. data/examples/ruby-on-rails-api/descope/bin/rake +4 -0
  90. data/examples/ruby-on-rails-api/descope/bin/setup +36 -0
  91. data/examples/ruby-on-rails-api/descope/build.js +30 -0
  92. data/examples/ruby-on-rails-api/descope/config/application.rb +42 -0
  93. data/examples/ruby-on-rails-api/descope/config/boot.rb +4 -0
  94. data/examples/ruby-on-rails-api/descope/config/cable.yml +10 -0
  95. data/examples/ruby-on-rails-api/descope/config/config.yml +9 -0
  96. data/examples/ruby-on-rails-api/descope/config/credentials.yml.enc +1 -0
  97. data/examples/ruby-on-rails-api/descope/config/database.yml +25 -0
  98. data/examples/ruby-on-rails-api/descope/config/environment.rb +5 -0
  99. data/examples/ruby-on-rails-api/descope/config/environments/development.rb +76 -0
  100. data/examples/ruby-on-rails-api/descope/config/environments/production.rb +97 -0
  101. data/examples/ruby-on-rails-api/descope/config/environments/test.rb +64 -0
  102. data/examples/ruby-on-rails-api/descope/config/initializers/assets.rb +13 -0
  103. data/examples/ruby-on-rails-api/descope/config/initializers/content_security_policy.rb +25 -0
  104. data/examples/ruby-on-rails-api/descope/config/initializers/filter_parameter_logging.rb +8 -0
  105. data/examples/ruby-on-rails-api/descope/config/initializers/inflections.rb +16 -0
  106. data/examples/ruby-on-rails-api/descope/config/initializers/load_config.rb +12 -0
  107. data/examples/ruby-on-rails-api/descope/config/initializers/permissions_policy.rb +13 -0
  108. data/examples/ruby-on-rails-api/descope/config/locales/en.yml +31 -0
  109. data/examples/ruby-on-rails-api/descope/config/puma.rb +35 -0
  110. data/examples/ruby-on-rails-api/descope/config/routes.rb +18 -0
  111. data/examples/ruby-on-rails-api/descope/config/storage.yml +34 -0
  112. data/examples/ruby-on-rails-api/descope/config.ru +6 -0
  113. data/examples/ruby-on-rails-api/descope/db/seeds.rb +9 -0
  114. data/examples/ruby-on-rails-api/descope/lib/assets/.keep +0 -0
  115. data/examples/ruby-on-rails-api/descope/lib/tasks/.keep +0 -0
  116. data/examples/ruby-on-rails-api/descope/log/.keep +0 -0
  117. data/examples/ruby-on-rails-api/descope/package-lock.json +19680 -0
  118. data/examples/ruby-on-rails-api/descope/package.json +51 -0
  119. data/examples/ruby-on-rails-api/descope/public/404.html +67 -0
  120. data/examples/ruby-on-rails-api/descope/public/422.html +67 -0
  121. data/examples/ruby-on-rails-api/descope/public/500.html +66 -0
  122. data/examples/ruby-on-rails-api/descope/public/apple-touch-icon-precomposed.png +0 -0
  123. data/examples/ruby-on-rails-api/descope/public/apple-touch-icon.png +0 -0
  124. data/examples/ruby-on-rails-api/descope/public/favicon.ico +0 -0
  125. data/examples/ruby-on-rails-api/descope/public/robots.txt +1 -0
  126. data/examples/ruby-on-rails-api/descope/storage/.keep +0 -0
  127. data/examples/ruby-on-rails-api/descope/tmp/.keep +0 -0
  128. data/examples/ruby-on-rails-api/descope/tmp/pids/.keep +0 -0
  129. data/examples/ruby-on-rails-api/descope/tmp/storage/.keep +0 -0
  130. data/examples/ruby-on-rails-api/descope/vendor/.keep +0 -0
  131. data/examples/ruby-on-rails-api/descope/yarn.lock +10780 -0
  132. data/lib/descope/api/v1/auth/enchantedlink.rb +156 -0
  133. data/lib/descope/api/v1/auth/magiclink.rb +170 -0
  134. data/lib/descope/api/v1/auth/oauth.rb +72 -0
  135. data/lib/descope/api/v1/auth/otp.rb +186 -0
  136. data/lib/descope/api/v1/auth/password.rb +100 -0
  137. data/lib/descope/api/v1/auth/saml.rb +48 -0
  138. data/lib/descope/api/v1/auth/totp.rb +72 -0
  139. data/lib/descope/api/v1/auth.rb +452 -0
  140. data/lib/descope/api/v1/management/access_key.rb +81 -0
  141. data/lib/descope/api/v1/management/audit.rb +82 -0
  142. data/lib/descope/api/v1/management/authz.rb +165 -0
  143. data/lib/descope/api/v1/management/common.rb +147 -0
  144. data/lib/descope/api/v1/management/flow.rb +55 -0
  145. data/lib/descope/api/v1/management/password.rb +58 -0
  146. data/lib/descope/api/v1/management/permission.rb +48 -0
  147. data/lib/descope/api/v1/management/project.rb +53 -0
  148. data/lib/descope/api/v1/management/role.rb +48 -0
  149. data/lib/descope/api/v1/management/scim.rb +206 -0
  150. data/lib/descope/api/v1/management/sso_settings.rb +153 -0
  151. data/lib/descope/api/v1/management/tenant.rb +71 -0
  152. data/lib/descope/api/v1/management/user.rb +619 -0
  153. data/lib/descope/api/v1/management.rb +38 -0
  154. data/lib/descope/api/v1/session.rb +84 -0
  155. data/lib/descope/api/v1.rb +13 -0
  156. data/lib/descope/client.rb +6 -0
  157. data/lib/descope/exception.rb +50 -0
  158. data/lib/descope/mixins/common.rb +129 -0
  159. data/lib/descope/mixins/headers.rb +15 -0
  160. data/lib/descope/mixins/http.rb +133 -0
  161. data/lib/descope/mixins/initializer.rb +80 -0
  162. data/lib/descope/mixins/logging.rb +30 -0
  163. data/lib/descope/mixins/validation.rb +79 -0
  164. data/lib/descope/mixins.rb +22 -0
  165. data/lib/descope/version.rb +7 -0
  166. data/lib/descope.rb +9 -0
  167. data/lib/descope_client.rb +5 -0
  168. data/release-please-config.json +18 -0
  169. data/renovate.json +6 -0
  170. data/spec/factories/user.rb +16 -0
  171. data/spec/lib.descope/api/v1/auth/enchantedlink_spec.rb +159 -0
  172. data/spec/lib.descope/api/v1/auth/magiclink_spec.rb +282 -0
  173. data/spec/lib.descope/api/v1/auth/oauth_spec.rb +117 -0
  174. data/spec/lib.descope/api/v1/auth/otp_spec.rb +285 -0
  175. data/spec/lib.descope/api/v1/auth/password_spec.rb +124 -0
  176. data/spec/lib.descope/api/v1/auth/saml_spec.rb +55 -0
  177. data/spec/lib.descope/api/v1/auth/totp_spec.rb +70 -0
  178. data/spec/lib.descope/api/v1/auth_spec.rb +372 -0
  179. data/spec/lib.descope/api/v1/management/access_key_spec.rb +118 -0
  180. data/spec/lib.descope/api/v1/management/audit_spec.rb +78 -0
  181. data/spec/lib.descope/api/v1/management/authz_spec.rb +336 -0
  182. data/spec/lib.descope/api/v1/management/flow_spec.rb +78 -0
  183. data/spec/lib.descope/api/v1/management/password_spec.rb +25 -0
  184. data/spec/lib.descope/api/v1/management/permission_spec.rb +81 -0
  185. data/spec/lib.descope/api/v1/management/project_spec.rb +63 -0
  186. data/spec/lib.descope/api/v1/management/role_spec.rb +85 -0
  187. data/spec/lib.descope/api/v1/management/scim_spec.rb +312 -0
  188. data/spec/lib.descope/api/v1/management/sso_settings_spec.rb +172 -0
  189. data/spec/lib.descope/api/v1/management/tenant_spec.rb +141 -0
  190. data/spec/lib.descope/api/v1/management/user_spec.rb +667 -0
  191. data/spec/lib.descope/api/v1/session_spec.rb +117 -0
  192. data/spec/lib.descope/client_spec.rb +40 -0
  193. data/spec/spec_helper.rb +72 -0
  194. data/spec/support/client_config.rb +14 -0
  195. data/spec/support/dummy_class.rb +36 -0
  196. data/spec/support/utils.rb +32 -0
  197. 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