0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/spec/models/user_api_key_spec.rb
Sam 5458a5f150
FEATURE: User API key device authorization flow (#40189)
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>
2026-06-10 16:09:44 -04:00

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