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

109 lines
2.8 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey::DeviceAuth::Authorize
include Service::Base
params do
attribute :device_code, :string
attribute :user_id, :integer
validates :device_code, presence: true
validates :user_id, presence: true
end
options { attribute :request_id, :string }
model :user
try Discourse::InvalidParameters, Discourse::InvalidAccess do
step :authorize_grant
end
private
def fetch_user(params:)
User.find_by(id: params.user_id)
end
def authorize_grant(params:, user:, options:)
UserApiKey::DeviceAuth::GrantStore.with_lock!(
params.device_code,
operation: "device_auth.authorize",
request_id: options.request_id,
) do
grant = UserApiKey::DeviceAuth::GrantStore.load(params.device_code)
if grant.blank?
fail_authorize_grant!(
"grant_missing",
params: params,
user: user,
request_id: options.request_id,
)
end
if !grant.pending?
fail_authorize_grant!(
"grant_not_pending",
params: params,
user: user,
grant: grant,
request_id: options.request_id,
)
end
if grant.bound_to_another_user?(user)
fail_authorize_grant!(
"bound_to_other_user",
params: params,
user: user,
grant: grant,
request_id: options.request_id,
)
end
key = UserApiKey::DeviceAuth::KeyCreator.create!(grant, user)
grant.authorize!(
payload: UserApiKey::DeviceAuth::PayloadBuilder.encrypted_payload!(grant, key),
)
UserApiKey::DeviceAuth::GrantStore.save!(
grant,
ttl: UserApiKey::DeviceAuth::GrantStore.authorized_payload_ttl(params.device_code),
)
UserApiKey::DeviceAuth::CodeRegistry.delete_indexes_for(grant)
context[:grant] = grant
UserApiKey::DeviceAuth.trace(
"device_auth.authorize.succeeded",
request_id: options.request_id,
client_id: grant.client_id,
device_code: grant.device_code,
user_id: user.id,
)
end
rescue Discourse::InvalidParameters, Discourse::InvalidAccess => exception
UserApiKey::DeviceAuth.trace(
"device_auth.authorize.failed",
request_id: options.request_id,
reason: exception.class.name,
exception: exception,
device_code: params.device_code,
user_id: user.id,
)
raise
end
def fail_authorize_grant!(reason, params:, user:, request_id:, grant: nil)
UserApiKey::DeviceAuth.trace(
"device_auth.authorize.failed",
request_id: request_id,
reason: reason,
status: grant&.status,
client_id: grant&.client_id,
device_code: params.device_code,
user_id: user.id,
)
context["result.step.authorize_grant"].fail(error: reason)
context.fail!
end
end