mirror of
https://github.com/discourse/discourse.git
synced 2026-08-07 13:19:19 +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>
57 lines
1.7 KiB
Ruby
Vendored
57 lines
1.7 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe "User API Key Show Page" do
|
|
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
|
|
|
let(:cdp) { PageObjects::CDP.new }
|
|
let(:user_api_key_page) { PageObjects::Pages::UserApiKeyShow.new }
|
|
|
|
let(:public_key) { OpenSSL::PKey::RSA.new(2048).public_key.to_pem }
|
|
|
|
before do
|
|
SiteSetting.user_api_key_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
|
|
SiteSetting.navigation_menu = "sidebar"
|
|
SiteSetting.enable_powered_by_discourse = true
|
|
end
|
|
|
|
it "lets a user authorize and copy the generated key" do
|
|
sign_in(user)
|
|
|
|
user_api_key_page.visit_authorization(public_key: public_key)
|
|
|
|
expect(user_api_key_page).to have_authorization_form
|
|
expect(user_api_key_page).to have_no_sidebar
|
|
expect(user_api_key_page).to have_no_powered_by_discourse
|
|
|
|
screenshot_marker(label: "user-api-key-auth")
|
|
|
|
user_api_key_page.click_authorize
|
|
|
|
expect(user_api_key_page).to have_payload
|
|
expect(user_api_key_page).to have_no_sidebar
|
|
expect(user_api_key_page).to have_no_powered_by_discourse
|
|
|
|
cdp.allow_clipboard
|
|
|
|
displayed_payload = user_api_key_page.payload
|
|
expect(displayed_payload).to match(/\s/)
|
|
|
|
user_api_key_page.click_copy_key
|
|
|
|
expect(user_api_key_page).to have_copied_button
|
|
|
|
clipboard_content = cdp.read_clipboard
|
|
expect(clipboard_content).not_to match(/\s/)
|
|
expect { Base64.decode64(clipboard_content) }.not_to raise_error
|
|
end
|
|
|
|
it "keeps one-time password authorization focused" do
|
|
sign_in(user)
|
|
|
|
user_api_key_page.visit_otp(public_key: public_key)
|
|
|
|
expect(user_api_key_page).to have_otp_form
|
|
expect(user_api_key_page).to have_no_sidebar
|
|
expect(user_api_key_page).to have_no_powered_by_discourse
|
|
end
|
|
end
|