loading
Generated 2026-01-27T06:22:25+00:00

All Files ( 96.09% covered at 539.33 hits/line )

39 files in total.
3451 relevant lines, 3316 lines covered and 135 lines missed. ( 96.09% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/rodauth/features/oauth_application_management.rb 100.00 % 210 117 117 0 96.22
lib/rodauth/features/oauth_assertion_base.rb 100.00 % 92 38 38 0 75.16
lib/rodauth/features/oauth_authorization_code_grant.rb 100.00 % 165 83 83 0 817.12
lib/rodauth/features/oauth_authorize_base.rb 97.67 % 260 129 126 3 1018.47
lib/rodauth/features/oauth_base.rb 96.09 % 878 435 418 17 1982.73
lib/rodauth/features/oauth_client_credentials_grant.rb 100.00 % 35 17 17 0 47.00
lib/rodauth/features/oauth_device_code_grant.rb 95.33 % 208 107 102 5 68.86
lib/rodauth/features/oauth_dpop.rb 92.67 % 409 191 177 14 94.73
lib/rodauth/features/oauth_dynamic_client_registration.rb 95.22 % 444 230 219 11 891.15
lib/rodauth/features/oauth_grant_management.rb 100.00 % 70 33 33 0 93.67
lib/rodauth/features/oauth_implicit_grant.rb 100.00 % 95 49 49 0 517.22
lib/rodauth/features/oauth_jwt.rb 100.00 % 136 59 59 0 433.98
lib/rodauth/features/oauth_jwt_base.rb 95.11 % 526 225 214 11 335.13
lib/rodauth/features/oauth_jwt_bearer_grant.rb 100.00 % 91 47 47 0 52.13
lib/rodauth/features/oauth_jwt_jwks.rb 100.00 % 47 23 23 0 62.83
lib/rodauth/features/oauth_jwt_secured_authorization_request.rb 100.00 % 143 68 68 0 191.28
lib/rodauth/features/oauth_jwt_secured_authorization_response_mode.rb 98.53 % 126 68 67 1 99.09
lib/rodauth/features/oauth_management_base.rb 100.00 % 72 39 39 0 213.03
lib/rodauth/features/oauth_pkce.rb 100.00 % 93 49 49 0 42.20
lib/rodauth/features/oauth_pushed_authorization_request.rb 92.75 % 144 69 64 5 71.10
lib/rodauth/features/oauth_resource_indicators.rb 93.90 % 166 82 77 5 76.24
lib/rodauth/features/oauth_resource_server.rb 96.30 % 59 27 26 1 84.37
lib/rodauth/features/oauth_saml_bearer_grant.rb 96.77 % 140 62 60 2 24.56
lib/rodauth/features/oauth_tls_client_auth.rb 86.90 % 167 84 73 11 176.52
lib/rodauth/features/oauth_token_introspection.rb 98.33 % 139 60 59 1 145.35
lib/rodauth/features/oauth_token_revocation.rb 100.00 % 124 60 60 0 106.17
lib/rodauth/features/oidc.rb 93.61 % 879 391 366 25 327.94
lib/rodauth/features/oidc_backchannel_logout.rb 98.15 % 120 54 53 1 63.02
lib/rodauth/features/oidc_dynamic_client_registration.rb 90.51 % 298 137 124 13 153.12
lib/rodauth/features/oidc_frontchannel_logout.rb 100.00 % 134 68 68 0 60.19
lib/rodauth/features/oidc_logout_base.rb 100.00 % 76 38 38 0 123.42
lib/rodauth/features/oidc_rp_initiated_logout.rb 95.24 % 140 63 60 3 58.49
lib/rodauth/features/oidc_self_issued.rb 97.06 % 73 34 33 1 30.79
lib/rodauth/features/oidc_session_management.rb 97.92 % 91 48 47 1 21.83
lib/rodauth/oauth.rb 100.00 % 38 19 19 0 3737.89
lib/rodauth/oauth/database_extensions.rb 100.00 % 92 42 42 0 2308.57
lib/rodauth/oauth/http_extensions.rb 95.56 % 74 45 43 2 191.27
lib/rodauth/oauth/jwe_extensions.rb 100.00 % 64 33 33 0 49.24
lib/rodauth/oauth/ttl_store.rb 92.86 % 67 28 26 2 17.46

lib/rodauth/features/oauth_application_management.rb

100.0% lines covered

117 relevant lines. 117 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_application_management, :OauthApplicationManagement) do
  5. 17 depends :oauth_management_base, :oauth_token_revocation
  6. 17 before "create_oauth_application"
  7. 17 after "create_oauth_application"
  8. 17 error_flash "There was an error registering your oauth application", "create_oauth_application"
  9. 17 notice_flash "Your oauth application has been registered", "create_oauth_application"
  10. 17 view "oauth_applications", "Oauth Applications", "oauth_applications"
  11. 17 view "oauth_application", "Oauth Application", "oauth_application"
  12. 17 view "new_oauth_application", "New Oauth Application", "new_oauth_application"
  13. 17 view "oauth_application_oauth_grants", "Oauth Application Grants", "oauth_application_oauth_grants"
  14. # Application
  15. 17 APPLICATION_REQUIRED_PARAMS = %w[name scopes homepage_url redirect_uri client_secret].freeze
  16. 17 auth_value_method :oauth_application_required_params, APPLICATION_REQUIRED_PARAMS
  17. 17 (APPLICATION_REQUIRED_PARAMS + %w[description client_id]).each do |param|
  18. 119 auth_value_method :"oauth_application_#{param}_param", param
  19. end
  20. 17 translatable_method :oauth_applications_name_label, "Name"
  21. 17 translatable_method :oauth_applications_description_label, "Description"
  22. 17 translatable_method :oauth_applications_scopes_label, "Default scopes"
  23. 17 translatable_method :oauth_applications_contacts_label, "Contacts"
  24. 17 translatable_method :oauth_applications_tos_uri_label, "Terms of service"
  25. 17 translatable_method :oauth_applications_policy_uri_label, "Policy"
  26. 17 translatable_method :oauth_applications_jwks_label, "JSON Web Keys"
  27. 17 translatable_method :oauth_applications_jwks_uri_label, "JSON Web Keys URI"
  28. 17 translatable_method :oauth_applications_homepage_url_label, "Homepage URL"
  29. 17 translatable_method :oauth_applications_redirect_uri_label, "Redirect URI"
  30. 17 translatable_method :oauth_applications_client_secret_label, "Client Secret"
  31. 17 translatable_method :oauth_applications_client_id_label, "Client ID"
  32. 17 %w[type token refresh_token expires_in revoked_at].each do |param|
  33. 85 translatable_method :"oauth_grants_#{param}_label", param.gsub("_", " ").capitalize
  34. end
  35. 17 button "Register", "oauth_application"
  36. 17 button "Revoke", "oauth_grant_revoke"
  37. 17 auth_value_method :oauth_applications_oauth_grants_path, "oauth-grants"
  38. 17 auth_value_method :oauth_applications_route, "oauth-applications"
  39. 17 auth_value_method :oauth_applications_per_page, 20
  40. 17 auth_value_method :oauth_applications_id_pattern, Integer
  41. 17 auth_value_method :oauth_grants_per_page, 20
  42. 17 translatable_method :invalid_url_message, "Invalid URL"
  43. 17 translatable_method :null_error_message, "is not filled"
  44. 17 translatable_method :oauth_no_applications_text, "No oauth applications yet!"
  45. 17 translatable_method :oauth_no_grants_text, "No oauth grants yet!"
  46. 17 auth_methods(
  47. :oauth_application_path
  48. )
  49. 17 def oauth_applications_path(opts = {})
  50. 1749 route_path(oauth_applications_route, opts)
  51. end
  52. 17 def oauth_application_path(id)
  53. 221 "#{oauth_applications_path}/#{id}"
  54. end
  55. # /oauth-applications routes
  56. 17 def load_oauth_application_management_routes
  57. 425 request.on(oauth_applications_route) do
  58. 425 check_csrf if check_csrf?
  59. 425 require_account
  60. 425 request.get "new" do
  61. 68 new_oauth_application_view
  62. end
  63. 357 request.on(oauth_applications_id_pattern) do |id|
  64. 136 oauth_application = db[oauth_applications_table]
  65. .where(oauth_applications_id_column => id)
  66. .where(oauth_applications_account_id_column => account_id)
  67. .first
  68. 136 next unless oauth_application
  69. 119 scope.instance_variable_set(:@oauth_application, oauth_application)
  70. 119 request.is do
  71. 51 request.get do
  72. 51 oauth_application_view
  73. end
  74. end
  75. 68 request.on(oauth_applications_oauth_grants_path) do
  76. 68 page = Integer(param_or_nil("page") || 1)
  77. 68 per_page = per_page_param(oauth_grants_per_page)
  78. 68 oauth_grants = db[oauth_grants_table]
  79. .where(oauth_grants_oauth_application_id_column => id)
  80. .order(Sequel.desc(oauth_grants_id_column))
  81. 68 scope.instance_variable_set(:@oauth_grants, oauth_grants.paginate(page, per_page))
  82. 68 request.is do
  83. 68 request.get do
  84. 68 oauth_application_oauth_grants_view
  85. end
  86. end
  87. end
  88. end
  89. 221 request.is do
  90. 221 request.get do
  91. 136 page = Integer(param_or_nil("page") || 1)
  92. 136 per_page = per_page_param(oauth_applications_per_page)
  93. 136 scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table]
  94. .where(oauth_applications_account_id_column => account_id)
  95. .order(Sequel.desc(oauth_applications_id_column))
  96. .paginate(page, per_page))
  97. 136 oauth_applications_view
  98. end
  99. 85 request.post do
  100. 85 catch_error do
  101. 85 validate_oauth_application_params
  102. 51 transaction do
  103. 51 before_create_oauth_application
  104. 51 id = create_oauth_application
  105. 51 after_create_oauth_application
  106. 51 set_notice_flash create_oauth_application_notice_flash
  107. 51 redirect "#{request.path}/#{id}"
  108. end
  109. end
  110. 34 set_error_flash create_oauth_application_error_flash
  111. 34 new_oauth_application_view
  112. end
  113. end
  114. end
  115. end
  116. 17 private
  117. 17 def oauth_application_params
  118. 391 @oauth_application_params ||= oauth_application_required_params.each_with_object({}) do |param, params|
  119. 425 value = request.params[__send__(:"oauth_application_#{param}_param")]
  120. 425 if value && !value.empty?
  121. 234 params[param] = value
  122. else
  123. 119 set_field_error(param, null_error_message)
  124. end
  125. end
  126. end
  127. 17 def validate_oauth_application_params
  128. 85 oauth_application_params.each do |key, value|
  129. 306 if key == oauth_application_homepage_url_param
  130. 68 set_field_error(key, invalid_url_message) unless check_valid_uri?(value)
  131. 238 elsif key == oauth_application_redirect_uri_param
  132. 68 if value.respond_to?(:each)
  133. 17 value.each do |uri|
  134. 34 next if uri.empty?
  135. 34 set_field_error(key, invalid_url_message) unless check_valid_no_fragment_uri?(uri)
  136. end
  137. else
  138. 51 set_field_error(key, invalid_url_message) unless check_valid_no_fragment_uri?(value)
  139. end
  140. 170 elsif key == oauth_application_scopes_param
  141. 51 value.each do |scope|
  142. 102 set_field_error(key, oauth_invalid_scope_message) unless oauth_application_scopes.include?(scope)
  143. end
  144. end
  145. end
  146. 85 throw :rodauth_error if @field_errors && !@field_errors.empty?
  147. end
  148. 17 def create_oauth_application
  149. 15 create_params = {
  150. 36 oauth_applications_account_id_column => account_id,
  151. oauth_applications_name_column => oauth_application_params[oauth_application_name_param],
  152. oauth_applications_description_column => oauth_application_params[oauth_application_description_param],
  153. oauth_applications_scopes_column => oauth_application_params[oauth_application_scopes_param],
  154. oauth_applications_homepage_url_column => oauth_application_params[oauth_application_homepage_url_param]
  155. }
  156. 51 redirect_uris = oauth_application_params[oauth_application_redirect_uri_param]
  157. 51 redirect_uris = redirect_uris.to_a.reject(&:empty?).join(" ") if redirect_uris.respond_to?(:each)
  158. 51 create_params[oauth_applications_redirect_uri_column] = redirect_uris unless redirect_uris.empty?
  159. # set client ID/secret pairs
  160. 51 set_client_secret(create_params, oauth_application_params[oauth_application_client_secret_param])
  161. 51 if create_params[oauth_applications_scopes_column]
  162. 39 create_params[oauth_applications_scopes_column] = create_params[oauth_applications_scopes_column].join(oauth_scope_separator)
  163. end
  164. 51 rescue_from_uniqueness_error do
  165. 39 create_params[oauth_applications_client_id_column] = oauth_unique_id_generator
  166. 51 db[oauth_applications_table].insert(create_params)
  167. end
  168. end
  169. end
  170. end

lib/rodauth/features/oauth_assertion_base.rb

100.0% lines covered

38 relevant lines. 38 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_assertion_base, :OauthAssertionBase) do
  5. 17 depends :oauth_base
  6. 17 auth_methods(
  7. :assertion_grant_type?,
  8. :client_assertion_type?,
  9. :assertion_grant_type,
  10. :client_assertion_type
  11. )
  12. 17 private
  13. 17 def validate_token_params
  14. 136 return super unless assertion_grant_type?
  15. 68 redirect_response_error("invalid_grant") unless param_or_nil("assertion")
  16. end
  17. 17 def require_oauth_application
  18. 272 if assertion_grant_type?
  19. 68 @oauth_application = __send__(:"require_oauth_application_from_#{assertion_grant_type}_assertion_issuer", param("assertion"))
  20. 204 elsif client_assertion_type?
  21. 153 @oauth_application = __send__(:"require_oauth_application_from_#{client_assertion_type}_assertion_subject",
  22. param("client_assertion"))
  23. 102 if (client_id = param_or_nil("client_id")) &&
  24. client_id != @oauth_application[oauth_applications_client_id_column]
  25. # If present, the value of the
  26. # "client_id" parameter MUST identify the same client as is
  27. # identified by the client assertion.
  28. 34 redirect_response_error("invalid_grant")
  29. end
  30. else
  31. 51 super
  32. end
  33. end
  34. 17 def account_from_bearer_assertion_subject(subject)
  35. 68 __insert_or_do_nothing_and_return__(
  36. db[accounts_table],
  37. account_id_column,
  38. [login_column],
  39. login_column => subject
  40. )
  41. end
  42. 17 def create_token(grant_type)
  43. 85 return super unless assertion_grant_type?(grant_type) && supported_grant_type?(grant_type)
  44. 68 account = __send__(:"account_from_#{assertion_grant_type}_assertion", param("assertion"))
  45. 68 redirect_response_error("invalid_grant") unless account
  46. 68 grant_scopes = if param_or_nil("scope")
  47. 34 redirect_response_error("invalid_scope") unless check_valid_scopes?
  48. 17 scopes
  49. else
  50. 34 @oauth_application[oauth_applications_scopes_column]
  51. end
  52. 15 grant_params = {
  53. 36 oauth_grants_type_column => grant_type,
  54. oauth_grants_account_id_column => account[account_id_column],
  55. oauth_grants_oauth_application_id_column => @oauth_application[oauth_applications_id_column],
  56. oauth_grants_scopes_column => grant_scopes
  57. }
  58. 51 generate_token(grant_params, false)
  59. end
  60. 17 def assertion_grant_type?(grant_type = param("grant_type"))
  61. 493 grant_type.start_with?("urn:ietf:params:oauth:grant-type:")
  62. end
  63. 17 def client_assertion_type?(client_assertion_type = param("client_assertion_type"))
  64. 204 client_assertion_type.start_with?("urn:ietf:params:oauth:client-assertion-type:")
  65. end
  66. 17 def assertion_grant_type(grant_type = param("grant_type"))
  67. 136 grant_type.delete_prefix("urn:ietf:params:oauth:grant-type:").tr("-", "_")
  68. end
  69. 17 def client_assertion_type(assertion_type = param("client_assertion_type"))
  70. 153 assertion_type.delete_prefix("urn:ietf:params:oauth:client-assertion-type:").tr("-", "_")
  71. end
  72. end
  73. end

lib/rodauth/features/oauth_authorization_code_grant.rb

100.0% lines covered

83 relevant lines. 83 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_authorization_code_grant, :OauthAuthorizationCodeGrant) do
  5. 17 depends :oauth_authorize_base
  6. 17 auth_value_method :oauth_response_mode, "form_post"
  7. 17 def oauth_grant_types_supported
  8. 6205 super | %w[authorization_code]
  9. end
  10. 17 def oauth_response_types_supported
  11. 2839 super | %w[code]
  12. end
  13. 17 def oauth_response_modes_supported
  14. 4726 super | %w[query form_post]
  15. end
  16. 17 private
  17. 17 def validate_authorize_params
  18. 3889 super
  19. 3617 response_mode = param_or_nil("response_mode")
  20. 3617 return unless response_mode
  21. 1411 redirect_response_error("invalid_request") unless oauth_response_modes_supported.include?(response_mode)
  22. 1411 response_type = param_or_nil("response_type")
  23. 1411 return unless response_type.nil? || response_type == "code"
  24. 1173 redirect_response_error("invalid_request") unless oauth_response_modes_for_code_supported.include?(response_mode)
  25. end
  26. 17 def oauth_response_modes_for_code_supported
  27. 1173 %w[query form_post]
  28. end
  29. 17 def validate_token_params
  30. 2584 redirect_response_error("invalid_request") if param_or_nil("grant_type") == "authorization_code" && !param_or_nil("code")
  31. 2584 super
  32. end
  33. 17 def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
  34. 1394 response_mode ||= oauth_response_mode
  35. 1394 redirect_response_error("invalid_request") unless response_mode.nil? || supported_response_mode?(response_mode)
  36. 1394 response_type = param_or_nil("response_type")
  37. 1394 redirect_response_error("invalid_request") unless response_type.nil? || supported_response_type?(response_type)
  38. 1066 case response_type
  39. when "code", nil
  40. 935 response_params.replace(_do_authorize_code)
  41. end
  42. 1377 response_params["state"] = param("state") if param_or_nil("state")
  43. 1377 [response_params, response_mode]
  44. end
  45. 17 def _do_authorize_code
  46. 325 create_params = {
  47. 780 oauth_grants_type_column => "authorization_code",
  48. **resource_owner_params
  49. }
  50. 1105 { "code" => create_oauth_grant(create_params) }
  51. end
  52. 17 def authorize_response(params, mode)
  53. 833 redirect_url = URI.parse(redirect_uri)
  54. 637 case mode
  55. when "query"
  56. 799 params = [URI.encode_www_form(params)]
  57. 799 params << redirect_url.query if redirect_url.query
  58. 799 redirect_url.query = params.join("&")
  59. 799 redirect(redirect_url.to_s)
  60. when "form_post"
  61. 34 inline_html = form_post_response_html(redirect_uri) do
  62. 32 params.map do |name, value|
  63. 34 "<input type=\"hidden\" name=\"#{scope.h(name)}\" value=\"#{scope.h(value)}\" />"
  64. 2 end.join
  65. end
  66. 34 scope.view layout: false, inline: inline_html
  67. end
  68. end
  69. 17 def _redirect_response_error(redirect_url, params)
  70. 544 response_mode = param_or_nil("response_mode") || oauth_response_mode
  71. 416 case response_mode
  72. when "form_post"
  73. 13 response["Content-Type"] = "text/html"
  74. 17 error_body = form_post_error_response_html(redirect_url) do
  75. 16 params.map do |name, value|
  76. 34 "<input type=\"hidden\" name=\"#{name}\" value=\"#{scope.h(value)}\" />"
  77. 1 end.join
  78. end
  79. 17 response.write(error_body)
  80. 17 request.halt
  81. else
  82. 527 super
  83. end
  84. end
  85. 17 def form_post_response_html(url)
  86. 39 <<-FORM
  87. 12 <html>
  88. <head><title>Authorized</title></head>
  89. <body onload="javascript:document.forms[0].submit()">
  90. 12 <form method="post" action="#{url}">
  91. 12 #{yield}
  92. 12 <input type="submit" class="btn btn-outline-primary" value="#{scope.h(oauth_authorize_post_button)}"/>
  93. </form>
  94. </body>
  95. </html>
  96. FORM
  97. end
  98. 17 def form_post_error_response_html(url)
  99. 13 <<-FORM
  100. 4 <html>
  101. <head><title></title></head>
  102. <body onload="javascript:document.forms[0].submit()">
  103. 4 <form method="post" action="#{url}">
  104. 4 #{yield}
  105. </form>
  106. </body>
  107. </html>
  108. FORM
  109. end
  110. 17 def create_token(grant_type)
  111. 2295 return super unless supported_grant_type?(grant_type, "authorization_code")
  112. 545 grant_params = {
  113. 1308 oauth_grants_code_column => param("code"),
  114. oauth_grants_redirect_uri_column => param("redirect_uri"),
  115. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column]
  116. }
  117. 1853 create_token_from_authorization_code(grant_params)
  118. end
  119. 17 def check_valid_response_type?
  120. 2478 response_type = param_or_nil("response_type")
  121. 2478 response_type == "code" || response_type == "none" || super
  122. end
  123. 17 def oauth_server_metadata_body(*)
  124. 459 super.tap do |data|
  125. 351 data[:authorization_endpoint] = authorize_url
  126. end
  127. end
  128. end
  129. end

lib/rodauth/features/oauth_authorize_base.rb

97.67% lines covered

129 relevant lines. 126 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "ipaddr"
  3. 17 require "rodauth/oauth"
  4. 17 module Rodauth
  5. 17 Feature.define(:oauth_authorize_base, :OauthAuthorizeBase) do
  6. 17 depends :oauth_base
  7. 17 before "authorize"
  8. 17 after "authorize"
  9. 17 view "authorize", "Authorize", "authorize"
  10. 17 view "authorize_error", "Authorize Error", "authorize_error"
  11. 17 button "Authorize", "oauth_authorize"
  12. 17 button "Back to Client Application", "oauth_authorize_post"
  13. 17 auth_value_method :use_oauth_access_type?, false
  14. 17 auth_value_method :oauth_grants_access_type_column, :access_type
  15. 17 translatable_method :authorize_page_lead, "The application %<name>s would like to access your data"
  16. 17 translatable_method :oauth_grants_scopes_label, "Scopes"
  17. 17 translatable_method :oauth_applications_contacts_label, "Contacts"
  18. 17 translatable_method :oauth_applications_tos_uri_label, "Terms of service URL"
  19. 17 translatable_method :oauth_applications_policy_uri_label, "Policy URL"
  20. 17 translatable_method :oauth_unsupported_response_type_message, "Unsupported response type"
  21. 17 translatable_method :oauth_authorize_parameter_required, "Invalid or missing '%<parameter>s'"
  22. 17 auth_methods(
  23. :resource_owner_params,
  24. :oauth_grants_resource_owner_columns
  25. )
  26. 17 OAUTH_ACCESS_TYPES = %w[offline online].freeze
  27. 17 OAUTH_APPROVAL_PROMPTS = %w[force auto].freeze
  28. # /authorize
  29. 17 auth_server_route(:authorize) do |r|
  30. 4293 require_authorizable_account
  31. 4123 before_authorize_route
  32. 4123 validate_authorize_params
  33. 3277 r.get do
  34. 1815 authorize_view
  35. end
  36. 1462 r.post do
  37. 1462 params, mode = transaction do
  38. 1462 before_authorize
  39. 1462 do_authorize
  40. end
  41. 1445 authorize_response(params, mode)
  42. end
  43. end
  44. 17 def check_csrf?
  45. 12639 case request.path
  46. when authorize_path
  47. 4293 only_json? ? false : super
  48. else
  49. 12226 super
  50. end
  51. end
  52. 17 def authorize_scopes
  53. 1815 scopes || begin
  54. 255 oauth_application[oauth_applications_scopes_column].split(oauth_scope_separator)
  55. end
  56. end
  57. 17 private
  58. 17 def validate_authorize_params
  59. 3838 redirect_authorize_error("client_id") unless oauth_application
  60. 3770 redirect_uris = oauth_application[oauth_applications_redirect_uri_column].split(" ")
  61. 3770 if (redirect_uri = param_or_nil("redirect_uri"))
  62. 799 normalized_redirect_uri = normalize_redirect_uri_for_comparison(redirect_uri)
  63. 799 unless redirect_uris.include?(normalized_redirect_uri) || redirect_uris.include?(redirect_uri)
  64. 17 redirect_authorize_error("redirect_uri")
  65. end
  66. 2971 elsif redirect_uris.size > 1
  67. 17 redirect_authorize_error("redirect_uri")
  68. end
  69. 3736 redirect_response_error("unsupported_response_type") unless check_valid_response_type?
  70. 3702 redirect_response_error("invalid_request") unless check_valid_access_type? && check_valid_approval_prompt?
  71. 3702 try_approval_prompt if use_oauth_access_type? && request.get?
  72. 3702 redirect_response_error("invalid_scope") if (request.post? || param_or_nil("scope")) && !check_valid_scopes?
  73. 3668 response_mode = param_or_nil("response_mode")
  74. 3668 redirect_response_error("invalid_request") unless response_mode.nil? || oauth_response_modes_supported.include?(response_mode)
  75. end
  76. 17 def check_valid_scopes?(scp = scopes)
  77. 3345 super(scp - %w[offline_access])
  78. end
  79. 17 def check_valid_response_type?
  80. 34 false
  81. end
  82. 17 def check_valid_access_type?
  83. 3702 return true unless use_oauth_access_type?
  84. 51 access_type = param_or_nil("access_type")
  85. 51 !access_type || OAUTH_ACCESS_TYPES.include?(access_type)
  86. end
  87. 17 def check_valid_approval_prompt?
  88. 3702 return true unless use_oauth_access_type?
  89. 51 approval_prompt = param_or_nil("approval_prompt")
  90. 51 !approval_prompt || OAUTH_APPROVAL_PROMPTS.include?(approval_prompt)
  91. end
  92. 17 def resource_owner_params
  93. 2074 { oauth_grants_account_id_column => account_id }
  94. end
  95. 17 def oauth_grants_resource_owner_columns
  96. [oauth_grants_account_id_column]
  97. end
  98. 17 def try_approval_prompt
  99. 34 approval_prompt = param_or_nil("approval_prompt")
  100. 34 return unless approval_prompt && approval_prompt == "auto"
  101. 16 return if db[oauth_grants_table].where(resource_owner_params).where(
  102. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
  103. oauth_grants_redirect_uri_column => redirect_uri,
  104. oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
  105. oauth_grants_access_type_column => "online"
  106. 1 ).none?
  107. # if there's a previous oauth grant for the params combo, it means that this user has approved before.
  108. 13 request.env["REQUEST_METHOD"] = "POST"
  109. end
  110. 17 def redirect_authorize_error(parameter, referer = request.referer || default_redirect)
  111. 136 error_message = oauth_authorize_parameter_required(parameter: parameter)
  112. 136 if accepts_json?
  113. status_code = oauth_invalid_response_status
  114. throw_json_response_error(status_code, "invalid_request", error_message)
  115. else
  116. 136 scope.instance_variable_set(:@error, error_message)
  117. 136 scope.instance_variable_set(:@back_url, referer)
  118. 136 return_response(authorize_error_view)
  119. end
  120. end
  121. 17 def authorization_required
  122. 578 if accepts_json?
  123. 561 throw_json_response_error(oauth_authorization_required_error_status, "invalid_client")
  124. else
  125. 17 set_redirect_error_flash(require_authorization_error_flash)
  126. 17 redirect(authorize_path)
  127. end
  128. end
  129. 17 def do_authorize(*args); end
  130. 17 def authorize_response(params, mode); end
  131. 17 def create_token_from_authorization_code(grant_params, should_generate_refresh_token = !use_oauth_access_type?, oauth_grant: nil)
  132. # fetch oauth grant
  133. 1785 oauth_grant ||= valid_locked_oauth_grant(grant_params)
  134. 1496 should_generate_refresh_token ||= oauth_grant[oauth_grants_access_type_column] == "offline"
  135. 1496 generate_token(oauth_grant, should_generate_refresh_token)
  136. end
  137. 17 def create_oauth_grant(create_params = {})
  138. 1173 create_params[oauth_grants_oauth_application_id_column] ||= oauth_application[oauth_applications_id_column]
  139. 1173 create_params[oauth_grants_redirect_uri_column] ||= redirect_uri
  140. 1173 create_params[oauth_grants_expires_in_column] ||= Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_grant_expires_in)
  141. 1173 create_params[oauth_grants_scopes_column] ||= scopes.join(oauth_scope_separator)
  142. 1173 if use_oauth_access_type? && (access_type = param_or_nil("access_type"))
  143. 26 create_params[oauth_grants_access_type_column] = access_type
  144. end
  145. 1173 ds = db[oauth_grants_table]
  146. 897 create_params[oauth_grants_code_column] = oauth_unique_id_generator
  147. 1173 if oauth_reuse_access_token
  148. 544 unique_conds = Hash[oauth_grants_unique_columns.map { |column| [column, create_params[column]] }]
  149. 136 valid_grant = valid_oauth_grant_ds(unique_conds).select(oauth_grants_id_column).first
  150. 136 if valid_grant
  151. 104 create_params[oauth_grants_id_column] = valid_grant[oauth_grants_id_column]
  152. 136 rescue_from_uniqueness_error do
  153. 136 __insert_or_update_and_return__(
  154. ds,
  155. oauth_grants_id_column,
  156. [oauth_grants_id_column],
  157. create_params
  158. )
  159. end
  160. 136 return create_params[oauth_grants_code_column]
  161. end
  162. end
  163. 1037 rescue_from_uniqueness_error do
  164. 1088 if __one_oauth_token_per_account
  165. 384 __insert_or_update_and_return__(
  166. ds,
  167. oauth_grants_id_column,
  168. oauth_grants_unique_columns,
  169. create_params,
  170. nil,
  171. {
  172. oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_grant_expires_in),
  173. oauth_grants_revoked_at_column => nil
  174. }
  175. )
  176. else
  177. 704 __insert_and_return__(ds, oauth_grants_id_column, create_params)
  178. end
  179. end
  180. 1020 create_params[oauth_grants_code_column]
  181. end
  182. 17 def normalize_redirect_uri_for_comparison(redirect_uri)
  183. 799 uri = URI(redirect_uri)
  184. 799 return redirect_uri unless uri.scheme == "http" && uri.port
  185. 68 hostname = uri.hostname
  186. # https://www.rfc-editor.org/rfc/rfc8252#section-7.3
  187. # ignore (potentially ephemeral) port number for native clients per RFC8252
  188. 4 begin
  189. 68 ip = IPAddr.new(hostname)
  190. 34 uri.port = nil if ip.loopback?
  191. rescue IPAddr::InvalidAddressError
  192. # https://www.rfc-editor.org/rfc/rfc8252#section-8.3
  193. # Although the use of localhost is NOT RECOMMENDED, it is still allowed.
  194. 34 uri.port = nil if hostname == "localhost"
  195. end
  196. 68 uri.to_s
  197. end
  198. end
  199. end

lib/rodauth/features/oauth_base.rb

96.09% lines covered

435 relevant lines. 418 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "time"
  3. 17 require "base64"
  4. 17 require "securerandom"
  5. 17 require "cgi"
  6. 17 require "digest/sha2"
  7. 17 require "rodauth/version"
  8. 17 require "rodauth/oauth"
  9. 17 require "rodauth/oauth/database_extensions"
  10. 17 require "rodauth/oauth/http_extensions"
  11. 17 module Rodauth
  12. 17 Feature.define(:oauth_base, :OauthBase) do
  13. 17 include OAuth::HTTPExtensions
  14. 17 EMPTY_HASH = {}.freeze
  15. 17 auth_value_methods(:http_request)
  16. 17 auth_value_methods(:http_request_cache)
  17. 17 before "token"
  18. 17 error_flash "Please authorize to continue", "require_authorization"
  19. 17 error_flash "You are not authorized to revoke this token", "revoke_unauthorized_account"
  20. 17 button "Cancel", "oauth_cancel"
  21. 17 auth_value_method :json_response_content_type, "application/json"
  22. 17 auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes
  23. 17 auth_value_method :oauth_access_token_expires_in, 60 * 60 # 60 minutes
  24. 17 auth_value_method :oauth_refresh_token_expires_in, 60 * 60 * 24 * 360 # 1 year
  25. 17 auth_value_method :oauth_unique_id_generation_retries, 3
  26. 17 auth_value_method :oauth_token_endpoint_auth_methods_supported, %w[client_secret_basic client_secret_post]
  27. 17 auth_value_method :oauth_grant_types_supported, %w[refresh_token]
  28. 17 auth_value_method :oauth_response_types_supported, []
  29. 17 auth_value_method :oauth_response_modes_supported, []
  30. 17 auth_value_method :oauth_valid_uri_schemes, %w[https]
  31. 17 auth_value_method :oauth_scope_separator, " "
  32. # OAuth Grants
  33. 17 auth_value_method :oauth_grants_table, :oauth_grants
  34. 17 auth_value_method :oauth_grants_id_column, :id
  35. 16 %i[
  36. account_id oauth_application_id type
  37. redirect_uri code scopes
  38. expires_in revoked_at
  39. token refresh_token
  40. 1 ].each do |column|
  41. 170 auth_value_method :"oauth_grants_#{column}_column", column
  42. end
  43. # Enables Token Hash
  44. 17 auth_value_method :oauth_grants_token_hash_column, :token
  45. 17 auth_value_method :oauth_grants_refresh_token_hash_column, :refresh_token
  46. # Access Token reuse
  47. 17 auth_value_method :oauth_reuse_access_token, false
  48. 17 auth_value_method :oauth_applications_table, :oauth_applications
  49. 17 auth_value_method :oauth_applications_id_column, :id
  50. 16 %i[
  51. account_id
  52. name description scopes
  53. client_id client_secret
  54. homepage_url redirect_uri
  55. token_endpoint_auth_method grant_types response_types response_modes
  56. logo_uri tos_uri policy_uri jwks jwks_uri
  57. contacts software_id software_version
  58. 1 ].each do |column|
  59. 340 auth_value_method :"oauth_applications_#{column}_column", column
  60. end
  61. # Enables client secret Hash
  62. 17 auth_value_method :oauth_applications_client_secret_hash_column, :client_secret
  63. 17 auth_value_method :oauth_authorization_required_error_status, 401
  64. 17 auth_value_method :oauth_invalid_response_status, 400
  65. 17 auth_value_method :oauth_already_in_use_response_status, 409
  66. # Feature options
  67. 17 auth_value_method :oauth_application_scopes, []
  68. 17 auth_value_method :oauth_token_type, "bearer"
  69. 17 auth_value_method :oauth_refresh_token_protection_policy, "rotation" # can be: none, sender_constrained, rotation
  70. 17 translatable_method :oauth_invalid_client_message, "Invalid client"
  71. 17 translatable_method :oauth_invalid_grant_type_message, "Invalid grant type"
  72. 17 translatable_method :oauth_invalid_grant_message, "Invalid grant"
  73. 17 translatable_method :oauth_invalid_scope_message, "Invalid scope"
  74. 17 translatable_method :oauth_unsupported_token_type_message, "Invalid token type hint"
  75. 17 translatable_method :oauth_already_in_use_message, "error generating unique token"
  76. 17 auth_value_method :oauth_already_in_use_error_code, "invalid_request"
  77. 17 auth_value_method :oauth_invalid_grant_type_error_code, "unsupported_grant_type"
  78. 17 auth_value_method :is_authorization_server?, true
  79. 17 auth_value_methods(:only_json?)
  80. 17 auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i
  81. # METADATA
  82. 17 auth_value_method :oauth_metadata_service_documentation, nil
  83. 17 auth_value_method :oauth_metadata_ui_locales_supported, nil
  84. 17 auth_value_method :oauth_metadata_op_policy_uri, nil
  85. 17 auth_value_method :oauth_metadata_op_tos_uri, nil
  86. 17 auth_value_methods(
  87. :authorization_server_url,
  88. :oauth_grants_unique_columns
  89. )
  90. 17 auth_methods(
  91. :fetch_access_token,
  92. :secret_hash,
  93. :generate_token_hash,
  94. :secret_matches?,
  95. :oauth_unique_id_generator,
  96. :require_authorizable_account,
  97. :oauth_account_ds,
  98. :oauth_application_ds
  99. )
  100. # /token
  101. 17 auth_server_route(:token) do |r|
  102. 3451 require_oauth_application
  103. 3043 before_token_route
  104. 3043 r.post do
  105. 3043 catch_error do
  106. 3043 validate_token_params
  107. 2788 oauth_grant = nil
  108. 2788 transaction do
  109. 2788 before_token
  110. 2788 oauth_grant = create_token(param("grant_type"))
  111. end
  112. 1836 json_response_success(json_access_token_payload(oauth_grant))
  113. end
  114. throw_json_response_error(oauth_invalid_response_status, "invalid_request")
  115. end
  116. end
  117. 17 def load_oauth_server_metadata_route(issuer = nil)
  118. 340 request.on(".well-known") do
  119. 340 request.get("oauth-authorization-server") do
  120. 340 json_response_success(oauth_server_metadata_body(issuer), true)
  121. end
  122. end
  123. end
  124. 17 def check_csrf?
  125. 11963 case request.path
  126. when token_path
  127. 3451 false
  128. else
  129. 12184 super
  130. end
  131. end
  132. 17 def oauth_token_subject
  133. 187 return unless authorization_token
  134. 187 authorization_token[oauth_grants_account_id_column] ||
  135. db[oauth_applications_table].where(
  136. oauth_applications_id_column => authorization_token[oauth_grants_oauth_application_id_column]
  137. ).select_map(oauth_applications_client_id_column).first
  138. end
  139. 17 def current_oauth_account
  140. 187 account_id = authorization_token[oauth_grants_account_id_column]
  141. 187 return unless account_id
  142. 153 oauth_account_ds(account_id).first
  143. end
  144. 17 def current_oauth_application
  145. 221 oauth_application_ds(authorization_token[oauth_grants_oauth_application_id_column]).first
  146. end
  147. 17 def accepts_json?
  148. 2856 return true if only_json?
  149. 2839 (accept = request.env["HTTP_ACCEPT"]) && accept =~ json_request_regexp
  150. end
  151. # copied from the jwt feature
  152. 17 def json_request?
  153. 340 return super if features.include?(:jsonn)
  154. 340 return @json_request if defined?(@json_request)
  155. 340 @json_request = request.content_type =~ json_request_regexp
  156. end
  157. 17 def scopes
  158. 8811 scope = request.params["scope"]
  159. 6747 case scope
  160. when Array
  161. 3485 scope
  162. when String
  163. 4799 scope.split(" ")
  164. end
  165. end
  166. 17 def redirect_uri
  167. 6439 param_or_nil("redirect_uri") || begin
  168. 5113 return unless oauth_application
  169. 5113 redirect_uris = oauth_application[oauth_applications_redirect_uri_column].split(" ")
  170. 5113 redirect_uris.size == 1 ? redirect_uris.first : nil
  171. end
  172. end
  173. 17 def oauth_application
  174. 59770 return @oauth_application if defined?(@oauth_application)
  175. 4739 client_id = param_or_nil("client_id")
  176. 4739 return unless client_id
  177. 4569 @oauth_application = db[oauth_applications_table].filter(oauth_applications_client_id_column => client_id).first
  178. end
  179. 17 def fetch_access_token
  180. 1156 if (token = request.params["access_token"])
  181. 34 if request.post? && !(request.content_type.start_with?("application/x-www-form-urlencoded") &&
  182. request.params.size == 1)
  183. return
  184. end
  185. else
  186. 1122 token = fetch_access_token_from_authorization_header
  187. end
  188. 1156 return if token.nil? || token.empty?
  189. 918 token
  190. end
  191. 17 def fetch_access_token_from_authorization_header(token_type = oauth_token_type)
  192. 1190 value = request.env["HTTP_AUTHORIZATION"]
  193. 1190 return unless value && !value.empty?
  194. 1054 scheme, token = value.split(" ", 2)
  195. 1054 return unless scheme.downcase == token_type
  196. 1020 token
  197. end
  198. 17 def authorization_token
  199. 1547 return @authorization_token if defined?(@authorization_token)
  200. # check if there is a token
  201. 493 access_token = fetch_access_token
  202. 493 return unless access_token
  203. 306 @authorization_token = oauth_grant_by_token(access_token)
  204. end
  205. 17 def require_oauth_authorization(*scopes)
  206. 459 authorization_required unless authorization_token
  207. 238 token_scopes = authorization_token[oauth_grants_scopes_column].split(oauth_scope_separator)
  208. 510 authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
  209. end
  210. 17 def use_date_arithmetic?
  211. 7470 true
  212. end
  213. # override
  214. 17 def translate(key, default, args = EMPTY_HASH)
  215. 41099 return i18n_translate(key, default, **args) if features.include?(:i18n)
  216. # do not attempt to translate by default
  217. 136 return default if args.nil?
  218. 136 default % args
  219. end
  220. 17 def post_configure
  221. 7844 super
  222. 7844 i18n_register(File.expand_path(File.join(__dir__, "..", "..", "..", "locales"))) if features.include?(:i18n)
  223. # all of the extensions below involve DB changes. Resource server mode doesn't use
  224. # database functions for OAuth though.
  225. 7844 return unless is_authorization_server?
  226. 7606 self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
  227. # Check whether we can reutilize db entries for the same account / application pair
  228. 7606 one_oauth_token_per_account = db.indexes(oauth_grants_table).values.any? do |definition|
  229. 40719 definition[:unique] &&
  230. definition[:columns] == oauth_grants_unique_columns
  231. end
  232. 10275 self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account }
  233. end
  234. 17 private
  235. 17 def oauth_account_ds(account_id)
  236. 374 account_ds(account_id)
  237. end
  238. 17 def oauth_application_ds(oauth_application_id)
  239. 221 db[oauth_applications_table].where(oauth_applications_id_column => oauth_application_id)
  240. end
  241. 17 def require_authorizable_account
  242. 4650 require_account
  243. end
  244. 17 def rescue_from_uniqueness_error(&block)
  245. 4029 retries = oauth_unique_id_generation_retries
  246. 237 begin
  247. 4131 transaction(savepoint: :only, &block)
  248. 40 rescue Sequel::UniqueConstraintViolation
  249. 136 redirect_response_error("already_in_use") if retries.zero?
  250. 78 retries -= 1
  251. 102 retry
  252. end
  253. end
  254. # OAuth Token Unique/Reuse
  255. 17 def oauth_grants_unique_columns
  256. 13496 [
  257. 27942 oauth_grants_oauth_application_id_column,
  258. oauth_grants_account_id_column,
  259. oauth_grants_scopes_column
  260. ]
  261. end
  262. 17 def authorization_server_url
  263. 2765 base_url
  264. end
  265. 17 def template_path(page)
  266. 89249 path = File.join(File.dirname(__FILE__), "../../../templates", "#{page}.str")
  267. 89249 return super unless File.exist?(path)
  268. 3321 path
  269. end
  270. # to be used internally. Same semantics as require account, must:
  271. # fetch an authorization basic header
  272. # parse client id and secret
  273. #
  274. 17 def require_oauth_application
  275. 3417 @oauth_application = if (token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Basic (.*)\Z/, 1])
  276. # client_secret_basic
  277. 935 require_oauth_application_from_client_secret_basic(token)
  278. 2482 elsif (client_id = param_or_nil("client_id"))
  279. 2363 if (client_secret = param_or_nil("client_secret"))
  280. # client_secret_post
  281. 1666 require_oauth_application_from_client_secret_post(client_id, client_secret)
  282. else
  283. # none
  284. 697 require_oauth_application_from_none(client_id)
  285. end
  286. else
  287. 119 authorization_required
  288. end
  289. end
  290. 17 def require_oauth_application_from_client_secret_basic(token)
  291. 935 client_id, client_secret = Base64.decode64(token).split(":", 2)
  292. 935 authorization_required unless client_id
  293. 935 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
  294. 880 authorization_required unless supports_auth_method?(oauth_application,
  295. 55 "client_secret_basic") && secret_matches?(oauth_application, client_secret)
  296. 901 oauth_application
  297. end
  298. 17 def require_oauth_application_from_client_secret_post(client_id, client_secret)
  299. 1666 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
  300. 1568 authorization_required unless supports_auth_method?(oauth_application,
  301. 98 "client_secret_post") && secret_matches?(oauth_application, client_secret)
  302. 1632 oauth_application
  303. end
  304. 17 def require_oauth_application_from_none(client_id)
  305. 697 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
  306. 697 authorization_required unless supports_auth_method?(oauth_application, "none")
  307. 544 oauth_application
  308. end
  309. 17 def supports_auth_method?(oauth_application, auth_method)
  310. 3638 return false unless oauth_application
  311. 3587 supported_auth_methods = if oauth_application[oauth_applications_token_endpoint_auth_method_column]
  312. 935 oauth_application[oauth_applications_token_endpoint_auth_method_column].split(/ +/)
  313. else
  314. 2652 oauth_token_endpoint_auth_methods_supported
  315. end
  316. 3587 supported_auth_methods.include?(auth_method)
  317. end
  318. 17 def require_oauth_application_from_account
  319. 17 ds = db[oauth_applications_table]
  320. .join(oauth_grants_table, Sequel[oauth_grants_table][oauth_grants_oauth_application_id_column] =>
  321. Sequel[oauth_applications_table][oauth_applications_id_column])
  322. .where(oauth_grant_by_token_ds(param("token")).opts.fetch(:where, true))
  323. .where(Sequel[oauth_applications_table][oauth_applications_account_id_column] => account_id)
  324. 17 @oauth_application = ds.qualify.first
  325. 17 return if @oauth_application
  326. set_redirect_error_flash revoke_unauthorized_account_error_flash
  327. redirect request.referer || "/"
  328. end
  329. 17 def secret_matches?(oauth_application, secret)
  330. 2533 if oauth_applications_client_secret_hash_column
  331. 2533 BCrypt::Password.new(oauth_application[oauth_applications_client_secret_hash_column]) == secret
  332. else
  333. oauth_application[oauth_applications_client_secret_column] == secret
  334. end
  335. end
  336. 17 def set_client_secret(params, secret)
  337. 969 if oauth_applications_client_secret_hash_column
  338. 741 params[oauth_applications_client_secret_hash_column] = secret_hash(secret)
  339. else
  340. params[oauth_applications_client_secret_column] = secret
  341. end
  342. end
  343. 17 def secret_hash(secret)
  344. 2278 password_hash(secret)
  345. end
  346. 17 def oauth_unique_id_generator
  347. 6341 SecureRandom.urlsafe_base64(32)
  348. end
  349. 17 def generate_token_hash(token)
  350. 425 Base64.urlsafe_encode64(Digest::SHA256.digest(token))
  351. end
  352. 17 def grant_from_application?(oauth_grant, oauth_application)
  353. 289 oauth_grant[oauth_grants_oauth_application_id_column] == oauth_application[oauth_applications_id_column]
  354. end
  355. 17 def password_hash(password)
  356. 2278 return super if features.include?(:login_password_requirements_base)
  357. BCrypt::Password.create(password, cost: BCrypt::Engine::DEFAULT_COST)
  358. end
  359. 17 def generate_token(grant_params = {}, should_generate_refresh_token = true)
  360. 1717 if grant_params[oauth_grants_id_column] && oauth_reuse_access_token &&
  361. (
  362. 272 if oauth_grants_token_hash_column
  363. 136 grant_params[oauth_grants_token_hash_column]
  364. else
  365. 136 grant_params[oauth_grants_token_column]
  366. end
  367. )
  368. 104 return grant_params
  369. end
  370. 465 update_params = {
  371. 1116 oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_access_token_expires_in),
  372. oauth_grants_code_column => nil
  373. }
  374. 1581 rescue_from_uniqueness_error do
  375. 1581 access_token = _generate_access_token(update_params)
  376. 1581 refresh_token = _generate_refresh_token(update_params) if should_generate_refresh_token
  377. 1581 oauth_grant = store_token(grant_params, update_params)
  378. 1581 return unless oauth_grant
  379. 1209 oauth_grant[oauth_grants_token_column] = access_token
  380. 1581 oauth_grant[oauth_grants_refresh_token_column] = refresh_token if refresh_token
  381. 1581 oauth_grant
  382. end
  383. end
  384. 17 def _generate_access_token(params = {})
  385. 901 token = oauth_unique_id_generator
  386. 901 if oauth_grants_token_hash_column
  387. 117 params[oauth_grants_token_hash_column] = generate_token_hash(token)
  388. else
  389. 572 params[oauth_grants_token_column] = token
  390. end
  391. 901 token
  392. end
  393. 17 def _generate_refresh_token(params)
  394. 1054 token = oauth_unique_id_generator
  395. 1054 if oauth_grants_refresh_token_hash_column
  396. 117 params[oauth_grants_refresh_token_hash_column] = generate_token_hash(token)
  397. else
  398. 689 params[oauth_grants_refresh_token_column] = token
  399. end
  400. 1054 token
  401. end
  402. 17 def _grant_with_access_token?(oauth_grant)
  403. if oauth_grants_token_hash_column
  404. oauth_grant[oauth_grants_token_hash_column]
  405. else
  406. oauth_grant[oauth_grants_token_column]
  407. end
  408. end
  409. 17 def store_token(grant_params, update_params = {})
  410. 1581 ds = db[oauth_grants_table]
  411. 1581 if __one_oauth_token_per_account
  412. to_update_if_null = [
  413. 558 oauth_grants_token_column,
  414. oauth_grants_token_hash_column,
  415. oauth_grants_refresh_token_column,
  416. oauth_grants_refresh_token_hash_column
  417. ].compact.map do |attribute|
  418. [
  419. 1224 attribute,
  420. (
  421. 1224 if ds.respond_to?(:supports_insert_conflict?) && ds.supports_insert_conflict?
  422. 612 Sequel.function(:coalesce, Sequel[oauth_grants_table][attribute], Sequel[:excluded][attribute])
  423. else
  424. 612 Sequel.function(:coalesce, Sequel[oauth_grants_table][attribute], update_params[attribute])
  425. end
  426. )
  427. ]
  428. end
  429. 558 token = __insert_or_update_and_return__(
  430. ds,
  431. oauth_grants_id_column,
  432. oauth_grants_unique_columns,
  433. grant_params.merge(update_params),
  434. Sequel.expr(Sequel[oauth_grants_table][oauth_grants_expires_in_column]) > Sequel::CURRENT_TIMESTAMP,
  435. Hash[to_update_if_null]
  436. )
  437. # if the previous operation didn't return a row, it means that the conditions
  438. # invalidated the update, and the existing token is still valid.
  439. 558 token || ds.where(
  440. oauth_grants_account_id_column => update_params[oauth_grants_account_id_column],
  441. oauth_grants_oauth_application_id_column => update_params[oauth_grants_oauth_application_id_column]
  442. ).first
  443. else
  444. 1023 if oauth_reuse_access_token
  445. 352 unique_conds = Hash[oauth_grants_unique_columns.map { |column| [column, update_params[column]] }]
  446. 88 valid_token_ds = valid_oauth_grant_ds(unique_conds)
  447. 88 if oauth_grants_token_hash_column
  448. 44 valid_token_ds.exclude(oauth_grants_token_hash_column => nil)
  449. else
  450. 44 valid_token_ds.exclude(oauth_grants_token_column => nil)
  451. end
  452. 88 valid_token = valid_token_ds.first
  453. 88 return valid_token if valid_token
  454. end
  455. 1023 if grant_params[oauth_grants_id_column]
  456. 880 __update_and_return__(ds.where(oauth_grants_id_column => grant_params[oauth_grants_id_column]), update_params)
  457. else
  458. 143 __insert_and_return__(ds, oauth_grants_id_column, grant_params.merge(update_params))
  459. end
  460. end
  461. end
  462. 17 def valid_locked_oauth_grant(grant_params = nil)
  463. 1853 oauth_grant = valid_oauth_grant_ds(grant_params).for_update.first
  464. 1853 redirect_response_error("invalid_grant") unless oauth_grant
  465. 1547 oauth_grant
  466. end
  467. 17 def valid_oauth_grant_ds(grant_params = nil)
  468. 3199 ds = db[oauth_grants_table]
  469. .where(Sequel[oauth_grants_table][oauth_grants_revoked_at_column] => nil)
  470. .where(Sequel.expr(Sequel[oauth_grants_table][oauth_grants_expires_in_column]) >= Sequel::CURRENT_TIMESTAMP)
  471. 3199 ds = ds.where(grant_params) if grant_params
  472. 3199 ds
  473. end
  474. 17 def oauth_grant_by_token_ds(token)
  475. 629 ds = valid_oauth_grant_ds
  476. 629 if oauth_grants_token_hash_column
  477. 68 ds.where(Sequel[oauth_grants_table][oauth_grants_token_hash_column] => generate_token_hash(token))
  478. else
  479. 561 ds.where(Sequel[oauth_grants_table][oauth_grants_token_column] => token)
  480. end
  481. end
  482. 17 def oauth_grant_by_token(token)
  483. 527 oauth_grant_by_token_ds(token).first
  484. end
  485. 17 def oauth_grant_by_refresh_token_ds(token, revoked: false)
  486. 561 ds = db[oauth_grants_table].where(oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column])
  487. #
  488. # filter expired refresh tokens out.
  489. # an expired refresh token is a token whose access token expired for a period longer than the
  490. # refresh token expiration period.
  491. #
  492. 561 ds = ds.where(Sequel.date_add(oauth_grants_expires_in_column,
  493. 561 seconds: (oauth_refresh_token_expires_in - oauth_access_token_expires_in)) >= Sequel::CURRENT_TIMESTAMP)
  494. 561 ds = if oauth_grants_refresh_token_hash_column
  495. 51 ds.where(oauth_grants_refresh_token_hash_column => generate_token_hash(token))
  496. else
  497. 510 ds.where(oauth_grants_refresh_token_column => token)
  498. end
  499. 561 ds = ds.where(oauth_grants_revoked_at_column => nil) unless revoked
  500. 561 ds
  501. end
  502. 17 def oauth_grant_by_refresh_token(token, **kwargs)
  503. 136 oauth_grant_by_refresh_token_ds(token, **kwargs).first
  504. end
  505. 17 def json_access_token_payload(oauth_grant)
  506. 575 payload = {
  507. 1380 "access_token" => oauth_grant[oauth_grants_token_column],
  508. "token_type" => oauth_token_type,
  509. "expires_in" => oauth_access_token_expires_in
  510. }
  511. 1955 payload["refresh_token"] = oauth_grant[oauth_grants_refresh_token_column] if oauth_grant[oauth_grants_refresh_token_column]
  512. 1955 payload
  513. end
  514. # Access Tokens
  515. 17 def validate_token_params
  516. 2805 unless (grant_type = param_or_nil("grant_type"))
  517. 85 redirect_response_error("invalid_request")
  518. end
  519. 2720 redirect_response_error("invalid_request") if grant_type == "refresh_token" && !param_or_nil("refresh_token")
  520. end
  521. 17 def create_token(grant_type)
  522. 612 redirect_response_error("invalid_request") unless supported_grant_type?(grant_type, "refresh_token")
  523. 425 refresh_token = param("refresh_token")
  524. # fetch potentially revoked oauth token
  525. 425 oauth_grant = oauth_grant_by_refresh_token_ds(refresh_token, revoked: true).for_update.first
  526. 425 update_params = { oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
  527. seconds: oauth_access_token_expires_in) }
  528. 425 if !oauth_grant || oauth_grant[oauth_grants_revoked_at_column]
  529. 204 redirect_response_error("invalid_grant")
  530. 221 elsif oauth_refresh_token_protection_policy == "rotation"
  531. # https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00#section-6.1
  532. #
  533. # If a refresh token is compromised and subsequently used by both the attacker and the legitimate
  534. # client, one of them will present an invalidated refresh token, which will inform the authorization
  535. # server of the breach. The authorization server cannot determine which party submitted the invalid
  536. # refresh token, but it will revoke the active refresh token. This stops the attack at the cost of
  537. # forcing the legitimate client to obtain a fresh authorization grant.
  538. 102 refresh_token = _generate_refresh_token(update_params)
  539. end
  540. 169 update_params[oauth_grants_oauth_application_id_column] = oauth_grant[oauth_grants_oauth_application_id_column]
  541. 221 oauth_grant = create_token_from_token(oauth_grant, update_params)
  542. 156 oauth_grant[oauth_grants_refresh_token_column] = refresh_token
  543. 204 oauth_grant
  544. end
  545. 17 def create_token_from_token(oauth_grant, update_params)
  546. 221 redirect_response_error("invalid_grant") unless grant_from_application?(oauth_grant, oauth_application)
  547. 221 rescue_from_uniqueness_error do
  548. 272 oauth_grants_ds = db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column])
  549. 272 access_token = _generate_access_token(update_params)
  550. 272 oauth_grant = __update_and_return__(oauth_grants_ds, update_params)
  551. 156 oauth_grant[oauth_grants_token_column] = access_token
  552. 204 oauth_grant
  553. end
  554. end
  555. 17 def supported_grant_type?(grant_type, expected_grant_type = grant_type)
  556. 3281 return false unless grant_type == expected_grant_type
  557. 2669 grant_types_supported = if oauth_application[oauth_applications_grant_types_column]
  558. 68 oauth_application[oauth_applications_grant_types_column].split(/ +/)
  559. else
  560. 2601 oauth_grant_types_supported
  561. end
  562. 2669 grant_types_supported.include?(grant_type)
  563. end
  564. 17 def supported_response_type?(response_type, expected_response_type = response_type)
  565. 1462 return false unless response_type == expected_response_type
  566. 1462 response_types_supported = if oauth_application[oauth_applications_response_types_column]
  567. 17 oauth_application[oauth_applications_response_types_column].split(/ +/)
  568. else
  569. 1445 oauth_response_types_supported
  570. end
  571. 1462 response_types = response_type.split(/ +/)
  572. 1462 (response_types - response_types_supported).empty?
  573. end
  574. 17 def supported_response_mode?(response_mode, expected_response_mode = response_mode)
  575. 1445 return false unless response_mode == expected_response_mode
  576. 1445 response_modes_supported = if oauth_application[oauth_applications_response_modes_column]
  577. oauth_application[oauth_applications_response_modes_column].split(/ +/)
  578. else
  579. 1445 oauth_response_modes_supported
  580. end
  581. 1445 response_modes_supported.include?(response_mode)
  582. end
  583. 17 def oauth_server_metadata_body(path = nil)
  584. 459 issuer = base_url
  585. 459 issuer += "/#{path}" if path
  586. 135 {
  587. 324 issuer: issuer,
  588. token_endpoint: token_url,
  589. scopes_supported: oauth_application_scopes,
  590. response_types_supported: oauth_response_types_supported,
  591. response_modes_supported: oauth_response_modes_supported,
  592. grant_types_supported: oauth_grant_types_supported,
  593. token_endpoint_auth_methods_supported: oauth_token_endpoint_auth_methods_supported,
  594. service_documentation: oauth_metadata_service_documentation,
  595. ui_locales_supported: oauth_metadata_ui_locales_supported,
  596. op_policy_uri: oauth_metadata_op_policy_uri,
  597. op_tos_uri: oauth_metadata_op_tos_uri
  598. }
  599. end
  600. 17 def redirect_response_error(error_code, message = nil)
  601. 2074 if accepts_json?
  602. 1309 status_code = if respond_to?(:"oauth_#{error_code}_response_status")
  603. 34 send(:"oauth_#{error_code}_response_status")
  604. else
  605. 1275 oauth_invalid_response_status
  606. end
  607. 1309 throw_json_response_error(status_code, error_code, message)
  608. else
  609. 765 redirect_url = redirect_uri || request.referer || default_redirect
  610. 765 redirect_url = URI.parse(redirect_url)
  611. 765 params = response_error_params(error_code, message)
  612. 765 state = param_or_nil("state")
  613. 765 params["state"] = state if state
  614. 765 _redirect_response_error(redirect_url, params)
  615. end
  616. end
  617. 17 def _redirect_response_error(redirect_url, params)
  618. 510 params = URI.encode_www_form(params)
  619. 510 if redirect_url.query
  620. params << "&" unless params.empty?
  621. params << redirect_url.query
  622. end
  623. 510 redirect_url.query = params
  624. 510 redirect(redirect_url.to_s)
  625. end
  626. 17 def response_error_params(error_code, message = nil)
  627. 4080 code = if respond_to?(:"oauth_#{error_code}_error_code")
  628. 119 send(:"oauth_#{error_code}_error_code")
  629. else
  630. 3961 error_code
  631. end
  632. 4080 payload = { "error" => code }
  633. 4080 error_description = message
  634. 4080 error_description ||= send(:"oauth_#{error_code}_message") if respond_to?(:"oauth_#{error_code}_message")
  635. 4080 payload["error_description"] = error_description if error_description
  636. 4080 payload
  637. end
  638. 17 def json_response_success(body, cache = false)
  639. 2958 response.status = 200
  640. 2958 response["Content-Type"] ||= json_response_content_type
  641. 2958 if cache
  642. # defaulting to 1-day for everyone, for now at least
  643. 510 max_age = 60 * 60 * 24
  644. 390 response["Cache-Control"] = "private, max-age=#{max_age}"
  645. else
  646. 1872 response["Cache-Control"] = "no-store"
  647. 1872 response["Pragma"] = "no-cache"
  648. end
  649. 2958 json_payload = _json_response_body(body)
  650. 2958 return_response(json_payload)
  651. end
  652. 17 def throw_json_response_error(status, error_code, message = nil)
  653. 3315 set_response_error_status(status)
  654. 3315 payload = response_error_params(error_code, message)
  655. 3315 json_payload = _json_response_body(payload)
  656. 3315 response["Content-Type"] ||= json_response_content_type
  657. 3315 response["WWW-Authenticate"] = www_authenticate_header(payload) if status == 401
  658. 3315 return_response(json_payload)
  659. end
  660. 17 def www_authenticate_header(*)
  661. 833 oauth_token_type.capitalize
  662. end
  663. 17 def _json_response_body(hash)
  664. 7225 return super if features.include?(:json)
  665. 7225 if request.respond_to?(:convert_to_json)
  666. request.send(:convert_to_json, hash)
  667. else
  668. 7225 JSON.dump(hash)
  669. end
  670. end
  671. 17 if Gem::Version.new(Rodauth.version) < Gem::Version.new("2.23")
  672. def return_response(body = nil)
  673. response.write(body) if body
  674. request.halt
  675. end
  676. end
  677. 17 def authorization_required
  678. 272 throw_json_response_error(oauth_authorization_required_error_status, "invalid_client")
  679. end
  680. 17 def check_valid_scopes?(scp = scopes)
  681. 3396 return false unless scp
  682. 3396 (scp - oauth_application[oauth_applications_scopes_column].split(oauth_scope_separator)).empty?
  683. end
  684. 17 def check_valid_uri?(uri)
  685. 12682 URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(uri)
  686. end
  687. 17 def check_valid_no_fragment_uri?(uri)
  688. 3910 check_valid_uri?(uri) && URI.parse(uri).fragment.nil?
  689. end
  690. # Resource server mode
  691. 17 def authorization_server_metadata
  692. 68 auth_url = URI(authorization_server_url).dup
  693. 68 auth_url.path = "/.well-known/oauth-authorization-server"
  694. 68 http_request_with_cache(auth_url)
  695. end
  696. end
  697. end

lib/rodauth/features/oauth_client_credentials_grant.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_client_credentials_grant, :OauthClientCredentialsGrant) do
  5. 17 depends :oauth_base
  6. 17 def oauth_grant_types_supported
  7. 136 super | %w[client_credentials]
  8. end
  9. 17 private
  10. 17 def create_token(grant_type)
  11. 102 return super unless supported_grant_type?(grant_type, "client_credentials")
  12. 85 grant_scopes = scopes
  13. 85 grant_scopes = if grant_scopes
  14. 17 redirect_response_error("invalid_scope") unless check_valid_scopes?
  15. 17 grant_scopes.join(oauth_scope_separator)
  16. else
  17. 68 oauth_application[oauth_applications_scopes_column]
  18. end
  19. 25 grant_params = {
  20. 60 oauth_grants_type_column => "client_credentials",
  21. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
  22. oauth_grants_scopes_column => grant_scopes
  23. }
  24. 85 generate_token(grant_params, false)
  25. end
  26. end
  27. end

lib/rodauth/features/oauth_device_code_grant.rb

95.33% lines covered

107 relevant lines. 102 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_device_code_grant, :OauthDeviceCodeGrant) do
  5. 17 depends :oauth_authorize_base
  6. 17 before "device_authorization"
  7. 17 before "device_verification"
  8. 17 notice_flash "The device is verified", "device_verification"
  9. 17 error_flash "No device to authorize with the given user code", "user_code_not_found"
  10. 17 view "device_verification", "Device Verification", "device_verification"
  11. 17 view "device_search", "Device Search", "device_search"
  12. 17 button "Verify", "oauth_device_verification"
  13. 17 button "Search", "oauth_device_search"
  14. 17 auth_value_method :oauth_grants_user_code_column, :user_code
  15. 17 auth_value_method :oauth_grants_last_polled_at_column, :last_polled_at
  16. 17 translatable_method :oauth_device_search_page_lead, "Insert the user code from the device you'd like to authorize."
  17. 17 translatable_method :oauth_device_verification_page_lead, "The device with user code %<user_code>s would like to access your data."
  18. 17 translatable_method :oauth_expired_token_message, "the device code has expired"
  19. 17 translatable_method :oauth_access_denied_message, "the authorization request has been denied"
  20. 17 translatable_method :oauth_authorization_pending_message, "the authorization request is still pending"
  21. 17 translatable_method :oauth_slow_down_message, "authorization request is still pending but poll interval should be increased"
  22. 17 auth_value_method :oauth_device_code_grant_polling_interval, 5 # seconds
  23. 17 auth_value_method :oauth_device_code_grant_user_code_size, 8 # characters
  24. 17 %w[user_code].each do |param|
  25. 17 auth_value_method :"oauth_grant_#{param}_param", param
  26. end
  27. 17 translatable_method :oauth_grant_user_code_label, "User code"
  28. 17 auth_methods(
  29. :generate_user_code
  30. )
  31. # /device-authorization
  32. 17 auth_server_route(:device_authorization) do |r|
  33. 34 require_oauth_application
  34. 34 before_device_authorization_route
  35. 34 r.post do
  36. 34 user_code = generate_user_code
  37. 34 device_code = transaction do
  38. 34 before_device_authorization
  39. 34 create_oauth_grant(
  40. oauth_grants_type_column => "device_code",
  41. oauth_grants_user_code_column => user_code
  42. )
  43. end
  44. 34 json_response_success \
  45. "device_code" => device_code,
  46. "user_code" => user_code,
  47. "verification_uri" => device_url,
  48. "verification_uri_complete" => device_url(user_code: user_code),
  49. "expires_in" => oauth_grant_expires_in,
  50. "interval" => oauth_device_code_grant_polling_interval
  51. end
  52. end
  53. # /device
  54. 17 auth_server_route(:device) do |r|
  55. 357 require_authorizable_account
  56. 340 before_device_route
  57. 340 r.get do
  58. 289 if (user_code = param_or_nil("user_code"))
  59. 102 oauth_grant = valid_oauth_grant_ds(oauth_grants_user_code_column => user_code).first
  60. 102 unless oauth_grant
  61. 51 set_redirect_error_flash user_code_not_found_error_flash
  62. 51 redirect device_path
  63. end
  64. 51 scope.instance_variable_set(:@oauth_grant, oauth_grant)
  65. 51 device_verification_view
  66. else
  67. 187 device_search_view
  68. end
  69. end
  70. 51 r.post do
  71. 51 catch_error do
  72. 51 unless (user_code = param_or_nil("user_code")) && !user_code.empty?
  73. 17 set_redirect_error_flash oauth_invalid_grant_message
  74. 17 redirect device_path
  75. end
  76. 34 transaction do
  77. 34 before_device_verification
  78. 34 create_token("device_code")
  79. end
  80. end
  81. 34 set_notice_flash device_verification_notice_flash
  82. 34 redirect device_path
  83. end
  84. end
  85. 17 def check_csrf?
  86. 611 case request.path
  87. when device_authorization_path
  88. 34 false
  89. else
  90. 765 super
  91. end
  92. end
  93. 17 def oauth_grant_types_supported
  94. 187 super | %w[urn:ietf:params:oauth:grant-type:device_code]
  95. end
  96. 17 private
  97. 17 def generate_user_code
  98. 34 user_code_size = oauth_device_code_grant_user_code_size
  99. 32 SecureRandom.random_number(36**user_code_size)
  100. .to_s(36) # 0 to 9, a to z
  101. .upcase
  102. 2 .rjust(user_code_size, "0")
  103. end
  104. # TODO: think about removing this and recommend PKCE
  105. 17 def supports_auth_method?(oauth_application, auth_method)
  106. 221 return super unless auth_method == "none"
  107. 187 request.path == device_authorization_path || request.params.key?("device_code") || super
  108. end
  109. 17 def create_token(grant_type)
  110. 204 if supported_grant_type?(grant_type, "urn:ietf:params:oauth:grant-type:device_code")
  111. 170 oauth_grant = db[oauth_grants_table].where(
  112. oauth_grants_type_column => "device_code",
  113. oauth_grants_code_column => param("device_code"),
  114. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column]
  115. ).for_update.first
  116. 170 throw_json_response_error(oauth_invalid_response_status, "invalid_grant") unless oauth_grant
  117. 153 now = Time.now
  118. 153 if oauth_grant[oauth_grants_user_code_column].nil?
  119. 24 return create_token_from_authorization_code(
  120. { oauth_grants_id_column => oauth_grant[oauth_grants_id_column] },
  121. oauth_grant: oauth_grant
  122. 2 )
  123. end
  124. 119 if oauth_grant[oauth_grants_revoked_at_column]
  125. 34 throw_json_response_error(oauth_invalid_response_status, "access_denied")
  126. 85 elsif oauth_grant[oauth_grants_expires_in_column] < now
  127. 17 throw_json_response_error(oauth_invalid_response_status, "expired_token")
  128. else
  129. 68 last_polled_at = oauth_grant[oauth_grants_last_polled_at_column]
  130. 68 if last_polled_at && convert_timestamp(last_polled_at) + oauth_device_code_grant_polling_interval > now
  131. 17 throw_json_response_error(oauth_invalid_response_status, "slow_down")
  132. else
  133. 51 db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column])
  134. 3 .update(oauth_grants_last_polled_at_column => Sequel::CURRENT_TIMESTAMP)
  135. 51 throw_json_response_error(oauth_invalid_response_status, "authorization_pending")
  136. end
  137. end
  138. 34 elsif grant_type == "device_code"
  139. # fetch oauth grant
  140. 34 rs = valid_oauth_grant_ds(
  141. oauth_grants_user_code_column => param("user_code")
  142. ).update(oauth_grants_user_code_column => nil, oauth_grants_type_column => "device_code")
  143. 34 rs if rs.positive?
  144. else
  145. super
  146. end
  147. end
  148. 17 def validate_token_params
  149. 187 grant_type = param_or_nil("grant_type")
  150. 187 if grant_type == "urn:ietf:params:oauth:grant-type:device_code" && !param_or_nil("device_code")
  151. 17 redirect_response_error("invalid_request")
  152. end
  153. 170 super
  154. end
  155. 17 def store_token(grant_params, update_params = {})
  156. 34 return super unless grant_params[oauth_grants_user_code_column]
  157. # do not clean up device code just yet
  158. update_params.delete(oauth_grants_code_column)
  159. update_params[oauth_grants_user_code_column] = nil
  160. update_params.merge!(resource_params)
  161. super(grant_params, update_params)
  162. end
  163. 17 def oauth_server_metadata_body(*)
  164. 17 super.tap do |data|
  165. 13 data[:device_authorization_endpoint] = device_authorization_url
  166. end
  167. end
  168. end
  169. end

lib/rodauth/features/oauth_dpop.rb

92.67% lines covered

191 relevant lines. 177 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_dpop, :OauthDpop) do
  5. 17 depends :oauth_jwt, :oauth_authorize_base
  6. 17 auth_value_method :oauth_invalid_token_error_response_status, 401
  7. 17 auth_value_method :oauth_multiple_auth_methods_response_status, 401
  8. 17 auth_value_method :oauth_access_token_dpop_bound_response_status, 401
  9. 17 translatable_method :oauth_invalid_dpop_proof_message, "Invalid DPoP proof"
  10. 17 translatable_method :oauth_multiple_auth_methods_message, "Multiple methods used to include access token"
  11. 17 auth_value_method :oauth_multiple_dpop_proofs_error_code, "invalid_request"
  12. 17 translatable_method :oauth_multiple_dpop_proofs_message, "Multiple DPoP proofs used"
  13. 17 auth_value_method :oauth_invalid_dpop_jkt_error_code, "invalid_dpop_proof"
  14. 17 translatable_method :oauth_invalid_dpop_jkt_message, "Invalid DPoP JKT"
  15. 17 auth_value_method :oauth_invalid_dpop_jti_error_code, "invalid_dpop_proof"
  16. 17 translatable_method :oauth_invalid_dpop_jti_message, "Invalid DPoP jti"
  17. 17 auth_value_method :oauth_invalid_dpop_htm_error_code, "invalid_dpop_proof"
  18. 17 translatable_method :oauth_invalid_dpop_htm_message, "Invalid DPoP htm"
  19. 17 auth_value_method :oauth_invalid_dpop_htu_error_code, "invalid_dpop_proof"
  20. 17 translatable_method :oauth_invalid_dpop_htu_message, "Invalid DPoP htu"
  21. 17 translatable_method :oauth_access_token_dpop_bound_message, "DPoP bound access token requires DPoP proof"
  22. 17 translatable_method :oauth_use_dpop_nonce_message, "DPoP nonce is required"
  23. 17 auth_value_method :oauth_dpop_proof_expires_in, 60 * 5 # 5 minutes
  24. 17 auth_value_method :oauth_dpop_bound_access_tokens, false
  25. 17 auth_value_method :oauth_dpop_use_nonce, false
  26. 17 auth_value_method :oauth_dpop_nonce_expires_in, 5 # 5 seconds
  27. 17 auth_value_method :oauth_dpop_signing_alg_values_supported,
  28. %w[
  29. RS256
  30. RS384
  31. RS512
  32. PS256
  33. PS384
  34. PS512
  35. ES256
  36. ES384
  37. ES512
  38. ES256K
  39. ]
  40. 17 auth_value_method :oauth_applications_dpop_bound_access_tokens_column, :dpop_bound_access_tokens
  41. 17 auth_value_method :oauth_grants_dpop_jkt_column, :dpop_jkt
  42. 17 auth_value_method :oauth_pushed_authorization_requests_dpop_jkt_column, :dpop_jkt
  43. 17 auth_value_method :oauth_dpop_proofs_table, :oauth_dpop_proofs
  44. 17 auth_value_method :oauth_dpop_proofs_jti_column, :jti
  45. 17 auth_value_method :oauth_dpop_proofs_first_use_column, :first_use
  46. 17 auth_methods(:validate_dpop_proof_usage)
  47. 17 def require_oauth_authorization(*scopes)
  48. 68 @dpop_access_token = fetch_access_token_from_authorization_header("dpop")
  49. 68 unless @dpop_access_token
  50. 51 authorization_required if oauth_dpop_bound_access_tokens
  51. # Specifically, such a protected resource MUST reject a DPoP-bound access token received as a bearer token
  52. 34 redirect_response_error("access_token_dpop_bound") if authorization_token && authorization_token.dig("cnf", "jkt")
  53. 13 return super
  54. end
  55. 17 dpop = fetch_dpop_token
  56. 17 dpop_claims = validate_dpop_token(dpop)
  57. # 4.3.12
  58. 17 validate_ath(dpop_claims, @dpop_access_token)
  59. 17 @authorization_token = decode_access_token(@dpop_access_token)
  60. # 4.3.12 - confirm that the public key to which the access token is bound matches the public key from the DPoP proof.
  61. 17 jkt = authorization_token.dig("cnf", "jkt")
  62. 17 redirect_response_error("invalid_dpop_jkt") if oauth_dpop_bound_access_tokens && !jkt
  63. 17 redirect_response_error("invalid_dpop_jkt") unless jkt == @dpop_thumbprint
  64. 17 super
  65. end
  66. 17 private
  67. 17 def validate_token_params
  68. 340 dpop = fetch_dpop_token
  69. 340 unless dpop
  70. 17 authorization_required if dpop_bound_access_tokens_required?
  71. return super
  72. end
  73. 323 validate_dpop_token(dpop)
  74. 187 super
  75. end
  76. 17 def validate_par_params
  77. 68 super
  78. 68 return unless (dpop = fetch_dpop_token)
  79. 51 validate_dpop_token(dpop)
  80. 51 if (dpop_jkt = param_or_nil("dpop_jkt"))
  81. 34 redirect_response_error("invalid_request") if dpop_jkt != @dpop_thumbprint
  82. else
  83. 13 request.params["dpop_jkt"] = @dpop_thumbprint
  84. end
  85. end
  86. 17 def validate_dpop_token(dpop)
  87. # 4.3.2
  88. 391 @dpop_claims = dpop_decode(dpop)
  89. 340 redirect_response_error("invalid_dpop_proof") unless @dpop_claims
  90. 323 validate_dpop_jwt_claims(@dpop_claims)
  91. # 4.3.10
  92. 289 validate_nonce(@dpop_claims)
  93. # 11.1
  94. # To prevent multiple uses of the same DPoP proof, servers can store, in the
  95. # context of the target URI, the jti value of each DPoP proof for the time window
  96. # in which the respective DPoP proof JWT would be accepted.
  97. 272 validate_dpop_proof_usage(@dpop_claims)
  98. 255 @dpop_claims
  99. end
  100. 17 def validate_dpop_proof_usage(claims)
  101. 272 jti = claims["jti"]
  102. 272 dpop_proof = __insert_or_do_nothing_and_return__(
  103. db[oauth_dpop_proofs_table],
  104. oauth_dpop_proofs_jti_column,
  105. [oauth_dpop_proofs_jti_column],
  106. oauth_dpop_proofs_jti_column => Digest::SHA256.hexdigest(jti),
  107. oauth_dpop_proofs_first_use_column => Sequel::CURRENT_TIMESTAMP
  108. )
  109. 272 return unless (Time.now - dpop_proof[oauth_dpop_proofs_first_use_column]) > oauth_dpop_proof_expires_in
  110. 17 redirect_response_error("invalid_dpop_proof")
  111. end
  112. 17 def dpop_decode(dpop)
  113. # decode first without verifying!
  114. 391 _, headers = jwt_decode_no_key(dpop)
  115. 391 redirect_response_error("invalid_dpop_proof") unless verify_dpop_jwt_headers(headers)
  116. 340 dpop_jwk = headers["jwk"]
  117. 340 jwt_decode(
  118. dpop,
  119. jws_key: jwk_key(dpop_jwk),
  120. jws_algorithm: headers["alg"],
  121. verify_iss: false,
  122. verify_aud: false,
  123. verify_jti: false
  124. )
  125. end
  126. 17 def verify_dpop_jwt_headers(headers)
  127. # 4.3.4 - A field with the value dpop+jwt
  128. 391 return false unless headers["typ"] == "dpop+jwt"
  129. # 4.3.5 - It MUST NOT be none or an identifier for a symmetric algorithm
  130. 374 alg = headers["alg"]
  131. 374 return false unless alg && oauth_dpop_signing_alg_values_supported.include?(alg)
  132. 357 dpop_jwk = headers["jwk"]
  133. 357 return false unless dpop_jwk
  134. # 4.3.7 - It MUST NOT contain a private key.
  135. 357 return false if private_jwk?(dpop_jwk)
  136. # store thumbprint for future assertions
  137. 340 @dpop_thumbprint = jwk_thumbprint(dpop_jwk)
  138. 340 true
  139. end
  140. 17 def validate_dpop_jwt_claims(claims)
  141. 323 jti = claims["jti"]
  142. 323 unless jti && jti == Digest::SHA256.hexdigest("#{request.request_method}:#{request.url}:#{claims['iat']}")
  143. redirect_response_error("invalid_dpop_jti")
  144. end
  145. 323 htm = claims["htm"]
  146. # 4.3.8 - Check if htm matches the request method
  147. 323 redirect_response_error("invalid_dpop_htm") unless htm && htm == request.request_method
  148. 306 htu = claims["htu"]
  149. # 4.3.9 - Check if htu matches the request URL
  150. 306 redirect_response_error("invalid_dpop_htu") unless htu && htu == request.url
  151. end
  152. 17 def validate_ath(claims, access_token)
  153. # When the DPoP proof is used in conjunction with the presentation of an access token in protected resource access
  154. # the DPoP proof MUST also contain the following claim
  155. 17 ath = claims["ath"]
  156. 17 redirect_response_error("invalid_token") unless ath
  157. # The value MUST be the result of a base64url encoding of the SHA-256 hash of the ASCII encoding of
  158. # the associated access token's value.
  159. 17 redirect_response_error("invalid_token") unless ath == Base64.urlsafe_encode64(Digest::SHA256.digest(access_token), padding: false)
  160. end
  161. 17 def validate_nonce(claims)
  162. 289 nonce = claims["nonce"]
  163. 289 unless nonce
  164. 272 dpop_nonce_required(claims) if dpop_use_nonce?
  165. 195 return
  166. end
  167. 17 dpop_nonce_required(claims) unless valid_dpop_nonce?(nonce)
  168. end
  169. 17 def jwt_claims(oauth_grant)
  170. 153 claims = super
  171. 153 if @dpop_thumbprint
  172. # the authorization server associates the issued access token with the
  173. # public key from the DPoP proof
  174. 117 claims[:cnf] = { jkt: @dpop_thumbprint }
  175. end
  176. 153 claims
  177. end
  178. 17 def generate_token(grant_params = {}, should_generate_refresh_token = true)
  179. # When an authorization server supporting DPoP issues a refresh token to a public client
  180. # that presents a valid DPoP proof at the token endpoint, the refresh token MUST be bound to the respective public key.
  181. 153 grant_params[oauth_grants_dpop_jkt_column] = @dpop_thumbprint if @dpop_thumbprint
  182. 153 super
  183. end
  184. 17 def valid_oauth_grant_ds(grant_params = nil)
  185. 187 ds = super
  186. 187 ds = ds.where(oauth_grants_dpop_jkt_column => nil)
  187. 187 ds = ds.or(oauth_grants_dpop_jkt_column => @dpop_thumbprint) if @dpop_thumbprint
  188. 187 ds
  189. end
  190. 17 def oauth_grant_by_refresh_token_ds(_token, revoked: false)
  191. ds = super
  192. # The binding MUST be validated when the refresh token is later presented to get new access tokens.
  193. ds = ds.where(oauth_grants_dpop_jkt_column => nil)
  194. ds = ds.or(oauth_grants_dpop_jkt_column => @dpop_thumbprint) if @dpop_thumbprint
  195. ds
  196. end
  197. 17 def oauth_grant_by_token_ds(_token)
  198. ds = super
  199. # The binding MUST be validated when the refresh token is later presented to get new access tokens.
  200. ds = ds.where(oauth_grants_dpop_jkt_column => nil)
  201. ds = ds.or(oauth_grants_dpop_jkt_column => @dpop_thumbprint) if @dpop_thumbprint
  202. ds
  203. end
  204. 17 def create_oauth_grant(create_params = {})
  205. # 10. Authorization Code Binding to DPoP Key
  206. # Binding the authorization code issued to the client's proof-of-possession key can enable end-to-end
  207. # binding of the entire authorization flow.
  208. 85 if (dpop_jkt = param_or_nil("dpop_jkt"))
  209. 65 create_params[oauth_grants_dpop_jkt_column] = dpop_jkt
  210. end
  211. 85 super
  212. end
  213. 17 def json_access_token_payload(oauth_grant)
  214. 153 payload = super
  215. # 5. A token_type of DPoP MUST be included in the access token response to
  216. # signal to the client that the access token was bound to its DPoP key
  217. 153 payload["token_type"] = "DPoP" if @dpop_claims
  218. 153 payload
  219. end
  220. 17 def fetch_dpop_token
  221. 425 dpop = request.env["HTTP_DPOP"]
  222. 425 return if dpop.nil? || dpop.empty?
  223. # 4.3.1 - There is not more than one DPoP HTTP request header field.
  224. 391 redirect_response_error("multiple_dpop_proofs") if dpop.split(";").size > 1
  225. 391 dpop
  226. end
  227. 17 def dpop_bound_access_tokens_required?
  228. 85 oauth_dpop_bound_access_tokens || (oauth_application && oauth_application[oauth_applications_dpop_bound_access_tokens_column])
  229. end
  230. 17 def dpop_use_nonce?
  231. 272 oauth_dpop_use_nonce || (oauth_application && oauth_application[oauth_applications_dpop_bound_access_tokens_column])
  232. end
  233. 17 def valid_dpop_proof_required(error_code = "invalid_dpop_proof")
  234. if @dpop_access_token
  235. # protected resource access
  236. throw_json_response_error(401, error_code)
  237. else
  238. redirect_response_error(error_code)
  239. end
  240. end
  241. 17 def dpop_nonce_required(dpop_claims)
  242. 13 response["DPoP-Nonce"] = generate_dpop_nonce(dpop_claims)
  243. 17 if @dpop_access_token
  244. # protected resource access
  245. throw_json_response_error(401, "use_dpop_nonce")
  246. else
  247. 17 redirect_response_error("use_dpop_nonce")
  248. end
  249. end
  250. 17 def www_authenticate_header(payload)
  251. 68 header = if dpop_bound_access_tokens_required?
  252. 34 "DPoP"
  253. else
  254. 26 "#{super}, DPoP"
  255. end
  256. 68 error_code = payload["error"]
  257. 68 unless error_code == "invalid_client"
  258. 17 header = "#{header} error=\"#{error_code}\""
  259. 17 if (desc = payload["error_description"])
  260. 17 header = "#{header} error_description=\"#{desc}\""
  261. end
  262. end
  263. 68 algs = oauth_dpop_signing_alg_values_supported.join(" ")
  264. 52 "#{header} algs=\"#{algs}\""
  265. end
  266. # Nonce
  267. 17 def generate_dpop_nonce(dpop_claims)
  268. 17 issued_at = Time.now.to_i
  269. 17 aud = "#{dpop_claims['htm']}:#{dpop_claims['htu']}"
  270. 5 nonce_claims = {
  271. 12 iss: oauth_jwt_issuer,
  272. iat: issued_at,
  273. exp: issued_at + oauth_dpop_nonce_expires_in,
  274. aud: aud
  275. }
  276. 17 jwt_encode(nonce_claims)
  277. end
  278. 17 def valid_dpop_nonce?(nonce)
  279. 17 nonce_claims = jwt_decode(nonce, verify_aud: false, verify_jti: false)
  280. 17 return false unless nonce_claims
  281. 17 jti = nonce_claims["jti"]
  282. 17 return false unless jti
  283. 17 return false unless jti == Digest::SHA256.hexdigest("#{request.request_method}:#{request.url}:#{nonce_claims['iat']}")
  284. 17 return false unless nonce_claims.key?("aud")
  285. 17 htm, htu = nonce_claims["aud"].split(":", 2)
  286. 17 htm == request.request_method && htu == request.url
  287. end
  288. 17 def json_token_introspect_payload(grant_or_claims)
  289. 17 claims = super
  290. 17 return claims unless grant_or_claims
  291. 17 if (jkt = grant_or_claims.dig("cnf", "jkt"))
  292. 13 (claims[:cnf] ||= {})[:jkt] = jkt
  293. 13 claims[:token_type] = "DPoP"
  294. end
  295. 17 claims
  296. end
  297. 17 def oauth_server_metadata_body(*)
  298. 17 super.tap do |data|
  299. 13 data[:dpop_signing_alg_values_supported] = oauth_dpop_signing_alg_values_supported
  300. end
  301. end
  302. end
  303. end

lib/rodauth/features/oauth_dynamic_client_registration.rb

95.22% lines covered

230 relevant lines. 219 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_dynamic_client_registration, :OauthDynamicClientRegistration) do
  5. 17 depends :oauth_base
  6. 17 before "register"
  7. 17 auth_value_method :oauth_client_registration_required_params, %w[redirect_uris client_name]
  8. 17 auth_value_method :oauth_applications_registration_access_token_column, :registration_access_token
  9. 17 auth_value_method :registration_client_uri_route, "register"
  10. 17 PROTECTED_APPLICATION_ATTRIBUTES = %w[account_id client_id].freeze
  11. 17 def load_registration_client_uri_routes
  12. 68 request.on(registration_client_uri_route) do
  13. # CLIENT REGISTRATION URI
  14. 68 request.on(String) do |client_id|
  15. 68 token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1]
  16. 68 next unless token
  17. 68 oauth_application = db[oauth_applications_table]
  18. .where(oauth_applications_client_id_column => client_id)
  19. .first
  20. 68 next unless oauth_application
  21. 68 authorization_required unless password_hash_match?(oauth_application[oauth_applications_registration_access_token_column], token)
  22. 68 request.is do
  23. 68 request.get do
  24. 17 json_response_oauth_application(oauth_application)
  25. end
  26. 51 request.on method: :put do
  27. 32 %w[client_id registration_access_token registration_client_uri client_secret_expires_at
  28. 2 client_id_issued_at].each do |prohibited_param|
  29. 102 if request.params.key?(prohibited_param)
  30. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(prohibited_param))
  31. end
  32. end
  33. 17 validate_client_registration_params
  34. # if the client includes the "client_secret" field in the request, the value of this field MUST match the currently
  35. # issued client secret for that client. The client MUST NOT be allowed to overwrite its existing client secret with
  36. # its own chosen value.
  37. 17 authorization_required if request.params.key?("client_secret") && secret_matches?(oauth_application,
  38. request.params["client_secret"])
  39. 17 oauth_application = transaction do
  40. 17 applications_ds = db[oauth_applications_table]
  41. 17 __update_and_return__(applications_ds, @oauth_application_params)
  42. end
  43. 17 json_response_oauth_application(oauth_application)
  44. end
  45. 17 request.on method: :delete do
  46. 17 applications_ds = db[oauth_applications_table]
  47. 17 applications_ds.where(oauth_applications_client_id_column => client_id).delete
  48. 17 response.status = 204
  49. 13 response["Cache-Control"] = "no-store"
  50. 13 response["Pragma"] = "no-cache"
  51. 17 response.finish
  52. end
  53. end
  54. end
  55. end
  56. end
  57. # /register
  58. 17 auth_server_route(:register) do |r|
  59. 1904 before_register_route
  60. 1904 r.post do
  61. 1904 oauth_client_registration_required_params.each do |required_param|
  62. 3740 unless request.params.key?(required_param)
  63. 68 register_throw_json_response_error("invalid_client_metadata", register_required_param_message(required_param))
  64. end
  65. end
  66. 1836 validate_client_registration_params
  67. 918 response_params = transaction do
  68. 918 before_register
  69. 918 do_register
  70. end
  71. 918 response.status = 201
  72. 702 response["Content-Type"] = json_response_content_type
  73. 702 response["Cache-Control"] = "no-store"
  74. 702 response["Pragma"] = "no-cache"
  75. 918 response.write(_json_response_body(response_params))
  76. end
  77. end
  78. 17 def check_csrf?
  79. 1508 case request.path
  80. when register_path
  81. 1904 false
  82. else
  83. 68 super
  84. end
  85. end
  86. 17 private
  87. 17 def _before_register
  88. raise %{dynamic client registration requires authentication.
  89. Override ´before_register` to perform it.
  90. example:
  91. before_register do
  92. account = _account_from_login(request.env["HTTP_X_USER_EMAIL"])
  93. authorization_required unless account
  94. @oauth_application_params[:account_id] = account[:id]
  95. end
  96. }
  97. end
  98. 17 def validate_client_registration_params(request_params = request.params)
  99. 1887 @oauth_application_params = request_params.each_with_object({}) do |(key, value), params|
  100. 17589 case key
  101. when "redirect_uris"
  102. 1836 if value.is_a?(Array)
  103. 1819 value = value.each do |uri|
  104. 3468 unless check_valid_no_fragment_uri?(uri)
  105. 34 register_throw_json_response_error("invalid_redirect_uri",
  106. register_invalid_uri_message(uri))
  107. end
  108. end.join(" ")
  109. else
  110. 17 register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(value))
  111. end
  112. 1785 key = oauth_applications_redirect_uri_column
  113. when "token_endpoint_auth_method"
  114. 867 unless oauth_token_endpoint_auth_methods_supported.include?(value)
  115. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_client_metadata_message(key, value))
  116. end
  117. # verify if in range
  118. 850 key = oauth_applications_token_endpoint_auth_method_column
  119. when "grant_types"
  120. 952 if value.is_a?(Array)
  121. 935 value = value.each do |grant_type|
  122. 1717 unless oauth_grant_types_supported.include?(grant_type)
  123. 34 register_throw_json_response_error("invalid_client_metadata", register_invalid_client_metadata_message(grant_type, value))
  124. end
  125. end.join(" ")
  126. else
  127. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_client_metadata_message(key, value))
  128. end
  129. 901 key = oauth_applications_grant_types_column
  130. when "response_types"
  131. 986 if value.is_a?(Array)
  132. 969 grant_types = request_params["grant_types"] || %w[authorization_code]
  133. 969 value = value.each do |response_type|
  134. 986 unless oauth_response_types_supported.include?(response_type)
  135. 17 register_throw_json_response_error("invalid_client_metadata",
  136. register_invalid_response_type_message(response_type))
  137. end
  138. 969 validate_client_registration_response_type(response_type, grant_types)
  139. end.join(" ")
  140. else
  141. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_client_metadata_message(key, value))
  142. end
  143. 884 key = oauth_applications_response_types_column
  144. # verify if in range and match grant type
  145. when "client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"
  146. 8483 register_throw_json_response_error("invalid_client_metadata", register_invalid_uri_message(value)) unless check_valid_uri?(value)
  147. 6422 case key
  148. when "client_uri"
  149. 1751 key = oauth_applications_homepage_url_column
  150. when "jwks_uri"
  151. 1598 if request_params.key?("jwks")
  152. 17 register_throw_json_response_error("invalid_client_metadata",
  153. register_invalid_jwks_param_message(key, "jwks"))
  154. end
  155. end
  156. 8381 key = __send__(:"oauth_applications_#{key}_column")
  157. when "jwks"
  158. 34 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(value)) unless value.is_a?(Hash)
  159. 17 if request_params.key?("jwks_uri")
  160. register_throw_json_response_error("invalid_client_metadata",
  161. register_invalid_jwks_param_message(key, "jwks_uri"))
  162. end
  163. 17 key = oauth_applications_jwks_column
  164. 17 value = JSON.dump(value)
  165. when "scope"
  166. 1768 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(value)) unless value.is_a?(String)
  167. 1768 scopes = value.split(" ") - oauth_application_scopes
  168. 1768 register_throw_json_response_error("invalid_client_metadata", register_invalid_scopes_message(value)) unless scopes.empty?
  169. 1734 key = oauth_applications_scopes_column
  170. # verify if in range
  171. when "contacts"
  172. 1700 register_throw_json_response_error("invalid_client_metadata", register_invalid_contacts_message(value)) unless value.is_a?(Array)
  173. 1683 value = value.join(" ")
  174. 1683 key = oauth_applications_contacts_column
  175. when "client_name"
  176. 1768 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(value)) unless value.is_a?(String)
  177. 1768 key = oauth_applications_name_column
  178. when "dpop_bound_access_tokens"
  179. 51 unless respond_to?(:oauth_applications_dpop_bound_access_tokens_column)
  180. register_throw_json_response_error("invalid_client_metadata",
  181. register_invalid_param_message(key))
  182. end
  183. 39 request_params[key] = value = convert_to_boolean(key, value)
  184. 34 key = oauth_applications_dpop_bound_access_tokens_column
  185. when "require_signed_request_object"
  186. 51 unless respond_to?(:oauth_applications_require_signed_request_object_column)
  187. register_throw_json_response_error("invalid_client_metadata",
  188. register_invalid_param_message(key))
  189. end
  190. 39 request_params[key] = value = convert_to_boolean(key, value)
  191. 34 key = oauth_applications_require_signed_request_object_column
  192. when "require_pushed_authorization_requests"
  193. 51 unless respond_to?(:oauth_applications_require_pushed_authorization_requests_column)
  194. register_throw_json_response_error("invalid_client_metadata",
  195. register_invalid_param_message(key))
  196. end
  197. 39 request_params[key] = value = convert_to_boolean(key, value)
  198. 34 key = oauth_applications_require_pushed_authorization_requests_column
  199. when "tls_client_certificate_bound_access_tokens"
  200. 17 property = :oauth_applications_tls_client_certificate_bound_access_tokens_column
  201. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key)) unless respond_to?(property)
  202. 13 request_params[key] = value = convert_to_boolean(key, value)
  203. 17 key = oauth_applications_tls_client_certificate_bound_access_tokens_column
  204. when /\Atls_client_auth_/
  205. 119 unless respond_to?(:"oauth_applications_#{key}_column")
  206. register_throw_json_response_error("invalid_client_metadata",
  207. register_invalid_param_message(key))
  208. end
  209. # client using the tls_client_auth authentication method MUST use exactly one of the below metadata
  210. # parameters to indicate the certificate subject value that the authorization server is to expect when
  211. # authenticating the respective client.
  212. 1445 if params.any? { |k, _| k.to_s.start_with?("tls_client_auth_") }
  213. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key))
  214. end
  215. 102 key = __send__(:"oauth_applications_#{key}_column")
  216. else
  217. 4318 if respond_to?(:"oauth_applications_#{key}_column")
  218. 4233 if PROTECTED_APPLICATION_ATTRIBUTES.include?(key)
  219. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key))
  220. end
  221. 4216 property = :"oauth_applications_#{key}_column"
  222. 4216 key = __send__(property)
  223. 85 elsif !db[oauth_applications_table].columns.include?(key.to_sym)
  224. 51 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message(key))
  225. end
  226. end
  227. 17186 params[key] = value
  228. end
  229. end
  230. 17 def validate_client_registration_response_type(response_type, grant_types)
  231. 689 case response_type
  232. when "code"
  233. 799 unless grant_types.include?("authorization_code")
  234. register_throw_json_response_error("invalid_client_metadata",
  235. register_invalid_response_type_for_grant_type_message(response_type,
  236. "authorization_code"))
  237. end
  238. when "token"
  239. 85 unless grant_types.include?("implicit")
  240. 34 register_throw_json_response_error("invalid_client_metadata",
  241. register_invalid_response_type_for_grant_type_message(response_type, "implicit"))
  242. end
  243. when "none"
  244. 17 if grant_types.include?("implicit") || grant_types.include?("authorization_code")
  245. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_response_type_message(response_type))
  246. end
  247. end
  248. end
  249. 17 def do_register(return_params = request.params.dup)
  250. 918 applications_ds = db[oauth_applications_table]
  251. 918 application_columns = applications_ds.columns
  252. # set defaults
  253. 918 create_params = @oauth_application_params
  254. # If omitted, an authorization server MAY register a client with a default set of scopes
  255. 918 create_params[oauth_applications_scopes_column] ||= return_params["scopes"] = oauth_application_scopes.join(" ")
  256. # https://datatracker.ietf.org/doc/html/rfc7591#section-2
  257. 918 if create_params[oauth_applications_grant_types_column] ||= begin
  258. # If omitted, the default behavior is that the client will use only the "authorization_code" Grant Type.
  259. 377 return_params["grant_types"] = %w[authorization_code] # rubocop:disable Lint/AssignmentInCondition
  260. 493 "authorization_code"
  261. end
  262. 918 create_params[oauth_applications_token_endpoint_auth_method_column] ||= begin
  263. # If unspecified or omitted, the default is "client_secret_basic", denoting the HTTP Basic
  264. # authentication scheme as specified in Section 2.3.1 of OAuth 2.0.
  265. 390 return_params["token_endpoint_auth_method"] =
  266. "client_secret_basic"
  267. 510 "client_secret_basic"
  268. end
  269. end
  270. 918 create_params[oauth_applications_response_types_column] ||= begin
  271. # If omitted, the default is that the client will use only the "code" response type.
  272. 377 return_params["response_types"] = %w[code]
  273. 493 "code"
  274. end
  275. 918 rescue_from_uniqueness_error do
  276. 918 initialize_register_params(create_params, return_params)
  277. 17935 create_params.delete_if { |k, _| !application_columns.include?(k) }
  278. 918 applications_ds.insert(create_params)
  279. end
  280. 918 return_params
  281. end
  282. 17 def initialize_register_params(create_params, return_params)
  283. 918 client_id = oauth_unique_id_generator
  284. 702 create_params[oauth_applications_client_id_column] = client_id
  285. 702 return_params["client_id"] = client_id
  286. 702 return_params["client_id_issued_at"] = Time.now.utc.iso8601
  287. 918 registration_access_token = oauth_unique_id_generator
  288. 702 create_params[oauth_applications_registration_access_token_column] = secret_hash(registration_access_token)
  289. 702 return_params["registration_access_token"] = registration_access_token
  290. 702 return_params["registration_client_uri"] = "#{base_url}/#{registration_client_uri_route}/#{return_params['client_id']}"
  291. 918 if create_params.key?(oauth_applications_client_secret_column)
  292. 17 set_client_secret(create_params, create_params[oauth_applications_client_secret_column])
  293. 17 return_params.delete("client_secret")
  294. else
  295. 901 client_secret = oauth_unique_id_generator
  296. 901 set_client_secret(create_params, client_secret)
  297. 689 return_params["client_secret"] = client_secret
  298. 689 return_params["client_secret_expires_at"] = 0
  299. end
  300. end
  301. 17 def register_throw_json_response_error(code, message)
  302. 1003 throw_json_response_error(oauth_invalid_response_status, code, message)
  303. end
  304. 17 def register_required_param_message(key)
  305. 85 "The param '#{key}' is required by this server."
  306. end
  307. 17 def register_invalid_param_message(key)
  308. 187 "The param '#{key}' is not supported by this server."
  309. end
  310. 17 def register_invalid_client_metadata_message(key, value)
  311. 272 "The value '#{value}' is not supported by this server for param '#{key}'."
  312. end
  313. 17 def register_invalid_contacts_message(contacts)
  314. 17 "The contacts '#{contacts}' are not allowed by this server."
  315. end
  316. 17 def register_invalid_uri_message(uri)
  317. 306 "The '#{uri}' URL is not allowed by this server."
  318. end
  319. 17 def register_invalid_jwks_param_message(key1, key2)
  320. 17 "The param '#{key1}' cannot be accepted together with param '#{key2}'."
  321. end
  322. 17 def register_invalid_scopes_message(scopes)
  323. 34 "The given scopes (#{scopes}) are not allowed by this server."
  324. end
  325. 17 def register_oauth_invalid_grant_type_message(grant_type)
  326. "The grant type #{grant_type} is not allowed by this server."
  327. end
  328. 17 def register_invalid_response_type_message(response_type)
  329. 34 "The response type #{response_type} is not allowed by this server."
  330. end
  331. 17 def register_invalid_response_type_for_grant_type_message(response_type, grant_type)
  332. 51 "The grant type '#{grant_type}' must be registered for the response " \
  333. 12 "type '#{response_type}' to be allowed."
  334. end
  335. 17 def convert_to_boolean(key, value)
  336. 156 case value
  337. when true, false then value
  338. 102 when "true" then true
  339. 51 when "false" then false
  340. else
  341. 51 register_throw_json_response_error(
  342. "invalid_client_metadata",
  343. register_invalid_param_message(key)
  344. )
  345. end
  346. end
  347. 17 def json_response_oauth_application(oauth_application)
  348. 14910 params = methods.map { |k| k.to_s[/\Aoauth_applications_(\w+)_column\z/, 1] }.compact
  349. 34 body = params.each_with_object({}) do |k, hash|
  350. 782 next if %w[id account_id client_id client_secret cliennt_secret_hash].include?(k)
  351. 646 value = oauth_application[__send__(:"oauth_applications_#{k}_column")]
  352. 646 next unless value
  353. 182 case k
  354. when "redirect_uri"
  355. 26 hash["redirect_uris"] = value.split(" ")
  356. when "token_endpoint_auth_method", "grant_types", "response_types", "request_uris", "post_logout_redirect_uris"
  357. hash[k] = value.split(" ")
  358. when "scopes"
  359. 26 hash["scope"] = value
  360. when "jwks"
  361. hash[k] = value.is_a?(String) ? JSON.parse(value) : value
  362. when "homepage_url"
  363. 26 hash["client_uri"] = value
  364. when "name"
  365. 26 hash["client_name"] = value
  366. else
  367. 78 hash[k] = value
  368. end
  369. end
  370. 34 response.status = 200
  371. 34 response["Content-Type"] ||= json_response_content_type
  372. 26 response["Cache-Control"] = "no-store"
  373. 26 response["Pragma"] = "no-cache"
  374. 34 json_payload = _json_response_body(body)
  375. 34 return_response(json_payload)
  376. end
  377. 17 def oauth_server_metadata_body(*)
  378. 34 super.tap do |data|
  379. 26 data[:registration_endpoint] = register_url
  380. end
  381. end
  382. end
  383. end

lib/rodauth/features/oauth_grant_management.rb

100.0% lines covered

33 relevant lines. 33 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_grant_management, :OauthTokenManagement) do
  5. 17 depends :oauth_management_base, :oauth_token_revocation
  6. 17 view "oauth_grants", "My Oauth Grants", "oauth_grants"
  7. 17 button "Revoke", "oauth_grant_revoke"
  8. 17 auth_value_method :oauth_grants_path, "oauth-grants"
  9. 17 %w[type token refresh_token expires_in revoked_at].each do |param|
  10. 85 translatable_method :"oauth_grants_#{param}_label", param.gsub("_", " ").capitalize
  11. end
  12. 17 translatable_method :oauth_no_grants_text, "No oauth grants yet!"
  13. 17 auth_value_method :oauth_grants_route, "oauth-grants"
  14. 17 auth_value_method :oauth_grants_id_pattern, Integer
  15. 17 auth_value_method :oauth_grants_per_page, 20
  16. 17 auth_methods(
  17. :oauth_grant_path
  18. )
  19. 17 def oauth_grants_path(opts = {})
  20. 1035 route_path(oauth_grants_route, opts)
  21. end
  22. 17 def oauth_grant_path(id)
  23. 290 "#{oauth_grants_path}/#{id}"
  24. end
  25. 17 def load_oauth_grant_management_routes
  26. 146 request.on(oauth_grants_route) do
  27. 146 check_csrf if check_csrf?
  28. 146 require_account
  29. 146 request.post(oauth_grants_id_pattern) do |id|
  30. 16 db[oauth_grants_table]
  31. .where(oauth_grants_id_column => id)
  32. .where(oauth_grants_account_id_column => account_id)
  33. 1 .update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
  34. 17 set_notice_flash revoke_oauth_grant_notice_flash
  35. 17 redirect oauth_grants_path || "/"
  36. end
  37. 129 request.is do
  38. 129 request.get do
  39. 129 page = Integer(param_or_nil("page") || 1)
  40. 129 per_page = per_page_param(oauth_grants_per_page)
  41. 129 scope.instance_variable_set(:@oauth_grants, db[oauth_grants_table]
  42. .select(Sequel[oauth_grants_table].*, Sequel[oauth_applications_table][oauth_applications_name_column])
  43. .join(oauth_applications_table, Sequel[oauth_grants_table][oauth_grants_oauth_application_id_column] =>
  44. Sequel[oauth_applications_table][oauth_applications_id_column])
  45. .where(Sequel[oauth_grants_table][oauth_grants_account_id_column] => account_id)
  46. .where(oauth_grants_revoked_at_column => nil)
  47. .order(Sequel.desc(oauth_grants_id_column))
  48. .paginate(page, per_page))
  49. 129 oauth_grants_view
  50. end
  51. end
  52. end
  53. end
  54. end
  55. end

lib/rodauth/features/oauth_implicit_grant.rb

100.0% lines covered

49 relevant lines. 49 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_implicit_grant, :OauthImplicitGrant) do
  5. 17 depends :oauth_authorize_base
  6. 17 def oauth_grant_types_supported
  7. 3927 super | %w[implicit]
  8. end
  9. 17 def oauth_response_types_supported
  10. 1989 super | %w[token]
  11. end
  12. 17 def oauth_response_modes_supported
  13. 2210 super | %w[fragment]
  14. end
  15. 17 private
  16. 17 def validate_authorize_params
  17. 2291 super
  18. 2155 response_mode = param_or_nil("response_mode")
  19. 2155 return unless response_mode
  20. 561 response_type = param_or_nil("response_type")
  21. 561 return unless response_type == "token"
  22. 102 redirect_response_error("invalid_request") unless oauth_response_modes_for_token_supported.include?(response_mode)
  23. end
  24. 17 def oauth_response_modes_for_token_supported
  25. 102 %w[fragment]
  26. end
  27. 17 def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
  28. 901 response_type = param("response_type")
  29. 901 return super unless response_type == "token" && supported_response_type?(response_type)
  30. 68 response_mode ||= "fragment"
  31. 68 redirect_response_error("invalid_request") unless supported_response_mode?(response_mode)
  32. 68 oauth_grant = _do_authorize_token
  33. 68 response_params.replace(json_access_token_payload(oauth_grant))
  34. 68 response_params["state"] = param("state") if param_or_nil("state")
  35. 68 [response_params, response_mode]
  36. end
  37. 17 def _do_authorize_token(grant_params = {})
  38. 25 grant_params = {
  39. 60 oauth_grants_type_column => "implicit",
  40. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
  41. oauth_grants_scopes_column => scopes,
  42. **resource_owner_params
  43. }.merge(grant_params)
  44. 85 generate_token(grant_params, false)
  45. end
  46. 17 def _redirect_response_error(redirect_url, params)
  47. 340 response_types = param("response_type").split(/ +/)
  48. 340 return super if response_types.empty? || response_types == %w[code]
  49. 408 params = params.map { |k, v| "#{k}=#{v}" }
  50. 187 redirect_url.fragment = params.join("&")
  51. 187 redirect(redirect_url.to_s)
  52. end
  53. 17 def authorize_response(params, mode)
  54. 765 return super unless mode == "fragment"
  55. 476 redirect_url = URI.parse(redirect_uri)
  56. 476 params = [URI.encode_www_form(params)]
  57. 476 params << redirect_url.query if redirect_url.query
  58. 476 redirect_url.fragment = params.join("&")
  59. 476 redirect(redirect_url.to_s)
  60. end
  61. 17 def check_valid_response_type?
  62. 1118 return true if param_or_nil("response_type") == "token"
  63. 931 super
  64. end
  65. end
  66. end

lib/rodauth/features/oauth_jwt.rb

100.0% lines covered

59 relevant lines. 59 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 require "rodauth/oauth/http_extensions"
  4. 17 module Rodauth
  5. 17 Feature.define(:oauth_jwt, :OauthJwt) do
  6. 17 depends :oauth_jwt_base, :oauth_jwt_jwks
  7. 17 auth_value_method :oauth_jwt_access_tokens, true
  8. 17 auth_methods(
  9. :jwt_claims,
  10. :verify_access_token_headers
  11. )
  12. 17 def require_oauth_authorization(*scopes)
  13. 357 return super unless oauth_jwt_access_tokens
  14. 357 authorization_required unless authorization_token
  15. 323 token_scopes = authorization_token["scope"].split(" ")
  16. 646 authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
  17. end
  18. 17 def oauth_token_subject
  19. 493 return super unless oauth_jwt_access_tokens
  20. 493 return unless authorization_token
  21. 493 authorization_token["sub"]
  22. end
  23. 17 def current_oauth_account
  24. 238 subject = oauth_token_subject
  25. 238 return if subject == authorization_token["client_id"]
  26. 221 oauth_account_ds(subject).first
  27. end
  28. 17 def current_oauth_application
  29. 272 db[oauth_applications_table].where(
  30. oauth_applications_client_id_column => authorization_token["client_id"]
  31. 17 ).first
  32. end
  33. 17 private
  34. 17 def authorization_token
  35. 2465 return super unless oauth_jwt_access_tokens
  36. 2465 return @authorization_token if defined?(@authorization_token)
  37. 544 @authorization_token = decode_access_token
  38. end
  39. 17 def verify_access_token_headers(headers)
  40. 527 headers["typ"] == "at+jwt"
  41. end
  42. 17 def decode_access_token(access_token = fetch_access_token)
  43. 561 return unless access_token
  44. 544 jwt_claims = jwt_decode(access_token, verify_headers: method(:verify_access_token_headers))
  45. 544 return unless jwt_claims
  46. 527 return unless jwt_claims["sub"]
  47. 527 return unless jwt_claims["aud"]
  48. 527 jwt_claims
  49. end
  50. # /token
  51. 17 def create_token_from_token(_grant, update_params)
  52. 136 oauth_grant = super
  53. 136 if oauth_jwt_access_tokens
  54. 136 access_token = _generate_jwt_access_token(oauth_grant)
  55. 104 oauth_grant[oauth_grants_token_column] = access_token
  56. end
  57. 136 oauth_grant
  58. end
  59. 17 def generate_token(_grant_params = {}, should_generate_refresh_token = true)
  60. 833 oauth_grant = super
  61. 833 if oauth_jwt_access_tokens
  62. 816 access_token = _generate_jwt_access_token(oauth_grant)
  63. 624 oauth_grant[oauth_grants_token_column] = access_token
  64. end
  65. 833 oauth_grant
  66. end
  67. 17 def _generate_jwt_access_token(oauth_grant)
  68. 986 claims = jwt_claims(oauth_grant)
  69. # one of the points of using jwt is avoiding database lookups, so we put here all relevant
  70. # token data.
  71. 754 claims[:scope] = oauth_grant[oauth_grants_scopes_column]
  72. # RFC8725 section 3.11: Use Explicit Typing
  73. # RFC9068 section 2.1 : The "typ" value used SHOULD be "at+jwt".
  74. 986 jwt_encode(claims, headers: { typ: "at+jwt" })
  75. end
  76. 17 def _generate_access_token(*)
  77. 969 super unless oauth_jwt_access_tokens
  78. end
  79. 17 def jwt_claims(oauth_grant)
  80. 1802 issued_at = Time.now.to_i
  81. 530 {
  82. 1272 iss: oauth_jwt_issuer, # issuer
  83. iat: issued_at, # issued at
  84. #
  85. # sub REQUIRED - as defined in section 4.1.2 of [RFC7519]. In case of
  86. # access tokens obtained through grants where a resource owner is
  87. # involved, such as the authorization code grant, the value of "sub"
  88. # SHOULD correspond to the subject identifier of the resource owner.
  89. # In case of access tokens obtained through grants where no resource
  90. # owner is involved, such as the client credentials grant, the value
  91. # of "sub" SHOULD correspond to an identifier the authorization
  92. # server uses to indicate the client application.
  93. sub: jwt_subject(oauth_grant[oauth_grants_account_id_column]),
  94. client_id: oauth_application[oauth_applications_client_id_column],
  95. exp: issued_at + oauth_access_token_expires_in,
  96. aud: oauth_jwt_audience
  97. }
  98. end
  99. end
  100. end

lib/rodauth/features/oauth_jwt_base.rb

95.11% lines covered

225 relevant lines. 214 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 require "rodauth/oauth/http_extensions"
  4. 17 module Rodauth
  5. 17 Feature.define(:oauth_jwt_base, :OauthJwtBase) do
  6. 17 depends :oauth_base
  7. 17 auth_value_method :oauth_application_jwt_public_key_param, "jwt_public_key"
  8. 17 auth_value_method :oauth_application_jwks_param, "jwks"
  9. 17 auth_value_method :oauth_jwt_keys, {}
  10. 17 auth_value_method :oauth_jwt_public_keys, {}
  11. 17 auth_value_method :oauth_jwt_jwe_keys, {}
  12. 17 auth_value_method :oauth_jwt_jwe_public_keys, {}
  13. 17 auth_value_method :oauth_jwt_jwe_copyright, nil
  14. 17 auth_methods(
  15. :jwt_encode,
  16. :jwt_decode,
  17. :jwt_decode_no_key,
  18. :generate_jti,
  19. :oauth_jwt_issuer,
  20. :oauth_jwt_audience,
  21. :resource_owner_params_from_jwt_claims
  22. )
  23. 17 private
  24. 17 def oauth_jwt_issuer
  25. # The JWT MUST contain an "iss" (issuer) claim that contains a
  26. # unique identifier for the entity that issued the JWT.
  27. 4047 @oauth_jwt_issuer ||= authorization_server_url
  28. end
  29. 17 def oauth_jwt_audience
  30. # The JWT MUST contain an "aud" (audience) claim containing a
  31. # value that identifies the authorization server as an intended
  32. # audience. The token endpoint URL of the authorization server
  33. # MAY be used as a value for an "aud" element to identify the
  34. # authorization server as an intended audience of the JWT.
  35. 1802 @oauth_jwt_audience ||= if is_authorization_server?
  36. 1377 oauth_application[oauth_applications_client_id_column]
  37. else
  38. metadata = authorization_server_metadata
  39. return unless metadata
  40. metadata[:token_endpoint]
  41. end
  42. end
  43. 17 def grant_from_application?(grant_or_claims, oauth_application)
  44. 153 return super if grant_or_claims[oauth_grants_id_column]
  45. if grant_or_claims["client_id"]
  46. grant_or_claims["client_id"] == oauth_application[oauth_applications_client_id_column]
  47. else
  48. Array(grant_or_claims["aud"]).include?(oauth_application[oauth_applications_client_id_column])
  49. end
  50. end
  51. 17 def jwt_subject(account_unique_id, client_application = oauth_application)
  52. 1853 (account_unique_id || client_application[oauth_applications_client_id_column]).to_s
  53. end
  54. 17 def resource_owner_params_from_jwt_claims(claims)
  55. 170 { oauth_grants_account_id_column => claims["sub"] }
  56. end
  57. 17 def oauth_server_metadata_body(path = nil)
  58. 272 metadata = super
  59. 272 metadata.merge! \
  60. token_endpoint_auth_signing_alg_values_supported: oauth_jwt_keys.keys.uniq
  61. 272 metadata
  62. end
  63. 17 def _jwt_key
  64. 378 @_jwt_key ||= (oauth_application_jwks(oauth_application) if oauth_application)
  65. end
  66. # Resource Server only!
  67. #
  68. # returns the jwks set from the authorization server.
  69. 17 def auth_server_jwks_set
  70. 68 metadata = authorization_server_metadata
  71. 68 return unless metadata && (jwks_uri = metadata[:jwks_uri])
  72. 68 jwks_uri = URI(jwks_uri)
  73. 68 http_request_with_cache(jwks_uri)
  74. end
  75. 17 def generate_jti(payload)
  76. # Use the key and iat to create a unique key per request to prevent replay attacks
  77. 765 jti_raw = [
  78. 1815 payload[:aud] || payload["aud"],
  79. payload[:iat] || payload["iat"]
  80. ].join(":").to_s
  81. 2580 Digest::SHA256.hexdigest(jti_raw)
  82. end
  83. 17 def verify_jti(jti, claims)
  84. 455 generate_jti(claims) == jti
  85. end
  86. 17 def verify_aud(expected_aud, aud)
  87. 935 expected_aud == aud
  88. end
  89. 17 def oauth_application_jwks(oauth_application)
  90. 1942 jwks = oauth_application[oauth_applications_jwks_column]
  91. 1942 if jwks
  92. 888 jwks = JSON.parse(jwks, symbolize_names: true) if jwks.is_a?(String)
  93. 679 return jwks
  94. end
  95. 1054 jwks_uri = oauth_application[oauth_applications_jwks_uri_column]
  96. 1054 return unless jwks_uri
  97. 34 jwks_uri = URI(jwks_uri)
  98. 34 http_request_with_cache(jwks_uri)
  99. end
  100. 17 if defined?(JSON::JWT)
  101. # json-jwt
  102. 4 auth_value_method :oauth_jwt_jws_algorithms_supported, %w[
  103. HS256 HS384 HS512
  104. RS256 RS384 RS512
  105. PS256 PS384 PS512
  106. ES256 ES384 ES512 ES256K
  107. ]
  108. 4 auth_value_method :oauth_jwt_jwe_algorithms_supported, %w[
  109. RSA1_5 RSA-OAEP dir A128KW A256KW
  110. ]
  111. 4 auth_value_method :oauth_jwt_jwe_encryption_methods_supported, %w[
  112. A128GCM A256GCM A128CBC-HS256 A256CBC-HS512
  113. ]
  114. 4 def key_to_jwk(key)
  115. 20 JSON::JWK.new(key)
  116. end
  117. 4 def jwk_export(key)
  118. 16 key_to_jwk(key)
  119. end
  120. 4 def jwk_import(jwk)
  121. 168 JSON::JWK.new(jwk)
  122. end
  123. 4 def jwk_key(jwk)
  124. 80 jwk = jwk_import(jwk) unless jwk.is_a?(JSON::JWK)
  125. 80 jwk.to_key
  126. end
  127. 4 def jwk_thumbprint(jwk)
  128. 88 jwk = jwk_import(jwk) if jwk.is_a?(Hash)
  129. 88 jwk.thumbprint
  130. end
  131. 4 def private_jwk?(jwk)
  132. 84 %w[d p q dp dq qi].any?(&jwk.method(:key?))
  133. end
  134. 4 def jwt_encode(payload,
  135. jwks: nil,
  136. headers: {},
  137. encryption_algorithm: oauth_jwt_jwe_keys.keys.dig(0, 0),
  138. encryption_method: oauth_jwt_jwe_keys.keys.dig(0, 1),
  139. jwe_key: oauth_jwt_jwe_keys[[encryption_algorithm,
  140. encryption_method]],
  141. signing_algorithm: oauth_jwt_keys.keys.first)
  142. 375 payload[:jti] = generate_jti(payload)
  143. 500 jwt = JSON::JWT.new(payload)
  144. 500 key = oauth_jwt_keys[signing_algorithm] || _jwt_key
  145. 500 key = key.first if key.is_a?(Array)
  146. 500 jwk = JSON::JWK.new(key || "")
  147. # update headers
  148. 500 headers.each_key do |k|
  149. 448 if jwt.respond_to?(:"#{k}=")
  150. 448 jwt.send(:"#{k}=", headers[k])
  151. 448 headers.delete(k)
  152. end
  153. end
  154. 500 jwt.header.merge(headers) unless headers.empty?
  155. 500 jwt = jwt.sign(jwk, signing_algorithm)
  156. 500 return jwt.to_s unless encryption_algorithm && encryption_method
  157. 76 if jwks && (jwk = jwks.find { |k| k[:use] == "enc" && k[:alg] == encryption_algorithm && k[:enc] == encryption_method })
  158. 24 jwk = JSON::JWK.new(jwk)
  159. 24 jwe = jwt.encrypt(jwk, encryption_algorithm.to_sym, encryption_method.to_sym)
  160. 24 jwe.to_s
  161. 4 elsif jwe_key
  162. 4 jwe_key = jwe_key.first if jwe_key.is_a?(Array)
  163. 4 algorithm = encryption_algorithm.to_sym
  164. 4 meth = encryption_method.to_sym
  165. 4 jwt.encrypt(jwe_key, algorithm, meth)
  166. else
  167. jwt.to_s
  168. end
  169. end
  170. 4 def jwt_decode(
  171. token,
  172. jwks: nil,
  173. jws_algorithm: oauth_jwt_public_keys.keys.first || oauth_jwt_keys.keys.first,
  174. jws_key: oauth_jwt_keys[jws_algorithm] || _jwt_key,
  175. jws_encryption_algorithm: oauth_jwt_jwe_keys.keys.dig(0, 0),
  176. jws_encryption_method: oauth_jwt_jwe_keys.keys.dig(0, 1),
  177. jwe_key: oauth_jwt_jwe_keys[[jws_encryption_algorithm, jws_encryption_method]] || oauth_jwt_jwe_keys.values.first,
  178. verify_claims: true,
  179. verify_jti: true,
  180. verify_iss: true,
  181. verify_aud: true,
  182. verify_headers: nil,
  183. **
  184. )
  185. 384 jws_key = jws_key.first if jws_key.is_a?(Array)
  186. 384 if jwe_key
  187. 12 jwe_key = jwe_key.first if jwe_key.is_a?(Array)
  188. 12 token = JSON::JWT.decode(token, jwe_key).plain_text
  189. end
  190. 384 claims = if is_authorization_server?
  191. 368 if jwks
  192. 96 jwks = jwks[:keys] if jwks.is_a?(Hash)
  193. 96 enc_algs = [jws_encryption_algorithm].compact
  194. 96 enc_meths = [jws_encryption_method].compact
  195. 200 sig_algs = jws_algorithm ? [jws_algorithm] : jwks.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
  196. 96 sig_algs = sig_algs.compact.map(&:to_sym)
  197. # JWKs may be set up without a KID, when there's a single one
  198. 96 if jwks.size == 1 && !jwks[0][:kid]
  199. 4 key = jwks[0]
  200. 4 jwk_key = JSON::JWK.new(key)
  201. 4 jws = JSON::JWT.decode(token, jwk_key)
  202. else
  203. 92 jws = JSON::JWT.decode(token, JSON::JWK::Set.new({ keys: jwks }), enc_algs + sig_algs, enc_meths)
  204. 84 jws = JSON::JWT.decode(jws.plain_text, JSON::JWK::Set.new({ keys: jwks }), sig_algs) if jws.is_a?(JSON::JWE)
  205. end
  206. 88 jws
  207. 272 elsif jws_key
  208. 268 JSON::JWT.decode(token, jws_key)
  209. else
  210. 4 JSON::JWT.decode(token, nil, jws_algorithm)
  211. end
  212. 16 elsif (jwks = auth_server_jwks_set)
  213. 16 JSON::JWT.decode(token, JSON::JWK::Set.new(jwks))
  214. end
  215. 368 now = Time.now
  216. 368 if verify_claims &&
  217. 258 (!claims[:exp] || Time.at(claims[:exp]) < now) &&
  218. claims[:nbf] && Time.at(claims[:nbf]) < now &&
  219. claims[:iat] && Time.at(claims[:iat]) < now &&
  220. verify_iss && claims[:iss] != oauth_jwt_issuer &&
  221. verify_aud && !verify_aud(claims[:aud], claims[:client_id]) &&
  222. verify_jti && !verify_jti(claims[:jti], claims)
  223. return
  224. end
  225. 368 return if verify_headers && !verify_headers.call(claims.header)
  226. 368 claims
  227. rescue JSON::JWT::Exception
  228. 16 nil
  229. end
  230. 4 def jwt_decode_no_key(token)
  231. 124 jws = JSON::JWT.decode(token, :skip_verification)
  232. 124 [jws.to_h, jws.header]
  233. end
  234. 13 elsif defined?(JWT)
  235. # ruby-jwt
  236. 13 require "rodauth/oauth/jwe_extensions" if defined?(JWE)
  237. 13 auth_value_method :oauth_jwt_jws_algorithms_supported, %w[
  238. HS256 HS384 HS512 HS512256
  239. RS256 RS384 RS512
  240. ED25519
  241. ES256 ES384 ES512
  242. PS256 PS384 PS512
  243. ]
  244. 13 if defined?(JWE)
  245. 13 auth_value_methods(
  246. :oauth_jwt_jwe_algorithms_supported,
  247. :oauth_jwt_jwe_encryption_methods_supported
  248. )
  249. 13 def oauth_jwt_jwe_algorithms_supported
  250. 390 JWE::VALID_ALG
  251. end
  252. 13 def oauth_jwt_jwe_encryption_methods_supported
  253. 377 JWE::VALID_ENC
  254. end
  255. else
  256. auth_value_method :oauth_jwt_jwe_algorithms_supported, []
  257. auth_value_method :oauth_jwt_jwe_encryption_methods_supported, []
  258. end
  259. 13 def key_to_jwk(key)
  260. 65 JWT::JWK.new(key)
  261. end
  262. 13 def jwk_export(key)
  263. 52 key_to_jwk(key).export
  264. end
  265. 13 def jwk_import(jwk)
  266. 806 JWT::JWK.import(jwk)
  267. end
  268. 13 def jwk_key(jwk)
  269. 260 jwk = jwk_import(jwk) unless jwk.is_a?(JWT::JWK)
  270. 260 jwk.keypair
  271. end
  272. 13 def jwk_thumbprint(jwk)
  273. 286 jwk = jwk_import(jwk) if jwk.is_a?(Hash)
  274. 286 JWT::JWK::Thumbprint.new(jwk).generate
  275. end
  276. 13 def private_jwk?(jwk)
  277. 273 jwk_import(jwk).private?
  278. end
  279. 13 def jwt_encode(payload,
  280. signing_algorithm: oauth_jwt_keys.keys.first,
  281. headers: {}, **)
  282. 1625 key = oauth_jwt_keys[signing_algorithm] || _jwt_key
  283. 1625 key = key.first if key.is_a?(Array)
  284. 1250 case key
  285. when OpenSSL::PKey::PKey
  286. 1313 jwk = JWT::JWK.new(key)
  287. 1010 headers[:kid] = jwk.kid
  288. 1313 key = jwk.keypair
  289. end
  290. # @see JWT reserved claims - https://tools.ietf.org/html/draft-jones-json-web-token-07#page-7
  291. 1250 payload[:jti] = generate_jti(payload)
  292. 1625 JWT.encode(payload, key, signing_algorithm, headers)
  293. end
  294. 13 if defined?(JWE)
  295. 13 def jwt_encode_with_jwe(
  296. payload,
  297. jwks: nil,
  298. encryption_algorithm: oauth_jwt_jwe_keys.keys.dig(0, 0),
  299. encryption_method: oauth_jwt_jwe_keys.keys.dig(0, 1),
  300. jwe_key: oauth_jwt_jwe_keys[[encryption_algorithm, encryption_method]],
  301. **args
  302. )
  303. 1625 token = jwt_encode_without_jwe(payload, **args)
  304. 1625 return token unless encryption_algorithm && encryption_method
  305. 221 if jwks && jwks.any? { |k| k[:use] == "enc" }
  306. 78 JWE.__rodauth_oauth_encrypt_from_jwks(token, jwks, alg: encryption_algorithm, enc: encryption_method)
  307. 13 elsif jwe_key
  308. 13 jwe_key = jwe_key.first if jwe_key.is_a?(Array)
  309. 10 params = {
  310. 3 zip: "DEF",
  311. copyright: oauth_jwt_jwe_copyright
  312. }
  313. 13 params[:enc] = encryption_method if encryption_method
  314. 13 params[:alg] = encryption_algorithm if encryption_algorithm
  315. 13 JWE.encrypt(token, jwe_key, **params)
  316. else
  317. token
  318. end
  319. end
  320. 13 alias_method :jwt_encode_without_jwe, :jwt_encode
  321. 13 alias_method :jwt_encode, :jwt_encode_with_jwe
  322. end
  323. 13 def jwt_decode(
  324. token,
  325. jwks: nil,
  326. jws_algorithm: oauth_jwt_public_keys.keys.first || oauth_jwt_keys.keys.first,
  327. jws_key: oauth_jwt_keys[jws_algorithm] || _jwt_key,
  328. verify_claims: true,
  329. verify_jti: true,
  330. verify_iss: true,
  331. verify_aud: true,
  332. verify_headers: nil
  333. )
  334. 1235 jws_key = jws_key.first if jws_key.is_a?(Array)
  335. # verifying the JWT implies verifying:
  336. #
  337. # issuer: check that server generated the token
  338. # aud: check the audience field (client is who he says he is)
  339. # iat: check that the token didn't expire
  340. #
  341. # subject can't be verified automatically without having access to the account id,
  342. # which we don't because that's the whole point.
  343. #
  344. 1235 verify_claims_params = if verify_claims
  345. 356 {
  346. 801 verify_iss: verify_iss,
  347. iss: oauth_jwt_issuer,
  348. # can't use stock aud verification, as it's dependent on the client application id
  349. verify_aud: false,
  350. 1157 verify_jti: (verify_jti ? method(:verify_jti) : false),
  351. verify_iat: true
  352. }
  353. else
  354. 78 {}
  355. end
  356. # decode jwt
  357. 1235 claims, headers = if is_authorization_server?
  358. 1183 if jwks
  359. 299 jwks = jwks[:keys] if jwks.is_a?(Hash)
  360. # JWKs may be set up without a KID, when there's a single one
  361. 299 if jwks.size == 1 && !jwks[0][:kid]
  362. 13 key = jwks[0]
  363. 13 algo = key[:alg]
  364. 13 key = JWT::JWK.import(key).keypair
  365. 13 JWT.decode(token, key, true, algorithms: [algo], **verify_claims_params)
  366. else
  367. 624 algorithms = jws_algorithm ? [jws_algorithm] : jwks.select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
  368. 286 JWT.decode(token, nil, true, algorithms: algorithms, jwks: { keys: jwks }, **verify_claims_params)
  369. end
  370. 884 elsif jws_key
  371. 871 JWT.decode(token, jws_key, true, algorithms: [jws_algorithm], **verify_claims_params)
  372. else
  373. 13 JWT.decode(token, jws_key, false, **verify_claims_params)
  374. end
  375. 52 elsif (jwks = auth_server_jwks_set)
  376. 156 algorithms = jwks[:keys].select { |k| k[:use] == "sig" }.map { |k| k[:alg] }
  377. 52 JWT.decode(token, nil, true, jwks: jwks, algorithms: algorithms, **verify_claims_params)
  378. end
  379. 1196 return if verify_claims && verify_aud && !verify_aud(claims["aud"], claims["client_id"])
  380. 1196 return if verify_headers && !verify_headers.call(headers)
  381. 1196 claims
  382. rescue JWT::DecodeError, JWT::JWKError
  383. 39 nil
  384. end
  385. 13 if defined?(JWE)
  386. 13 def jwt_decode_with_jwe(
  387. token,
  388. jwks: nil,
  389. jws_encryption_algorithm: oauth_jwt_jwe_keys.keys.dig(0, 0),
  390. jws_encryption_method: oauth_jwt_jwe_keys.keys.dig(0, 1),
  391. jwe_key: oauth_jwt_jwe_keys[[jws_encryption_algorithm, jws_encryption_method]] || oauth_jwt_jwe_keys.values.first,
  392. **args
  393. )
  394. 1625 token = if jwks && jwks.any? { |k| k[:use] == "enc" }
  395. 39 JWE.__rodauth_oauth_decrypt_from_jwks(token, jwks, alg: jws_encryption_algorithm, enc: jws_encryption_method)
  396. 1209 elsif jwe_key
  397. 39 jwe_key = jwe_key.first if jwe_key.is_a?(Array)
  398. 39 JWE.decrypt(token, jwe_key)
  399. else
  400. 1170 token
  401. end
  402. 1222 jwt_decode_without_jwe(token, jwks: jwks, **args)
  403. rescue JWE::DecodeError => e
  404. 26 jwt_decode_without_jwe(token, jwks: jwks, **args) if e.message.include?("Not enough or too many segments")
  405. end
  406. 13 alias_method :jwt_decode_without_jwe, :jwt_decode
  407. 13 alias_method :jwt_decode, :jwt_decode_with_jwe
  408. end
  409. 13 def jwt_decode_no_key(token)
  410. 403 JWT.decode(token, nil, false)
  411. end
  412. else
  413. skipped # :nocov:
  414. skipped def jwk_export(_key)
  415. skipped raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
  416. skipped end
  417. skipped
  418. skipped def jwk_import(_jwk)
  419. skipped raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
  420. skipped end
  421. skipped
  422. skipped def jwk_thumbprint(_jwk)
  423. skipped raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
  424. skipped end
  425. skipped
  426. skipped def jwt_encode(_token)
  427. skipped raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
  428. skipped end
  429. skipped
  430. skipped def jwt_decode(_token, **)
  431. skipped raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
  432. skipped end
  433. skipped
  434. skipped def private_jwk?(_jwk)
  435. skipped raise "#{__method__} is undefined, redefine it or require either \"jwt\" or \"json-jwt\""
  436. skipped end
  437. skipped # :nocov:
  438. end
  439. end
  440. end

lib/rodauth/features/oauth_jwt_bearer_grant.rb

100.0% lines covered

47 relevant lines. 47 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_jwt_bearer_grant, :OauthJwtBearerGrant) do
  5. 17 depends :oauth_assertion_base, :oauth_jwt
  6. 17 auth_value_method :max_param_bytesize, nil if Rodauth::VERSION >= "2.26.0"
  7. 17 auth_methods(
  8. :require_oauth_application_from_jwt_bearer_assertion_issuer,
  9. :require_oauth_application_from_jwt_bearer_assertion_subject,
  10. :account_from_jwt_bearer_assertion
  11. )
  12. 17 def oauth_token_endpoint_auth_methods_supported
  13. 51 if oauth_applications_client_secret_hash_column.nil?
  14. 17 super | %w[client_secret_jwt private_key_jwt urn:ietf:params:oauth:client-assertion-type:jwt-bearer]
  15. else
  16. 34 super | %w[private_key_jwt]
  17. end
  18. end
  19. 17 def oauth_grant_types_supported
  20. 136 super | %w[urn:ietf:params:oauth:grant-type:jwt-bearer]
  21. end
  22. 17 private
  23. 17 def require_oauth_application_from_jwt_bearer_assertion_issuer(assertion)
  24. 51 claims = jwt_assertion(assertion)
  25. 51 return unless claims
  26. 48 db[oauth_applications_table].where(
  27. oauth_applications_client_id_column => claims["iss"]
  28. 3 ).first
  29. end
  30. 17 def require_oauth_application_from_jwt_bearer_assertion_subject(assertion)
  31. 136 claims, header = jwt_decode_no_key(assertion)
  32. 136 client_id = claims["sub"]
  33. 104 case header["alg"]
  34. when "none"
  35. # do not accept jwts with no alg set
  36. 17 authorization_required
  37. when /\AHS/
  38. 51 require_oauth_application_from_client_secret_jwt(client_id, assertion, header["alg"])
  39. else
  40. 68 require_oauth_application_from_private_key_jwt(client_id, assertion)
  41. end
  42. end
  43. 17 def require_oauth_application_from_client_secret_jwt(client_id, assertion, alg)
  44. 51 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
  45. 51 authorization_required unless oauth_application && supports_auth_method?(oauth_application, "client_secret_jwt")
  46. 34 client_secret = oauth_application[oauth_applications_client_secret_column]
  47. 34 claims = jwt_assertion(assertion, jws_key: client_secret, jws_algorithm: alg)
  48. 34 authorization_required unless claims && claims["iss"] == client_id
  49. 34 oauth_application
  50. end
  51. 17 def require_oauth_application_from_private_key_jwt(client_id, assertion)
  52. 68 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
  53. 68 authorization_required unless oauth_application && supports_auth_method?(oauth_application, "private_key_jwt")
  54. 51 jwks = oauth_application_jwks(oauth_application)
  55. 51 claims = jwt_assertion(assertion, jwks: jwks)
  56. 51 authorization_required unless claims
  57. 51 oauth_application
  58. end
  59. 17 def account_from_jwt_bearer_assertion(assertion)
  60. 51 claims = jwt_assertion(assertion)
  61. 51 return unless claims
  62. 51 account_from_bearer_assertion_subject(claims["sub"])
  63. end
  64. 17 def jwt_assertion(assertion, **kwargs)
  65. 187 claims = jwt_decode(assertion, verify_iss: false, verify_aud: false, verify_jti: false, **kwargs)
  66. 187 return unless claims && verify_aud(request.url, claims["aud"])
  67. 187 claims
  68. end
  69. end
  70. end

lib/rodauth/features/oauth_jwt_jwks.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 require "rodauth/oauth/http_extensions"
  4. 17 module Rodauth
  5. 17 Feature.define(:oauth_jwt_jwks, :OauthJwtJwks) do
  6. 17 depends :oauth_jwt_base
  7. 17 auth_methods(:jwks_set)
  8. 17 auth_server_route(:jwks) do |r|
  9. 51 before_jwks_route
  10. 51 r.get do
  11. 51 json_response_success({ keys: jwks_set }, true)
  12. end
  13. end
  14. 17 private
  15. 17 def oauth_server_metadata_body(path = nil)
  16. 272 metadata = super
  17. 272 metadata.merge!(jwks_uri: jwks_url)
  18. 272 metadata
  19. end
  20. 17 def jwks_set
  21. 51 @jwks_set ||= [
  22. *(
  23. 51 unless oauth_jwt_public_keys.empty?
  24. 102 oauth_jwt_public_keys.flat_map { |algo, pkeys| Array(pkeys).map { |pkey| jwk_export(pkey).merge(use: "sig", alg: algo) } }
  25. end
  26. ),
  27. *(
  28. 51 unless oauth_jwt_jwe_public_keys.empty?
  29. 17 oauth_jwt_jwe_public_keys.flat_map do |(algo, _enc), pkeys|
  30. 17 Array(pkeys).map do |pkey|
  31. 17 jwk_export(pkey).merge(use: "enc", alg: algo)
  32. end
  33. end
  34. end
  35. )
  36. ].compact
  37. end
  38. end
  39. end

lib/rodauth/features/oauth_jwt_secured_authorization_request.rb

100.0% lines covered

68 relevant lines. 68 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_jwt_secured_authorization_request, :OauthJwtSecuredAuthorizationRequest) do
  5. 17 ALLOWED_REQUEST_URI_CONTENT_TYPES = %w[application/jose application/oauth-authz-req+jwt].freeze
  6. 17 depends :oauth_authorize_base, :oauth_jwt_base
  7. 17 auth_value_method :oauth_require_request_uri_registration, false
  8. 17 auth_value_method :oauth_require_signed_request_object, false
  9. 17 auth_value_method :oauth_request_object_signing_alg_allow_none, false
  10. 16 %i[
  11. request_uris require_signed_request_object request_object_signing_alg
  12. request_object_encryption_alg request_object_encryption_enc
  13. 1 ].each do |column|
  14. 85 auth_value_method :"oauth_applications_#{column}_column", column
  15. end
  16. 17 translatable_method :oauth_invalid_request_object_message, "request object is invalid"
  17. 17 auth_value_method :max_param_bytesize, nil if Rodauth::VERSION >= "2.26.0"
  18. 17 private
  19. # /authorize
  20. 17 def validate_authorize_params
  21. 765 request_object = param_or_nil("request")
  22. 765 request_uri = param_or_nil("request_uri")
  23. 765 unless (request_object || request_uri) && oauth_application
  24. 187 if request.path == authorize_path && request.get? && require_signed_request_object?
  25. 17 redirect_response_error("invalid_request_object")
  26. end
  27. 130 return super
  28. end
  29. 578 if request_uri
  30. 153 request_uri = CGI.unescape(request_uri)
  31. 153 redirect_response_error("invalid_request_uri") unless supported_request_uri?(request_uri, oauth_application)
  32. 85 response = http_request(request_uri)
  33. 85 unless response.code.to_i == 200 && ALLOWED_REQUEST_URI_CONTENT_TYPES.include?(response["content-type"])
  34. 17 redirect_response_error("invalid_request_uri")
  35. end
  36. 68 request_object = response.body
  37. end
  38. 493 claims = decode_request_object(request_object)
  39. 323 redirect_response_error("invalid_request_object") unless claims
  40. 323 if (iss = claims["iss"]) && (iss != oauth_application[oauth_applications_client_id_column])
  41. 17 redirect_response_error("invalid_request_object")
  42. end
  43. 306 if (aud = claims["aud"]) && !verify_aud(aud, oauth_jwt_issuer)
  44. 17 redirect_response_error("invalid_request_object")
  45. end
  46. # If signed, the Authorization Request
  47. # Object SHOULD contain the Claims "iss" (issuer) and "aud" (audience)
  48. # as members, with their semantics being the same as defined in the JWT
  49. # [RFC7519] specification. The value of "aud" should be the value of
  50. # the Authorization Server (AS) "issuer" as defined in RFC8414
  51. # [RFC8414].
  52. 289 claims.delete("iss")
  53. 289 audience = claims.delete("aud")
  54. 289 redirect_response_error("invalid_request_object") if audience && audience != oauth_jwt_issuer
  55. 289 claims.each do |k, v|
  56. 1365 request.params[k.to_s] = v
  57. end
  58. 289 super
  59. end
  60. 17 def supported_request_uri?(request_uri, oauth_application)
  61. 153 return false unless check_valid_uri?(request_uri)
  62. 119 request_uris = oauth_application[oauth_applications_request_uris_column]
  63. 204 request_uris.nil? || request_uris.split(oauth_scope_separator).one? { |uri| request_uri.start_with?(uri) }
  64. end
  65. 17 def require_signed_request_object?
  66. 85 return @require_signed_request_object if defined?(@require_signed_request_object)
  67. 68 @require_signed_request_object = (oauth_application[oauth_applications_require_signed_request_object_column] if oauth_application)
  68. 68 @require_signed_request_object = oauth_require_signed_request_object if @require_signed_request_object.nil?
  69. 68 @require_signed_request_object
  70. end
  71. 17 def decode_request_object(request_object)
  72. 150 request_sig_enc_opts = {
  73. 360 jws_algorithm: oauth_application[oauth_applications_request_object_signing_alg_column],
  74. jws_encryption_algorithm: oauth_application[oauth_applications_request_object_encryption_alg_column],
  75. jws_encryption_method: oauth_application[oauth_applications_request_object_encryption_enc_column]
  76. }.compact
  77. 510 request_sig_enc_opts[:jws_algorithm] ||= "none" if oauth_request_object_signing_alg_allow_none
  78. 510 if request_sig_enc_opts[:jws_algorithm] == "none"
  79. 51 redirect_response_error("invalid_request_object") if require_signed_request_object?
  80. 17 jwks = nil
  81. 459 elsif (jwks = oauth_application_jwks(oauth_application))
  82. 357 jwks = JSON.parse(jwks, symbolize_names: true) if jwks.is_a?(String)
  83. else
  84. 102 redirect_response_error("invalid_request_object")
  85. end
  86. 374 claims = jwt_decode(request_object,
  87. jwks: jwks,
  88. verify_jti: false,
  89. verify_iss: false,
  90. verify_aud: false,
  91. **request_sig_enc_opts)
  92. 374 redirect_response_error("invalid_request_object") unless claims
  93. 340 claims
  94. end
  95. 17 def oauth_server_metadata_body(*)
  96. 51 super.tap do |data|
  97. 39 data[:request_parameter_supported] = true
  98. 39 data[:request_uri_parameter_supported] = true
  99. 39 data[:require_request_uri_registration] = oauth_require_request_uri_registration
  100. 39 data[:require_signed_request_object] = oauth_require_signed_request_object
  101. end
  102. end
  103. end
  104. end

lib/rodauth/features/oauth_jwt_secured_authorization_response_mode.rb

98.53% lines covered

68 relevant lines. 67 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_jwt_secured_authorization_response_mode, :OauthJwtSecuredAuthorizationResponseMode) do
  5. 17 depends :oauth_authorize_base, :oauth_jwt_base
  6. 17 auth_value_method :oauth_authorization_response_mode_expires_in, 60 * 5 # 5 minutes
  7. 17 auth_value_method :oauth_applications_authorization_signed_response_alg_column, :authorization_signed_response_alg
  8. 17 auth_value_method :oauth_applications_authorization_encrypted_response_alg_column, :authorization_encrypted_response_alg
  9. 17 auth_value_method :oauth_applications_authorization_encrypted_response_enc_column, :authorization_encrypted_response_enc
  10. 17 auth_value_methods(
  11. :authorization_signing_alg_values_supported,
  12. :authorization_encryption_alg_values_supported,
  13. :authorization_encryption_enc_values_supported
  14. )
  15. 17 def oauth_response_modes_supported
  16. 731 jwt_response_modes = %w[jwt]
  17. 731 jwt_response_modes.push("query.jwt", "form_post.jwt") if features.include?(:oauth_authorization_code_grant)
  18. 731 jwt_response_modes << "fragment.jwt" if features.include?(:oauth_implicit_grant)
  19. 731 super | jwt_response_modes
  20. end
  21. 17 def authorization_signing_alg_values_supported
  22. 17 oauth_jwt_jws_algorithms_supported
  23. end
  24. 17 def authorization_encryption_alg_values_supported
  25. 34 oauth_jwt_jwe_algorithms_supported
  26. end
  27. 17 def authorization_encryption_enc_values_supported
  28. 34 oauth_jwt_jwe_encryption_methods_supported
  29. end
  30. 17 private
  31. 17 def oauth_response_modes_for_code_supported
  32. 204 return [] unless features.include?(:oauth_authorization_code_grant)
  33. 204 super | %w[query.jwt form_post.jwt jwt]
  34. end
  35. 17 def oauth_response_modes_for_token_supported
  36. 85 return [] unless features.include?(:oauth_implicit_grant)
  37. 85 super | %w[fragment.jwt jwt]
  38. end
  39. 17 def authorize_response(params, mode)
  40. 170 return super unless mode.end_with?("jwt")
  41. 170 response_type = param_or_nil("response_type")
  42. 170 redirect_url = URI.parse(redirect_uri)
  43. 170 jwt = jwt_encode_authorization_response_mode(params)
  44. 170 if mode == "query.jwt" || (mode == "jwt" && response_type == "code")
  45. 85 return super unless features.include?(:oauth_authorization_code_grant)
  46. 85 params = ["response=#{CGI.escape(jwt)}"]
  47. 85 params << redirect_url.query if redirect_url.query
  48. 85 redirect_url.query = params.join("&")
  49. 85 redirect(redirect_url.to_s)
  50. 85 elsif mode == "form_post.jwt"
  51. 17 return super unless features.include?(:oauth_authorization_code_grant)
  52. 13 response["Content-Type"] = "text/html"
  53. 17 body = form_post_response_html(redirect_url) do
  54. 17 "<input type=\"hidden\" name=\"response\" value=\"#{scope.h(jwt)}\" />"
  55. end
  56. 17 response.write(body)
  57. 17 request.halt
  58. 68 elsif mode == "fragment.jwt" || (mode == "jwt" && response_type == "token")
  59. 68 return super unless features.include?(:oauth_implicit_grant)
  60. 68 params = ["response=#{CGI.escape(jwt)}"]
  61. 68 params << redirect_url.query if redirect_url.query
  62. 68 redirect_url.fragment = params.join("&")
  63. 68 redirect(redirect_url.to_s)
  64. else
  65. super
  66. end
  67. end
  68. 17 def _redirect_response_error(redirect_url, params)
  69. 51 response_mode = param_or_nil("response_mode")
  70. 51 return super unless response_mode.end_with?("jwt")
  71. 51 authorize_response(Hash[params], response_mode)
  72. end
  73. 17 def jwt_encode_authorization_response_mode(params)
  74. 170 now = Time.now.to_i
  75. 50 claims = {
  76. 120 iss: oauth_jwt_issuer,
  77. aud: oauth_application[oauth_applications_client_id_column],
  78. exp: now + oauth_authorization_response_mode_expires_in,
  79. iat: now
  80. }.merge(params)
  81. 50 encode_params = {
  82. 120 jwks: oauth_application_jwks(oauth_application),
  83. signing_algorithm: oauth_application[oauth_applications_authorization_signed_response_alg_column],
  84. encryption_algorithm: oauth_application[oauth_applications_authorization_encrypted_response_alg_column],
  85. encryption_method: oauth_application[oauth_applications_authorization_encrypted_response_enc_column]
  86. }.compact
  87. 170 jwt_encode(claims, **encode_params)
  88. end
  89. 17 def oauth_server_metadata_body(*)
  90. 34 super.tap do |data|
  91. 26 data[:authorization_signing_alg_values_supported] = authorization_signing_alg_values_supported
  92. 26 data[:authorization_encryption_alg_values_supported] = authorization_encryption_alg_values_supported
  93. 26 data[:authorization_encryption_enc_values_supported] = authorization_encryption_enc_values_supported
  94. end
  95. end
  96. end
  97. end

lib/rodauth/features/oauth_management_base.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_management_base, :OauthManagementBase) do
  5. 17 depends :oauth_authorize_base
  6. 17 button "Previous", "oauth_management_pagination_previous"
  7. 17 button "Next", "oauth_management_pagination_next"
  8. 17 def oauth_management_pagination_links(paginated_ds)
  9. 265 html = +'<nav aria-label="Pagination"><ul class="pagination">'
  10. 265 html << oauth_management_pagination_link(paginated_ds.prev_page, label: oauth_management_pagination_previous_button)
  11. 265 html << oauth_management_pagination_link(paginated_ds.current_page - 1) unless paginated_ds.first_page?
  12. 265 html << oauth_management_pagination_link(paginated_ds.current_page, label: paginated_ds.current_page, current: true)
  13. 265 html << oauth_management_pagination_link(paginated_ds.current_page + 1) unless paginated_ds.last_page?
  14. 265 html << oauth_management_pagination_link(paginated_ds.next_page, label: oauth_management_pagination_next_button)
  15. 265 html << "</ul></nav>"
  16. end
  17. 17 def oauth_management_pagination_link(page, label: page, current: false, classes: "")
  18. 879 classes += " disabled" if current || !page
  19. 879 classes += " active" if current
  20. 879 if page
  21. 433 params = URI.encode_www_form(request.GET.merge("page" => page))
  22. 433 href = "#{request.path}?#{params}"
  23. 317 <<-HTML
  24. 236 <li class="page-item #{classes}" #{'aria-current="page"' if current}>
  25. 116 <a class="page-link" href="#{href}" tabindex="-1" aria-disabled="#{current || !page}">
  26. 116 #{label}
  27. </a>
  28. </li>
  29. HTML
  30. else
  31. 334 <<-HTML
  32. 112 <li class="page-item #{classes}">
  33. <span class="page-link">
  34. 112 #{label}
  35. 112 #{'<span class="sr-only">(current)</span>' if current}
  36. </span>
  37. </li>
  38. HTML
  39. end
  40. end
  41. 17 def post_configure
  42. 130 super
  43. # TODO: remove this in v1, when resource-server mode does not load all of the provider features.
  44. 130 return unless db
  45. 130 db.extension :pagination
  46. end
  47. 17 private
  48. 17 def per_page_param(default_per_page)
  49. 333 per_page = param_or_nil("per_page")
  50. 333 return default_per_page unless per_page
  51. 84 per_page = per_page.to_i
  52. 84 return default_per_page if per_page <= 0
  53. 84 [per_page, default_per_page].min
  54. end
  55. end
  56. end

lib/rodauth/features/oauth_pkce.rb

100.0% lines covered

49 relevant lines. 49 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_pkce, :OauthPkce) do
  5. 17 depends :oauth_authorization_code_grant
  6. 17 auth_value_method :oauth_require_pkce, true
  7. 17 auth_value_method :oauth_pkce_challenge_method, "S256"
  8. 17 auth_value_method :oauth_grants_code_challenge_column, :code_challenge
  9. 17 auth_value_method :oauth_grants_code_challenge_method_column, :code_challenge_method
  10. 17 auth_value_method :oauth_code_challenge_required_error_code, "invalid_request"
  11. 17 translatable_method :oauth_code_challenge_required_message, "code challenge required"
  12. 17 auth_value_method :oauth_unsupported_transform_algorithm_error_code, "invalid_request"
  13. 17 translatable_method :oauth_unsupported_transform_algorithm_message, "transform algorithm not supported"
  14. 17 private
  15. 17 def supports_auth_method?(oauth_application, auth_method)
  16. 136 return super unless auth_method == "none"
  17. 102 request.params.key?("code_verifier") || super
  18. end
  19. 17 def validate_authorize_params
  20. 102 validate_pkce_challenge_params
  21. 85 super
  22. end
  23. 17 def create_oauth_grant(create_params = {})
  24. # PKCE flow
  25. 34 if (code_challenge = param_or_nil("code_challenge"))
  26. 34 code_challenge_method = param_or_nil("code_challenge_method") || oauth_pkce_challenge_method
  27. 26 create_params[oauth_grants_code_challenge_column] = code_challenge
  28. 26 create_params[oauth_grants_code_challenge_method_column] = code_challenge_method
  29. end
  30. 34 super
  31. end
  32. 17 def create_token_from_authorization_code(grant_params, *args, oauth_grant: nil)
  33. 136 oauth_grant ||= valid_locked_oauth_grant(grant_params)
  34. 119 if oauth_grant[oauth_grants_code_challenge_column]
  35. 102 code_verifier = param_or_nil("code_verifier")
  36. 102 redirect_response_error("invalid_request") unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier)
  37. 17 elsif oauth_require_pkce
  38. 17 redirect_response_error("code_challenge_required")
  39. end
  40. 51 super({ oauth_grants_id_column => oauth_grant[oauth_grants_id_column] }, *args, oauth_grant: oauth_grant)
  41. end
  42. 17 def validate_pkce_challenge_params
  43. 102 if param_or_nil("code_challenge")
  44. 68 challenge_method = param_or_nil("code_challenge_method")
  45. 68 redirect_response_error("code_challenge_required") unless oauth_pkce_challenge_method == challenge_method
  46. else
  47. 34 return unless oauth_require_pkce
  48. 17 redirect_response_error("code_challenge_required")
  49. end
  50. end
  51. 17 def check_valid_grant_challenge?(grant, verifier)
  52. 85 challenge = grant[oauth_grants_code_challenge_column]
  53. 65 case grant[oauth_grants_code_challenge_method_column]
  54. when "plain"
  55. 17 challenge == verifier
  56. when "S256"
  57. 51 generated_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
  58. 51 challenge == generated_challenge
  59. else
  60. 17 redirect_response_error("unsupported_transform_algorithm")
  61. end
  62. end
  63. 17 def oauth_server_metadata_body(*)
  64. 17 super.tap do |data|
  65. 13 data[:code_challenge_methods_supported] = oauth_pkce_challenge_method
  66. end
  67. end
  68. end
  69. end

lib/rodauth/features/oauth_pushed_authorization_request.rb

92.75% lines covered

69 relevant lines. 64 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_pushed_authorization_request, :OauthJwtPushedAuthorizationRequest) do
  5. 17 depends :oauth_authorize_base
  6. 17 auth_value_method :oauth_require_pushed_authorization_requests, false
  7. 17 auth_value_method :oauth_applications_require_pushed_authorization_requests_column, :require_pushed_authorization_requests
  8. 17 auth_value_method :oauth_pushed_authorization_request_expires_in, 90 # 90 seconds
  9. 17 auth_value_method :oauth_require_pushed_authorization_request_iss_request_object, true
  10. 17 auth_value_method :oauth_pushed_authorization_requests_table, :oauth_pushed_requests
  11. 16 %i[
  12. oauth_application_id params code expires_in
  13. 1 ].each do |column|
  14. 68 auth_value_method :"oauth_pushed_authorization_requests_#{column}_column", column
  15. end
  16. # /par
  17. 17 auth_server_route(:par) do |r|
  18. 136 require_oauth_application
  19. 119 before_par_route
  20. 119 r.post do
  21. 119 validate_par_params
  22. 85 ds = db[oauth_pushed_authorization_requests_table]
  23. 85 code = oauth_unique_id_generator
  24. 25 push_request_params = {
  25. 60 oauth_pushed_authorization_requests_oauth_application_id_column => oauth_application[oauth_applications_id_column],
  26. oauth_pushed_authorization_requests_code_column => code,
  27. oauth_pushed_authorization_requests_params_column => URI.encode_www_form(request.params),
  28. oauth_pushed_authorization_requests_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
  29. seconds: oauth_pushed_authorization_request_expires_in)
  30. }
  31. 85 rescue_from_uniqueness_error do
  32. 85 ds.insert(push_request_params)
  33. end
  34. 85 json_response_success(
  35. 20 "request_uri" => "urn:ietf:params:oauth:request_uri:#{code}",
  36. "expires_in" => oauth_pushed_authorization_request_expires_in
  37. )
  38. end
  39. end
  40. 17 def check_csrf?
  41. 598 case request.path
  42. when par_path
  43. 136 false
  44. else
  45. 646 super
  46. end
  47. end
  48. 17 private
  49. 17 def validate_par_params
  50. # https://datatracker.ietf.org/doc/html/rfc9126#section-2.1
  51. # The request_uri authorization request parameter is one exception, and it MUST NOT be provided.
  52. 119 redirect_response_error("invalid_request") if param_or_nil("request_uri")
  53. 102 if features.include?(:oauth_jwt_secured_authorization_request)
  54. 17 if (request_object = param_or_nil("request"))
  55. 17 claims = decode_request_object(request_object)
  56. # https://datatracker.ietf.org/doc/html/rfc9126#section-3-5.3
  57. # reject the request if the authenticated client_id does not match the client_id claim in the Request Object
  58. 17 if (client_id = claims["client_id"]) && (client_id != oauth_application[oauth_applications_client_id_column])
  59. redirect_response_error("invalid_request_object")
  60. end
  61. # requiring the iss claim to match the client_id is at the discretion of the authorization server
  62. 17 if oauth_require_pushed_authorization_request_iss_request_object &&
  63. 17 (iss = claims.delete("iss")) &&
  64. iss != oauth_application[oauth_applications_client_id_column]
  65. redirect_response_error("invalid_request_object")
  66. end
  67. 17 if (aud = claims.delete("aud")) && !verify_aud(aud, oauth_jwt_issuer)
  68. redirect_response_error("invalid_request_object")
  69. end
  70. 17 claims.delete("exp")
  71. 17 request.params.delete("request")
  72. 17 claims.each do |k, v|
  73. 78 request.params[k.to_s] = v
  74. end
  75. elsif require_signed_request_object?
  76. redirect_response_error("invalid_request_object")
  77. end
  78. end
  79. 102 validate_authorize_params
  80. end
  81. 17 def validate_authorize_params
  82. 323 return super unless request.get? && request.path == authorize_path
  83. 153 if (request_uri = param_or_nil("request_uri"))
  84. 85 code = request_uri.delete_prefix("urn:ietf:params:oauth:request_uri:")
  85. 85 table = oauth_pushed_authorization_requests_table
  86. 85 ds = db[table]
  87. 85 pushed_request = ds.where(
  88. oauth_pushed_authorization_requests_oauth_application_id_column => oauth_application[oauth_applications_id_column],
  89. oauth_pushed_authorization_requests_code_column => code
  90. ).where(
  91. Sequel.expr(Sequel[table][oauth_pushed_authorization_requests_expires_in_column]) >= Sequel::CURRENT_TIMESTAMP
  92. ).first
  93. 85 redirect_response_error("invalid_request") unless pushed_request
  94. 68 URI.decode_www_form(pushed_request[oauth_pushed_authorization_requests_params_column]).each do |k, v|
  95. 221 request.params[k.to_s] = v
  96. end
  97. 68 request.params.delete("request_uri")
  98. # we're removing the request_uri here, so the checkup for signed reqest has to be invalidated.
  99. 68 @require_signed_request_object = false
  100. 68 elsif oauth_require_pushed_authorization_requests ||
  101. 36 (oauth_application && oauth_application[oauth_applications_require_pushed_authorization_requests_column])
  102. 34 redirect_authorize_error("request_uri")
  103. end
  104. 102 super
  105. end
  106. 17 def oauth_server_metadata_body(*)
  107. 17 super.tap do |data|
  108. 13 data[:require_pushed_authorization_requests] = oauth_require_pushed_authorization_requests
  109. 13 data[:pushed_authorization_request_endpoint] = par_url
  110. end
  111. end
  112. end
  113. end

lib/rodauth/features/oauth_resource_indicators.rb

93.9% lines covered

82 relevant lines. 77 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_resource_indicators, :OauthResourceIndicators) do
  5. 17 depends :oauth_authorize_base
  6. 17 auth_value_method :oauth_grants_resource_column, :resource
  7. 17 def resource_indicators
  8. 680 return @resource_indicators if defined?(@resource_indicators)
  9. 170 resources = param_or_nil("resource")
  10. 170 return unless resources
  11. 170 if json_request? || param_or_nil("request") # signed request
  12. 34 resources = Array(resources)
  13. else
  14. 136 query = if request.form_data?
  15. 85 request.body.rewind
  16. 85 request.body.read
  17. else
  18. 51 request.query_string
  19. end
  20. # resource query param does not conform to rack parsing rules
  21. 136 resources = URI.decode_www_form(query).each_with_object([]) do |(k, v), memo|
  22. 714 memo << v if k == "resource"
  23. end
  24. end
  25. 170 @resource_indicators = resources
  26. end
  27. 17 def require_oauth_authorization(*)
  28. 119 super
  29. # done so to support token-in-grant-db, jwt, and resource-server mode
  30. 102 token_indicators = authorization_token[oauth_grants_resource_column] || authorization_token["resource"]
  31. 102 return unless token_indicators
  32. 85 token_indicators = token_indicators.split(" ") if token_indicators.is_a?(String)
  33. 170 authorization_required unless token_indicators.any? { |resource| base_url.start_with?(resource) }
  34. end
  35. 17 private
  36. 17 def validate_token_params
  37. 68 super
  38. 68 return unless resource_indicators
  39. 68 resource_indicators.each do |resource|
  40. 68 redirect_response_error("invalid_target") unless check_valid_no_fragment_uri?(resource)
  41. end
  42. end
  43. 17 def create_token_from_token(oauth_grant, update_params)
  44. return super unless resource_indicators
  45. grant_indicators = oauth_grant[oauth_grants_resource_column]
  46. grant_indicators = grant_indicators.split(" ") if grant_indicators.is_a?(String)
  47. redirect_response_error("invalid_target") unless (grant_indicators - resource_indicators) != grant_indicators
  48. super(oauth_grant, update_params.merge(oauth_grants_resource_column => resource_indicators))
  49. end
  50. 17 module IndicatorAuthorizationCodeGrant
  51. 17 private
  52. 17 def validate_authorize_params
  53. 102 super
  54. 102 return unless resource_indicators
  55. 102 resource_indicators.each do |resource|
  56. 102 redirect_response_error("invalid_target") unless check_valid_no_fragment_uri?(resource)
  57. end
  58. end
  59. 17 def create_token_from_authorization_code(grant_params, *args, oauth_grant: nil)
  60. 68 return super unless resource_indicators
  61. 68 oauth_grant ||= valid_locked_oauth_grant(grant_params)
  62. 68 redirect_response_error("invalid_target") unless oauth_grant[oauth_grants_resource_column]
  63. 68 grant_indicators = oauth_grant[oauth_grants_resource_column]
  64. 68 grant_indicators = grant_indicators.split(" ") if grant_indicators.is_a?(String)
  65. 68 redirect_response_error("invalid_target") unless (grant_indicators - resource_indicators) != grant_indicators
  66. # update ownership
  67. 51 if grant_indicators != resource_indicators
  68. 17 oauth_grant = __update_and_return__(
  69. db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column]),
  70. oauth_grants_resource_column => resource_indicators
  71. )
  72. end
  73. 51 super({ oauth_grants_id_column => oauth_grant[oauth_grants_id_column] }, *args, oauth_grant: oauth_grant)
  74. end
  75. 17 def create_oauth_grant(create_params = {})
  76. 17 create_params[oauth_grants_resource_column] = resource_indicators.join(" ") if resource_indicators
  77. 17 super
  78. end
  79. end
  80. 17 module IndicatorIntrospection
  81. 17 def json_token_introspect_payload(grant)
  82. 17 return super unless grant && grant[oauth_grants_id_column]
  83. 17 payload = super
  84. 17 token_indicators = grant[oauth_grants_resource_column]
  85. 17 token_indicators = token_indicators.split(" ") if token_indicators.is_a?(String)
  86. 13 payload[:aud] = token_indicators
  87. 17 payload
  88. end
  89. 17 def introspection_request(*)
  90. 51 payload = super
  91. 51 payload[oauth_grants_resource_column] = payload["aud"] if payload["aud"]
  92. 51 payload
  93. end
  94. end
  95. 17 module IndicatorJwt
  96. 17 def jwt_claims(*)
  97. 17 return super unless resource_indicators
  98. 17 super.merge(aud: resource_indicators)
  99. end
  100. 17 def jwt_decode(token, verify_aud: true, **args)
  101. 51 claims = super(token, verify_aud: false, **args)
  102. 51 return claims unless verify_aud
  103. 34 return unless claims["aud"] && claims["aud"].one? { |aud| request.url.starts_with?(aud) }
  104. 17 claims
  105. end
  106. end
  107. 17 def self.included(rodauth)
  108. 255 super
  109. 255 rodauth.send(:include, IndicatorAuthorizationCodeGrant) if rodauth.features.include?(:oauth_authorization_code_grant)
  110. 255 rodauth.send(:include, IndicatorIntrospection) if rodauth.features.include?(:oauth_token_introspection)
  111. 255 rodauth.send(:include, IndicatorJwt) if rodauth.features.include?(:oauth_jwt)
  112. end
  113. end
  114. end

lib/rodauth/features/oauth_resource_server.rb

96.3% lines covered

27 relevant lines. 26 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_resource_server, :OauthResourceServer) do
  5. 17 depends :oauth_token_introspection
  6. 17 auth_value_method :is_authorization_server?, false
  7. 17 auth_methods(
  8. :before_introspection_request
  9. )
  10. 17 def authorization_token
  11. 306 return @authorization_token if defined?(@authorization_token)
  12. # check if there is a token
  13. 153 access_token = fetch_access_token
  14. 153 return unless access_token
  15. # where in resource server, NOT the authorization server.
  16. 119 payload = introspection_request("access_token", access_token)
  17. 119 return unless payload["active"]
  18. 102 @authorization_token = payload
  19. end
  20. 17 def require_oauth_authorization(*scopes)
  21. 153 authorization_required unless authorization_token
  22. 102 aux_scopes = authorization_token["scope"]
  23. 102 token_scopes = if aux_scopes
  24. 102 aux_scopes.split(oauth_scope_separator)
  25. else
  26. []
  27. end
  28. 204 authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
  29. end
  30. 17 private
  31. 17 def introspection_request(token_type_hint, token)
  32. 119 introspect_url = URI("#{authorization_server_url}#{introspect_path}")
  33. 119 response = http_request(introspect_url, { "token_type_hint" => token_type_hint, "token" => token }) do |request|
  34. 119 before_introspection_request(request)
  35. end
  36. 119 JSON.parse(response.body)
  37. end
  38. 17 def before_introspection_request(request); end
  39. end
  40. end

lib/rodauth/features/oauth_saml_bearer_grant.rb

96.77% lines covered

62 relevant lines. 60 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "onelogin/ruby-saml"
  3. 17 require "rodauth/oauth"
  4. 17 module Rodauth
  5. 17 Feature.define(:oauth_saml_bearer_grant, :OauthSamlBearerGrant) do
  6. 17 depends :oauth_assertion_base
  7. 17 auth_value_method :oauth_saml_name_identifier_format, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
  8. 17 auth_value_method :oauth_saml_idp_cert_check_expiration, true
  9. 17 auth_value_method :max_param_bytesize, nil if Rodauth::VERSION >= "2.26.0"
  10. 17 auth_value_method :oauth_saml_settings_table, :oauth_saml_settings
  11. 16 %i[
  12. id oauth_application_id
  13. idp_cert idp_cert_fingerprint idp_cert_fingerprint_algorithm
  14. name_identifier_format
  15. issuer
  16. audience
  17. idp_cert_check_expiration
  18. 1 ].each do |column|
  19. 153 auth_value_method :"oauth_saml_settings_#{column}_column", column
  20. end
  21. 17 translatable_method :oauth_saml_assertion_not_base64_message, "SAML assertion must be in base64 format"
  22. 17 translatable_method :oauth_saml_assertion_single_issuer_message, "SAML assertion must have a single issuer"
  23. 17 translatable_method :oauth_saml_settings_not_found_message, "No SAML settings found for issuer"
  24. 17 auth_methods(
  25. :require_oauth_application_from_saml2_bearer_assertion_issuer,
  26. :require_oauth_application_from_saml2_bearer_assertion_subject,
  27. :account_from_saml2_bearer_assertion
  28. )
  29. 17 def oauth_grant_types_supported
  30. 34 super | %w[urn:ietf:params:oauth:grant-type:saml2-bearer]
  31. end
  32. 17 private
  33. 17 def require_oauth_application_from_saml2_bearer_assertion_issuer(assertion)
  34. 17 parse_saml_assertion(assertion)
  35. 17 return unless @saml_settings
  36. 16 db[oauth_applications_table].where(
  37. oauth_applications_id_column => @saml_settings[oauth_saml_settings_oauth_application_id_column]
  38. 1 ).first
  39. end
  40. 17 def require_oauth_application_from_saml2_bearer_assertion_subject(assertion)
  41. 17 parse_saml_assertion(assertion)
  42. 17 return unless @assertion
  43. # 3.3.8 - For client authentication, the Subject MUST be the "client_id" of the OAuth client.
  44. 16 db[oauth_applications_table].where(
  45. oauth_applications_client_id_column => @assertion.nameid
  46. 1 ).first
  47. end
  48. 17 def account_from_saml2_bearer_assertion(assertion)
  49. 17 parse_saml_assertion(assertion)
  50. 17 return unless @assertion
  51. 17 account_from_bearer_assertion_subject(@assertion.nameid)
  52. end
  53. 17 def generate_saml_settings(saml_settings)
  54. 34 settings = OneLogin::RubySaml::Settings.new
  55. # issuer
  56. 34 settings.idp_entity_id = saml_settings[oauth_saml_settings_issuer_column]
  57. # audience
  58. 34 settings.sp_entity_id = saml_settings[oauth_saml_settings_audience_column] || token_url
  59. # recipient
  60. 34 settings.assertion_consumer_service_url = token_url
  61. 34 settings.idp_cert = saml_settings[oauth_saml_settings_idp_cert_column]
  62. 34 settings.idp_cert_fingerprint = saml_settings[oauth_saml_settings_idp_cert_fingerprint_column]
  63. 34 settings.idp_cert_fingerprint_algorithm = saml_settings[oauth_saml_settings_idp_cert_fingerprint_algorithm_column]
  64. 34 if settings.idp_cert
  65. 34 check_idp_cert_expiration = saml_settings[oauth_saml_settings_idp_cert_check_expiration_column]
  66. 34 check_idp_cert_expiration = oauth_saml_idp_cert_check_expiration if check_idp_cert_expiration.nil?
  67. 26 settings.security[:check_idp_cert_expiration] = check_idp_cert_expiration
  68. end
  69. 26 settings.security[:strict_audience_validation] = true
  70. 26 settings.security[:want_name_id] = true
  71. 34 settings.name_identifier_format = saml_settings[oauth_saml_settings_name_identifier_format_column] ||
  72. oauth_saml_name_identifier_format
  73. 34 settings
  74. end
  75. # rubocop:disable Naming/MemoizedInstanceVariableName
  76. 17 def parse_saml_assertion(assertion)
  77. 51 return @assertion if defined?(@assertion)
  78. 34 response = OneLogin::RubySaml::Response.new(assertion)
  79. # The SAML Assertion XML data MUST be encoded using base64url
  80. 34 redirect_response_error("invalid_grant", oauth_saml_assertion_not_base64_message) unless response.send(:base64_encoded?, assertion)
  81. # 1. The Assertion's <Issuer> element MUST contain a unique identifier
  82. # for the entity that issued the Assertion.
  83. 34 redirect_response_error("invalid_grant", oauth_saml_assertion_single_issuer_message) unless response.issuers.size == 1
  84. 34 @saml_settings = db[oauth_saml_settings_table].where(
  85. oauth_saml_settings_issuer_column => response.issuers.first
  86. ).first
  87. 34 redirect_response_error("invalid_grant", oauth_saml_settings_not_found_message) unless @saml_settings
  88. 34 response.settings = generate_saml_settings(@saml_settings)
  89. # 2. The Assertion MUST contain a <Conditions> element ...
  90. # 3. he Assertion MUST have an expiry that limits the time window ...
  91. # 4. The Assertion MUST have an expiry that limits the time window ...
  92. # 5. The <Subject> element MUST contain at least one ...
  93. # 6. The authorization server MUST reject the entire Assertion if the ...
  94. # 7. If the Assertion issuer directly authenticated the subject, ...
  95. 34 redirect_response_error("invalid_grant", response.errors.join("; ")) unless response.is_valid?
  96. 34 @assertion = response
  97. end
  98. # rubocop:enable Naming/MemoizedInstanceVariableName
  99. 17 def oauth_server_metadata_body(*)
  100. super.tap do |data|
  101. data[:token_endpoint_auth_methods_supported] << "urn:ietf:params:oauth:client-assertion-type:saml2-bearer"
  102. end
  103. end
  104. end
  105. end

lib/rodauth/features/oauth_tls_client_auth.rb

86.9% lines covered

84 relevant lines. 73 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "openssl"
  3. 17 require "ipaddr"
  4. 17 require "uri"
  5. 17 require "rodauth/oauth"
  6. 17 module Rodauth
  7. 17 Feature.define(:oauth_tls_client_auth, :OauthTlsClientAuth) do
  8. 17 depends :oauth_base
  9. 17 auth_value_method :oauth_tls_client_certificate_bound_access_tokens, false
  10. 16 %i[
  11. tls_client_auth_subject_dn tls_client_auth_san_dns
  12. tls_client_auth_san_uri tls_client_auth_san_ip
  13. tls_client_auth_san_email tls_client_certificate_bound_access_tokens
  14. 1 ].each do |column|
  15. 102 auth_value_method :"oauth_applications_#{column}_column", column
  16. end
  17. 17 auth_value_method :oauth_grants_certificate_thumbprint_column, :certificate_thumbprint
  18. 17 def oauth_token_endpoint_auth_methods_supported
  19. 17 super | %w[tls_client_auth self_signed_tls_client_auth]
  20. end
  21. 17 private
  22. 17 def validate_token_params
  23. # For all requests to the authorization server utilizing mutual-TLS client authentication,
  24. # the client MUST include the client_id parameter
  25. 340 redirect_response_error("invalid_request") if client_certificate && !param_or_nil("client_id")
  26. 340 super
  27. end
  28. 17 def require_oauth_application
  29. 510 return super unless client_certificate
  30. 476 authorization_required unless oauth_application
  31. 476 if supports_auth_method?(oauth_application, "tls_client_auth")
  32. # It relies on a validated certificate chain [RFC5280]
  33. 459 ssl_verify = request.env["SSL_CLIENT_VERIFY"] || request.env["HTTP_SSL_CLIENT_VERIFY"] || request.env["HTTP_X_SSL_CLIENT_VERIFY"]
  34. 459 authorization_required unless ssl_verify == "SUCCESS"
  35. # and a single subject distinguished name (DN) or a single subject alternative name (SAN) to
  36. # authenticate the client. Only one subject name value of any type is used for each client.
  37. 459 name_matches = if oauth_application[:tls_client_auth_subject_dn]
  38. 391 distinguished_name_match?(client_certificate.subject, oauth_application[:tls_client_auth_subject_dn])
  39. 68 elsif (dns = oauth_application[:tls_client_auth_san_dns])
  40. 34 client_certificate_sans.any? { |san| san.tag == 2 && OpenSSL::SSL.verify_hostname(dns, san.value) }
  41. 51 elsif (uri = oauth_application[:tls_client_auth_san_uri])
  42. 17 uri = URI(uri)
  43. 68 client_certificate_sans.any? { |san| san.tag == 6 && URI(san.value) == uri }
  44. 34 elsif (ip = oauth_application[:tls_client_auth_san_ip])
  45. 17 ip = IPAddr.new(ip).hton
  46. 51 client_certificate_sans.any? { |san| san.tag == 7 && san.value == ip }
  47. 17 elsif (email = oauth_application[:tls_client_auth_san_email])
  48. 85 client_certificate_sans.any? { |san| san.tag == 1 && san.value == email }
  49. else
  50. false
  51. end
  52. 459 authorization_required unless name_matches
  53. 459 oauth_application
  54. 17 elsif supports_auth_method?(oauth_application, "self_signed_tls_client_auth")
  55. 17 jwks = oauth_application_jwks(oauth_application)
  56. 17 thumbprint = jwk_thumbprint(key_to_jwk(client_certificate.public_key))
  57. # The client is successfully authenticated if the certificate that it presented during the handshake
  58. # matches one of the certificates configured or registered for that particular client.
  59. 34 authorization_required unless jwks.any? { |jwk| Array(jwk[:x5c]).first == thumbprint }
  60. 17 oauth_application
  61. else
  62. super
  63. end
  64. rescue URI::InvalidURIError, IPAddr::InvalidAddressError
  65. authorization_required
  66. end
  67. 17 def store_token(grant_params, update_params = {})
  68. 275 return super unless client_certificate && (
  69. 132 oauth_tls_client_certificate_bound_access_tokens ||
  70. oauth_application[oauth_applications_tls_client_certificate_bound_access_tokens_column]
  71. )
  72. update_params[oauth_grants_certificate_thumbprint_column] = jwk_thumbprint(key_to_jwk(client_certificate.public_key))
  73. super
  74. end
  75. 17 def jwt_claims(oauth_grant)
  76. claims = super
  77. return claims unless oauth_grant[oauth_grants_certificate_thumbprint_column]
  78. claims[:cnf] = {
  79. "x5t#S256" => oauth_grant[oauth_grants_certificate_thumbprint_column]
  80. }
  81. claims
  82. end
  83. 17 def json_token_introspect_payload(grant_or_claims)
  84. 119 claims = super
  85. 119 return claims unless grant_or_claims && grant_or_claims[oauth_grants_certificate_thumbprint_column]
  86. 65 (claims[:cnf] ||= {})["x5t#S256"] = grant_or_claims[oauth_grants_certificate_thumbprint_column]
  87. 85 claims
  88. end
  89. 17 def oauth_server_metadata_body(*)
  90. 17 super.tap do |data|
  91. 13 data[:tls_client_certificate_bound_access_tokens] = oauth_tls_client_certificate_bound_access_tokens
  92. end
  93. end
  94. 17 def client_certificate
  95. 1581 return @client_certificate if defined?(@client_certificate)
  96. 1581 unless (pem_cert = request.env["SSL_CLIENT_CERT"] || request.env["HTTP_SSL_CLIENT_CERT"] || request.env["HTTP_X_SSL_CLIENT_CERT"])
  97. 26 return
  98. end
  99. 1547 return if pem_cert.empty?
  100. 1547 @certificate = OpenSSL::X509::Certificate.new(pem_cert)
  101. end
  102. 17 def client_certificate_sans
  103. 68 return @client_certificate_sans if defined?(@client_certificate_sans)
  104. 20 @client_certificate_sans =
  105. 68 if client_certificate
  106. 340 if (san = client_certificate.extensions.find { |ext| ext.oid == "subjectAltName" })
  107. 68 ostr = OpenSSL::ASN1.decode(san.to_der).value.last
  108. 68 sans = OpenSSL::ASN1.decode(ostr.value)
  109. 68 sans ? sans.value : []
  110. else
  111. []
  112. end
  113. else
  114. []
  115. end
  116. end
  117. 17 def distinguished_name_match?(sub1, sub2)
  118. 391 sub1 = OpenSSL::X509::Name.parse(sub1) if sub1.is_a?(String)
  119. 391 sub2 = OpenSSL::X509::Name.parse(sub2) if sub2.is_a?(String)
  120. # OpenSSL::X509::Name#cp calls X509_NAME_cmp via openssl.
  121. # https://www.openssl.org/docs/manmaster/man3/X509_NAME_cmp.html
  122. # This procedure adheres to the matching rules for Distinguished Names (DN) given in
  123. # RFC 4517 section 4.2.15 and RFC 5280 section 7.1.
  124. 391 sub1.cmp(sub2).zero?
  125. end
  126. end
  127. end

lib/rodauth/features/oauth_token_introspection.rb

98.33% lines covered

60 relevant lines. 59 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 require "rodauth/oauth/http_extensions"
  4. 17 module Rodauth
  5. 17 Feature.define(:oauth_token_introspection, :OauthTokenIntrospection) do
  6. 17 depends :oauth_base
  7. 17 before "introspect"
  8. 17 auth_methods(
  9. :resource_owner_identifier
  10. )
  11. # /introspect
  12. 17 auth_server_route(:introspect) do |r|
  13. 391 require_oauth_application_for_introspect
  14. 391 before_introspect_route
  15. 391 r.post do
  16. 391 catch_error do
  17. 391 validate_introspect_params
  18. 323 token_type_hint = param_or_nil("token_type_hint")
  19. 323 before_introspect
  20. 323 oauth_grant = case token_type_hint
  21. when "access_token", nil
  22. 289 if features.include?(:oauth_jwt) && oauth_jwt_access_tokens
  23. 68 jwt_decode(param("token"))
  24. else
  25. 221 oauth_grant_by_token(param("token"))
  26. end
  27. when "refresh_token"
  28. 34 oauth_grant_by_refresh_token(param("token"))
  29. end
  30. 323 oauth_grant ||= oauth_grant_by_refresh_token(param("token")) if token_type_hint.nil?
  31. 323 json_response_success(json_token_introspect_payload(oauth_grant))
  32. end
  33. throw_json_response_error(oauth_invalid_response_status, "invalid_request")
  34. end
  35. end
  36. # Token introspect
  37. 17 def validate_introspect_params(token_hint_types = %w[access_token refresh_token].freeze)
  38. # check if valid token hint type
  39. 391 if param_or_nil("token_type_hint") && !token_hint_types.include?(param("token_type_hint"))
  40. 34 redirect_response_error("unsupported_token_type")
  41. end
  42. 357 redirect_response_error("invalid_request") unless param_or_nil("token")
  43. end
  44. 17 def json_token_introspect_payload(grant_or_claims)
  45. 323 return { active: false } unless grant_or_claims
  46. 255 if grant_or_claims["sub"]
  47. # JWT
  48. 52 {
  49. 16 active: true,
  50. scope: grant_or_claims["scope"],
  51. client_id: grant_or_claims["client_id"],
  52. username: resource_owner_identifier(grant_or_claims),
  53. token_type: oauth_token_type.capitalize,
  54. exp: grant_or_claims["exp"],
  55. iat: grant_or_claims["iat"],
  56. nbf: grant_or_claims["nbf"],
  57. sub: grant_or_claims["sub"],
  58. aud: grant_or_claims["aud"],
  59. iss: grant_or_claims["iss"],
  60. jti: grant_or_claims["jti"]
  61. }
  62. else
  63. 143 {
  64. 44 active: true,
  65. scope: grant_or_claims[oauth_grants_scopes_column],
  66. client_id: oauth_application[oauth_applications_client_id_column],
  67. username: resource_owner_identifier(grant_or_claims),
  68. token_type: oauth_token_type,
  69. exp: grant_or_claims[oauth_grants_expires_in_column].to_i
  70. }
  71. end
  72. end
  73. 17 def check_csrf?
  74. 390 case request.path
  75. when introspect_path
  76. 391 false
  77. else
  78. 119 super
  79. end
  80. end
  81. 17 private
  82. 17 def require_oauth_application_for_introspect
  83. 391 token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1]
  84. 391 return require_oauth_application unless token
  85. 51 oauth_application = current_oauth_application
  86. 51 authorization_required unless oauth_application
  87. 51 @oauth_application = oauth_application
  88. end
  89. 17 def oauth_server_metadata_body(*)
  90. 34 super.tap do |data|
  91. 26 data[:introspection_endpoint] = introspect_url
  92. 26 data[:introspection_endpoint_auth_methods_supported] = %w[client_secret_basic]
  93. end
  94. end
  95. 17 def resource_owner_identifier(grant_or_claims)
  96. 255 if (account_id = grant_or_claims[oauth_grants_account_id_column])
  97. 153 account_ds(account_id).select(login_column).first[login_column]
  98. 102 elsif (app_id = grant_or_claims[oauth_grants_oauth_application_id_column])
  99. 32 db[oauth_applications_table].where(oauth_applications_id_column => app_id)
  100. .select(oauth_applications_name_column)
  101. 2 .first[oauth_applications_name_column]
  102. 68 elsif (subject = grant_or_claims["sub"])
  103. # JWT
  104. 68 if subject == grant_or_claims["client_id"]
  105. 16 db[oauth_applications_table].where(oauth_applications_client_id_column => subject)
  106. .select(oauth_applications_name_column)
  107. 1 .first[oauth_applications_name_column]
  108. else
  109. 51 account_ds(subject).select(login_column).first[login_column]
  110. end
  111. end
  112. end
  113. end
  114. end

lib/rodauth/features/oauth_token_revocation.rb

100.0% lines covered

60 relevant lines. 60 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oauth_token_revocation, :OauthTokenRevocation) do
  5. 17 depends :oauth_base
  6. 17 before "revoke"
  7. 17 after "revoke"
  8. 17 notice_flash "The oauth grant has been revoked", "revoke_oauth_grant"
  9. # /revoke
  10. 17 auth_server_route(:revoke) do |r|
  11. 170 if logged_in?
  12. 17 require_account
  13. 17 require_oauth_application_from_account
  14. else
  15. 153 require_oauth_application
  16. end
  17. 170 before_revoke_route
  18. 170 r.post do
  19. 170 catch_error do
  20. 170 validate_revoke_params
  21. 119 oauth_grant = nil
  22. 119 transaction do
  23. 119 before_revoke
  24. 119 oauth_grant = revoke_oauth_grant
  25. 68 after_revoke
  26. end
  27. 68 if accepts_json?
  28. 15 json_payload = {
  29. 36 "revoked_at" => convert_timestamp(oauth_grant[oauth_grants_revoked_at_column])
  30. }
  31. 51 if param("token_type_hint") == "refresh_token"
  32. 26 json_payload["refresh_token"] = oauth_grant[oauth_grants_refresh_token_column]
  33. else
  34. 13 json_payload["token"] = oauth_grant[oauth_grants_token_column]
  35. end
  36. 51 json_response_success json_payload
  37. else
  38. 17 set_notice_flash revoke_oauth_grant_notice_flash
  39. 17 redirect request.referer || "/"
  40. end
  41. end
  42. 34 redirect_response_error("invalid_request")
  43. end
  44. end
  45. 17 def validate_revoke_params(token_hint_types = %w[access_token refresh_token].freeze)
  46. 170 token_hint = param_or_nil("token_type_hint")
  47. 170 if features.include?(:oauth_jwt) && oauth_jwt_access_tokens && (!token_hint || token_hint == "access_token")
  48. # JWT access tokens can't be revoked
  49. 34 throw(:rodauth_error)
  50. end
  51. # check if valid token hint type
  52. 136 redirect_response_error("unsupported_token_type") if token_hint && !token_hint_types.include?(token_hint)
  53. 119 redirect_response_error("invalid_request") unless param_or_nil("token")
  54. end
  55. 17 def check_csrf?
  56. 1095 case request.path
  57. when revoke_path
  58. 170 !json_request?
  59. else
  60. 1273 super
  61. end
  62. end
  63. 17 private
  64. 17 def revoke_oauth_grant
  65. 119 token = param("token")
  66. 119 if param("token_type_hint") == "refresh_token"
  67. 34 oauth_grant = oauth_grant_by_refresh_token(token)
  68. 34 token_column = oauth_grants_refresh_token_column
  69. else
  70. 85 oauth_grant = oauth_grant_by_token_ds(token).where(
  71. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column]
  72. ).first
  73. 85 token_column = oauth_grants_token_column
  74. end
  75. 119 redirect_response_error("invalid_request") unless oauth_grant
  76. 68 redirect_response_error("invalid_request") unless grant_from_application?(oauth_grant, oauth_application)
  77. 68 update_params = { oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP }
  78. 68 ds = db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column])
  79. 68 oauth_grant = __update_and_return__(ds, update_params)
  80. 52 oauth_grant[token_column] = token
  81. 68 oauth_grant
  82. # If the particular
  83. # token is a refresh token and the authorization server supports the
  84. # revocation of access tokens, then the authorization server SHOULD
  85. # also invalidate all access tokens based on the same authorization
  86. # grant
  87. #
  88. # we don't need to do anything here, as we revalidate existing tokens
  89. end
  90. 17 def oauth_server_metadata_body(*)
  91. 34 super.tap do |data|
  92. 26 data[:revocation_endpoint] = revoke_url
  93. 26 data[:revocation_endpoint_auth_methods_supported] = nil # because it's client_secret_basic
  94. end
  95. end
  96. end
  97. end

lib/rodauth/features/oidc.rb

93.61% lines covered

391 relevant lines. 366 lines covered and 25 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc, :Oidc) do
  5. # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
  6. 5 OIDC_SCOPES_MAP = {
  7. 12 "profile" => %i[name family_name given_name middle_name nickname preferred_username
  8. profile picture website gender birthdate zoneinfo locale updated_at].freeze,
  9. "email" => %i[email email_verified].freeze,
  10. "address" => %i[formatted street_address locality region postal_code country].freeze,
  11. "phone" => %i[phone_number phone_number_verified].freeze
  12. }.freeze
  13. 17 VALID_METADATA_KEYS = %i[
  14. issuer
  15. authorization_endpoint
  16. end_session_endpoint
  17. backchannel_logout_session_supported
  18. token_endpoint
  19. userinfo_endpoint
  20. jwks_uri
  21. registration_endpoint
  22. scopes_supported
  23. response_types_supported
  24. response_modes_supported
  25. grant_types_supported
  26. acr_values_supported
  27. subject_types_supported
  28. id_token_signing_alg_values_supported
  29. id_token_encryption_alg_values_supported
  30. id_token_encryption_enc_values_supported
  31. userinfo_signing_alg_values_supported
  32. userinfo_encryption_alg_values_supported
  33. userinfo_encryption_enc_values_supported
  34. request_object_signing_alg_values_supported
  35. request_object_encryption_alg_values_supported
  36. request_object_encryption_enc_values_supported
  37. token_endpoint_auth_methods_supported
  38. token_endpoint_auth_signing_alg_values_supported
  39. display_values_supported
  40. claim_types_supported
  41. claims_supported
  42. service_documentation
  43. claims_locales_supported
  44. ui_locales_supported
  45. claims_parameter_supported
  46. request_parameter_supported
  47. request_uri_parameter_supported
  48. require_request_uri_registration
  49. op_policy_uri
  50. op_tos_uri
  51. check_session_iframe
  52. frontchannel_logout_supported
  53. frontchannel_logout_session_supported
  54. backchannel_logout_supported
  55. backchannel_logout_session_supported
  56. ].freeze
  57. 17 REQUIRED_METADATA_KEYS = %i[
  58. issuer
  59. authorization_endpoint
  60. token_endpoint
  61. jwks_uri
  62. response_types_supported
  63. subject_types_supported
  64. id_token_signing_alg_values_supported
  65. ].freeze
  66. 17 depends :active_sessions, :oauth_jwt, :oauth_jwt_jwks, :oauth_authorization_code_grant, :oauth_implicit_grant
  67. 17 auth_value_method :oauth_application_scopes, %w[openid]
  68. 16 %i[
  69. subject_type application_type sector_identifier_uri initiate_login_uri
  70. id_token_signed_response_alg id_token_encrypted_response_alg id_token_encrypted_response_enc
  71. userinfo_signed_response_alg userinfo_encrypted_response_alg userinfo_encrypted_response_enc
  72. 1 ].each do |column|
  73. 170 auth_value_method :"oauth_applications_#{column}_column", column
  74. end
  75. 17 %i[nonce acr claims_locales claims].each do |column|
  76. 68 auth_value_method :"oauth_grants_#{column}_column", column
  77. end
  78. 17 auth_value_method :oauth_jwt_subject_type, "public" # fallback subject type: public, pairwise
  79. 17 auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
  80. 17 translatable_method :oauth_invalid_scope_message, "The Access Token expired"
  81. 17 auth_value_method :oauth_prompt_login_cookie_key, "_rodauth_oauth_prompt_login"
  82. 17 auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
  83. 17 auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
  84. 17 auth_value_methods(
  85. :userinfo_signing_alg_values_supported,
  86. :userinfo_encryption_alg_values_supported,
  87. :userinfo_encryption_enc_values_supported,
  88. :request_object_signing_alg_values_supported,
  89. :request_object_encryption_alg_values_supported,
  90. :request_object_encryption_enc_values_supported,
  91. :oauth_acr_values_supported
  92. )
  93. 17 auth_methods(
  94. :get_oidc_account_last_login_at,
  95. :oidc_authorize_on_prompt_none?,
  96. :fill_with_account_claims,
  97. :get_oidc_param,
  98. :get_additional_param,
  99. :require_acr_value_phr,
  100. :require_acr_value_phrh,
  101. :require_acr_value,
  102. :json_webfinger_payload
  103. )
  104. # /userinfo
  105. 17 auth_server_route(:userinfo) do |r|
  106. 170 r.on method: %i[get post] do
  107. 170 catch_error do
  108. 170 claims = authorization_token
  109. 170 throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless claims
  110. 170 oauth_scopes = claims["scope"].split(" ")
  111. 170 throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
  112. 170 account = account_ds(claims["sub"]).first
  113. 170 throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless account
  114. 170 oauth_scopes.delete("openid")
  115. 170 oidc_claims = { "sub" => claims["sub"] }
  116. 170 @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
  117. 170 throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless @oauth_application
  118. 170 oauth_grant = valid_oauth_grant_ds(
  119. oauth_grants_oauth_application_id_column => @oauth_application[oauth_applications_id_column],
  120. **resource_owner_params_from_jwt_claims(claims)
  121. ).first
  122. 170 throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless oauth_grant
  123. 153 claims_locales = oauth_grant[oauth_grants_claims_locales_column]
  124. 153 if (claims = oauth_grant[oauth_grants_claims_column])
  125. 17 claims = JSON.parse(claims)
  126. 17 if (userinfo_essential_claims = claims["userinfo"])
  127. 13 oauth_scopes |= userinfo_essential_claims.to_a
  128. end
  129. end
  130. # 5.4 - The Claims requested by the profile, email, address, and phone scope values are returned from the UserInfo Endpoint
  131. 153 fill_with_account_claims(oidc_claims, account, oauth_scopes, claims_locales)
  132. 153 if (algo = @oauth_application[oauth_applications_userinfo_signed_response_alg_column])
  133. 10 params = {
  134. 24 jwks: oauth_application_jwks(@oauth_application),
  135. encryption_algorithm: @oauth_application[oauth_applications_userinfo_encrypted_response_alg_column],
  136. encryption_method: @oauth_application[oauth_applications_userinfo_encrypted_response_enc_column]
  137. }.compact
  138. 34 jwt = jwt_encode(
  139. oidc_claims.merge(
  140. # If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value
  141. # SHOULD be the OP's Issuer Identifier URL. The aud value SHOULD be or include the RP's Client ID value.
  142. iss: oauth_jwt_issuer,
  143. aud: @oauth_application[oauth_applications_client_id_column]
  144. ),
  145. signing_algorithm: algo,
  146. **params
  147. )
  148. 34 jwt_response_success(jwt)
  149. else
  150. 119 json_response_success(oidc_claims)
  151. end
  152. end
  153. throw_json_response_error(oauth_authorization_required_error_status, "invalid_token")
  154. end
  155. end
  156. 17 def load_openid_configuration_route(alt_issuer = nil)
  157. 136 request.on(".well-known/openid-configuration") do
  158. 136 allow_cors(request)
  159. 119 request.is do
  160. 119 request.get do
  161. 119 json_response_success(openid_configuration_body(alt_issuer), cache: true)
  162. end
  163. end
  164. end
  165. end
  166. 17 def load_webfinger_route
  167. 34 request.on(".well-known/webfinger") do
  168. 34 request.get do
  169. 34 resource = param_or_nil("resource")
  170. 34 throw_json_response_error(400, "invalid_request") unless resource
  171. 17 response.status = 200
  172. 17 response["Content-Type"] ||= "application/jrd+json"
  173. 17 return_response(json_webfinger_payload)
  174. end
  175. end
  176. end
  177. 17 def check_csrf?
  178. 5304 case request.path
  179. when userinfo_path
  180. 170 false
  181. else
  182. 6746 super
  183. end
  184. end
  185. 17 def oauth_response_types_supported
  186. 1700 grant_types = oauth_grant_types_supported
  187. 1700 oidc_response_types = %w[id_token none]
  188. 1700 oidc_response_types |= ["code id_token"] if grant_types.include?("authorization_code")
  189. 1700 oidc_response_types |= ["code token", "id_token token", "code id_token token"] if grant_types.include?("implicit")
  190. 1700 super | oidc_response_types
  191. end
  192. 17 def current_oauth_account
  193. 17 subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
  194. 17 super unless subject_type == "pairwise"
  195. end
  196. 17 private
  197. 17 if defined?(::I18n)
  198. 17 def before_authorize_route
  199. 1964 if (ui_locales = param_or_nil("ui_locales"))
  200. 17 ui_locales = ui_locales.split(" ").map(&:to_sym)
  201. 13 ui_locales &= ::I18n.available_locales
  202. 17 ::I18n.locale = ui_locales.first unless ui_locales.empty?
  203. end
  204. 1964 super
  205. end
  206. end
  207. 17 def userinfo_signing_alg_values_supported
  208. oauth_jwt_jws_algorithms_supported
  209. end
  210. 17 def userinfo_encryption_alg_values_supported
  211. oauth_jwt_jwe_algorithms_supported
  212. end
  213. 17 def userinfo_encryption_enc_values_supported
  214. oauth_jwt_jwe_encryption_methods_supported
  215. end
  216. 17 def request_object_signing_alg_values_supported
  217. oauth_jwt_jws_algorithms_supported
  218. end
  219. 17 def request_object_encryption_alg_values_supported
  220. oauth_jwt_jwe_algorithms_supported
  221. end
  222. 17 def request_object_encryption_enc_values_supported
  223. oauth_jwt_jwe_encryption_methods_supported
  224. end
  225. 17 def oauth_acr_values_supported
  226. 204 acr_values = []
  227. 204 acr_values << "phrh" if features.include?(:webauthn_login)
  228. 204 acr_values << "phr" if respond_to?(:require_two_factor_authenticated)
  229. 204 acr_values
  230. end
  231. 17 def oidc_authorize_on_prompt_none?(_account)
  232. 17 false
  233. end
  234. 17 def validate_authorize_params
  235. 1964 if (max_age = param_or_nil("max_age"))
  236. 26 max_age = Integer(max_age)
  237. 26 redirect_response_error("invalid_request") unless max_age.positive?
  238. 26 if Time.now - get_oidc_account_last_login_at(session_value) > max_age
  239. # force user to re-login
  240. 13 clear_session
  241. 13 set_session_value(login_redirect_session_key, request.fullpath)
  242. 13 redirect require_login_redirect
  243. end
  244. end
  245. 1951 if (claims = param_or_nil("claims"))
  246. # The value is a JSON object listing the requested Claims.
  247. 34 claims = JSON.parse(claims)
  248. 34 claims.each_value do |individual_claims|
  249. 68 redirect_response_error("invalid_request") unless individual_claims.is_a?(Hash)
  250. 68 individual_claims.each_value do |claim|
  251. 102 redirect_response_error("invalid_request") unless claim.nil? || individual_claims.is_a?(Hash)
  252. end
  253. end
  254. end
  255. 1951 sc = scopes
  256. # MUST ensure that the prompt parameter contains consent
  257. # MUST ignore the offline_access request unless the Client
  258. # is using a response_type value that would result in an
  259. # Authorization Code
  260. 1951 if sc && sc.include?("offline_access") && !(param_or_nil("prompt") == "consent" &&
  261. 51 (response_type = param_or_nil("response_type")) && response_type.split(" ").include?("code"))
  262. 34 sc.delete("offline_access")
  263. 26 request.params["scope"] = sc.join(" ")
  264. end
  265. 1951 super
  266. 1849 response_type = param_or_nil("response_type")
  267. 1849 is_id_token_response_type = response_type.include?("id_token")
  268. 1849 redirect_response_error("invalid_request") if is_id_token_response_type && !param_or_nil("nonce")
  269. 1832 return unless is_id_token_response_type || response_type == "code token"
  270. 1037 response_mode = param_or_nil("response_mode")
  271. # id_token: The default Response Mode for this Response Type is the fragment encoding and the query encoding MUST NOT be used.
  272. 1037 redirect_response_error("invalid_request") unless response_mode.nil? || response_mode == "fragment"
  273. end
  274. 17 def require_authorizable_account
  275. 2202 try_prompt
  276. 2100 super
  277. 2066 @acr = try_acr_values
  278. end
  279. 17 def get_oidc_account_last_login_at(account_id)
  280. 842 return get_activity_timestamp(account_id, account_activity_last_activity_column) if features.include?(:account_expiration)
  281. # active sessions based
  282. 842 ds = db[active_sessions_table].where(active_sessions_account_id_column => account_id)
  283. 842 ds = ds.order(Sequel.desc(active_sessions_created_at_column))
  284. 842 convert_timestamp(ds.get(active_sessions_created_at_column))
  285. end
  286. 17 def jwt_subject(account_unique_id, client_application = oauth_application)
  287. 1377 subject_type = client_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
  288. 1053 case subject_type
  289. when "public"
  290. 1326 super
  291. when "pairwise"
  292. 51 identifier_uri = client_application[oauth_applications_sector_identifier_uri_column]
  293. 51 unless identifier_uri
  294. 51 identifier_uri = client_application[oauth_applications_redirect_uri_column]
  295. 51 identifier_uri = identifier_uri.split(" ")
  296. # If the Client has not provided a value for sector_identifier_uri in Dynamic Client Registration
  297. # [OpenID.Registration], the Sector Identifier used for pairwise identifier calculation is the host
  298. # component of the registered redirect_uri. If there are multiple hostnames in the registered redirect_uris,
  299. # the Client MUST register a sector_identifier_uri.
  300. 51 if identifier_uri.size > 1
  301. # return error message
  302. end
  303. 51 identifier_uri = identifier_uri.first
  304. end
  305. 51 identifier_uri = URI(identifier_uri).host
  306. 51 values = [identifier_uri, account_unique_id, oauth_jwt_subject_secret]
  307. 51 Digest::SHA256.hexdigest(values.join)
  308. else
  309. raise StandardError, "unexpected subject (#{subject_type})"
  310. end
  311. end
  312. # this executes before checking for a logged in account
  313. 17 def try_prompt
  314. 2202 return unless (prompt = param_or_nil("prompt"))
  315. 221 case prompt
  316. when "none"
  317. 51 return unless request.get?
  318. 51 redirect_response_error("login_required") unless logged_in?
  319. 34 require_account
  320. 34 redirect_response_error("interaction_required") unless oidc_authorize_on_prompt_none?(account_from_session)
  321. 13 request.env["REQUEST_METHOD"] = "POST"
  322. when "login"
  323. 102 return unless request.get?
  324. 68 if logged_in? && request.cookies[oauth_prompt_login_cookie_key] == "login"
  325. 34 ::Rack::Utils.delete_cookie_header!(response.headers, oauth_prompt_login_cookie_key, oauth_prompt_login_cookie_options)
  326. 26 return
  327. end
  328. # logging out
  329. 34 clear_session
  330. 34 set_session_value(login_redirect_session_key, request.fullpath)
  331. 34 login_cookie_opts = Hash[oauth_prompt_login_cookie_options]
  332. 26 login_cookie_opts[:value] = "login"
  333. 34 if oauth_prompt_login_interval
  334. 26 login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
  335. end
  336. 34 ::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts)
  337. 34 redirect require_login_redirect
  338. when "consent"
  339. 85 return unless request.post?
  340. 34 require_account
  341. 34 sc = scopes || []
  342. 34 redirect_response_error("consent_required") if sc.empty?
  343. when "select-account"
  344. 51 return unless request.get?
  345. # only works if select_account plugin is available
  346. 34 require_select_account if respond_to?(:require_select_account)
  347. else
  348. redirect_response_error("invalid_request")
  349. end
  350. end
  351. 17 def try_acr_values
  352. 2066 return unless (acr_values = param_or_nil("acr_values"))
  353. 85 acr_values.split(" ").each do |acr_value|
  354. 85 next unless oauth_acr_values_supported.include?(acr_value)
  355. 52 case acr_value
  356. when "phr"
  357. 34 return acr_value if require_acr_value_phr
  358. when "phrh"
  359. 34 return acr_value if require_acr_value_phrh
  360. else
  361. return acr_value if require_acr_value(acr_value)
  362. end
  363. end
  364. 12 nil
  365. end
  366. 17 def require_acr_value_phr
  367. 68 return false unless respond_to?(:require_two_factor_authenticated)
  368. 68 require_two_factor_authenticated
  369. 68 true
  370. end
  371. 17 def require_acr_value_phrh
  372. 34 return false unless features.include?(:webauthn_login)
  373. 34 require_acr_value_phr && two_factor_login_type_match?("webauthn")
  374. end
  375. 17 def require_acr_value(_acr)
  376. true
  377. end
  378. 17 def create_oauth_grant(create_params = {})
  379. 493 create_params.replace(oidc_grant_params.merge(create_params))
  380. 493 super
  381. end
  382. 17 def create_oauth_grant_with_token(create_params = {})
  383. 34 create_params.merge!(resource_owner_params)
  384. 26 create_params[oauth_grants_type_column] = "hybrid"
  385. 26 create_params[oauth_grants_expires_in_column] = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_access_token_expires_in)
  386. 34 authorization_code = create_oauth_grant(create_params)
  387. 34 access_token = if oauth_jwt_access_tokens
  388. 34 _generate_jwt_access_token(create_params)
  389. else
  390. oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => authorization_code).first
  391. _generate_access_token(oauth_grant)
  392. end
  393. 34 json_access_token_payload(oauth_grants_token_column => access_token).merge("code" => authorization_code)
  394. end
  395. 17 def create_token(*)
  396. 442 oauth_grant = super
  397. 391 generate_id_token(oauth_grant)
  398. 391 oauth_grant
  399. end
  400. 17 def generate_id_token(oauth_grant, include_claims = false)
  401. 816 oauth_scopes = oauth_grant[oauth_grants_scopes_column].split(oauth_scope_separator)
  402. 816 return unless oauth_scopes.include?("openid")
  403. 816 signing_algorithm = oauth_application[oauth_applications_id_token_signed_response_alg_column] ||
  404. oauth_jwt_keys.keys.first
  405. 816 id_claims = id_token_claims(oauth_grant, signing_algorithm)
  406. 816 account = db[accounts_table].where(account_id_column => oauth_grant[oauth_grants_account_id_column]).first
  407. # this should never happen!
  408. # a newly minted oauth token from a grant should have been assigned to an account
  409. # who just authorized its generation.
  410. 816 return unless account
  411. 816 if (claims = oauth_grant[oauth_grants_claims_column])
  412. 34 claims = JSON.parse(claims)
  413. 34 if (id_token_essential_claims = claims["id_token"])
  414. 26 oauth_scopes |= id_token_essential_claims.to_a
  415. 34 include_claims = true
  416. end
  417. end
  418. # OpenID Connect Core 1.0's 5.4 Requesting Claims using Scope Values:
  419. # If standard claims (profile, email, etc) are requested as scope values in the Authorization Request,
  420. # include in the response.
  421. 816 include_claims ||= (OIDC_SCOPES_MAP.keys & oauth_scopes).any?
  422. # However, when no Access Token is issued (which is the case for the response_type value id_token),
  423. # the resulting Claims are returned in the ID Token.
  424. 816 fill_with_account_claims(id_claims, account, oauth_scopes, param_or_nil("claims_locales")) if include_claims
  425. 240 params = {
  426. 576 jwks: oauth_application_jwks(oauth_application),
  427. signing_algorithm: signing_algorithm,
  428. encryption_algorithm: oauth_application[oauth_applications_id_token_encrypted_response_alg_column],
  429. encryption_method: oauth_application[oauth_applications_id_token_encrypted_response_enc_column],
  430. # Not officially part of the spec, but some providers follow this convention.
  431. # This is useful for distinguishing between ID Tokens and JWT Access Tokens.
  432. headers: { typ: "id_token+jwt" }
  433. }.compact
  434. 624 oauth_grant[:id_token] = jwt_encode(id_claims, **params)
  435. end
  436. 17 def id_token_claims(oauth_grant, signing_algorithm)
  437. 816 claims = jwt_claims(oauth_grant)
  438. 816 claims[:nonce] = oauth_grant[oauth_grants_nonce_column] if oauth_grant[oauth_grants_nonce_column]
  439. 816 claims[:acr] = oauth_grant[oauth_grants_acr_column] if oauth_grant[oauth_grants_acr_column]
  440. # Time when the End-User authentication occurred.
  441. 624 claims[:auth_time] = get_oidc_account_last_login_at(oauth_grant[oauth_grants_account_id_column]).to_i
  442. # Access Token hash value.
  443. 816 if (access_token = oauth_grant[oauth_grants_token_column])
  444. 325 claims[:at_hash] = id_token_hash(access_token, signing_algorithm)
  445. end
  446. # code hash value.
  447. 816 if (code = oauth_grant[oauth_grants_code_column])
  448. 143 claims[:c_hash] = id_token_hash(code, signing_algorithm)
  449. end
  450. 816 claims
  451. end
  452. # aka fill_with_standard_claims
  453. 17 def fill_with_account_claims(claims, account, scopes, claims_locales)
  454. 459 additional_claims_info = {}
  455. 459 scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
  456. 578 next if scope == "openid"
  457. 272 if scope.is_a?(Array)
  458. # essential claims
  459. 85 param, additional_info = scope
  460. 85 param = param.to_sym
  461. 85 oidc, = OIDC_SCOPES_MAP.find do |_, oidc_scopes|
  462. 187 oidc_scopes.include?(param)
  463. end || param.to_s
  464. 85 param = nil if oidc == param.to_s
  465. 65 additional_claims_info[param] = additional_info
  466. else
  467. 187 oidc, param = scope.split(".", 2)
  468. 187 param = param.to_sym if param
  469. end
  470. 272 by_oidc[oidc] ||= []
  471. 272 by_oidc[oidc] << param.to_sym if param
  472. end
  473. 731 oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
  474. 459 claims_locales = claims_locales.split(" ").map(&:to_sym) if claims_locales
  475. 459 unless oidc_scopes.empty?
  476. 153 if respond_to?(:get_oidc_param)
  477. 153 get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales, additional_claims_info)
  478. 153 oidc_scopes.each do |scope|
  479. 153 scope_claims = claims
  480. 153 params = scopes_by_claim[scope]
  481. 153 params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
  482. 153 scope_claims = (claims["address"] = {}) if scope == "address"
  483. 153 params.each do |param|
  484. 374 get_oidc_param[account, param, scope_claims]
  485. end
  486. end
  487. else
  488. warn "`get_oidc_param(account, claim)` must be implemented to use oidc scopes."
  489. end
  490. end
  491. 459 return if additional_scopes.empty?
  492. 119 if respond_to?(:get_additional_param)
  493. 119 get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales, additional_claims_info)
  494. 119 additional_scopes.each do |scope|
  495. 119 get_additional_param[account, scope.to_sym]
  496. end
  497. else
  498. warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes."
  499. end
  500. end
  501. 17 def proxy_get_param(get_param_func, claims, claims_locales, additional_claims_info)
  502. 272 meth = method(get_param_func)
  503. 272 if meth.arity == 2
  504. 238 lambda do |account, param, cl = claims|
  505. 459 additional_info = additional_claims_info[param] || EMPTY_HASH
  506. 459 value = additional_info["value"] || meth[account, param]
  507. 459 value = nil if additional_info["values"] && additional_info["values"].include?(value)
  508. 459 cl[param] = value unless value.nil?
  509. end
  510. 34 elsif claims_locales.nil?
  511. lambda do |account, param, cl = claims|
  512. additional_info = additional_claims_info[param] || EMPTY_HASH
  513. value = additional_info["value"] || meth[account, param, nil]
  514. value = nil if additional_info["values"] && additional_info["values"].include?(value)
  515. cl[param] = value unless value.nil?
  516. end
  517. else
  518. 34 lambda do |account, param, cl = claims|
  519. 34 claims_values = claims_locales.map do |locale|
  520. 68 additional_info = additional_claims_info[param] || EMPTY_HASH
  521. 68 value = additional_info["value"] || meth[account, param, locale]
  522. 68 value = nil if additional_info["values"] && additional_info["values"].include?(value)
  523. 68 value
  524. end.compact
  525. 34 if claims_values.uniq.size == 1
  526. cl[param] = claims_values.first
  527. else
  528. 34 claims_locales.zip(claims_values).each do |locale, value|
  529. 68 cl["#{param}##{locale}"] = value if value
  530. end
  531. end
  532. end
  533. end
  534. end
  535. 17 def json_access_token_payload(oauth_grant)
  536. 459 payload = super
  537. 459 payload["id_token"] = oauth_grant[:id_token] if oauth_grant[:id_token]
  538. 459 payload
  539. end
  540. # Authorize
  541. 17 def check_valid_response_type?
  542. 1430 case param_or_nil("response_type")
  543. when "none", "id_token", "code id_token", # multiple
  544. "code token", "id_token token", "code id_token token"
  545. 1088 true
  546. else
  547. 778 super
  548. end
  549. end
  550. 17 def supported_response_mode?(response_mode, *)
  551. 765 return super unless response_mode == "none"
  552. 17 param("response_type") == "none"
  553. end
  554. 17 def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
  555. 765 response_type = param("response_type")
  556. 585 case response_type
  557. when "id_token"
  558. 221 grant_params = oidc_grant_params
  559. 221 generate_id_token(grant_params, true)
  560. 221 response_params.replace("id_token" => grant_params[:id_token])
  561. when "code token"
  562. 17 response_params.replace(create_oauth_grant_with_token)
  563. when "code id_token"
  564. 170 params = _do_authorize_code
  565. 170 oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first
  566. 170 generate_id_token(oauth_grant)
  567. 170 response_params.replace(
  568. "id_token" => oauth_grant[:id_token],
  569. "code" => params["code"]
  570. )
  571. when "id_token token"
  572. 17 grant_params = oidc_grant_params.merge(oauth_grants_type_column => "hybrid")
  573. 17 oauth_grant = _do_authorize_token(grant_params)
  574. 17 generate_id_token(oauth_grant)
  575. 17 response_params.replace(json_access_token_payload(oauth_grant))
  576. when "code id_token token"
  577. 17 params = create_oauth_grant_with_token
  578. 17 oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first
  579. 13 oauth_grant[oauth_grants_token_column] = params["access_token"]
  580. 17 generate_id_token(oauth_grant)
  581. 17 response_params.replace(params.merge("id_token" => oauth_grant[:id_token]))
  582. when "none"
  583. 17 response_mode ||= "none"
  584. end
  585. 765 response_mode ||= "fragment" unless response_params.empty?
  586. 765 super(response_params, response_mode)
  587. end
  588. 17 def oidc_grant_params
  589. 731 grant_params = {
  590. **resource_owner_params,
  591. oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
  592. oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
  593. oauth_grants_redirect_uri_column => param_or_nil("redirect_uri")
  594. }
  595. 731 if (nonce = param_or_nil("nonce"))
  596. 429 grant_params[oauth_grants_nonce_column] = nonce
  597. end
  598. 731 grant_params[oauth_grants_acr_column] = @acr if @acr
  599. 731 if (claims_locales = param_or_nil("claims_locales"))
  600. 26 grant_params[oauth_grants_claims_locales_column] = claims_locales
  601. end
  602. 731 if (claims = param_or_nil("claims"))
  603. 13 grant_params[oauth_grants_claims_column] = claims
  604. end
  605. 731 grant_params
  606. end
  607. 17 def generate_token(grant_params = {}, should_generate_refresh_token = true)
  608. 357 scopes = grant_params[oauth_grants_scopes_column].split(oauth_scope_separator)
  609. 357 super(grant_params, scopes.include?("offline_access") && should_generate_refresh_token)
  610. end
  611. 17 def authorize_response(params, mode)
  612. 765 redirect_url = URI.parse(redirect_uri)
  613. 765 redirect(redirect_url.to_s) if mode == "none"
  614. 748 super
  615. end
  616. # Webfinger
  617. 17 def json_webfinger_payload
  618. 17 JSON.dump({
  619. subject: param("resource"),
  620. links: [{
  621. rel: "http://openid.net/specs/connect/1.0/issuer",
  622. href: authorization_server_url
  623. }]
  624. })
  625. end
  626. # Metadata
  627. 17 def openid_configuration_body(path = nil)
  628. 119 metadata = oauth_server_metadata_body(path).slice(*VALID_METADATA_KEYS)
  629. 119 scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
  630. 187 oidc, param = scope.split(".", 2)
  631. 187 if param
  632. 17 claims << param
  633. else
  634. 170 oidc_claims = OIDC_SCOPES_MAP[oidc]
  635. 170 claims.concat(oidc_claims) if oidc_claims
  636. end
  637. end
  638. 119 scope_claims.unshift("auth_time")
  639. 112 metadata.merge(
  640. userinfo_endpoint: userinfo_url,
  641. subject_types_supported: %w[public pairwise],
  642. acr_values_supported: oauth_acr_values_supported,
  643. claims_parameter_supported: true,
  644. id_token_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
  645. id_token_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
  646. id_token_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
  647. userinfo_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
  648. userinfo_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
  649. userinfo_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
  650. request_object_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
  651. request_object_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
  652. request_object_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
  653. # These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
  654. # Values defined by this specification are normal, aggregated, and distributed.
  655. # If omitted, the implementation supports only normal Claims.
  656. claim_types_supported: %w[normal],
  657. claims_supported: %w[sub iss iat exp aud] | scope_claims
  658. 7 ).reject do |key, val|
  659. # Filter null values in optional items
  660. 3553 (!REQUIRED_METADATA_KEYS.include?(key.to_sym) && val.nil?) ||
  661. # Claims with zero elements MUST be omitted from the response
  662. 3077 (val.respond_to?(:empty?) && val.empty?)
  663. end
  664. end
  665. 17 def allow_cors(request)
  666. 153 return unless request.request_method == "OPTIONS"
  667. 13 response["Access-Control-Allow-Origin"] = "*"
  668. 13 response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
  669. 13 response["Access-Control-Max-Age"] = "3600"
  670. 17 response.status = 200
  671. 17 return_response
  672. end
  673. 17 def jwt_response_success(jwt, cache = false)
  674. 34 response.status = 200
  675. 34 response["Content-Type"] ||= "application/jwt"
  676. 34 if cache
  677. # defaulting to 1-day for everyone, for now at least
  678. max_age = 60 * 60 * 24
  679. response["Cache-Control"] = "private, max-age=#{max_age}"
  680. else
  681. 26 response["Cache-Control"] = "no-store"
  682. 26 response["Pragma"] = "no-cache"
  683. end
  684. 34 return_response(jwt)
  685. end
  686. 17 def id_token_hash(hash, algo)
  687. 612 digest = case algo
  688. 612 when /256/ then Digest::SHA256
  689. when /384/ then Digest::SHA384
  690. when /512/ then Digest::SHA512
  691. end
  692. 612 return unless digest
  693. 612 hash = digest.digest(hash)
  694. 612 hash = hash[0...(hash.size / 2)]
  695. 612 Base64.urlsafe_encode64(hash).tr("=", "")
  696. end
  697. end
  698. end

lib/rodauth/features/oidc_backchannel_logout.rb

98.15% lines covered

54 relevant lines. 53 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc_backchannel_logout, :OidBackchannelLogout) do
  5. 17 depends :logout, :oidc_logout_base
  6. 17 auth_value_method :oauth_logout_token_expires_in, 60 # 1 minute
  7. 17 auth_value_method :backchannel_logout_session_supported, true
  8. 17 auth_value_method :oauth_applications_backchannel_logout_uri_column, :backchannel_logout_uri
  9. 17 auth_value_method :oauth_applications_backchannel_logout_session_required_column, :backchannel_logout_session_required
  10. 17 auth_methods(
  11. :perform_logout_requests
  12. )
  13. 17 def logout
  14. 102 visited_sites = session[visited_sites_key]
  15. 102 return super unless visited_sites
  16. 102 oauth_applications = db[oauth_applications_table].where(oauth_applications_client_id_column => visited_sites.map(&:first))
  17. .as_hash(oauth_applications_id_column)
  18. 102 logout_params = oauth_applications.flat_map do |_id, oauth_application|
  19. 102 logout_url = oauth_application[oauth_applications_backchannel_logout_uri_column]
  20. 102 next unless logout_url
  21. 102 client_id = oauth_application[oauth_applications_client_id_column]
  22. 204 sids = visited_sites.select { |cid, _| cid == client_id }.map(&:last)
  23. 102 sids.map do |sid|
  24. 102 logout_token = generate_logout_token(oauth_application, sid)
  25. 102 [logout_url, logout_token]
  26. end
  27. end.compact
  28. 102 perform_logout_requests(logout_params) unless logout_params.empty?
  29. # now we can clear the session
  30. 102 super
  31. end
  32. 17 private
  33. 17 def generate_logout_token(oauth_application, sid)
  34. 102 issued_at = Time.now.to_i
  35. 30 logout_claims = {
  36. 72 iss: oauth_jwt_issuer, # issuer
  37. iat: issued_at, # issued at
  38. exp: issued_at + oauth_logout_token_expires_in,
  39. aud: oauth_application[oauth_applications_client_id_column],
  40. events: {
  41. "http://schemas.openid.net/event/backchannel-logout": {}
  42. }
  43. }
  44. 102 logout_claims[:sid] = sid if sid
  45. 102 signing_algorithm = oauth_application[oauth_applications_id_token_signed_response_alg_column] ||
  46. oauth_jwt_keys.keys.first
  47. 30 params = {
  48. 72 jwks: oauth_application_jwks(oauth_application),
  49. headers: { typ: "logout+jwt" },
  50. signing_algorithm: signing_algorithm,
  51. encryption_algorithm: oauth_application[oauth_applications_id_token_encrypted_response_alg_column],
  52. encryption_method: oauth_application[oauth_applications_id_token_encrypted_response_enc_column]
  53. }.compact
  54. 102 jwt_encode(logout_claims, **params)
  55. end
  56. 17 def perform_logout_requests(logout_params)
  57. # performs logout requests sequentially
  58. 102 logout_params.each do |logout_url, logout_token|
  59. 102 http_request(logout_url, { "logout_token" => logout_token })
  60. rescue StandardError
  61. warn "failed to perform backchannel logout on #{logout_url}"
  62. end
  63. end
  64. 17 def id_token_claims(oauth_grant, signing_algorithm)
  65. 102 claims = super
  66. 102 return claims unless oauth_application[oauth_applications_backchannel_logout_uri_column]
  67. 102 session_id_in_claims(oauth_grant, claims)
  68. 102 claims
  69. end
  70. 17 def should_set_oauth_application_in_visited_sites?
  71. 51 true
  72. end
  73. 17 def should_set_sid_in_visited_sites?(oauth_application)
  74. 153 super || requires_backchannel_logout_session?(oauth_application)
  75. end
  76. 17 def requires_backchannel_logout_session?(oauth_application)
  77. 36 (
  78. 153 oauth_application &&
  79. oauth_application[oauth_applications_backchannel_logout_session_required_column]
  80. 9 ) || backchannel_logout_session_supported
  81. end
  82. 17 def oauth_server_metadata_body(*)
  83. 17 super.tap do |data|
  84. 13 data[:backchannel_logout_supported] = true
  85. 13 data[:backchannel_logout_session_supported] = backchannel_logout_session_supported
  86. end
  87. end
  88. end
  89. end

lib/rodauth/features/oidc_dynamic_client_registration.rb

90.51% lines covered

137 relevant lines. 124 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc_dynamic_client_registration, :OidcDynamicClientRegistration) do
  5. 17 depends :oauth_dynamic_client_registration, :oidc
  6. 17 auth_value_method :oauth_applications_application_type_column, :application_type
  7. 17 private
  8. 17 def validate_client_registration_params(*)
  9. 867 super
  10. 816 if (value = @oauth_application_params[oauth_applications_application_type_column])
  11. 91 case value
  12. when "native"
  13. 68 request.params["redirect_uris"].each do |uri|
  14. 68 uri = URI(uri)
  15. # Native Clients MUST only register redirect_uris using custom URI schemes or
  16. # URLs using the http: scheme with localhost as the hostname.
  17. 52 case uri.scheme
  18. when "http"
  19. 34 register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(uri)) unless uri.host == "localhost"
  20. when "https"
  21. 17 register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(uri))
  22. end
  23. end
  24. when "web"
  25. # Web Clients using the OAuth Implicit Grant Type MUST only register URLs using the https scheme as redirect_uris;
  26. # they MUST NOT use localhost as the hostname.
  27. 51 if request.params["grant_types"].include?("implicit")
  28. 34 request.params["redirect_uris"].each do |uri|
  29. 34 uri = URI(uri)
  30. 34 unless uri.scheme == "https" && uri.host != "localhost"
  31. 17 register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(uri))
  32. end
  33. end
  34. end
  35. else
  36. register_throw_json_response_error("invalid_client_metadata", register_invalid_application_type_message(type))
  37. end
  38. end
  39. 765 if (value = @oauth_application_params[oauth_applications_sector_identifier_uri_column]) && !check_valid_uri?(value)
  40. register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(value))
  41. end
  42. 765 if (value = @oauth_application_params[oauth_applications_initiate_login_uri_column])
  43. 34 uri = URI(value)
  44. 34 unless uri.scheme == "https" || uri.host == "localhost"
  45. 17 register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(uri))
  46. end
  47. end
  48. 748 if features.include?(:oauth_jwt_secured_authorization_request)
  49. 153 if (value = @oauth_application_params[oauth_applications_request_uris_column])
  50. 34 if value.is_a?(Array)
  51. 13 @oauth_application_params[oauth_applications_request_uris_column] = value.each do |req_uri|
  52. 17 unless check_valid_uri?(req_uri)
  53. register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(req_uri))
  54. end
  55. end.join(" ")
  56. else
  57. 17 register_throw_json_response_error("invalid_redirect_uri", register_invalid_uri_message(value))
  58. end
  59. 119 elsif oauth_require_request_uri_registration
  60. 17 register_throw_json_response_error("invalid_client_metadata", register_required_param_message("request_uris"))
  61. end
  62. end
  63. 714 if (value = @oauth_application_params[oauth_applications_subject_type_column])
  64. 119 unless %w[pairwise public].include?(value)
  65. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_client_metadata_message("subject_type", value))
  66. end
  67. 102 if value == "pairwise"
  68. 85 sector_identifier_uri = @oauth_application_params[oauth_applications_sector_identifier_uri_column]
  69. 85 if sector_identifier_uri
  70. 34 response = http_request(sector_identifier_uri)
  71. 34 unless response.code.to_i == 200
  72. register_throw_json_response_error("invalid_client_metadata",
  73. register_invalid_param_message("sector_identifier_uri"))
  74. end
  75. 34 uris = JSON.parse(response.body)
  76. 34 if uris != @oauth_application_params[oauth_applications_redirect_uri_column].split(" ")
  77. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message("sector_identifier_uri"))
  78. end
  79. end
  80. end
  81. end
  82. 680 if (value = @oauth_application_params[oauth_applications_id_token_signed_response_alg_column])
  83. 68 if value == "none"
  84. # The value none MUST NOT be used as the ID Token alg value unless the Client uses only Response Types
  85. # that return no ID Token from the Authorization Endpoint
  86. response_types = @oauth_application_params[oauth_applications_response_types_column]
  87. if response_types && response_types.include?("id_token")
  88. register_throw_json_response_error("invalid_client_metadata", register_invalid_param_message("id_token_signed_response_alg"))
  89. end
  90. 68 elsif !oauth_jwt_jws_algorithms_supported.include?(value)
  91. 17 register_throw_json_response_error("invalid_client_metadata",
  92. register_invalid_client_metadata_message("id_token_signed_response_alg", value))
  93. end
  94. end
  95. 663 if features.include?(:oauth_jwt_secured_authorization_request)
  96. 119 if defined?(oauth_applications_request_object_signing_alg_column) &&
  97. 119 (value = @oauth_application_params[oauth_applications_request_object_signing_alg_column]) &&
  98. 17 !oauth_jwt_jws_algorithms_supported.include?(value) && !(value == "none" && oauth_request_object_signing_alg_allow_none)
  99. 17 register_throw_json_response_error("invalid_client_metadata",
  100. register_invalid_client_metadata_message("request_object_signing_alg", value))
  101. end
  102. 102 if defined?(oauth_applications_request_object_encryption_alg_column) &&
  103. 102 (value = @oauth_application_params[oauth_applications_request_object_encryption_alg_column]) &&
  104. !oauth_jwt_jwe_algorithms_supported.include?(value)
  105. 17 register_throw_json_response_error("invalid_client_metadata",
  106. register_invalid_client_metadata_message("request_object_encryption_alg", value))
  107. end
  108. 85 if defined?(oauth_applications_request_object_encryption_enc_column) &&
  109. 85 (value = @oauth_application_params[oauth_applications_request_object_encryption_enc_column]) &&
  110. !oauth_jwt_jwe_encryption_methods_supported.include?(value)
  111. 17 register_throw_json_response_error("invalid_client_metadata",
  112. register_invalid_client_metadata_message("request_object_encryption_enc", value))
  113. end
  114. end
  115. 612 if features.include?(:oidc_rp_initiated_logout) && defined?(oauth_applications_post_logout_redirect_uris_column) &&
  116. 34 (value = @oauth_application_params[oauth_applications_post_logout_redirect_uris_column])
  117. 34 if value.is_a?(Array)
  118. 13 @oauth_application_params[oauth_applications_post_logout_redirect_uris_column] = value.each do |redirect_uri|
  119. 17 unless check_valid_uri?(redirect_uri)
  120. register_throw_json_response_error("invalid_client_metadata", register_invalid_uri_message(redirect_uri))
  121. end
  122. end.join(" ")
  123. else
  124. 17 register_throw_json_response_error("invalid_client_metadata", register_invalid_uri_message(value))
  125. end
  126. end
  127. 595 if features.include?(:oidc_frontchannel_logout)
  128. 51 if (value = @oauth_application_params[oauth_applications_frontchannel_logout_uri_column]) && !check_valid_no_fragment_uri?(value)
  129. 34 register_throw_json_response_error("invalid_client_metadata",
  130. register_invalid_uri_message(value))
  131. end
  132. 17 if (value = @oauth_application_params[oauth_applications_frontchannel_logout_session_required_column])
  133. 13 @oauth_application_params[oauth_applications_frontchannel_logout_session_required_column] =
  134. convert_to_boolean("frontchannel_logout_session_required", value)
  135. end
  136. end
  137. 561 if features.include?(:oidc_backchannel_logout)
  138. 51 if (value = @oauth_application_params[oauth_applications_backchannel_logout_uri_column]) && !check_valid_no_fragment_uri?(value)
  139. 34 register_throw_json_response_error("invalid_client_metadata",
  140. register_invalid_uri_message(value))
  141. end
  142. 17 if @oauth_application_params.key?(oauth_applications_backchannel_logout_session_required_column)
  143. 17 value = @oauth_application_params[oauth_applications_backchannel_logout_session_required_column]
  144. 13 @oauth_application_params[oauth_applications_backchannel_logout_session_required_column] =
  145. convert_to_boolean("backchannel_logout_session_required", value)
  146. end
  147. end
  148. 527 if (value = @oauth_application_params[oauth_applications_id_token_encrypted_response_alg_column]) &&
  149. !oauth_jwt_jwe_algorithms_supported.include?(value)
  150. 17 register_throw_json_response_error("invalid_client_metadata",
  151. register_invalid_client_metadata_message("id_token_encrypted_response_alg", value))
  152. end
  153. 510 if (value = @oauth_application_params[oauth_applications_id_token_encrypted_response_enc_column]) &&
  154. !oauth_jwt_jwe_encryption_methods_supported.include?(value)
  155. 17 register_throw_json_response_error("invalid_client_metadata",
  156. register_invalid_client_metadata_message("id_token_encrypted_response_enc", value))
  157. end
  158. 493 if (value = @oauth_application_params[oauth_applications_userinfo_signed_response_alg_column]) &&
  159. !oauth_jwt_jws_algorithms_supported.include?(value)
  160. 17 register_throw_json_response_error("invalid_client_metadata",
  161. register_invalid_client_metadata_message("userinfo_signed_response_alg", value))
  162. end
  163. 476 if (value = @oauth_application_params[oauth_applications_userinfo_encrypted_response_alg_column]) &&
  164. !oauth_jwt_jwe_algorithms_supported.include?(value)
  165. 17 register_throw_json_response_error("invalid_client_metadata",
  166. register_invalid_client_metadata_message("userinfo_encrypted_response_alg", value))
  167. end
  168. 459 if (value = @oauth_application_params[oauth_applications_userinfo_encrypted_response_enc_column]) &&
  169. !oauth_jwt_jwe_encryption_methods_supported.include?(value)
  170. 17 register_throw_json_response_error("invalid_client_metadata",
  171. register_invalid_client_metadata_message("userinfo_encrypted_response_enc", value))
  172. end
  173. 442 if features.include?(:oauth_jwt_secured_authorization_response_mode)
  174. 34 if defined?(oauth_applications_authorization_signed_response_alg_column) &&
  175. 34 (value = @oauth_application_params[oauth_applications_authorization_signed_response_alg_column]) &&
  176. 24 (!oauth_jwt_jws_algorithms_supported.include?(value) || value == "none")
  177. register_throw_json_response_error("invalid_client_metadata",
  178. register_invalid_client_metadata_message("authorization_signed_response_alg", value))
  179. end
  180. 34 if defined?(oauth_applications_authorization_encrypted_response_alg_column) &&
  181. 34 (value = @oauth_application_params[oauth_applications_authorization_encrypted_response_alg_column]) &&
  182. !oauth_jwt_jwe_algorithms_supported.include?(value)
  183. register_throw_json_response_error("invalid_client_metadata",
  184. register_invalid_client_metadata_message("authorization_encrypted_response_alg", value))
  185. end
  186. 34 if defined?(oauth_applications_authorization_encrypted_response_enc_column)
  187. 34 if (value = @oauth_application_params[oauth_applications_authorization_encrypted_response_enc_column])
  188. 17 unless @oauth_application_params[oauth_applications_authorization_encrypted_response_alg_column]
  189. # When authorization_encrypted_response_enc is included, authorization_encrypted_response_alg MUST also be provided.
  190. 17 register_throw_json_response_error("invalid_client_metadata",
  191. register_invalid_client_metadata_message("authorization_encrypted_response_alg", value))
  192. end
  193. unless oauth_jwt_jwe_encryption_methods_supported.include?(value)
  194. register_throw_json_response_error("invalid_client_metadata",
  195. register_invalid_client_metadata_message("authorization_encrypted_response_enc", value))
  196. end
  197. 17 elsif @oauth_application_params[oauth_applications_authorization_encrypted_response_alg_column]
  198. # If authorization_encrypted_response_alg is specified, the default for this value is A128CBC-HS256.
  199. 13 @oauth_application_params[oauth_applications_authorization_encrypted_response_enc_column] = "A128CBC-HS256"
  200. end
  201. end
  202. end
  203. 425 @oauth_application_params
  204. end
  205. 17 def validate_client_registration_response_type(response_type, grant_types)
  206. 663 case response_type
  207. when "id_token"
  208. 68 unless grant_types.include?("implicit")
  209. 17 register_throw_json_response_error("invalid_client_metadata",
  210. register_invalid_response_type_for_grant_type_message(response_type, "implicit"))
  211. end
  212. else
  213. 799 super
  214. end
  215. end
  216. 17 def do_register(return_params = request.params.dup)
  217. # set defaults
  218. 391 create_params = @oauth_application_params
  219. 391 create_params[oauth_applications_application_type_column] ||= begin
  220. 247 return_params["application_type"] = "web"
  221. 323 "web"
  222. end
  223. 391 create_params[oauth_applications_id_token_signed_response_alg_column] ||= return_params["id_token_signed_response_alg"] =
  224. oauth_jwt_keys.keys.first
  225. 391 if create_params.key?(oauth_applications_id_token_encrypted_response_alg_column)
  226. 17 create_params[oauth_applications_id_token_encrypted_response_enc_column] ||= return_params["id_token_encrypted_response_enc"] =
  227. "A128CBC-HS256"
  228. end
  229. 391 if create_params.key?(oauth_applications_userinfo_encrypted_response_alg_column)
  230. 17 create_params[oauth_applications_userinfo_encrypted_response_enc_column] ||= return_params["userinfo_encrypted_response_enc"] =
  231. "A128CBC-HS256"
  232. end
  233. 391 if defined?(oauth_applications_request_object_encryption_alg_column) &&
  234. create_params.key?(oauth_applications_request_object_encryption_alg_column)
  235. 17 create_params[oauth_applications_request_object_encryption_enc_column] ||= return_params["request_object_encryption_enc"] =
  236. "A128CBC-HS256"
  237. end
  238. 391 super(return_params)
  239. end
  240. 17 def register_invalid_application_type_message(application_type)
  241. "The application type '#{application_type}' is not allowed."
  242. end
  243. 17 def initialize_register_params(create_params, return_params)
  244. 391 super
  245. 391 registration_access_token = oauth_unique_id_generator
  246. 299 create_params[oauth_applications_registration_access_token_column] = secret_hash(registration_access_token)
  247. 299 return_params["registration_access_token"] = registration_access_token
  248. 299 return_params["registration_client_uri"] = "#{base_url}/#{registration_client_uri_route}/#{return_params['client_id']}"
  249. end
  250. end
  251. end

lib/rodauth/features/oidc_frontchannel_logout.rb

100.0% lines covered

68 relevant lines. 68 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. skipped # :nocov:
  4. skipped raise LoadError, "the `:oidc_frontchannel_logout` requires rodauth 2.32.0 or higher" if Rodauth::VERSION < "2.32.0"
  5. skipped
  6. skipped # :nocov:
  7. 17 module Rodauth
  8. 17 Feature.define(:oidc_frontchannel_logout, :OidFrontchannelLogout) do
  9. 17 depends :logout, :oidc_logout_base
  10. 17 view "frontchannel_logout", "Logout", "frontchannel_logout"
  11. 17 translatable_method :oauth_frontchannel_logout_redirecting_lead, "You are being redirected..."
  12. 17 translatable_method :oauth_frontchannel_logout_redirecting_label, "please click %<link>s if your browser does not " \
  13. "redirect you in a few seconds."
  14. 17 translatable_method :oauth_frontchannel_logout_redirecting_link_label, "here"
  15. 17 auth_value_method :frontchannel_logout_session_supported, true
  16. 17 auth_value_method :frontchannel_logout_redirect_timeout, 5
  17. 17 auth_value_method :oauth_applications_frontchannel_logout_uri_column, :frontchannel_logout_uri
  18. 17 auth_value_method :oauth_applications_frontchannel_logout_session_required_column, :frontchannel_logout_session_required
  19. 17 attr_reader :frontchannel_logout_urls
  20. 17 attr_reader :frontchannel_logout_redirect
  21. 17 def logout
  22. 119 @visited_sites = session[visited_sites_key]
  23. 119 super
  24. end
  25. 17 def _logout_response
  26. 102 visited_sites = @visited_sites
  27. 102 return super unless visited_sites
  28. 102 logout_urls = db[oauth_applications_table]
  29. .where(oauth_applications_client_id_column => visited_sites.map(&:first))
  30. .as_hash(oauth_applications_client_id_column, oauth_applications_frontchannel_logout_uri_column)
  31. 102 return super if logout_urls.empty?
  32. 102 generate_frontchannel_logout_urls(visited_sites, logout_urls)
  33. 102 @frontchannel_logout_redirect = logout_redirect
  34. 102 set_notice_flash logout_notice_flash
  35. 102 return_response frontchannel_logout_view
  36. end
  37. # overrides rp-initiate logout response
  38. 17 def _oidc_logout_response
  39. 17 visited_sites = @visited_sites
  40. 17 return super unless visited_sites
  41. 17 logout_urls = db[oauth_applications_table]
  42. .where(oauth_applications_client_id_column => visited_sites.map(&:first))
  43. .as_hash(oauth_applications_client_id_column, oauth_applications_frontchannel_logout_uri_column)
  44. 17 return super if logout_urls.empty?
  45. 17 generate_frontchannel_logout_urls(visited_sites, logout_urls)
  46. 17 @frontchannel_logout_redirect = oidc_logout_redirect
  47. 17 set_notice_flash logout_notice_flash
  48. 17 return_response frontchannel_logout_view
  49. end
  50. 17 private
  51. 17 def generate_frontchannel_logout_urls(visited_sites, logout_urls)
  52. 119 @frontchannel_logout_urls = logout_urls.flat_map do |client_id, logout_url|
  53. 119 next unless logout_url
  54. 238 sids = visited_sites.select { |cid, _| cid == client_id }.map(&:last)
  55. 119 sids.map do |sid|
  56. 119 logout_url = URI(logout_url)
  57. 119 if sid
  58. 85 query = logout_url.query
  59. 85 query = if query
  60. 34 URI.decode_www_form(query)
  61. else
  62. 51 []
  63. end
  64. 85 query << ["iss", oauth_jwt_issuer]
  65. 85 query << ["sid", sid]
  66. 85 logout_url.query = URI.encode_www_form(query)
  67. end
  68. 119 logout_url
  69. end
  70. end.compact
  71. end
  72. 17 def id_token_claims(oauth_grant, signing_algorithm)
  73. 119 claims = super
  74. 119 return claims unless oauth_application[oauth_applications_frontchannel_logout_uri_column]
  75. 119 session_id_in_claims(oauth_grant, claims)
  76. 119 claims
  77. end
  78. 17 def should_set_oauth_application_in_visited_sites?
  79. 68 true
  80. end
  81. 17 def should_set_sid_in_visited_sites?(oauth_application)
  82. 187 super || requires_frontchannel_logout_session?(oauth_application)
  83. end
  84. 17 def requires_frontchannel_logout_session?(oauth_application)
  85. 44 (
  86. 187 oauth_application &&
  87. oauth_application[oauth_applications_frontchannel_logout_session_required_column]
  88. 11 ) || frontchannel_logout_session_supported
  89. end
  90. 17 def oauth_server_metadata_body(*)
  91. 17 super.tap do |data|
  92. 13 data[:frontchannel_logout_supported] = true
  93. 13 data[:frontchannel_logout_session_supported] = frontchannel_logout_session_supported
  94. end
  95. end
  96. end
  97. end

lib/rodauth/features/oidc_logout_base.rb

100.0% lines covered

38 relevant lines. 38 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc_logout_base, :OidcLogoutBase) do
  5. 17 depends :oidc
  6. 17 session_key :visited_sites_key, :visited_sites
  7. 17 private
  8. # set application/sid in visited sites when required
  9. 17 def create_oauth_grant(create_params = {})
  10. 187 sid_in_visited_sites
  11. 187 super
  12. end
  13. 17 def active_sessions?(session_id)
  14. 17 !active_sessions_ds.where(active_sessions_session_id_column => session_id).empty?
  15. end
  16. 17 def session_id_in_claims(oauth_grant, claims)
  17. 221 oauth_application_in_visited_sites do
  18. 221 if should_set_sid_in_visited_sites?(oauth_application)
  19. # id_token or token response types
  20. 153 session_id = if (sess = session[session_id_session_key])
  21. 85 compute_hmac(sess)
  22. else
  23. # code response type
  24. 68 ds = db[active_sessions_table]
  25. 68 ds = ds.where(active_sessions_account_id_column => oauth_grant[oauth_grants_account_id_column])
  26. 68 ds = ds.order(Sequel.desc(active_sessions_last_use_column))
  27. 68 ds.get(active_sessions_session_id_column)
  28. end
  29. 117 claims[:sid] = session_id
  30. end
  31. end
  32. end
  33. 17 def oauth_application_in_visited_sites
  34. 340 visited_sites = session[visited_sites_key] || []
  35. 340 session_id = yield
  36. 340 visited_site = [oauth_application[oauth_applications_client_id_column], session_id]
  37. 340 return if visited_sites.include?(visited_site)
  38. 323 visited_sites << visited_site
  39. 323 set_session_value(visited_sites_key, visited_sites)
  40. end
  41. 17 def sid_in_visited_sites
  42. 187 return unless should_set_oauth_application_in_visited_sites?
  43. 119 oauth_application_in_visited_sites do
  44. 119 if should_set_sid_in_visited_sites?(oauth_application)
  45. 85 ds = active_sessions_ds.order(Sequel.desc(active_sessions_last_use_column))
  46. 85 ds.get(active_sessions_session_id_column)
  47. end
  48. end
  49. end
  50. 17 def should_set_oauth_application_in_visited_sites?
  51. 68 false
  52. end
  53. 17 def should_set_sid_in_visited_sites?(*)
  54. 340 false
  55. end
  56. end
  57. end

lib/rodauth/features/oidc_rp_initiated_logout.rb

95.24% lines covered

63 relevant lines. 60 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc_rp_initiated_logout, :OidcRpInitiatedLogout) do
  5. 17 depends :oidc_logout_base
  6. 17 response "oidc_logout"
  7. 17 auth_value_method :oauth_applications_post_logout_redirect_uris_column, :post_logout_redirect_uris
  8. 17 translatable_method :oauth_invalid_id_token_hint_message, "Invalid ID token hint"
  9. 17 translatable_method :oauth_invalid_post_logout_redirect_uri_message, "Invalid post logout redirect URI"
  10. 17 attr_reader :oidc_logout_redirect
  11. # /oidc-logout
  12. 17 auth_server_route(:oidc_logout) do |r|
  13. 102 require_authorizable_account
  14. 102 before_oidc_logout_route
  15. # OpenID Providers MUST support the use of the HTTP GET and POST methods
  16. 102 r.on method: %i[get post] do
  17. 102 catch_error do
  18. 102 validate_oidc_logout_params
  19. 102 claims = nil
  20. 102 if (id_token_hint = param_or_nil("id_token_hint"))
  21. #
  22. # why this is done:
  23. #
  24. # we need to decode the id token in order to get the application, because, if the
  25. # signing key is application-specific, we don't know how to verify the signature
  26. # beforehand. Hence, we have to do it twice: decode-and-do-not-verify, initialize
  27. # the @oauth_application, and then decode-and-verify.
  28. #
  29. 102 claims = jwt_decode(id_token_hint, verify_claims: false)
  30. 102 redirect_logout_with_error(oauth_invalid_id_token_hint_message) unless claims
  31. # If the ID Token's sid claim does not correspond to the RP's current session or a
  32. # recent session at the OP, the OP SHOULD treat the logout request as suspect, and
  33. # MAY decline to act upon it.
  34. 102 redirect_logout_with_error(oauth_invalid_client_message) if claims["sid"] && !active_sessions?(claims["sid"])
  35. 102 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["aud"]).first
  36. 102 oauth_grant = db[oauth_grants_table]
  37. .where(resource_owner_params)
  38. .where(oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column])
  39. .first
  40. 102 unique_account_id = if oauth_grant
  41. 85 oauth_grant[oauth_grants_account_id_column]
  42. else
  43. 17 account_id
  44. end
  45. # check whether ID token belongs to currently logged-in user
  46. 102 redirect_logout_with_error(oauth_invalid_client_message) unless claims["sub"] == jwt_subject(unique_account_id,
  47. oauth_application)
  48. # When an id_token_hint parameter is present, the OP MUST validate that it was the issuer of the ID Token.
  49. 102 redirect_logout_with_error(oauth_invalid_client_message) unless claims && claims["iss"] == oauth_jwt_issuer
  50. end
  51. # now let's logout from IdP
  52. 102 transaction do
  53. 102 before_logout
  54. 102 logout
  55. 102 after_logout
  56. end
  57. 102 error_message = logout_notice_flash
  58. 102 if (post_logout_redirect_uri = param_or_nil("post_logout_redirect_uri"))
  59. 85 error_message = catch(:default_logout_redirect) do
  60. 85 throw(:default_logout_redirect, oauth_invalid_id_token_hint_message) unless claims
  61. 85 oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
  62. 85 throw(:default_logout_redirect, oauth_invalid_client_message) unless oauth_application
  63. 85 post_logout_redirect_uris = oauth_application[oauth_applications_post_logout_redirect_uris_column].split(" ")
  64. 85 unless post_logout_redirect_uris.include?(post_logout_redirect_uri)
  65. throw(:default_logout_redirect,
  66. oauth_invalid_post_logout_redirect_uri_message)
  67. end
  68. 85 if (state = param_or_nil("state"))
  69. 17 post_logout_redirect_uri = URI(post_logout_redirect_uri)
  70. 17 params = ["state=#{CGI.escape(state)}"]
  71. 17 params << post_logout_redirect_uri.query if post_logout_redirect_uri.query
  72. 17 post_logout_redirect_uri.query = params.join("&")
  73. 17 post_logout_redirect_uri = post_logout_redirect_uri.to_s
  74. end
  75. 85 @oidc_logout_redirect = post_logout_redirect_uri
  76. 85 require_response(:_oidc_logout_response)
  77. end
  78. end
  79. 17 redirect_logout_with_error(error_message)
  80. end
  81. redirect_response_error("invalid_request")
  82. end
  83. end
  84. 17 def _oidc_logout_response
  85. 68 redirect(oidc_logout_redirect)
  86. end
  87. 17 private
  88. # Logout
  89. 17 def validate_oidc_logout_params
  90. # check if valid token hint type
  91. 102 return unless (redirect_uri = param_or_nil("post_logout_redirect_uri"))
  92. 85 return if check_valid_no_fragment_uri?(redirect_uri)
  93. redirect_logout_with_error(oauth_invalid_client_message)
  94. end
  95. 17 def redirect_logout_with_error(error_message = oauth_invalid_client_message)
  96. 17 set_notice_flash(error_message)
  97. 17 redirect(logout_redirect)
  98. end
  99. 17 def oauth_server_metadata_body(*)
  100. 17 super.tap do |data|
  101. 13 data[:end_session_endpoint] = oidc_logout_url
  102. end
  103. end
  104. end
  105. end

lib/rodauth/features/oidc_self_issued.rb

97.06% lines covered

34 relevant lines. 33 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc_self_issued, :OidcSelfIssued) do
  5. 17 depends :oidc, :oidc_dynamic_client_registration
  6. 17 auth_value_method :oauth_application_scopes, %w[openid profile email address phone]
  7. 17 auth_value_method :oauth_jwt_jws_algorithms_supported, %w[RS256]
  8. 13 SELF_ISSUED_DEFAULT_APPLICATION_PARAMS = {
  9. 4 "scope" => "openid profile email address phone",
  10. "response_types" => ["id_token"],
  11. "subject_type" => "pairwise",
  12. "id_token_signed_response_alg" => "RS256",
  13. "request_object_signing_alg" => "RS256",
  14. "grant_types" => %w[implicit]
  15. }.freeze
  16. 17 def oauth_application
  17. 391 return @oauth_application if defined?(@oauth_application)
  18. 34 return super unless (registration = param_or_nil("registration"))
  19. # self-issued!
  20. 34 redirect_uri = param_or_nil("client_id")
  21. 34 registration_params = JSON.parse(registration)
  22. 34 registration_params = SELF_ISSUED_DEFAULT_APPLICATION_PARAMS.merge(registration_params)
  23. 34 client_params = validate_client_registration_params(registration_params)
  24. 26 request.params["redirect_uri"] = client_params[oauth_applications_client_id_column] = redirect_uri
  25. 34 client_params[oauth_applications_redirect_uri_column] ||= redirect_uri
  26. 34 @oauth_application = client_params
  27. end
  28. 17 private
  29. 17 def oauth_response_types_supported
  30. 34 %w[id_token]
  31. end
  32. 17 def request_object_signing_alg_values_supported
  33. %w[none RS256]
  34. end
  35. 17 def id_token_claims(oauth_grant, signing_algorithm)
  36. 17 claims = super
  37. 17 return claims unless claims[:client_id] == oauth_grant[oauth_grants_redirect_uri_column]
  38. # https://openid.net/specs/openid-connect-core-1_0.html#SelfIssued - 7.4
  39. 17 pub_key = oauth_jwt_public_keys[signing_algorithm]
  40. 17 pub_key = pub_key.first if pub_key.is_a?(Array)
  41. 13 claims[:sub_jwk] = sub_jwk = jwk_export(pub_key)
  42. 13 claims[:iss] = "https://self-issued.me"
  43. 13 claims[:aud] = oauth_grant[oauth_grants_redirect_uri_column]
  44. 17 jwk_thumbprint = jwk_thumbprint(sub_jwk)
  45. 13 claims[:sub] = Base64.urlsafe_encode64(jwk_thumbprint, padding: false)
  46. 17 claims
  47. end
  48. end
  49. end

lib/rodauth/features/oidc_session_management.rb

97.92% lines covered

48 relevant lines. 47 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth/oauth"
  3. 17 module Rodauth
  4. 17 Feature.define(:oidc_session_management, :OidcSessionManagement) do
  5. 17 depends :oidc
  6. 17 view "check_session", "Check Session", "check_session"
  7. 17 auth_value_method :oauth_oidc_user_agent_state_cookie_key, "_rodauth_oauth_user_agent_state"
  8. 17 auth_value_method :oauth_oidc_user_agent_state_cookie_options, {}.freeze
  9. 17 auth_value_method :oauth_oidc_user_agent_state_cookie_expires_in, 365 * 24 * 60 * 60 # 1 year
  10. 17 auth_value_method :oauth_oidc_user_agent_state_js, nil
  11. 17 auth_value_methods(
  12. :oauth_oidc_session_management_salt
  13. )
  14. # /authorize
  15. 17 auth_server_route(:check_session) do |r|
  16. 17 allow_cors(r)
  17. 17 r.get do
  18. 17 set_title(:check_session_page_title)
  19. 17 scope.view(_view_opts("check_session").merge(layout: false))
  20. end
  21. end
  22. 17 def clear_session
  23. 51 super
  24. # update user agent state in the process
  25. # TODO: dangerous if this gets overidden by the user
  26. 51 user_agent_state_cookie_opts = Hash[oauth_oidc_user_agent_state_cookie_options]
  27. 39 user_agent_state_cookie_opts[:value] = oauth_unique_id_generator
  28. 39 user_agent_state_cookie_opts[:secure] = true
  29. 51 if oauth_oidc_user_agent_state_cookie_expires_in
  30. 39 user_agent_state_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_oidc_user_agent_state_cookie_expires_in)
  31. end
  32. 51 ::Rack::Utils.set_cookie_header!(response.headers, oauth_oidc_user_agent_state_cookie_key, user_agent_state_cookie_opts)
  33. end
  34. 17 private
  35. 17 def do_authorize(*)
  36. 17 params, mode = super
  37. 13 params["session_state"] = generate_session_state
  38. 17 [params, mode]
  39. end
  40. 17 def response_error_params(*)
  41. 17 payload = super
  42. 17 return payload unless request.path == authorize_path
  43. 13 payload["session_state"] = generate_session_state
  44. 17 payload
  45. end
  46. 17 def generate_session_state
  47. 34 salt = oauth_oidc_session_management_salt
  48. 34 uri = URI(redirect_uri)
  49. 34 origin = if uri.respond_to?(:origin)
  50. 24 uri.origin
  51. else
  52. # TODO: remove when not supporting uri < 0.11
  53. 10 "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
  54. end
  55. 34 session_id = "#{oauth_application[oauth_applications_client_id_column]} " \
  56. 8 "#{origin} " \
  57. 8 "#{request.cookies[oauth_oidc_user_agent_state_cookie_key]} #{salt}"
  58. 26 "#{Digest::SHA256.hexdigest(session_id)}.#{salt}"
  59. end
  60. 17 def oauth_server_metadata_body(*)
  61. 17 super.tap do |data|
  62. 13 data[:check_session_iframe] = check_session_url
  63. end
  64. end
  65. 17 def oauth_oidc_session_management_salt
  66. oauth_unique_id_generator
  67. end
  68. end
  69. end

lib/rodauth/oauth.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "rodauth"
  3. 17 require "rodauth/oauth/version"
  4. 17 module Rodauth
  5. 17 module OAuth
  6. 17 module FeatureExtensions
  7. 17 def auth_server_route(name, *args, &blk)
  8. 204 routes = route(name, *args, &blk)
  9. 204 handle_meth = routes.last
  10. 204 define_method(:"#{handle_meth}_for_auth_server") do
  11. 11076 next unless is_authorization_server?
  12. 11076 send(:"#{handle_meth}_not_for_auth_server")
  13. end
  14. 204 alias_method :"#{handle_meth}_not_for_auth_server", handle_meth
  15. 204 alias_method handle_meth, :"#{handle_meth}_for_auth_server"
  16. # make all requests usable via internal_request feature
  17. 204 internal_request_method name
  18. end
  19. # override
  20. 17 def translatable_method(meth, value)
  21. 44295 define_method(meth) { |*args| translate(meth, value, *args) }
  22. 3196 auth_value_methods(meth)
  23. end
  24. end
  25. end
  26. 17 Feature.prepend OAuth::FeatureExtensions
  27. end
  28. 17 require "rodauth/oauth/railtie" if defined?(Rails)

lib/rodauth/oauth/database_extensions.rb

100.0% lines covered

42 relevant lines. 42 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 module Rodauth
  3. 17 module OAuth
  4. # rubocop:disable Naming/MethodName
  5. 17 def self.ExtendDatabase(db)
  6. 7606 Module.new do
  7. 7606 dataset = db.dataset
  8. 7606 if dataset.supports_returning?(:insert)
  9. 5370 def __insert_and_return__(dataset, _pkey, params)
  10. 1388 dataset.returning.insert(params).first
  11. end
  12. else
  13. 2236 def __insert_and_return__(dataset, pkey, params)
  14. 442 id = dataset.insert(params)
  15. 422 if params.key?(pkey)
  16. # mysql returns 0 when the primary key is a varchar.
  17. 60 id = params[pkey]
  18. end
  19. 422 dataset.where(pkey => id).first
  20. end
  21. end
  22. 7606 if dataset.supports_returning?(:update)
  23. 5370 def __update_and_return__(dataset, params)
  24. 1040 dataset.returning.update(params).first
  25. end
  26. else
  27. 2236 def __update_and_return__(dataset, params)
  28. 553 dataset.update(params)
  29. 533 dataset.first
  30. end
  31. end
  32. 7606 if dataset.respond_to?(:supports_insert_conflict?) && dataset.supports_insert_conflict?
  33. 2235 def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, to_update_extra = nil)
  34. 7340 to_update = Hash[(params.keys - unique_columns).map { |attribute| [attribute, Sequel[:excluded][attribute]] }]
  35. 511 to_update.merge!(to_update_extra) if to_update_extra
  36. 511 dataset = dataset.insert_conflict(
  37. target: unique_columns,
  38. update: to_update,
  39. update_where: conds
  40. )
  41. 511 __insert_and_return__(dataset, pkey, params)
  42. end
  43. 2235 def __insert_or_do_nothing_and_return__(dataset, pkey, unique_columns, params)
  44. 100 __insert_and_return__(
  45. dataset.insert_conflict(target: unique_columns),
  46. pkey,
  47. params
  48. ) || dataset.where(params).first
  49. end
  50. else
  51. 5371 def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, to_update_extra = nil)
  52. 10410 find_params, update_params = params.partition { |key, _| unique_columns.include?(key) }.map { |h| Hash[h] }
  53. 567 dataset_where = dataset.where(find_params)
  54. 567 record = if conds
  55. 279 dataset_where_conds = dataset_where.where(conds)
  56. # this means that there's still a valid entry there, so return early
  57. 279 return if dataset_where.count != dataset_where_conds.count
  58. 279 dataset_where_conds.first
  59. else
  60. 288 dataset_where.first
  61. end
  62. 567 if record
  63. 339 update_params.merge!(to_update_extra) if to_update_extra
  64. 339 __update_and_return__(dataset_where, update_params)
  65. else
  66. 228 __insert_and_return__(dataset, pkey, params)
  67. end
  68. end
  69. 5371 def __insert_or_do_nothing_and_return__(dataset, pkey, unique_columns, params)
  70. 240 find_params = params.slice(*unique_columns)
  71. 240 dataset.where(find_params).first || __insert_and_return__(dataset, pkey, params)
  72. end
  73. end
  74. end
  75. end
  76. # rubocop:enable Naming/MethodName
  77. end
  78. end

lib/rodauth/oauth/http_extensions.rb

95.56% lines covered

45 relevant lines. 43 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 17 require "uri"
  3. 17 require "net/http"
  4. 17 require "rodauth/oauth/ttl_store"
  5. 17 module Rodauth
  6. 17 module OAuth
  7. 17 module HTTPExtensions
  8. 17 REQUEST_CACHE = OAuth::TtlStore.new
  9. 17 private
  10. 17 def http_request(uri, form_data = nil)
  11. 493 uri = URI(uri)
  12. 493 http = Net::HTTP.new(uri.host, uri.port)
  13. 493 http.use_ssl = uri.scheme == "https"
  14. 493 http.open_timeout = 15
  15. 493 http.read_timeout = 15
  16. 493 http.write_timeout = 15 if http.respond_to?(:write_timeout)
  17. 493 if form_data
  18. 221 request = Net::HTTP::Post.new(uri.request_uri)
  19. 169 request["content-type"] = "application/x-www-form-urlencoded"
  20. 221 request.set_form_data(form_data)
  21. else
  22. 272 request = Net::HTTP::Get.new(uri.request_uri)
  23. end
  24. 377 request["accept"] = json_response_content_type
  25. 493 yield request if block_given?
  26. 493 response = http.request(request)
  27. 493 authorization_required unless (200..299).include?(response.code.to_i)
  28. 493 response
  29. end
  30. 17 def http_request_with_cache(uri, *args)
  31. 170 uri = URI(uri)
  32. 170 response = http_request_cache[uri]
  33. 170 return response if response
  34. 153 http_request_cache.set(uri) do
  35. 153 response = http_request(uri, *args)
  36. 153 ttl = if response.key?("cache-control")
  37. 102 cache_control = response["cache-control"]
  38. 102 if cache_control.include?("no-cache")
  39. nil
  40. else
  41. 102 max_age = cache_control[/max-age=(\d+)/, 1].to_i
  42. 102 max_age.zero? ? nil : max_age
  43. end
  44. 51 elsif response.key?("expires")
  45. 51 expires = response["expires"]
  46. 3 begin
  47. 51 Time.parse(expires).to_i - Time.now.to_i
  48. rescue ArgumentError
  49. nil
  50. end
  51. end
  52. 153 [JSON.parse(response.body, symbolize_names: true), ttl]
  53. end
  54. end
  55. 17 def http_request_cache
  56. 51 REQUEST_CACHE
  57. end
  58. end
  59. end
  60. end

lib/rodauth/oauth/jwe_extensions.rb

100.0% lines covered

33 relevant lines. 33 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 13 module JWE
  3. #
  4. # this is a monkey-patch!
  5. # it's necessary, as the original jwe does not support jwks.
  6. # if this works long term, it may be merged upstreamm.
  7. #
  8. 13 def self.__rodauth_oauth_decrypt_from_jwks(payload, jwks, alg: "RSA-OAEP", enc: "A128GCM")
  9. 39 header, enc_key, iv, ciphertext, tag = Serialization::Compact.decode(payload)
  10. 26 header = JSON.parse(header)
  11. 26 key = find_key_by_kid(jwks, header["kid"], alg, enc)
  12. 13 check_params(header, key)
  13. 13 cek = Alg.decrypt_cek(header["alg"], key, enc_key)
  14. 13 cipher = Enc.for(header["enc"], cek, iv, tag)
  15. 13 plaintext = cipher.decrypt(ciphertext, payload.split(".").first)
  16. 13 apply_zip(header, plaintext, :decompress)
  17. end
  18. 13 def self.__rodauth_oauth_encrypt_from_jwks(payload, jwks, alg: "RSA-OAEP", enc: "A128GCM", **more_headers)
  19. 78 header = generate_header(alg, enc, more_headers)
  20. 78 key = find_key_by_alg_enc(jwks, alg, enc)
  21. 78 check_params(header, key)
  22. 78 payload = apply_zip(header, payload, :compress)
  23. 78 cipher = Enc.for(enc)
  24. 78 cipher.cek = key if alg == "dir"
  25. 78 json_hdr = header.to_json
  26. 78 ciphertext = cipher.encrypt(payload, Base64.jwe_encode(json_hdr))
  27. 78 generate_serialization(json_hdr, Alg.encrypt_cek(alg, key, cipher.cek), ciphertext, cipher)
  28. end
  29. 13 def self.find_key_by_kid(jwks, kid, alg, enc)
  30. 26 raise DecodeError, "No key id (kid) found from token headers" unless kid
  31. 91 jwk = jwks.find { |key, _| (key[:kid] || key["kid"]) == kid }
  32. 26 raise DecodeError, "Could not find public key for kid #{kid}" unless jwk
  33. 26 raise DecodeError, "Expected a different encryption algorithm" unless alg == (jwk[:alg] || jwk["alg"])
  34. 26 raise DecodeError, "Expected a different encryption method" unless enc == (jwk[:enc] || jwk["enc"])
  35. 13 ::JWT::JWK.import(jwk).keypair
  36. end
  37. 13 def self.find_key_by_alg_enc(jwks, alg, enc)
  38. 78 jwk = jwks.find do |key, _|
  39. 156 (key[:alg] || key["alg"]) == alg &&
  40. 104 (key[:enc] || key["enc"]) == enc
  41. end
  42. 78 raise DecodeError, "No key found" unless jwk
  43. 78 ::JWT::JWK.import(jwk).keypair
  44. end
  45. end

lib/rodauth/oauth/ttl_store.rb

92.86% lines covered

28 relevant lines. 26 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. #
  3. # The TTL store is a data structure which keeps data by a key, and with a time-to-live.
  4. # It is specifically designed for data which is static, i.e. for a certain key in a
  5. # sufficiently large span, the value will be the same.
  6. #
  7. # Because of that, synchronizations around reads do not exist, while write synchronizations
  8. # will be short-circuited by a read.
  9. #
  10. 17 class Rodauth::OAuth::TtlStore
  11. 17 DEFAULT_TTL = 60 * 60 * 24 # default TTL is one day
  12. 17 def initialize
  13. 17 @store_mutex = Mutex.new
  14. 17 @store = {}
  15. end
  16. 17 def [](key)
  17. 34 lookup(key, now)
  18. end
  19. 17 def set(key, &block)
  20. 17 @store_mutex.synchronize do
  21. # short circuit
  22. 17 return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
  23. end
  24. 17 payload, ttl = block.call
  25. 17 return payload unless ttl
  26. 17 @store_mutex.synchronize do
  27. # given that the block call triggers network, and two requests for the same key be processed
  28. # at the same time, this ensures the first one wins.
  29. 17 return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
  30. 13 @store[key] = { payload: payload, ttl: ttl || (now + DEFAULT_TTL) }
  31. end
  32. 17 @store[key][:payload]
  33. end
  34. 17 def uncache(key)
  35. @store_mutex.synchronize do
  36. @store.delete(key)
  37. end
  38. end
  39. 17 private
  40. 17 def now
  41. 34 Process.clock_gettime(Process::CLOCK_MONOTONIC)
  42. end
  43. # do not use directly!
  44. 17 def lookup(key, ttl)
  45. 34 return unless @store.key?(key)
  46. 17 value = @store[key]
  47. 17 return if value.empty?
  48. 17 return unless value[:ttl] > ttl
  49. 17 value[:payload]
  50. end
  51. end