lib/rodauth/features/oauth.rb in rodauth-oauth-0.7.4 vs lib/rodauth/features/oauth.rb in rodauth-oauth-0.8.0
- old
+ new
@@ -1,1424 +1,9 @@
# 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, :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"
- error_flash "You are not authorized to revoke this token", "revoke_unauthorized_account"
-
- 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_route, "oauth-applications"
- def oauth_applications_path(opts = {})
- route_path(oauth_applications_route, opts)
- end
-
- def oauth_applications_url(opts = {})
- route_url(oauth_applications_route, opts)
- end
-
- 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
-
- translatable_method :invalid_client_message, "Invalid client"
- translatable_method :invalid_grant_type_message, "Invalid grant type"
- translatable_method :invalid_grant_message, "Invalid grant"
- translatable_method :invalid_scope_message, "Invalid scope"
-
- translatable_method :invalid_url_message, "Invalid URL"
- translatable_method :unsupported_token_type_message, "Invalid token type hint"
-
- translatable_method :unique_error_message, "is already in use"
- translatable_method :null_error_message, "is not filled"
- translatable_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"
- translatable_method :code_challenge_required_message, "code challenge required"
- auth_value_method :unsupported_transform_algorithm_error_code, "invalid_request"
- translatable_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(
- :oauth_application_path,
- :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
-
- if logged_in?
- require_account
- require_oauth_application_from_account
- else
- require_oauth_application
- end
-
- 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
- <html>
- <head><title>Authorized</title></head>
- <body onload="javascript:document.forms[0].submit()">
- <form method="post" action="#{redirect_uri}">
- #{
- params.map do |name, value|
- "<input type=\"hidden\" name=\"#{name}\" value=\"#{scope.h(value)}\" />"
- end.join
- }
- <input type="submit" class="btn btn-outline-primary" value="#{scope.h(oauth_authorize_post_button)}"/>
- </form>
- </body>
- </html>
- 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
-
- def oauth_application_path(id)
- "#{oauth_applications_path}/#{id}"
- end
-
- # /oauth-applications routes
- def oauth_applications
- request.on(oauth_applications_route) 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)
- .where(oauth_applications_account_id_column => account_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]
- .where(oauth_applications_account_id_column => account_id))
- 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, oauth_applications_path
- only_json? ? false : super
- else
- super
- end
- end
-
- # Overrides session_value, so that a valid authorization token also authenticates a request
- def session_value
- super || begin
- return unless authorization_token
-
- authorization_token[oauth_tokens_account_id_column]
- end
- 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 scopes
- scope = request.params["scope"]
- case scope
- when Array
- scope
- when String
- scope.split(" ")
- when nil
- Array(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 = db.indexes(oauth_tokens_table).values.any? do |definition|
- definition[:unique] &&
- definition[:columns] == oauth_tokens_unique_columns
- end
-
- self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account }
-
- i18n_register(File.expand_path(File.join(__dir__, "..", "..", "..", "locales"))) if features.include?(:i18n)
- 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 require_oauth_application_from_account
- ds = db[oauth_applications_table]
- .join(oauth_tokens_table, Sequel[oauth_tokens_table][oauth_tokens_oauth_application_id_column] =>
- Sequel[oauth_applications_table][oauth_applications_id_column])
- .where(oauth_token_by_token_ds(param("token")).opts.fetch(:where, true))
- .where(Sequel[oauth_applications_table][oauth_applications_account_id_column] => account_id)
-
- @oauth_application = ds.qualify.first
- return if @oauth_application
-
- set_redirect_error_flash revoke_unauthorized_account_error_flash
- redirect request.referer || "/"
- 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_ds(token)
- ds = db[oauth_tokens_table]
-
- ds = if oauth_tokens_token_hash_column
- ds.where(Sequel[oauth_tokens_table][oauth_tokens_token_hash_column] => generate_token_hash(token))
- else
- ds.where(Sequel[oauth_tokens_table][oauth_tokens_token_column] => token)
- end
-
- ds.where(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
- .where(Sequel[oauth_tokens_table][oauth_tokens_revoked_at_column] => nil)
- end
-
- def oauth_token_by_token(token)
- oauth_token_by_token_ds(token).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]
- }
-
- refresh_token = oauth_unique_id_generator
-
- if oauth_tokens_refresh_token_hash_column
- insert_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
- else
- insert_params[oauth_tokens_refresh_token_column] = refresh_token
- end
-
- # 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[oauth_tokens_refresh_token_column] = refresh_token if refresh_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,
- exp: token[oauth_tokens_expires_in_column].to_i
- }
- 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: oauth_applications_url,
- 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
+ depends :oauth_base, :oauth_authorization_code_grant, :oauth_pkce, :oauth_implicit_grant,
+ :oauth_device_grant, :oauth_token_introspection, :oauth_token_revocation,
+ :oauth_application_management, :oauth_token_management
end
end