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