lib/rodauth/features/oauth.rb in rodauth-oauth-0.0.4 vs lib/rodauth/features/oauth.rb in rodauth-oauth-0.0.5

- old
+ new

@@ -1,12 +1,17 @@ # frozen-string-literal: true require "base64" +require "securerandom" +require "net/http" +require "rodauth/oauth/ttl_store" + module Rodauth Feature.define(:oauth) do # RUBY EXTENSIONS + # :nocov: unless Regexp.method_defined?(:match?) module RegexpExtensions refine(Regexp) do def match?(*args) !match(*args).nil? @@ -30,10 +35,11 @@ end end end using(SuffixExtensions) end + # :nocov: SCOPES = %w[profile.read].freeze before "authorize" after "authorize" @@ -63,40 +69,34 @@ view "new_oauth_application", "New Oauth Application", "new_oauth_application" view "oauth_tokens", "Oauth Tokens", "oauth_tokens" auth_value_method :json_response_content_type, "application/json" - auth_value_method :oauth_grant_expires_in, 60 * 5 # 60 minutes + auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes auth_value_method :use_oauth_implicit_grant_type?, false auth_value_method :use_oauth_pkce?, true auth_value_method :use_oauth_access_type?, true auth_value_method :oauth_require_pkce, false auth_value_method :oauth_pkce_challenge_method, "S256" auth_value_method :oauth_valid_uri_schemes, %w[http https] - # URL PARAMS + auth_value_method :oauth_scope_separator, " " - # Authorize / token - %w[ - grant_type code refresh_token client_id client_secret scope - state redirect_uri scopes token_type_hint token - access_type approval_prompt response_type - code_challenge code_challenge_method code_verifier - ].each do |param| - auth_value_method :"#{param}_param", param - end - # Application APPLICATION_REQUIRED_PARAMS = %w[name description scopes homepage_url redirect_uri client_secret].freeze auth_value_method :oauth_application_required_params, APPLICATION_REQUIRED_PARAMS (APPLICATION_REQUIRED_PARAMS + %w[client_id]).each do |param| auth_value_method :"oauth_application_#{param}_param", param + translatable_method :"#{param}_label", param.gsub("_", " ").capitalize end + button "Register", "oauth_application" + button "Authorize", "oauth_authorize" + button "Revoke", "oauth_token_revoke" # OAuth Token auth_value_method :oauth_tokens_path, "oauth-tokens" auth_value_method :oauth_tokens_table, :oauth_tokens auth_value_method :oauth_tokens_id_column, :id @@ -171,15 +171,22 @@ auth_value_method :oauth_metadata_service_documentation, nil auth_value_method :oauth_metadata_ui_locales_supported, nil auth_value_method :oauth_metadata_op_policy_uri, nil auth_value_method :oauth_metadata_op_tos_uri, nil + # Resource Server params + # Only required to use if the plugin is to be used in a resource server + auth_value_method :is_authorization_server?, true + auth_value_methods( :fetch_access_token, :oauth_unique_id_generator, :secret_matches?, - :secret_hash + :secret_hash, + :generate_token_hash, + :authorization_server_url, + :before_introspection_request ) auth_value_methods(:only_json?) redirect(:oauth_application) do |id| @@ -196,10 +203,12 @@ end end auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i + SERVER_METADATA = OAuth::TtlStore.new + def check_csrf? case request.path when oauth_token_path, oauth_introspect_path false when oauth_revoke_path @@ -221,55 +230,58 @@ (accept = request.env["HTTP_ACCEPT"]) && accept =~ json_request_regexp end unless method_defined?(:json_request?) + # :nocov: # copied from the jwt feature def json_request? return @json_request if defined?(@json_request) @json_request = request.content_type =~ json_request_regexp end + # :nocov: end def initialize(scope) @scope = scope end def state - param_or_nil(state_param) + param_or_nil("state") end def scopes - (param_or_nil(scopes_param) || oauth_application_default_scope).split(" ") + (param_or_nil("scope") || oauth_application_default_scope).split(" ") end def client_id - param_or_nil(client_id_param) + param_or_nil("client_id") end - def client_secret - param_or_nil(client_secret_param) - end - def redirect_uri - param_or_nil(redirect_uri_param) || oauth_application[oauth_applications_redirect_uri_column] + param_or_nil("redirect_uri") || begin + return unless oauth_application + + redirect_uris = oauth_application[oauth_applications_redirect_uri_column].split(" ") + redirect_uris.size == 1 ? redirect_uris.first : nil + end end def token_type_hint - param_or_nil(token_type_hint_param) || "access_token" + param_or_nil("token_type_hint") || "access_token" end def token - param_or_nil(token_param) + param_or_nil("token") end def oauth_application return @oauth_application if defined?(@oauth_application) @oauth_application = begin - client_id = param(client_id_param) + client_id = param_or_nil("client_id") return unless client_id db[oauth_applications_table].filter(oauth_applications_client_id_column => client_id).first end @@ -289,22 +301,41 @@ def authorization_token return @authorization_token if defined?(@authorization_token) # check if there is a token + bearer_token = fetch_access_token + + return unless bearer_token + # check if token has not expired # check if token has been revoked - @authorization_token = oauth_token_by_token(fetch_access_token) + @authorization_token = oauth_token_by_token(bearer_token) end def require_oauth_authorization(*scopes) - authorization_required unless authorization_token + token_scopes = if is_authorization_server? + authorization_required unless authorization_token - scopes << oauth_application_default_scope if scopes.empty? + scopes << oauth_application_default_scope if scopes.empty? - token_scopes = authorization_token[oauth_tokens_scopes_column].split(",") + authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator) + else + bearer_token = fetch_access_token + authorization_required unless bearer_token + + scopes << oauth_application_default_scope if scopes.empty? + + # where in resource server, NOT the authorization server. + payload = introspection_request("access_token", bearer_token) + + authorization_required unless payload["active"] + + payload["scope"].split(oauth_scope_separator) + end + authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) } end # /oauth-applications routes def oauth_applications @@ -312,10 +343,11 @@ require_account request.get "new" do new_oauth_application_view end + request.on(oauth_applications_id_pattern) do |id| oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first scope.instance_variable_set(:@oauth_application, oauth_application) request.is do @@ -364,10 +396,68 @@ end end private + def authorization_server_url + base_url + end + + def authorization_server_metadata + auth_url = URI(authorization_server_url) + + server_metadata = SERVER_METADATA[auth_url] + + return server_metadata if server_metadata + + SERVER_METADATA.set(auth_url) do + http = Net::HTTP.new(auth_url.host, auth_url.port) + http.use_ssl = auth_url.scheme == "https" + + request = Net::HTTP::Get.new("/.well-known/oauth-authorization-server") + request["accept"] = json_response_content_type + response = http.request(request) + authorization_required unless response.code.to_i == 200 + + # time-to-live + ttl = if response.key?("cache-control") + cache_control = response["cache_control"] + cache_control[/max-age=(\d+)/, 1] + elsif response.key?("expires") + Time.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i + end + + [JSON.parse(response.body, symbolize_names: true), ttl] + end + end + + def introspection_request(token_type_hint, token) + auth_url = URI(authorization_server_url) + http = Net::HTTP.new(auth_url.host, auth_url.port) + http.use_ssl = auth_url.scheme == "https" + + request = Net::HTTP::Post.new(oauth_introspect_path) + request["content-type"] = json_response_content_type + request["accept"] = json_response_content_type + request.body = JSON.dump({ "token_type_hint" => token_type_hint, "token" => token }) + + before_introspection_request(request) + response = http.request(request) + authorization_required unless response.code.to_i == 200 + + JSON.parse(response.body) + end + + def before_introspection_request(request); end + + def template_path(page) + path = File.join(File.dirname(__FILE__), "../../../templates", "#{page}.str") + return super unless File.exist?(path) + + path + end + # to be used internally. Same semantics as require account, must: # fetch an authorization basic header # parse client id and secret # def require_oauth_application @@ -376,20 +466,20 @@ # client_secret_basic if (token = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Basic (.*)\Z/, 1])) client_id, client_secret = Base64.decode64(token).split(/:/, 2) else - client_id = param_or_nil(client_id_param) - client_secret = param_or_nil(client_secret_param) + client_id = param_or_nil("client_id") + client_secret = param_or_nil("client_secret") end authorization_required unless client_id @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first # skip if using pkce - return if @oauth_application && use_oauth_pkce? && param_or_nil(code_verifier_param) + return if @oauth_application && use_oauth_pkce? && param_or_nil("code_verifier") authorization_required unless @oauth_application && secret_matches?(@oauth_application, client_secret) end def secret_matches?(oauth_application, secret) @@ -411,26 +501,26 @@ def token_from_application?(oauth_token, oauth_application) oauth_token[oauth_tokens_oauth_application_id_column] == oauth_application[oauth_applications_id_column] end unless method_defined?(:password_hash) + # :nocov: # From login_requirements_base feature if ENV["RACK_ENV"] == "test" def password_hash_cost BCrypt::Engine::MIN_COST end else - # :nocov: def password_hash_cost BCrypt::Engine::DEFAULT_COST end - # :nocov: end def password_hash(password) BCrypt::Password.create(password, cost: password_hash_cost) end + # :nocov: end def generate_oauth_token(params = {}, should_generate_refresh_token = true) create_params = { oauth_grants_expires_in_column => Time.now + oauth_token_expires_in @@ -464,11 +554,11 @@ def _generate_oauth_token(params = {}) ds = db[oauth_tokens_table] begin if ds.supports_returning?(:insert) - ds.returning.insert(params) + ds.returning.insert(params).first else id = ds.insert(params) ds.where(oauth_tokens_id_column => id).first end rescue Sequel::UniqueConstraintViolation @@ -521,15 +611,25 @@ end end def validate_oauth_application_params oauth_application_params.each do |key, value| - if key == oauth_application_homepage_url_param || - key == oauth_application_redirect_uri_param + if key == oauth_application_homepage_url_param - set_field_error(key, invalid_url_message) unless URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(value) + set_field_error(key, invalid_url_message) unless check_valid_uri?(value) + elsif key == oauth_application_redirect_uri_param + + if value.respond_to?(:each) + value.each do |uri| + next if uri.empty? + + set_field_error(key, invalid_url_message) unless check_valid_uri?(uri) + end + else + set_field_error(key, invalid_url_message) unless check_valid_uri?(value) + end elsif key == oauth_application_scopes_param value.each do |scope| set_field_error(key, invalid_scope_message) unless oauth_application_scopes.include?(scope) end @@ -543,41 +643,36 @@ create_params = { oauth_applications_account_id_column => account_id, oauth_applications_name_column => oauth_application_params[oauth_application_name_param], oauth_applications_description_column => oauth_application_params[oauth_application_description_param], oauth_applications_scopes_column => oauth_application_params[oauth_application_scopes_param], - oauth_applications_homepage_url_column => oauth_application_params[oauth_application_homepage_url_param], - oauth_applications_redirect_uri_column => oauth_application_params[oauth_application_redirect_uri_param] + oauth_applications_homepage_url_column => oauth_application_params[oauth_application_homepage_url_param] } + redirect_uris = oauth_application_params[oauth_application_redirect_uri_param] + redirect_uris = redirect_uris.to_a.reject(&:empty?).join(" ") if redirect_uris.respond_to?(:each) + create_params[oauth_applications_redirect_uri_column] = redirect_uris unless redirect_uris.empty? # set client ID/secret pairs create_params.merge! \ oauth_applications_client_id_column => oauth_unique_id_generator, oauth_applications_client_secret_column => \ secret_hash(oauth_application_params[oauth_application_client_secret_param]) create_params[oauth_applications_scopes_column] = if create_params[oauth_applications_scopes_column] - create_params[oauth_applications_scopes_column].join(",") + create_params[oauth_applications_scopes_column].join(oauth_scope_separator) else oauth_application_default_scope end - ds = db[oauth_applications_table] - id = nil raised = begin - id = if ds.supports_returning?(:insert) - ds.returning(oauth_applications_id_column).insert(create_params) - else - id = db[oauth_applications_table].insert(create_params) - db[oauth_applications_table].where(oauth_applications_id_column => id).get(oauth_applications_id_column) - end - false + id = db[oauth_applications_table].insert(create_params) + false rescue Sequel::ConstraintViolation => e e - end + end if raised field = raised.message[/\.(.*)$/, 1] case raised when Sequel::UniqueConstraintViolation @@ -594,29 +689,31 @@ def before_authorize require_account end def validate_oauth_grant_params + redirect_response_error("invalid_request", request.referer || default_redirect) unless oauth_application && check_valid_redirect_uri? + unless oauth_application && check_valid_redirect_uri? && check_valid_access_type? && check_valid_approval_prompt? && check_valid_response_type? redirect_response_error("invalid_request") end redirect_response_error("invalid_scope") unless check_valid_scopes? validate_pkce_challenge_params if use_oauth_pkce? end def try_approval_prompt - approval_prompt = param_or_nil(approval_prompt_param) + approval_prompt = param_or_nil("approval_prompt") return unless approval_prompt && approval_prompt == "auto" return if db[oauth_grants_table].where( oauth_grants_account_id_column => account_id, oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], oauth_grants_redirect_uri_column => redirect_uri, - oauth_grants_scopes_column => scopes.join(","), + oauth_grants_scopes_column => scopes.join(oauth_scope_separator), oauth_grants_access_type_column => "online" ).count.zero? # if there's a previous oauth grant for the params combo, it means that this user has approved before. @@ -626,27 +723,26 @@ def create_oauth_grant create_params = { oauth_grants_account_id_column => account_id, oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], oauth_grants_redirect_uri_column => redirect_uri, - oauth_grants_code_column => oauth_unique_id_generator, oauth_grants_expires_in_column => Time.now + oauth_grant_expires_in, - oauth_grants_scopes_column => scopes.join(",") + oauth_grants_scopes_column => scopes.join(oauth_scope_separator) } # Access Type flow if use_oauth_access_type? - if (access_type = param_or_nil(access_type_param)) + if (access_type = param_or_nil("access_type")) create_params[oauth_grants_access_type_column] = access_type end end # PKCE flow if use_oauth_pkce? - if (code_challenge = param_or_nil(code_challenge_param)) - code_challenge_method = param_or_nil(code_challenge_method_param) + if (code_challenge = param_or_nil("code_challenge")) + code_challenge_method = param_or_nil("code_challenge_method") create_params[oauth_grants_code_challenge_column] = code_challenge create_params[oauth_grants_code_challenge_method_column] = code_challenge_method elsif oauth_require_pkce redirect_response_error("code_challenge_required") @@ -654,16 +750,14 @@ end ds = db[oauth_grants_table] begin - if ds.supports_returning?(:insert) - ds.returning(authorize_code_column).insert(create_params) - else - id = ds.insert(create_params) - ds.where(oauth_grants_id_column => id).get(oauth_grants_code_column) - end + authorization_code = oauth_unique_id_generator + create_params[oauth_grants_code_column] = authorization_code + ds.insert(create_params) + authorization_code rescue Sequel::UniqueConstraintViolation retry end end @@ -672,27 +766,27 @@ def before_token require_oauth_application end def validate_oauth_token_params - unless (grant_type = param_or_nil(grant_type_param)) + unless (grant_type = param_or_nil("grant_type")) redirect_response_error("invalid_request") end case grant_type when "authorization_code" - redirect_response_error("invalid_request") unless param_or_nil(code_param) + redirect_response_error("invalid_request") unless param_or_nil("code") when "refresh_token" - redirect_response_error("invalid_request") unless param_or_nil(refresh_token_param) + redirect_response_error("invalid_request") unless param_or_nil("refresh_token") else redirect_response_error("invalid_request") end end def create_oauth_token - case param(grant_type_param) + case param("grant_type") when "authorization_code" create_oauth_token_from_authorization_code(oauth_application) when "refresh_token" create_oauth_token_from_token(oauth_application) else @@ -701,12 +795,12 @@ end def create_oauth_token_from_authorization_code(oauth_application) # fetch oauth grant oauth_grant = db[oauth_grants_table].where( - oauth_grants_code_column => param(code_param), - oauth_grants_redirect_uri_column => param(redirect_uri_param), + oauth_grants_code_column => param("code"), + oauth_grants_redirect_uri_column => param("redirect_uri"), oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], oauth_grants_revoked_at_column => nil ).where(Sequel[oauth_grants_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) .for_update .first @@ -714,11 +808,11 @@ redirect_response_error("invalid_grant") unless oauth_grant # PKCE if use_oauth_pkce? if oauth_grant[oauth_grants_code_challenge_column] - code_verifier = param_or_nil(code_verifier_param) + code_verifier = param_or_nil("code_verifier") redirect_response_error("invalid_request") unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier) elsif oauth_require_pkce redirect_response_error("code_challenge_required") end @@ -741,11 +835,11 @@ generate_oauth_token(create_params, should_generate_refresh_token) end def create_oauth_token_from_token(oauth_application) # fetch oauth token - oauth_token = oauth_token_by_refresh_token(param(refresh_token_param)) + oauth_token = oauth_token_by_refresh_token(param("refresh_token")) redirect_response_error("invalid_grant") unless oauth_token && token_from_application?(oauth_token, oauth_application) token = oauth_unique_id_generator @@ -762,11 +856,11 @@ ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) oauth_token = begin if ds.supports_returning?(:update) - ds.returning.update(update_params) + ds.returning.update(update_params).first else ds.update(update_params) ds.first end rescue Sequel::UniqueConstraintViolation @@ -785,28 +879,26 @@ # check if valid token hint type if token_type_hint redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(token_type_hint) end - redirect_response_error("invalid_request") unless param_or_nil(token_param) + redirect_response_error("invalid_request") unless param_or_nil("token") end def json_token_introspect_payload(token) return { active: false } unless token { active: true, - scope: token[oauth_tokens_scopes_column].gsub(",", " "), + scope: token[oauth_tokens_scopes_column], client_id: oauth_application[oauth_applications_client_id_column], # username token_type: oauth_token_type } end - def before_introspect - require_oauth_application - end + def before_introspect; end # Token revocation def before_revoke require_oauth_application @@ -814,29 +906,36 @@ def validate_oauth_revoke_params # check if valid token hint type redirect_response_error("unsupported_token_type") unless TOKEN_HINT_TYPES.include?(token_type_hint) - redirect_response_error("invalid_request") unless param_or_nil(token_param) + redirect_response_error("invalid_request") unless param_or_nil("token") end def revoke_oauth_token oauth_token = case token_type_hint when "access_token" oauth_token_by_token(token) when "refresh_token" oauth_token_by_refresh_token(token) end - redirect_response_error("invalid_request") unless oauth_token && token_from_application?(oauth_token, oauth_application) + redirect_response_error("invalid_request") unless oauth_token + if oauth_application + redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application) + else + @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => + oauth_token[oauth_tokens_oauth_application_id_column]).first + end + update_params = { oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP } ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) oauth_token = if ds.supports_returning?(:update) - ds.returning.update(update_params) + ds.returning.update(update_params).first else ds.update(update_params) ds.first end @@ -852,11 +951,11 @@ # we don't need to do anything here, as we revalidate existing tokens end # Response helpers - def redirect_response_error(error_code, redirect_url = request.referer || default_redirect) + def redirect_response_error(error_code, redirect_url = redirect_uri || request.referer || default_redirect) if accepts_json? throw_json_response_error(invalid_oauth_response_status, error_code) else redirect_url = URI.parse(redirect_url) query_params = [] @@ -901,17 +1000,19 @@ response.write(json_payload) request.halt end unless method_defined?(:_json_response_body) + # :nocov: def _json_response_body(hash) if request.respond_to?(:convert_to_json) request.send(:convert_to_json, hash) else JSON.dump(hash) end end + # :nocov: end def authorization_required if accepts_json? throw_json_response_error(authorization_required_error_status, "invalid_client") @@ -919,40 +1020,44 @@ set_redirect_error_flash(require_authorization_error_flash) redirect(require_authorization_redirect) end end + def check_valid_uri?(uri) + URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(uri) + end + def check_valid_scopes? return false unless scopes - (scopes - oauth_application[oauth_applications_scopes_column].split(",")).empty? + (scopes - oauth_application[oauth_applications_scopes_column].split(oauth_scope_separator)).empty? end def check_valid_redirect_uri? - redirect_uri == oauth_application[oauth_applications_redirect_uri_column] + oauth_application[oauth_applications_redirect_uri_column].split(" ").include?(redirect_uri) end ACCESS_TYPES = %w[offline online].freeze def check_valid_access_type? return true unless use_oauth_access_type? - access_type = param_or_nil(access_type_param) + access_type = param_or_nil("access_type") !access_type || ACCESS_TYPES.include?(access_type) end APPROVAL_PROMPTS = %w[force auto].freeze def check_valid_approval_prompt? return true unless use_oauth_access_type? - approval_prompt = param_or_nil(approval_prompt_param) + approval_prompt = param_or_nil("approval_prompt") !approval_prompt || APPROVAL_PROMPTS.include?(approval_prompt) end def check_valid_response_type? - response_type = param_or_nil(response_type_param) + response_type = param_or_nil("response_type") return true if response_type.nil? || response_type == "code" return use_oauth_implicit_grant_type? if response_type == "token" @@ -960,13 +1065,13 @@ end # PKCE def validate_pkce_challenge_params - if param_or_nil(code_challenge_param) + if param_or_nil("code_challenge") - challenge_method = param_or_nil(code_challenge_method_param) + challenge_method = param_or_nil("code_challenge_method") redirect_response_error("code_challenge_required") unless oauth_pkce_challenge_method == challenge_method else return unless oauth_require_pkce redirect_response_error("code_challenge_required") @@ -1052,20 +1157,25 @@ r.post do catch_error do validate_oauth_introspect_params - oauth_token = case param(token_type_hint_param) + oauth_token = case param("token_type_hint") when "access_token" - oauth_token_by_token(param(token_param)) + oauth_token_by_token(param("token")) when "refresh_token" - oauth_token_by_refresh_token(param(token_param)) + oauth_token_by_refresh_token(param("token")) else - oauth_token_by_token(param(token_param)) || oauth_token_by_refresh_token(param(token_param)) + oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token")) end - redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application) + if oauth_application + redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application) + elsif oauth_token + @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => + oauth_token[oauth_tokens_oauth_application_id_column]).first + end json_response_success(json_token_introspect_payload(oauth_token)) end throw_json_response_error(invalid_oauth_response_status, "invalid_request") @@ -1118,12 +1228,12 @@ code = nil query_params = [] fragment_params = [] transaction do - case param(response_type_param) + case param("response_type") when "token" - redirect_response_error("invalid_request", redirect_uri) unless use_oauth_implicit_grant_type? + redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type? create_params = { oauth_tokens_account_id_column => account_id, oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column], oauth_tokens_scopes_column => scopes