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

84 lines
2.5 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey::DeviceAuth::CreateRequest
include Service::Base
params do
attribute :nonce, :string
attribute :scopes, :string
attribute :client_id, :string
attribute :application_name, :string
attribute :public_key, :string
attribute :push_url, :string
attribute :padding, :string
attribute :expires_in_seconds, :string
validates :nonce, presence: true
validates :scopes, presence: true
validates :client_id, presence: true
end
options { attribute :request_id, :string }
model :client, optional: true
try Discourse::InvalidParameters, Discourse::InvalidAccess do
step :validate_request
step :build_grant
step :validate_grant_size
step :reserve_codes
step :store_grant
end
private
def fetch_client(params:)
UserApiKeyClient.find_by(client_id: params.client_id)
end
def validate_request(params:, client:)
UserApiKey::DeviceAuth::RequestValidator.validate!(params.attributes.symbolize_keys, client)
end
def build_grant(params:, client:)
request_params = params.attributes.symbolize_keys
scopes = request_params[:scopes].split(",")
expires_in_seconds = UserApiKey::Expiry.parse_seconds!(request_params[:expires_in_seconds])
device_code = SecureRandom.hex(32)
context[:device_request] = { device_code: device_code }
context[:grant] = UserApiKey::DeviceAuth::Grant.build(
request_params,
client,
scopes,
expires_in_seconds,
device_code,
)
UserApiKey::DeviceAuth::PayloadBuilder.validate_size!(context[:grant])
end
def validate_grant_size(grant:)
UserApiKey::DeviceAuth::KeyCreator.validate_grant_size!(grant)
end
def reserve_codes(device_request:, grant:)
codes = UserApiKey::DeviceAuth::CodeRegistry.reserve_for(device_request[:device_code])
grant.assign_codes!(user_code: codes.user_code, request_token: codes.request_token)
device_request[:user_code] = codes.user_code
device_request[:request_token] = codes.request_token
end
def store_grant(device_request:, grant:, options:)
UserApiKey::DeviceAuth::KeyCreator.validate_grant_size!(grant)
UserApiKey::DeviceAuth::GrantStore.save!(grant, ttl: UserApiKey::DeviceAuth::DEVICE_AUTH_TTL)
UserApiKey::DeviceAuth.trace(
"device_auth.create.succeeded",
request_id: options.request_id,
client_id: grant.client_id,
device_code: grant.device_code,
request_token: grant.request_token,
user_code: grant.user_code,
)
end
end