Files
akkounts/spec/controllers/rs/oauth_controller_spec.rb
T
raucao 4bca003983
CI / Test (pull_request) Successful in 45s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
Hash remoteStorage access tokens at rest
Store only SHA256(token) in a new token_digest column and use the digest for the Redis authorization keys instead of the plaintext token. The plaintext is now held in memory only for issuance.

This is the expand step: the plaintext token column stays in place so the old code can keep running during the deploy. A follow-up migration will drop it once rs:rekey_tokens has re-keyed Redis and confirmed no plaintext keys remain.

Refs #259
2026-11-04 09:33:57 +04:00

718 lines
22 KiB
Ruby

require 'rails_helper'
RSpec.describe Rs::OauthController, type: :controller do
let(:user) { create :user }
before do
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
end
describe "GET /rs/oauth/:username" do
context "when user is signed in" do
before do
sign_in user
end
context "when username is different than current user" do
let(:other_user) { create :user, id: 23, cn: "jomokenyatta", email: "jomo@hotmail.com" }
before do
get :new, params: {
username: other_user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "examples"
}
end
it "logs out the users and repeats the request" do
url = new_rs_oauth_url other_user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "examples"
expect(response).to redirect_to(url)
end
end
context "when no valid token exists" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
state: "foobar123"
}
end
it "returns a 200" do
expect(response.response_code).to eq(200)
end
it "sets the instance variables" do
expected_scopes = %w(documents photos contacts:rw videos:r tasks/work:r)
expect(assigns["user"]).to eq(user)
expect(assigns["redirect_uri"]).to eq("https://example.com")
expect(assigns["scopes"]).to eq(expected_scopes)
expect(assigns["client_id"]).to eq("example.com")
expect(assigns["root_access_requested"]).to eq(false)
expect(assigns["state"]).to eq("foobar123")
expect(assigns["denial_url"]).to eq("https://example.com#error=access_denied&state=foobar123")
end
context "no redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "https://example.com"
}
end
it "returns a 400" do
expect(response.response_code).to eq(400)
end
end
context "no client_id" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com"
}
end
it "redirects with invalid_request error" do
expect(response).to redirect_to("https://example.com#error=invalid_request")
end
end
context "different host for client_id and redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com/foobar",
client_id: "https://google.com"
}
end
it "redirects with invalid_client error" do
expect(response).to redirect_to("https://example.com/foobar#error=invalid_client")
end
end
end
context "when valid token already exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
client_id: "example.com", redirect_uri: "https://example.com",
expire_at: 1.day.from_now
)
end
after { @auth.destroy }
context "with same host for client_id and redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "https://example.com"
}
end
it "does not reuse the existing token (renders the consent dialog)" do
expect(response.response_code).to eq(200)
expect(response.location).to be_nil
expect(user.remote_storage_authorizations.count).to eq(1)
end
end
context "with different host for client_id and redirect_uri" do
before do
get :new, params: {
username: user.cn,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://app.example.com",
client_id: "https://example.com"
}
end
it "redirects with invalid_client error" do
expect(response).to redirect_to("https://app.example.com#error=invalid_client")
end
end
end
context "when response_type is code" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
state: "foobar123"
}
end
it "renders the consent dialog and exposes the code challenge" do
expect(response.response_code).to eq(200)
expect(assigns["response_type"]).to eq("code")
expect(assigns["code_challenge"]).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(assigns["code_challenge_method"]).to eq("S256")
end
it "uses a query-based denial url" do
expect(assigns["denial_url"]).to eq("https://example.com?error=access_denied&state=foobar123")
end
end
context "when response_type is code without a code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
state: "foobar123"
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
end
context "when response_type is code with the plain method" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
context "with an unsupported response_type" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "id_token"
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with a malformed code_challenge" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "example.com",
scope: "documents",
response_type: "code",
code_challenge: "too-short",
code_challenge_method: "S256"
}
end
it "redirects with invalid_request" do
expect(response).to redirect_to("https://example.com?error=invalid_request")
end
end
context "no scope" do
before do
get :new, params: {
username: user.cn,
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "redirects to the redirect_uri with an error code" do
expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123")
end
end
context "empty scope" do
before do
get :new, params: {
username: user.cn,
scope: "",
redirect_uri: "https://example.com",
client_id: "https://example.com",
state: "foobar123"
}
end
it "redirects to the redirect_uri with an error code" do
expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123")
end
end
end
context "when user is not signed in" do
it "redirects to the signin page with username pre-filled" do
get :new, params: {
username: user.cn,
scope: "documents,photos",
redirect_uri: "https://example.com"
}
expect(response).to redirect_to(new_user_session_path(cn: user.cn, ou: user.ou))
end
context "without a redirect_uri" do
it "returns a 400 without storing the return location" do
get :new, params: {
username: user.cn,
scope: "documents,photos",
client_id: "https://example.com"
}
expect(response.response_code).to eq(400)
expect(session[:user_return_to]).to be_nil
end
end
end
describe "root access" do
before do
sign_in user
end
describe "full" do
before do
get :new, params: {
username: user.cn,
scope: "*:rw",
redirect_uri: "https://example.com",
client_id: "example.com"
}
end
it "sets the instance variables" do
expect(assigns["scopes"]).to eq([":rw"])
expect(assigns["root_access_requested"]).to be(true)
end
end
describe "read-only" do
before do
get :new, params: {
username: user.cn,
scope: "*:r",
redirect_uri: "https://example.com",
client_id: "example.com"
}
end
it "sets the instance variables" do
expect(assigns["scopes"]).to eq([":r"])
expect(assigns["root_access_requested"]).to be(true)
end
end
end
end
describe "POST /rs/oauth/:username" do
context "when user is signed in" do
before do
sign_in user
request.user_agent = "RSpec Device"
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "when no valid token exists" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
expire_at: 1.day.from_now
}
@auth = user.reload.remote_storage_authorizations.first
end
it "creates a new token" do
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
end
it "stores the device's user agent" do
expect(@auth.user_agent).to eq("RSpec Device")
end
it "redirects to the redirect_uri" do
expect(response).to redirect_to(
%r{\Ahttps://example\.com#access_token=[a-f0-9]{32}&state=foobar123\z}
)
end
end
context "when token is expired" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents),
client_id: "example.com",
redirect_uri: "https://example.com",
expire_at: 1.day.ago,
token: nil
)
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
expire_at: 1.month.from_now
}
end
it "updates the token" do
expect(@auth.reload.token_digest).not_to be_nil
end
end
context "root access with several scopes" do
before do
post :create, params: {
user_id: user.id,
scope: "*:rw contacts:r",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
@auth = user.reload.remote_storage_authorizations.first
end
it "removes all scopes except for the root permission" do
expect(@auth.permissions).to eq(%w(:rw))
end
end
context "no redirect_uri" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "example.com",
expire_at: 1.month.from_now
}
end
it "returns a 400" do
expect(response.response_code).to eq(400)
end
end
context "no client_id" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
redirect_uri: "https://example.com",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_request error" do
expect(response).to redirect_to("https://example.com#error=invalid_request")
end
end
context "hostnames of client_id and redirect_uri do not match" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw videos:r tasks/work/:r",
client_id: "fishing.com",
redirect_uri: "https://example.com",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_client error" do
expect(response).to redirect_to("https://example.com#error=invalid_client")
end
end
context "empty scope" do
before do
post :create, params: {
user_id: user.id,
scope: "",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
expire_at: 1.month.from_now
}
end
it "redirects to the redirect_uri with an error code" do
expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123")
end
end
context "with response_type=code" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,[photos], contacts:rw",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now
}
@code = RsAuthorizationCode.last
end
it "creates an authorization code" do
expect(@code.permissions).to eq(%w(documents photos contacts:rw))
expect(@code.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(@code.code_challenge_method).to eq("S256")
expect(@code.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do
expect(user.reload.remote_storage_authorizations.count).to eq(0)
end
it "redirects with the code in the query string" do
location = response.location
expect(location).to match(%r{\Ahttps://example\.com\?code=.+&state=foobar123\z})
raw_code = CGI.parse(URI.parse(location).query)["code"].first
expect(RsAuthorizationCode.find_by_code(raw_code)).to eq(@code)
end
end
context "with response_type=code but no code_challenge" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
state: "foobar123",
response_type: "code",
expire_at: 1.month.from_now
}
end
it "redirects with invalid_request using a query param" do
expect(response).to redirect_to("https://example.com?error=invalid_request&state=foobar123")
end
it "does not create an authorization code" do
expect(RsAuthorizationCode.count).to eq(0)
end
end
context "with an unsupported response_type" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com",
client_id: "example.com",
response_type: "id_token",
expire_at: 1.month.from_now
}
end
it "redirects with unsupported_response_type" do
expect(response).to redirect_to("https://example.com#error=unsupported_response_type")
end
end
context "with response_type=code and a redirect_uri that already has a query" do
before do
post :create, params: {
user_id: user.id,
scope: "documents",
redirect_uri: "https://example.com/?foo=bar",
client_id: "example.com",
state: "foobar123",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expire_at: 1.month.from_now
}
end
it "appends the code with an ampersand" do
expect(response.location).to match(%r{\Ahttps://example\.com/\?foo=bar&code=.+&state=foobar123\z})
end
end
context "when the user_id is different from the signed in user" do
before do
post :create, params: {
user_id: user.id,
scope: "documents,photos",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
end
it "returns a 403" do
post :create, params: {
user_id: "69",
scope: "documents,photos",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
expect(response.response_code).to eq(403)
end
end
end
context "when user is not signed in" do
it "redirects to the signin page" do
post :create, params: {
user_id: user.id,
scope: "documents,photos",
redirect_uri: "https://example.com",
client_id: "example.com",
expire_at: 1.month.from_now
}
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe "POST /rs/oauth/token" do
# Example values from RFC 7636, Appendix B
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
def issue_code
RsOauthManager::IssueCode.call(
user: user,
client_id: "example.com",
redirect_uri: "https://example.com",
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256"
)
end
def token_params(code, **overrides)
{
grant_type: "authorization_code",
code: code.code,
client_id: "https://example.com",
redirect_uri: "https://example.com",
code_verifier: code_verifier
}.merge(overrides)
end
after do
user.remote_storage_authorizations.destroy_all
RsAuthorizationCode.destroy_all
end
context "with a valid code" do
before do
@code = issue_code
post :token, params: token_params(@code)
end
it "returns an access token" do
expect(response.response_code).to eq(200)
json = JSON.parse(response.body)
expect(json["access_token"]).to be_present
expect(json["token_type"]).to eq("bearer")
expect(json["scope"]).to eq("documents:rw")
end
it "does not cache the response" do
expect(response.headers["Cache-Control"]).to eq("no-store")
end
it "sets CORS headers" do
expect(response.headers["Access-Control-Allow-Origin"]).to eq("*")
end
end
context "with a bad code_verifier" do
before do
@code = issue_code
post :token, params: token_params(@code, code_verifier: "wrong-verifier")
end
it "returns invalid_grant" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("invalid_grant")
end
end
context "with an unsupported grant_type" do
before { post :token, params: { grant_type: "password" } }
it "returns unsupported_grant_type" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("unsupported_grant_type")
end
end
context "with missing required parameters" do
before { post :token, params: { grant_type: "authorization_code", code: "x" } }
it "returns invalid_request" do
expect(response.response_code).to eq(400)
expect(JSON.parse(response.body)["error"]).to eq("invalid_request")
end
end
context "on error" do
before { post :token, params: { grant_type: "password" } }
it "does not cache the response" do
expect(response.headers["Cache-Control"]).to eq("no-store")
end
end
end
end