discourse/app/services/user_api_key/device_auth/poll.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

116 lines
3.2 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey::DeviceAuth::Poll
include Service::Base
params do
attribute :device_code, :string
validates :device_code, presence: true
end
options { attribute :request_id, :string }
model :poll_response, :poll_device_request
private
def poll_device_request(params:, options:)
device_code = params.device_code
request_id = options.request_id
if !UserApiKey::DeviceAuth::DEVICE_CODE_REGEX.match?(device_code.to_s)
return(
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_EXPIRED_TOKEN,
reason: "invalid_device_code",
device_code: device_code,
request_id: request_id,
)
)
end
grant = UserApiKey::DeviceAuth::GrantStore.load(device_code)
if grant.blank?
return(
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_EXPIRED_TOKEN,
reason: "grant_missing",
device_code: device_code,
request_id: request_id,
)
)
end
if grant.pending?
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_AUTHORIZATION_PENDING,
reason: "grant_pending",
grant: grant,
device_code: device_code,
request_id: request_id,
)
elsif grant.authorized?
authorized_grant =
UserApiKey::DeviceAuth::GrantStore.consume_authorized(device_code, request_id: request_id)
if authorized_grant == UserApiKey::DeviceAuth::GrantStore::CONSUME_LOCKED
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_AUTHORIZATION_PENDING,
reason: "authorized_grant_locked",
grant: grant,
device_code: device_code,
request_id: request_id,
)
elsif authorized_grant.present?
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_AUTHORIZED,
reason: "authorized_grant_consumed",
grant: authorized_grant,
device_code: device_code,
request_id: request_id,
payload: authorized_grant.payload,
)
else
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_EXPIRED_TOKEN,
reason: "authorized_grant_missing_after_lock",
grant: grant,
device_code: device_code,
request_id: request_id,
)
end
elsif grant.denied?
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_ACCESS_DENIED,
reason: "grant_denied",
grant: grant,
device_code: device_code,
request_id: request_id,
)
else
poll_response(
UserApiKey::DeviceAuth::POLL_STATUS_EXPIRED_TOKEN,
reason: "unknown_grant_status",
grant: grant,
device_code: device_code,
request_id: request_id,
)
end
end
def poll_response(status, device_code:, request_id:, reason:, grant: nil, payload: nil)
UserApiKey::DeviceAuth.trace(
"device_auth.poll.checked",
request_id: request_id,
reason: reason,
status: status,
grant_status: grant&.status,
client_id: grant&.client_id,
device_code: device_code,
)
response = { status: status }
response[:payload] = payload if payload.present?
response
end
end