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
710 lines
22 KiB
Ruby
710 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
|
|
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 "redirects to the redirect_uri" do
|
|
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
|
|
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).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")
|
|
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
|