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

140 lines
4 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey::DeviceAuth::CodeRegistry
USER_CODE_ALPHABET = UserApiKey::DeviceAuth::USER_CODE_ALPHABET
REQUEST_TOKEN_REGEX = UserApiKey::DeviceAuth::DEVICE_REQUEST_TOKEN_REGEX
MAX_COLLISION_ATTEMPTS = UserApiKey::DeviceAuth::MAX_CODE_REGISTRY_COLLISION_ATTEMPTS
CodeSet =
if const_defined?(:CodeSet, false)
const_get(:CodeSet)
else
Struct.new(:user_code, :request_token, keyword_init: true)
end
def self.valid_request_token?(request_token)
REQUEST_TOKEN_REGEX.match?(request_token.to_s)
end
def self.normalize_user_code(value)
code = value.to_s.upcase.gsub(/[^A-Z0-9]/, "")
return if code.length != 8
"#{code[0, 4]}-#{code[4, 4]}"
end
def self.reserve_for(device_code)
request_token = nil
user_code = nil
begin
request_token = reserve_request_token!(device_code)
user_code = reserve_user_code!(device_code)
CodeSet.new(user_code: user_code, request_token: request_token)
rescue StandardError
delete_request_token(request_token) if request_token.present?
delete_user_code(user_code) if user_code.present?
raise
end
end
def self.load_by_user_code(user_code)
device_code = Discourse.redis.get(user_code_key(user_code))
return if device_code.blank?
grant = UserApiKey::DeviceAuth::GrantStore.load(device_code)
delete_user_code(user_code) if grant.blank?
grant
end
def self.load_by_request_token(request_token)
return if !valid_request_token?(request_token)
device_code = Discourse.redis.get(request_token_key(request_token))
return if device_code.blank?
grant = UserApiKey::DeviceAuth::GrantStore.load(device_code)
delete_request_token(request_token) if grant.blank?
grant
end
def self.user_code_matches_grant?(user_code, grant)
normalized_code = normalize_user_code(user_code)
return false if normalized_code.blank?
device_code = Discourse.redis.get(user_code_key(normalized_code))
device_code.present? && device_code == grant.device_code
end
def self.delete_indexes_for(grant)
delete_user_code(grant.user_code) if grant.user_code.present?
delete_request_token(grant.request_token) if grant.request_token.present?
end
def self.delete_user_code(user_code)
Discourse.redis.del(user_code_key(user_code))
end
def self.delete_request_token(request_token)
Discourse.redis.del(request_token_key(request_token))
end
def self.user_code_key(user_code)
"#{UserApiKey::DeviceAuth::DEVICE_USER_CODE_REDIS_PREFIX}#{user_code}"
end
def self.request_token_key(request_token)
"#{UserApiKey::DeviceAuth::DEVICE_REQUEST_REDIS_PREFIX}#{request_token}"
end
def self.clear!
[
UserApiKey::DeviceAuth::DEVICE_USER_CODE_REDIS_PREFIX,
UserApiKey::DeviceAuth::DEVICE_REQUEST_REDIS_PREFIX,
].each do |prefix|
Discourse.redis.scan_each(match: "#{prefix}*") { |key| Discourse.redis.del(key) }
end
end
def self.reserve_user_code!(device_code)
MAX_COLLISION_ATTEMPTS.times do
user_code = generate_user_code
if Discourse.redis.set(
user_code_key(user_code),
device_code,
nx: true,
ex: UserApiKey::DeviceAuth::DEVICE_AUTH_TTL.to_i,
)
return user_code
end
end
raise Discourse::InvalidAccess
end
def self.reserve_request_token!(device_code)
MAX_COLLISION_ATTEMPTS.times do
request_token = SecureRandom.urlsafe_base64(6)
if Discourse.redis.set(
request_token_key(request_token),
device_code,
nx: true,
ex: UserApiKey::DeviceAuth::DEVICE_AUTH_TTL.to_i,
)
return request_token
end
end
raise Discourse::InvalidAccess
end
def self.generate_user_code
code =
Array
.new(8) { USER_CODE_ALPHABET[SecureRandom.random_number(USER_CODE_ALPHABET.length)] }
.join
"#{code[0, 4]}-#{code[4, 4]}"
end
private_class_method :reserve_user_code!, :reserve_request_token!, :generate_user_code
end