mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +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>
107 lines
3.7 KiB
Ruby
Vendored
107 lines
3.7 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe UserApiKey do
|
|
describe "#allow?" do
|
|
def request_env(method, path, **path_parameters)
|
|
ActionDispatch::TestRequest
|
|
.create
|
|
.tap do |request|
|
|
request.request_method = method
|
|
request.path = path
|
|
request.path_parameters = path_parameters
|
|
end
|
|
.env
|
|
end
|
|
|
|
it "can look up permissions correctly" do
|
|
key =
|
|
UserApiKey.new(
|
|
scopes: %w[message_bus notifications].map { |name| UserApiKeyScope.new(name: name) },
|
|
)
|
|
|
|
expect(key.allow?(request_env("GET", "/random"))).to eq(false)
|
|
expect(key.allow?(request_env("POST", "/message-bus/1234/poll"))).to eq(true)
|
|
|
|
expect(
|
|
key.allow?(request_env("PUT", "/xyz", controller: "notifications", action: "mark_read")),
|
|
).to eq(true)
|
|
|
|
expect(
|
|
key.allow?(request_env("POST", "/xyz", controller: "user_api_keys", action: "revoke")),
|
|
).to eq(true)
|
|
end
|
|
|
|
it "can allow all correct scopes to write" do
|
|
key = UserApiKey.new(scopes: ["write"].map { |name| UserApiKeyScope.new(name: name) })
|
|
|
|
expect(key.allow?(request_env("GET", "/random"))).to eq(true)
|
|
expect(key.allow?(request_env("PUT", "/random"))).to eq(true)
|
|
expect(key.allow?(request_env("PATCH", "/random"))).to eq(true)
|
|
expect(key.allow?(request_env("DELETE", "/random"))).to eq(true)
|
|
expect(key.allow?(request_env("POST", "/random"))).to eq(true)
|
|
end
|
|
|
|
it "can allow blanket read" do
|
|
key = UserApiKey.new(scopes: ["read"].map { |name| UserApiKeyScope.new(name: name) })
|
|
|
|
expect(key.allow?(request_env("GET", "/random"))).to eq(true)
|
|
expect(key.allow?(request_env("PUT", "/random"))).to eq(false)
|
|
end
|
|
end
|
|
|
|
describe ".active" do
|
|
it "includes unexpired keys and excludes expired and revoked keys" do
|
|
freeze_time
|
|
|
|
active_key = Fabricate(:readonly_user_api_key, expires_at: 1.hour.from_now)
|
|
key_without_expiry = Fabricate(:readonly_user_api_key, expires_at: nil)
|
|
expired_key = Fabricate(:readonly_user_api_key, expires_at: 1.minute.ago)
|
|
revoked_key = Fabricate(:readonly_user_api_key, revoked_at: Time.zone.now)
|
|
|
|
expect(described_class.active).to include(active_key, key_without_expiry)
|
|
expect(described_class.active).not_to include(expired_key, revoked_key)
|
|
end
|
|
end
|
|
|
|
describe "#expired?" do
|
|
it "returns true only for keys past their expiry" do
|
|
freeze_time
|
|
|
|
expect(described_class.new(expires_at: 1.second.ago)).to be_expired
|
|
expect(described_class.new(expires_at: 1.second.from_now)).not_to be_expired
|
|
expect(described_class.new(expires_at: nil)).not_to be_expired
|
|
end
|
|
end
|
|
|
|
describe ".push_clients_for" do
|
|
it "excludes expired keys" do
|
|
freeze_time
|
|
SiteSetting.allow_user_api_key_scopes = "push"
|
|
SiteSetting.allowed_user_api_push_urls = "https://push.example.com"
|
|
user = Fabricate(:user)
|
|
active_client = Fabricate(:user_api_key_client, client_id: "active-client")
|
|
expired_client = Fabricate(:user_api_key_client, client_id: "expired-client")
|
|
|
|
Fabricate(
|
|
:user_api_key,
|
|
user: user,
|
|
client: active_client,
|
|
push_url: "https://push.example.com",
|
|
expires_at: 1.hour.from_now,
|
|
scopes: [Fabricate.build(:user_api_key_scope, name: "push")],
|
|
)
|
|
Fabricate(
|
|
:user_api_key,
|
|
user: user,
|
|
client: expired_client,
|
|
push_url: "https://push.example.com",
|
|
expires_at: 1.hour.ago,
|
|
scopes: [Fabricate.build(:user_api_key_scope, name: "push")],
|
|
)
|
|
|
|
expect(described_class.push_clients_for(user)).to eq(
|
|
[%w[active-client https://push.example.com]],
|
|
)
|
|
end
|
|
end
|
|
end
|