Files
akkounts/app/controllers/rs/oauth_controller.rb
T
raucao 295261b332
CI / Test (pull_request) Failing after 46s
Address review findings in the PKCE flow
- Make authorization-code redemption atomic: claim the code with a guarded
  delete inside the transaction that creates the authorization, so concurrent
  exchanges can't both issue a token (RFC 6749 4.1.3)
- Compare the token request's client_id host to the code's stored client_id
  (defense-in-depth alongside the exact redirect_uri match)
- Reject unknown response_type values with unsupported_response_type
- Validate the PKCE code_challenge (43-128 unreserved chars, S256) so a client
  can't persist an over-long or malformed challenge
- Return invalid_request for missing token-endpoint parameters, and set
  Cache-Control: no-store on error responses too
- Use the right separator when the redirect_uri already has a query string
- Look codes up through the active scope; drop the unused consume!; serialize
  permissions as JSON
- Purge abandoned authorization codes via a recurring Solid Queue task

Part of #254
2026-11-03 11:40:18 +04:00

233 lines
7.1 KiB
Ruby

class Rs::OauthController < ApplicationController
skip_before_action :verify_authenticity_token, only: :token
prepend_before_action :assert_redirect_uri, only: [:new, :create]
before_action :require_signed_in_with_username, only: :new
before_action :authenticate_user!, only: :create
before_action :set_cors_headers, only: :token
def new
@user = User.where(cn: params[:username].downcase, ou: Setting.primary_domain).first
@scopes = parse_scopes params[:scope]
@redirect_uri = params[:redirect_uri]
@client_id = params[:client_id]
@state = params[:state]
@response_type = params[:response_type].presence || "token"
@code_challenge = params[:code_challenge]
@code_challenge_method = params[:code_challenge_method].presence || "S256"
@root_access_requested = (@scopes & [":r",":rw"]).any?
@denial_url = error_url(@redirect_uri, "error=access_denied", @state)
@expire_at_dates = [["Never", nil],
["In 1 month", 1.month.from_now],
["In 1 day", 1.day.from_now]]
unless current_user == @user
sign_out :user
redirect_params = {
scope: params[:scope],
redirect_uri: params[:redirect_uri],
client_id: params[:client_id],
state: params[:state]
}
if @response_type == "code"
redirect_params[:response_type] = "code"
redirect_params[:code_challenge] = @code_challenge
redirect_params[:code_challenge_method] = @code_challenge_method
end
redirect_to new_rs_oauth_url(@user.cn, redirect_params)
return
end
unless @client_id.present?
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
end
if @scopes.empty?
error_redirect(@redirect_uri, "error=invalid_scope", @state) and return
end
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
error_redirect(@redirect_uri, "error=invalid_client", @state) and return
end
unless %w[token code].include?(@response_type)
error_redirect(@redirect_uri, "error=unsupported_response_type", @state) and return
end
if @response_type == "code" &&
!valid_code_challenge?(@code_challenge, @code_challenge_method)
error_redirect(@redirect_uri, "error=invalid_request", @state) and return
end
@client_id.gsub!(/http(s)?:\/\//, "")
end
def create
unless current_user.id.to_s == params[:user_id]
Rails.logger.info("NO MATCH: #{params[:user_id]}, #{current_user.id}")
http_status :forbidden and return
end
response_type = params[:response_type].presence || "token"
permissions = parse_scopes params[:scope]
redirect_uri = params[:redirect_uri].presence
client_id = params[:client_id].presence
state = params[:state].presence
expire_at = params[:expire_at].presence
if permissions.empty?
error_redirect(redirect_uri, "error=invalid_scope", state, response_type) and return
end
unless client_id.present?
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
end
unless hostname_of(client_id) == hostname_of(redirect_uri)
error_redirect(redirect_uri, "error=invalid_client", state, response_type) and return
end
unless %w[token code].include?(response_type)
error_redirect(redirect_uri, "error=unsupported_response_type", state, response_type) and return
end
client_id.gsub!(/http(s)?:\/\//, "")
if response_type == "code"
challenge = params[:code_challenge].presence
method = params[:code_challenge_method].presence || "S256"
unless valid_code_challenge?(challenge, method)
error_redirect(redirect_uri, "error=invalid_request", state, response_type) and return
end
code = RsOauthManager::IssueCode.call(
user: current_user,
client_id: client_id,
redirect_uri: redirect_uri,
permissions: permissions,
expire_at: expire_at,
code_challenge: challenge,
code_challenge_method: method
)
redirect_to url_with_state("#{redirect_uri}#{query_separator(redirect_uri)}code=#{CGI.escape(code.code)}", state),
allow_other_host: true
else
auth = current_user.remote_storage_authorizations.create!(
permissions: permissions,
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id,
expire_at: expire_at
)
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
allow_other_host: true
end
end
def token
return head :ok if request.options?
unless params[:grant_type] == "authorization_code"
return render_token_error("unsupported_grant_type")
end
if params[:code].blank? || params[:code_verifier].blank? ||
params[:client_id].blank? || params[:redirect_uri].blank?
return render_token_error("invalid_request")
end
auth = RsOauthManager::ExchangeCode.call(
code: params[:code],
client_id: params[:client_id],
redirect_uri: params[:redirect_uri],
code_verifier: params[:code_verifier]
)
render_token(token_response(auth))
rescue RsOauthManagerService::InvalidGrant
render_token_error("invalid_grant")
end
private
def assert_redirect_uri
http_status :bad_request unless params[:redirect_uri].present?
end
def require_signed_in_with_username
unless user_signed_in?
session[:user_return_to] = request.url
redirect_to new_user_session_path(cn: params[:username], ou: Setting.primary_domain)
end
end
def set_cors_headers
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
headers["Access-Control-Allow-Headers"] = "Content-Type"
end
def token_response(auth)
body = {
access_token: auth.token,
token_type: "bearer",
scope: auth.permissions.join(" ")
}
if auth.expire_at.present?
body[:expires_in] = (auth.expire_at - Time.current).to_i
end
body
end
def hostname_of(uri)
RsOauthManagerService.hostname_of(uri)
end
def parse_scopes(scope_string)
RsOauthManagerService.parse_scopes(scope_string)
end
def error_redirect(redirect_uri, error, state, response_type = @response_type)
redirect_to error_url(redirect_uri, error, state, response_type),
allow_other_host: true
end
def error_url(redirect_uri, error, state, response_type = @response_type)
separator = response_type == "code" ? query_separator(redirect_uri) : "#"
url_with_state("#{redirect_uri}#{separator}#{error}", state)
end
def query_separator(uri)
uri.to_s.include?("?") ? "&" : "?"
end
def valid_code_challenge?(challenge, method)
method == "S256" && challenge.to_s.match?(/\A[A-Za-z0-9\-._~]{43,128}\z/)
end
def render_token(body)
response.headers["Cache-Control"] = "no-store"
render json: body
end
def render_token_error(error)
response.headers["Cache-Control"] = "no-store"
render json: { error: error }, status: :bad_request
end
def url_with_state(url, state)
state ? "#{url}&state=#{CGI.escape(state)}" : url
end
end