CI / Test (pull_request) Successful in 42s
- Enqueue the expiry job and the "app connected" notification on after_commit instead of after_create, so they only fire once the (now transactional) code exchange commits - Enforce the PKCE code_challenge format (43-128 unreserved chars, S256) with model validations, not only at the controller - Drop the now-unused RsAuthorizationCode#expired? - Cover the client_id-vs-stored-client_id check Part of #254
117 lines
3.3 KiB
Ruby
117 lines
3.3 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe RsOauthManager::ExchangeCode do
|
|
let(:user) { create :user }
|
|
|
|
# Example values from RFC 7636, Appendix B
|
|
let(:code_verifier) { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
|
|
let(:code_challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }
|
|
|
|
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
|
|
|
|
def issue_code(**overrides)
|
|
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",
|
|
**overrides
|
|
)
|
|
end
|
|
|
|
def exchange(code, **overrides)
|
|
described_class.call(
|
|
code: code.code,
|
|
client_id: "https://example.com",
|
|
redirect_uri: "https://example.com",
|
|
code_verifier: code_verifier,
|
|
**overrides
|
|
)
|
|
end
|
|
|
|
it "creates an authorization and consumes the code" do
|
|
code = issue_code
|
|
auth = nil
|
|
|
|
expect { auth = exchange(code) }.to change(RemoteStorageAuthorization, :count).by(1)
|
|
|
|
expect(auth.permissions).to eq(%w(documents:rw))
|
|
expect(auth.client_id).to eq("example.com")
|
|
expect(auth.token).to be_present
|
|
expect(RsAuthorizationCode.exists?(code.id)).to be(false)
|
|
end
|
|
|
|
it "carries over the permission expiry" do
|
|
expire_at = 1.month.from_now
|
|
code = issue_code(expire_at: expire_at)
|
|
|
|
auth = exchange(code)
|
|
|
|
expect(auth.expire_at).to be_within(1.second).of(expire_at)
|
|
end
|
|
|
|
it "rejects an unknown code" do
|
|
expect do
|
|
described_class.call(
|
|
code: "unknown",
|
|
client_id: "https://example.com",
|
|
redirect_uri: "https://example.com",
|
|
code_verifier: code_verifier
|
|
)
|
|
end.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
|
|
it "rejects a reused code" do
|
|
code = issue_code
|
|
exchange(code)
|
|
|
|
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
|
|
it "rejects an expired code" do
|
|
code = issue_code
|
|
code.update_columns(expires_at: 1.minute.ago)
|
|
|
|
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
|
|
it "rejects a mismatching redirect_uri" do
|
|
code = issue_code
|
|
|
|
expect { exchange(code, redirect_uri: "https://example.com/other") }
|
|
.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
|
|
it "rejects a mismatching client_id host" do
|
|
code = issue_code
|
|
|
|
expect { exchange(code, client_id: "https://evil.example.org") }
|
|
.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
|
|
it "rejects a code whose stored client_id host differs from the presented one" do
|
|
code = RsAuthorizationCode.create!(
|
|
user: user,
|
|
client_id: "evil.example.org",
|
|
redirect_uri: "https://example.com",
|
|
permissions: %w(documents:rw),
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: "S256"
|
|
)
|
|
|
|
expect { exchange(code) }.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
|
|
it "rejects a bad code_verifier" do
|
|
code = issue_code
|
|
|
|
expect { exchange(code, code_verifier: "wrong-verifier") }
|
|
.to raise_error(RsOauthManagerService::InvalidGrant)
|
|
end
|
|
end
|