mirror of
https://github.com/discourse/discourse.git
synced 2026-08-12 05:37:26 +08:00
Adds an OAuth-style device authorization flow for user API keys so applications that can't open a browser (CLIs, headless tools, IoT clients) can request a key by displaying a short user-facing code. The client POSTs to `/user-api-key/device` to obtain a device code, a user code, and a verification URL. The user visits the URL, authenticates, confirms the application and scopes, and either approves or denies the request. Meanwhile the client polls `/user-api-key/device/poll` until it receives the encrypted key payload, a denial, or expiry. The flow is implemented as a `UserApiKey::DeviceAuth` namespace of service objects (`CreateRequest`, `Authorize`, `Deny`, `Poll`, `Store`, `Crypto`, `ApprovalTokenStore`, `GrantPresenter`). Pending grants live in Redis with a short TTL and are rate limited per IP and per user code. Encrypted payload generation is shared with the existing redirect-based flow. Also adds first-class expiration for user API keys: - New `expires_at` column on `user_api_keys`. - New `max_user_api_key_expiry_days` site setting (default 365). - Clients can request a key lifetime via `expires_in_seconds`, which is surfaced to the user on the authorization screen and serialized back to the client. - A `user_api_key` rake task for listing, inspecting, expiring, and revoking keys from the console. --------- Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
79 lines
2 KiB
Ruby
Vendored
79 lines
2 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe UserApiKey::DeviceAuth::Deny do
|
|
let(:key) { OpenSSL::PKey::RSA.new(2048) }
|
|
let(:device_request) do
|
|
create_user_api_key_device_auth_request!(
|
|
params: {
|
|
nonce: "nonce",
|
|
scopes: "read",
|
|
client_id: "device-client",
|
|
application_name: "Device Client",
|
|
public_key: key.public_key.to_pem,
|
|
},
|
|
)
|
|
end
|
|
let(:params) { { device_code: device_request[:device_code] } }
|
|
|
|
after { clear_user_api_key_device_auth_redis! }
|
|
|
|
describe described_class::Contract, type: :model do
|
|
it { is_expected.to validate_presence_of(:device_code) }
|
|
end
|
|
|
|
describe ".call" do
|
|
subject(:result) { described_class.call(params: params) }
|
|
|
|
it { is_expected.to run_successfully }
|
|
|
|
it "marks the grant as denied" do
|
|
result
|
|
|
|
grant = UserApiKey::DeviceAuth::GrantStore.load(device_request[:device_code])
|
|
|
|
expect(grant).to be_denied
|
|
end
|
|
|
|
context "when the grant does not exist" do
|
|
let(:params) { { device_code: SecureRandom.hex(32) } }
|
|
|
|
it { is_expected.to fail_a_step(:deny_grant) }
|
|
end
|
|
|
|
context "when the grant is already denied" do
|
|
before do
|
|
grant = UserApiKey::DeviceAuth::GrantStore.load(device_request[:device_code])
|
|
grant.deny!
|
|
UserApiKey::DeviceAuth::GrantStore.save!(
|
|
grant,
|
|
ttl: UserApiKey::DeviceAuth::GrantStore.ttl_for_update(grant.device_code),
|
|
)
|
|
end
|
|
|
|
it { is_expected.to run_successfully }
|
|
|
|
it "keeps the grant denied" do
|
|
result
|
|
|
|
grant = UserApiKey::DeviceAuth::GrantStore.load(device_request[:device_code])
|
|
|
|
expect(grant).to be_denied
|
|
end
|
|
end
|
|
|
|
context "when the grant is authorized" do
|
|
fab!(:user)
|
|
|
|
before do
|
|
UserApiKey::DeviceAuth::Authorize.call(
|
|
params: {
|
|
device_code: device_request[:device_code],
|
|
user_id: user.id,
|
|
},
|
|
)
|
|
end
|
|
|
|
it { is_expected.to fail_a_step(:deny_grant) }
|
|
end
|
|
end
|
|
end
|