discourse/spec/system/page_objects/pages/user_api_key_show.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

79 lines
1.8 KiB
Ruby
Vendored

# frozen_string_literal: true
module PageObjects
module Pages
class UserApiKeyShow < PageObjects::Pages::Base
def visit_authorization(public_key:)
page.visit(
"/user-api-key/new?#{
URI.encode_www_form(
scopes: "read",
client_id: "x" * 32,
application_name: "Test Application",
public_key: public_key,
nonce: SecureRandom.hex,
)
}",
)
self
end
def visit_otp(public_key:)
page.visit(
"/user-api-key/otp?#{
URI.encode_www_form(
application_name: "Test Application",
public_key: public_key,
auth_redirect: "discourse://auth_redirect",
)
}",
)
self
end
def click_authorize
click_button(I18n.t("user_api_key.authorize"))
self
end
def click_copy_key
find("#copy-api-key-btn").click
self
end
def payload
find("#user-api-key-payload").text
end
def has_authorization_form?
has_css?(".authorize-api-key h1", text: "Test Application") &&
has_button?(I18n.t("user_api_key.authorize"))
end
def has_payload?
has_css?("#user-api-key-payload") && has_css?("#copy-api-key-btn")
end
def has_copied_button?
has_button?(I18n.t("user_api_key.copied"))
end
def has_otp_form?
has_css?(
".authorize-api-key h1",
text: I18n.t("user_api_key.otp_description", application_name: "Test Application"),
) && has_button?(I18n.t("user_api_key.authorize"))
end
def has_no_sidebar?
has_no_css?("#d-sidebar")
end
def has_no_powered_by_discourse?
has_no_css?(".powered-by-discourse")
end
end
end
end