# frozen-string-literal: true require "time" require "base64" require "securerandom" require "net/http" require "rodauth/oauth/ttl_store" require "rodauth/oauth/database_extensions" module Rodauth Feature.define(:oauth) do # RUBY EXTENSIONS unless Regexp.method_defined?(:match?) # If you wonder why this is there: the oauth feature uses a refinement to enhance the # Regexp class locally with #match? , but this is never tested, because ActiveSupport # monkey-patches the same method... Please ActiveSupport, stop being so intrusive! # :nocov: module RegexpExtensions refine(Regexp) do def match?(*args) !match(*args).nil? end end end using(RegexpExtensions) # :nocov: end unless String.method_defined?(:delete_suffix!) module SuffixExtensions refine(String) do def delete_suffix!(suffix) suffix = suffix.to_s chomp! if frozen? len = suffix.length return unless len.positive? && index(suffix, -len) self[-len..-1] = "" self end end end using(SuffixExtensions) end SCOPES = %w[profile.read].freeze SERVER_METADATA = OAuth::TtlStore.new before "authorize" after "authorize" before "token" before "revoke" after "revoke" before "introspect" before "create_oauth_application" after "create_oauth_application" error_flash "Please authorize to continue", "require_authorization" error_flash "There was an error registering your oauth application", "create_oauth_application" notice_flash "Your oauth application has been registered", "create_oauth_application" notice_flash "The oauth token has been revoked", "revoke_oauth_token" view "authorize", "Authorize", "authorize" view "oauth_applications", "Oauth Applications", "oauth_applications" view "oauth_application", "Oauth Application", "oauth_application" 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 # 5 minutes auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes auth_value_method :oauth_refresh_token_expires_in, 60 * 60 * 24 * 360 # 1 year 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_response_mode, "query" auth_value_method :oauth_valid_uri_schemes, %w[https] auth_value_method :oauth_scope_separator, " " # 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" button "Back to Client Application", "oauth_authorize_post" # 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 %i[ oauth_application_id oauth_token_id oauth_grant_id account_id token refresh_token scopes expires_in revoked_at ].each do |column| auth_value_method :"oauth_tokens_#{column}_column", column end # Oauth Token Hash auth_value_method :oauth_tokens_token_hash_column, nil auth_value_method :oauth_tokens_refresh_token_hash_column, nil # Access Token reuse auth_value_method :oauth_reuse_access_token, false # OAuth Grants auth_value_method :oauth_grants_table, :oauth_grants auth_value_method :oauth_grants_id_column, :id %i[ account_id oauth_application_id redirect_uri code scopes access_type expires_in revoked_at code_challenge code_challenge_method ].each do |column| auth_value_method :"oauth_grants_#{column}_column", column end auth_value_method :authorization_required_error_status, 401 auth_value_method :invalid_oauth_response_status, 400 auth_value_method :already_in_use_response_status, 409 # OAuth Applications auth_value_method :oauth_applications_path, "oauth-applications" auth_value_method :oauth_applications_table, :oauth_applications auth_value_method :oauth_applications_id_column, :id auth_value_method :oauth_applications_id_pattern, Integer %i[ account_id name description scopes client_id client_secret homepage_url redirect_uri ].each do |column| auth_value_method :"oauth_applications_#{column}_column", column end # Feature options auth_value_method :oauth_application_default_scope, SCOPES.first auth_value_method :oauth_application_scopes, SCOPES auth_value_method :oauth_token_type, "bearer" auth_value_method :oauth_refresh_token_protection_policy, "none" # can be: none, sender_constrained, rotation auth_value_method :invalid_client_message, "Invalid client" auth_value_method :invalid_grant_type_message, "Invalid grant type" auth_value_method :invalid_grant_message, "Invalid grant" auth_value_method :invalid_scope_message, "Invalid scope" auth_value_method :invalid_url_message, "Invalid URL" auth_value_method :unsupported_token_type_message, "Invalid token type hint" auth_value_method :unique_error_message, "is already in use" auth_value_method :null_error_message, "is not filled" auth_value_method :already_in_use_message, "error generating unique token" auth_value_method :already_in_use_error_code, "invalid_request" # PKCE auth_value_method :code_challenge_required_error_code, "invalid_request" auth_value_method :code_challenge_required_message, "code challenge required" auth_value_method :unsupported_transform_algorithm_error_code, "invalid_request" auth_value_method :unsupported_transform_algorithm_message, "transform algorithm not supported" # METADATA 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_method :oauth_unique_id_generation_retries, 3 auth_value_methods( :fetch_access_token, :oauth_unique_id_generator, :secret_matches?, :secret_hash, :generate_token_hash, :authorization_server_url, :before_introspection_request, :require_authorizable_account, :oauth_tokens_unique_columns ) auth_value_methods(:only_json?) auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i # /token route(:token) do |r| next unless is_authorization_server? before_token_route require_oauth_application r.post do catch_error do validate_oauth_token_params oauth_token = nil transaction do before_token oauth_token = create_oauth_token end json_response_success(json_access_token_payload(oauth_token)) end throw_json_response_error(invalid_oauth_response_status, "invalid_request") end end # /introspect route(:introspect) do |r| next unless is_authorization_server? before_introspect_route r.post do catch_error do validate_oauth_introspect_params before_introspect oauth_token = case param("token_type_hint") when "access_token" oauth_token_by_token(param("token")) when "refresh_token" oauth_token_by_refresh_token(param("token")) else oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token")) end 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") end end # /revoke route(:revoke) do |r| next unless is_authorization_server? before_revoke_route require_oauth_application r.post do catch_error do validate_oauth_revoke_params oauth_token = nil transaction do before_revoke oauth_token = revoke_oauth_token after_revoke end if accepts_json? json_response_success \ "token" => oauth_token[oauth_tokens_token_column], "refresh_token" => oauth_token[oauth_tokens_refresh_token_column], "revoked_at" => convert_timestamp(oauth_token[oauth_tokens_revoked_at_column]) else set_notice_flash revoke_oauth_token_notice_flash redirect request.referer || "/" end end redirect_response_error("invalid_request", request.referer || "/") end end # /authorize route(:authorize) do |r| next unless is_authorization_server? before_authorize_route require_authorizable_account validate_oauth_grant_params try_approval_prompt if use_oauth_access_type? && request.get? r.get do authorize_view end r.post do redirect_url = URI.parse(redirect_uri) params, mode = transaction do before_authorize do_authorize end case mode when "query" params = params.map { |k, v| "#{k}=#{v}" } params << redirect_url.query if redirect_url.query redirect_url.query = params.join("&") redirect(redirect_url.to_s) when "fragment" params = params.map { |k, v| "#{k}=#{v}" } params << redirect_url.query if redirect_url.query redirect_url.fragment = params.join("&") redirect(redirect_url.to_s) when "form_post" scope.view layout: false, inline: <<-FORM Authorized
#{ params.map do |name, value| "" end.join }
FORM when "none" redirect(redirect_url.to_s) end end end def oauth_server_metadata(issuer = nil) request.on(".well-known") do request.on("oauth-authorization-server") do request.get do json_response_success(oauth_server_metadata_body(issuer), true) end end end end # /oauth-applications routes def oauth_applications request.on(oauth_applications_path) do 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 next unless oauth_application scope.instance_variable_set(:@oauth_application, oauth_application) request.is do request.get do oauth_application_view end end request.on(oauth_tokens_path) do oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id) scope.instance_variable_set(:@oauth_tokens, oauth_tokens) request.get do oauth_tokens_view end end end request.get do scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table]) oauth_applications_view end request.post do catch_error do validate_oauth_application_params transaction do before_create_oauth_application id = create_oauth_application after_create_oauth_application set_notice_flash create_oauth_application_notice_flash redirect "#{request.path}/#{id}" end end set_error_flash create_oauth_application_error_flash new_oauth_application_view end end end def check_csrf? case request.path when token_path, introspect_path false when revoke_path !json_request? when authorize_path, %r{/#{oauth_applications_path}} only_json? ? false : super else super end end # Overrides logged_in?, so that a valid authorization token also authnenticates a request def logged_in? super || authorization_token end def accepts_json? return true if only_json? (accept = request.env["HTTP_ACCEPT"]) && accept =~ json_request_regexp end unless method_defined?(:json_request?) # copied from the jwt feature def json_request? return @json_request if defined?(@json_request) @json_request = request.content_type =~ json_request_regexp end end def initialize(scope) @scope = scope end def scopes scope = request.params["scope"] case scope when Array scope when String scope.split(" ") when nil [oauth_application_default_scope] end end def redirect_uri 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 oauth_application return @oauth_application if defined?(@oauth_application) @oauth_application = begin 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 end def fetch_access_token value = request.env["HTTP_AUTHORIZATION"] return unless value && !value.empty? scheme, token = value.split(" ", 2) return unless scheme.downcase == oauth_token_type return if token.nil? || token.empty? token end 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 @authorization_token = if is_authorization_server? # check if token has not expired # check if token has been revoked oauth_token_by_token(bearer_token) else # where in resource server, NOT the authorization server. payload = introspection_request("access_token", bearer_token) return unless payload["active"] payload end end def require_oauth_authorization(*scopes) authorization_required unless authorization_token scopes << oauth_application_default_scope if scopes.empty? token_scopes = if is_authorization_server? authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator) else aux_scopes = authorization_token["scope"] if aux_scopes aux_scopes.split(oauth_scope_separator) else [] end end authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) } end def post_configure super # all of the extensions below involve DB changes. Resource server mode doesn't use # database functions for OAuth though. return unless is_authorization_server? self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db)) # Check whether we can reutilize db entries for the same account / application pair one_oauth_token_per_account = begin db.indexes(oauth_tokens_table).values.any? do |definition| definition[:unique] && definition[:columns] == oauth_tokens_unique_columns end end self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account } end def use_date_arithmetic? true end private def rescue_from_uniqueness_error(&block) retries = oauth_unique_id_generation_retries begin transaction(savepoint: :only, &block) rescue Sequel::UniqueConstraintViolation redirect_response_error("already_in_use") if retries.zero? retries -= 1 retry end end # OAuth Token Unique/Reuse def oauth_tokens_unique_columns [ oauth_tokens_oauth_application_id_column, oauth_tokens_account_id_column, oauth_tokens_scopes_column ] end 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].to_i elsif response.key?("expires") Time.parse(response["expires"]).to_i - Time.now.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(introspect_path) request["content-type"] = "application/x-www-form-urlencoded" request["accept"] = json_response_content_type request.set_form_data({ "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 # get client credenntials client_id = client_secret = nil # 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") 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") authorization_required unless @oauth_application && secret_matches?(@oauth_application, client_secret) end def secret_matches?(oauth_application, secret) BCrypt::Password.new(oauth_application[oauth_applications_client_secret_column]) == secret end def secret_hash(secret) password_hash(secret) end def oauth_unique_id_generator SecureRandom.urlsafe_base64(32) end def generate_token_hash(token) Base64.urlsafe_encode64(Digest::SHA256.digest(token)) end 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) # From login_requirements_base feature def password_hash(password) BCrypt::Password.create(password, cost: BCrypt::Engine::DEFAULT_COST) end end def generate_oauth_token(params = {}, should_generate_refresh_token = true) create_params = { oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in) }.merge(params) rescue_from_uniqueness_error do token = oauth_unique_id_generator if oauth_tokens_token_hash_column create_params[oauth_tokens_token_hash_column] = generate_token_hash(token) else create_params[oauth_tokens_token_column] = token end refresh_token = nil if should_generate_refresh_token refresh_token = oauth_unique_id_generator if oauth_tokens_refresh_token_hash_column create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token) else create_params[oauth_tokens_refresh_token_column] = refresh_token end end oauth_token = _generate_oauth_token(create_params) oauth_token[oauth_tokens_token_column] = token oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token oauth_token end end def _generate_oauth_token(params = {}) ds = db[oauth_tokens_table] if __one_oauth_token_per_account token = __insert_or_update_and_return__( ds, oauth_tokens_id_column, oauth_tokens_unique_columns, params, Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP, ([oauth_tokens_token_column, oauth_tokens_refresh_token_column] if oauth_reuse_access_token) ) # if the previous operation didn't return a row, it means that the conditions # invalidated the update, and the existing token is still valid. token || ds.where( oauth_tokens_account_id_column => params[oauth_tokens_account_id_column], oauth_tokens_oauth_application_id_column => params[oauth_tokens_oauth_application_id_column] ).first else if oauth_reuse_access_token unique_conds = Hash[oauth_tokens_unique_columns.map { |column| [column, params[column]] }] valid_token = ds.where(Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP) .where(unique_conds).first return valid_token if valid_token end __insert_and_return__(ds, oauth_tokens_id_column, params) end end def oauth_token_by_token(token) ds = db[oauth_tokens_table] ds = if oauth_tokens_token_hash_column ds.where(oauth_tokens_token_hash_column => generate_token_hash(token)) else ds.where(oauth_tokens_token_column => token) end ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) .where(oauth_tokens_revoked_at_column => nil).first end def oauth_token_by_refresh_token(token, revoked: false) ds = db[oauth_tokens_table] # # filter expired refresh tokens out. # an expired refresh token is a token whose access token expired for a period longer than the # refresh token expiration period. # ds = ds.where(Sequel.date_add(oauth_tokens_expires_in_column, seconds: oauth_refresh_token_expires_in) >= Sequel::CURRENT_TIMESTAMP) ds = if oauth_tokens_refresh_token_hash_column ds.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token)) else ds.where(oauth_tokens_refresh_token_column => token) end ds = ds.where(oauth_tokens_revoked_at_column => nil) unless revoked ds.first end def json_access_token_payload(oauth_token) payload = { "access_token" => oauth_token[oauth_tokens_token_column], "token_type" => oauth_token_type, "expires_in" => oauth_token_expires_in } payload["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] if oauth_token[oauth_tokens_refresh_token_column] payload end # Oauth Application def oauth_application_params @oauth_application_params ||= oauth_application_required_params.each_with_object({}) do |param, params| value = request.params[__send__(:"oauth_application_#{param}_param")] if value && !value.empty? params[param] = value else set_field_error(param, null_error_message) end end end def validate_oauth_application_params oauth_application_params.each do |key, value| if key == oauth_application_homepage_url_param 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 end end throw :rodauth_error if @field_errors && !@field_errors.empty? end def create_oauth_application 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] } 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_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(oauth_scope_separator) else oauth_application_default_scope end rescue_from_uniqueness_error do create_params[oauth_applications_client_id_column] = oauth_unique_id_generator db[oauth_applications_table].insert(create_params) end end # Authorize def require_authorizable_account 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? if (response_mode = param_or_nil("response_mode")) && response_mode != "form_post" redirect_response_error("invalid_request") end validate_pkce_challenge_params if use_oauth_pkce? end def try_approval_prompt 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_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. request.env["REQUEST_METHOD"] = "POST" end def create_oauth_grant(create_params = {}) create_params.merge!( 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_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_grant_expires_in), oauth_grants_scopes_column => scopes.join(oauth_scope_separator) ) # Access Type flow if use_oauth_access_type? && (access_type = param_or_nil("access_type")) create_params[oauth_grants_access_type_column] = access_type end # PKCE flow if use_oauth_pkce? && (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 end ds = db[oauth_grants_table] rescue_from_uniqueness_error do create_params[oauth_grants_code_column] = oauth_unique_id_generator __insert_and_return__(ds, oauth_grants_id_column, create_params) end create_params[oauth_grants_code_column] end def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode")) case param("response_type") when "token" redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type? response_mode ||= "fragment" response_params.replace(_do_authorize_token) when "code" response_mode ||= "query" response_params.replace(_do_authorize_code) when "none" response_mode ||= "none" when "", nil response_mode ||= oauth_response_mode response_params.replace(_do_authorize_code) end response_params["state"] = param("state") if param_or_nil("state") [response_params, response_mode] end def _do_authorize_code { "code" => create_oauth_grant } end def _do_authorize_token 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 } oauth_token = generate_oauth_token(create_params, false) json_access_token_payload(oauth_token) end # Access Tokens def validate_oauth_token_params 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") when "refresh_token" 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") when "authorization_code" # fetch oauth grant oauth_grant = db[oauth_grants_table].where( 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 redirect_response_error("invalid_grant") unless oauth_grant create_params = { oauth_tokens_account_id_column => oauth_grant[oauth_grants_account_id_column], oauth_tokens_oauth_application_id_column => oauth_grant[oauth_grants_oauth_application_id_column], oauth_tokens_oauth_grant_id_column => oauth_grant[oauth_grants_id_column], oauth_tokens_scopes_column => oauth_grant[oauth_grants_scopes_column] } create_oauth_token_from_authorization_code(oauth_grant, create_params) when "refresh_token" # fetch potentially revoked oauth token oauth_token = oauth_token_by_refresh_token(param("refresh_token"), revoked: true) if !oauth_token redirect_response_error("invalid_grant") elsif oauth_token[oauth_tokens_revoked_at_column] if oauth_refresh_token_protection_policy == "rotation" # https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00#section-6.1 # # If a refresh token is compromised and subsequently used by both the attacker and the legitimate # client, one of them will present an invalidated refresh token, which will inform the authorization # server of the breach. The authorization server cannot determine which party submitted the invalid # refresh token, but it will revoke the active refresh token. This stops the attack at the cost of # forcing the legitimate client to obtain a fresh authorization grant. db[oauth_tokens_table].where(oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column]) .update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP) end redirect_response_error("invalid_grant") end update_params = { oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column], oauth_tokens_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in) } create_oauth_token_from_token(oauth_token, update_params) end end def create_oauth_token_from_authorization_code(oauth_grant, create_params) # PKCE if use_oauth_pkce? if oauth_grant[oauth_grants_code_challenge_column] 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 end # revoke oauth grant db[oauth_grants_table].where(oauth_grants_id_column => oauth_grant[oauth_grants_id_column]) .update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP) should_generate_refresh_token = !use_oauth_access_type? || oauth_grant[oauth_grants_access_type_column] == "offline" generate_oauth_token(create_params, should_generate_refresh_token) end def create_oauth_token_from_token(oauth_token, update_params) redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application) rescue_from_uniqueness_error do oauth_tokens_ds = db[oauth_tokens_table] token = oauth_unique_id_generator if oauth_tokens_token_hash_column update_params[oauth_tokens_token_hash_column] = generate_token_hash(token) else update_params[oauth_tokens_token_column] = token end oauth_token = if oauth_refresh_token_protection_policy == "rotation" insert_params = { **update_params, oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column], oauth_tokens_scopes_column => oauth_token[oauth_tokens_scopes_column] } # revoke the refresh token oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) .update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP) insert_params[oauth_tokens_oauth_token_id_column] = oauth_token[oauth_tokens_id_column] __insert_and_return__(oauth_tokens_ds, oauth_tokens_id_column, insert_params) else # includes none ds = oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) __update_and_return__(ds, update_params) end oauth_token[oauth_tokens_token_column] = token oauth_token end end TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze # Token introspect def validate_oauth_introspect_params # check if valid token hint type if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint")) redirect_response_error("unsupported_token_type") end 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], client_id: oauth_application[oauth_applications_client_id_column], # username token_type: oauth_token_type } end # Token revocation def validate_oauth_revoke_params # check if valid token hint type if param_or_nil("token_type_hint") && !TOKEN_HINT_TYPES.include?(param("token_type_hint")) redirect_response_error("unsupported_token_type") end redirect_response_error("invalid_request") unless param_or_nil("token") end def revoke_oauth_token token = param("token") oauth_token = if param("token_type_hint") == "refresh_token" oauth_token_by_refresh_token(token) else oauth_token_by_token(token) end redirect_response_error("invalid_request") unless oauth_token redirect_response_error("invalid_request") unless token_from_application?(oauth_token, oauth_application) 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 = __update_and_return__(ds, update_params) oauth_token[oauth_tokens_token_column] = token oauth_token # If the particular # token is a refresh token and the authorization server supports the # revocation of access tokens, then the authorization server SHOULD # also invalidate all access tokens based on the same authorization # grant # # we don't need to do anything here, as we revalidate existing tokens end # Response helpers def redirect_response_error(error_code, redirect_url = redirect_uri || request.referer || default_redirect) if accepts_json? status_code = if respond_to?(:"#{error_code}_response_status") send(:"#{error_code}_response_status") else invalid_oauth_response_status end throw_json_response_error(status_code, error_code) else redirect_url = URI.parse(redirect_url) query_params = [] query_params << if respond_to?(:"#{error_code}_error_code") "error=#{send(:"#{error_code}_error_code")}" else "error=#{error_code}" end if respond_to?(:"#{error_code}_message") message = send(:"#{error_code}_message") query_params << ["error_description=#{CGI.escape(message)}"] end query_params << redirect_url.query if redirect_url.query redirect_url.query = query_params.join("&") redirect(redirect_url.to_s) end end def json_response_success(body, cache = false) response.status = 200 response["Content-Type"] ||= json_response_content_type if cache # defaulting to 1-day for everyone, for now at least max_age = 60 * 60 * 24 response["Cache-Control"] = "private, max-age=#{max_age}" else response["Cache-Control"] = "no-store" response["Pragma"] = "no-cache" end json_payload = _json_response_body(body) response.write(json_payload) request.halt end def throw_json_response_error(status, error_code) set_response_error_status(status) code = if respond_to?(:"#{error_code}_error_code") send(:"#{error_code}_error_code") else error_code end payload = { "error" => code } payload["error_description"] = send(:"#{error_code}_message") if respond_to?(:"#{error_code}_message") json_payload = _json_response_body(payload) response["Content-Type"] ||= json_response_content_type response["WWW-Authenticate"] = oauth_token_type.upcase if status == 401 response.write(json_payload) request.halt end unless method_defined?(:_json_response_body) def _json_response_body(hash) if request.respond_to?(:convert_to_json) request.send(:convert_to_json, hash) else JSON.dump(hash) end end end def authorization_required if accepts_json? throw_json_response_error(authorization_required_error_status, "invalid_client") else set_redirect_error_flash(require_authorization_error_flash) redirect(authorize_path) 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(oauth_scope_separator)).empty? end def check_valid_redirect_uri? 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") !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") !approval_prompt || APPROVAL_PROMPTS.include?(approval_prompt) end def check_valid_response_type? 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" false end # PKCE def validate_pkce_challenge_params if param_or_nil("code_challenge") 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") end end def check_valid_grant_challenge?(grant, verifier) challenge = grant[oauth_grants_code_challenge_column] case grant[oauth_grants_code_challenge_method_column] when "plain" challenge == verifier when "S256" generated_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier)) generated_challenge.delete_suffix!("=") while generated_challenge.end_with?("=") challenge == generated_challenge else redirect_response_error("unsupported_transform_algorithm") end end # Server metadata def oauth_server_metadata_body(path) issuer = base_url issuer += "/#{path}" if path responses_supported = %w[code] response_modes_supported = %w[query form_post] grant_types_supported = %w[authorization_code] if use_oauth_implicit_grant_type? responses_supported << "token" response_modes_supported << "fragment" grant_types_supported << "implicit" end { issuer: issuer, authorization_endpoint: authorize_url, token_endpoint: token_url, registration_endpoint: route_url(oauth_applications_path), scopes_supported: oauth_application_scopes, response_types_supported: responses_supported, response_modes_supported: response_modes_supported, grant_types_supported: grant_types_supported, token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post], service_documentation: oauth_metadata_service_documentation, ui_locales_supported: oauth_metadata_ui_locales_supported, op_policy_uri: oauth_metadata_op_policy_uri, op_tos_uri: oauth_metadata_op_tos_uri, revocation_endpoint: revoke_url, revocation_endpoint_auth_methods_supported: nil, # because it's client_secret_basic introspection_endpoint: introspect_url, introspection_endpoint_auth_methods_supported: %w[client_secret_basic], code_challenge_methods_supported: (use_oauth_pkce? ? oauth_pkce_challenge_method : nil) } end end end