Implements the optional OAuth 2.0 Authorization Code grant with PKCE (RFC 7636) for remoteStorage, as described in the remoteStorage spec (section 10.1). - Advertise the authorization and token endpoints plus the supported code_challenge method (S256) in WebFinger, and bump the advertised spec version to draft-dejong-remotestorage-27 - Add RsAuthorizationCode: a short-lived (5 min), single-use authorization code stored only as a SHA256 digest - Add RsOauthManager services for issuing and exchanging codes, including S256 verification - Add a cross-origin POST /rs/oauth/token endpoint that exchanges a code (and code_verifier) for an access token, with CORS and no CSRF token - Consent dialog now carries response_type / code_challenge through the form and redirects with the code in the query string for the code flow - Remove the existing-token reuse shortcut so every authorization results in a distinct authorization (and token) Part of #254
203 lines
6.2 KiB
Ruby
203 lines
6.2 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
|
|
|
|
if @response_type == "code" &&
|
|
(@code_challenge.blank? || @code_challenge_method != "S256")
|
|
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
|
|
|
|
client_id.gsub!(/http(s)?:\/\//, "")
|
|
|
|
if response_type == "code"
|
|
challenge = params[:code_challenge].presence
|
|
method = params[:code_challenge_method].presence || "S256"
|
|
|
|
if challenge.blank? || method != "S256"
|
|
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}?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 json: { error: "unsupported_grant_type" }, status: :bad_request
|
|
end
|
|
|
|
auth = RsOauthManager::ExchangeCode.call(
|
|
code: params[:code],
|
|
client_id: params[:client_id],
|
|
redirect_uri: params[:redirect_uri],
|
|
code_verifier: params[:code_verifier]
|
|
)
|
|
|
|
response.headers["Cache-Control"] = "no-store"
|
|
render json: token_response(auth)
|
|
rescue RsOauthManagerService::InvalidGrant
|
|
render json: { error: "invalid_grant" }, status: :bad_request
|
|
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" ? "?" : "#"
|
|
url_with_state("#{redirect_uri}#{separator}#{error}", state)
|
|
end
|
|
|
|
def url_with_state(url, state)
|
|
state ? "#{url}&state=#{CGI.escape(state)}" : url
|
|
end
|
|
|
|
end
|