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
718 lines
22 KiB
Ruby
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
|