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

- old
+ new

@@ -1,7 +1,9 @@ # frozen-string-literal: true +require "base64" + module Rodauth Feature.define(:oauth) do # RUBY EXTENSIONS unless Regexp.method_defined?(:match?) module RegexpExtensions @@ -36,15 +38,16 @@ before "authorize" after "authorize" after "authorize_failure" before "token" - after "token" before "revoke" after "revoke" + before "introspect" + before "create_oauth_application" after "create_oauth_application" error_flash "OAuth Authorization invalid parameters", "oauth_grant_valid_parameters" @@ -141,11 +144,11 @@ auth_value_method :"oauth_applications_#{column}_column", column end 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_token_type, "bearer" auth_value_method :invalid_request, "Request is missing a required parameter" auth_value_method :invalid_client, "Invalid client" auth_value_method :unauthorized_client, "Unauthorized client" auth_value_method :invalid_grant_type_message, "Invalid grant type" @@ -162,11 +165,18 @@ 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 + auth_value_methods( + :fetch_access_token, :oauth_unique_id_generator, :secret_matches?, :secret_hash ) @@ -188,11 +198,11 @@ auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i def check_csrf? case request.path - when oauth_token_path + when oauth_token_path, oauth_introspect_path false when oauth_revoke_path !json_request? when oauth_authorize_path, %r{/#{oauth_applications_path}} only_json? ? false : super @@ -219,12 +229,10 @@ @json_request = request.content_type =~ json_request_regexp end end - attr_reader :oauth_application - def initialize(scope) @scope = scope end def state @@ -265,29 +273,31 @@ db[oauth_applications_table].filter(oauth_applications_client_id_column => client_id).first end end - def authorization_token - return @authorization_token if defined?(@authorization_token) + def fetch_access_token + value = request.env["HTTP_AUTHORIZATION"] - @authorization_token = begin - value = request.get_header("HTTP_AUTHORIZATION").to_s + return unless value - scheme, token = value.split(" ", 2) + scheme, token = value.split(" ", 2) - return unless scheme == "Bearer" + return unless scheme.downcase == oauth_token_type - # check if there is a token - # check if token has not expired - # check if token has been revoked - oauth_token_by_token(token).where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) - .where(oauth_tokens_revoked_at_column => nil) - .first - end + token end + def authorization_token + return @authorization_token if defined?(@authorization_token) + + # check if there is a token + # check if token has not expired + # check if token has been revoked + @authorization_token = oauth_token_by_token(fetch_access_token) + end + def require_oauth_authorization(*scopes) authorization_required unless authorization_token scopes << oauth_application_default_scope if scopes.empty? @@ -342,12 +352,48 @@ new_oauth_application_view 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)) + end + end + end + end + private + # 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_param) + client_secret = param_or_nil(client_secret_param) + 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) + + 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) @@ -360,10 +406,14 @@ 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 if ENV["RACK_ENV"] == "test" def password_hash_cost BCrypt::Engine::MIN_COST @@ -424,35 +474,39 @@ rescue Sequel::UniqueConstraintViolation retry end end - def oauth_token_by_token(token) - if oauth_tokens_token_hash_column - db[oauth_tokens_table].where(oauth_tokens_token_hash_column => generate_token_hash(token)) - else - db[oauth_tokens_table].where(oauth_tokens_token_column => token) - end + def oauth_token_by_token(token, dataset = db[oauth_tokens_table]) + ds = if oauth_tokens_token_hash_column + dataset.where(oauth_tokens_token_hash_column => generate_token_hash(token)) + else + dataset.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) - if oauth_tokens_refresh_token_hash_column - db[oauth_tokens_table].where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token)) - else - db[oauth_tokens_table].where(oauth_tokens_refresh_token_column => token) - end + def oauth_token_by_refresh_token(token, dataset = db[oauth_tokens_table]) + ds = if oauth_tokens_refresh_token_hash_column + dataset.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token)) + else + dataset.where(oauth_tokens_refresh_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 json_access_token_payload(oauth_token) payload = { "access_token" => oauth_token[oauth_tokens_token_column], - "token_type" => oauth_token_type.downcase, + "token_type" => oauth_token_type, "expires_in" => oauth_token_expires_in } - if oauth_token[oauth_tokens_refresh_token_column] - payload["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] - end + payload["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] if oauth_token[oauth_tokens_refresh_token_column] payload end # Oauth Application @@ -470,13 +524,11 @@ 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 - unless URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(value) - set_field_error(key, invalid_url_message) - end + set_field_error(key, invalid_url_message) unless URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(value) elsif key == oauth_application_scopes_param value.each do |scope| set_field_error(key, invalid_scope_message) unless oauth_application_scopes.include?(scope) @@ -615,17 +667,15 @@ end end # Access Tokens - def validate_oauth_token_params - redirect_response_error("invalid_request") unless param_or_nil(client_id_param) + def before_token + require_oauth_application + end - unless param_or_nil(client_secret_param) - redirect_response_error("invalid_request") unless param_or_nil(code_verifier_param) - end - + def validate_oauth_token_params unless (grant_type = param_or_nil(grant_type_param)) redirect_response_error("invalid_request") end case grant_type @@ -638,20 +688,10 @@ redirect_response_error("invalid_request") end end def create_oauth_token - oauth_application = db[oauth_applications_table].where( - oauth_applications_client_id_column => param(client_id_param) - ).first - - redirect_response_error("invalid_request") unless oauth_application - - if (client_secret = param_or_nil(client_secret_param)) - redirect_response_error("invalid_request") unless secret_matches?(oauth_application, client_secret) - end - case param(grant_type_param) when "authorization_code" create_oauth_token_from_authorization_code(oauth_application) when "refresh_token" create_oauth_token_from_token(oauth_application) @@ -676,13 +716,11 @@ # PKCE if use_oauth_pkce? if oauth_grant[oauth_grants_code_challenge_column] code_verifier = param_or_nil(code_verifier_param) - unless code_verifier && check_valid_grant_challenge?(oauth_grant, code_verifier) - redirect_response_error("invalid_request") - end + 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 @@ -703,15 +741,13 @@ 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)).where( - oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column] - ).where(oauth_grants_revoked_at_column => nil).for_update.first + oauth_token = oauth_token_by_refresh_token(param(refresh_token_param)) - redirect_response_error("invalid_grant") unless oauth_token + redirect_response_error("invalid_grant") unless oauth_token && token_from_application?(oauth_token, oauth_application) token = oauth_unique_id_generator update_params = { oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column], @@ -739,46 +775,61 @@ oauth_token[oauth_tokens_token_column] = token oauth_token end + TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze + + # Token introspect + + def validate_oauth_introspect_params + # 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) + end + + def json_token_introspect_payload(token) + return { active: false } unless token + + { + active: true, + scope: token[oauth_tokens_scopes_column].gsub(",", " "), + client_id: oauth_application[oauth_applications_client_id_column], + # username + token_type: oauth_token_type + } + end + + def before_introspect + require_oauth_application + end + # Token revocation def before_revoke - require_account + require_oauth_application end - TOKEN_HINT_TYPES = %w[access_token refresh_token].freeze - 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(token_param) + redirect_response_error("invalid_request") unless param_or_nil(token_param) end def revoke_oauth_token - ds = case token_type_hint - when "access_token" - oauth_token_by_token(token) - when "refresh_token" - oauth_token_by_refresh_token(token) - end - # one can only revoke tokens which haven't been revoked before, and which are - # either our tokens, or tokens from applications we own. - oauth_token = ds.where(oauth_tokens_revoked_at_column => nil) - .where( - Sequel.or( - oauth_tokens_account_id_column => account_id, - oauth_tokens_oauth_application_id_column => db[oauth_applications_table].where( - oauth_applications_client_id_column => param(client_id_param), - oauth_applications_account_id_column => account_id - ).select(oauth_applications_id_column) - ) - ).for_update.first + 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 + redirect_response_error("invalid_request") unless oauth_token && 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]) @@ -825,10 +876,18 @@ redirect_url.query = query_params.join("&") redirect(redirect_url.to_s) end end + def json_response_success(body) + response.status = 200 + response["Content-Type"] ||= json_response_content_type + 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 @@ -836,11 +895,11 @@ 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 if status == 401 + response["WWW-Authenticate"] = oauth_token_type.upcase if status == 401 response.write(json_payload) request.halt end unless method_defined?(:_json_response_body) @@ -928,10 +987,47 @@ else redirect_response_error("unsupported_transform_algorithm") end end + # Server metadata + + def oauth_server_metadata_body(path) + issuer = base_url + issuer += "/#{path}" if issuer + + responses_supported = %w[code] + response_modes_supported = %w[query] + 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: oauth_authorize_url, + token_endpoint: oauth_token_url, + registration_endpoint: "#{base_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: oauth_revoke_url, + revocation_endpoint_auth_methods_supported: nil, # because it's client_secret_basic + introspection_endpoint: oauth_introspect_url, + introspection_endpoint_auth_methods_supported: %w[client_secret_basic], + code_challenge_methods_supported: (use_oauth_pkce? ? oauth_pkce_challenge_method : nil) + } + end + # /oauth-token route(:oauth_token) do |r| before_token r.post do @@ -939,27 +1035,47 @@ validate_oauth_token_params oauth_token = nil transaction do oauth_token = create_oauth_token - after_token end - response.status = 200 - response["Content-Type"] ||= json_response_content_type - json_payload = _json_response_body(json_access_token_payload(oauth_token)) - response.write(json_payload) - request.halt + json_response_success(json_access_token_payload(oauth_token)) end throw_json_response_error(invalid_oauth_response_status, "invalid_request") end end + # /oauth-introspect + route(:oauth_introspect) do |r| + before_introspect + + r.post do + catch_error do + validate_oauth_introspect_params + + oauth_token = case param(token_type_hint_param) + when "access_token" + oauth_token_by_token(param(token_param)) + when "refresh_token" + oauth_token_by_refresh_token(param(token_param)) + else + oauth_token_by_token(param(token_param)) || oauth_token_by_refresh_token(param(token_param)) + end + + redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application) + + json_response_success(json_token_introspect_payload(oauth_token)) + end + + throw_json_response_error(invalid_oauth_response_status, "invalid_request") + end + end + # /oauth-revoke route(:oauth_revoke) do |r| - require_account before_revoke # access-token r.post do catch_error do @@ -970,19 +1086,13 @@ oauth_token = revoke_oauth_token after_revoke end if accepts_json? - response.status = 200 - response["Content-Type"] ||= json_response_content_type - json_response = { + json_response_success \ "token" => oauth_token[oauth_tokens_token_column], "refresh_token" => oauth_token[oauth_tokens_refresh_token_column], "revoked_at" => oauth_token[oauth_tokens_revoked_at_column] - } - json_payload = _json_response_body(json_response) - response.write(json_payload) - request.halt else set_notice_flash revoke_oauth_token_notice_flash redirect request.referer || "/" end end