CI / Test (pull_request) Failing after 46s
- 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
233 lines
7.1 KiB
Ruby
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
|