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

35 lines
1.2 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey::DeviceAuth::Crypto
def self.parse_public_key!(value)
OpenSSL::PKey::RSA.new(value)
rescue OpenSSL::PKey::RSAError, TypeError
raise Discourse::InvalidParameters.new(:public_key)
end
def self.validate_payload_size!(payload, public_key, padding: nil)
key_size_bytes = public_key.n.num_bytes
max_payload_size =
if padding == "oaep"
key_size_bytes - 2 * 20 - 2
else
key_size_bytes - 11
end
if payload.bytesize > max_payload_size
padding_name = padding == "oaep" ? "OAEP" : "PKCS#1"
raise Discourse::InvalidParameters.new(
"Payload too large for #{padding_name} encryption with this key size. " \
"Maximum: #{max_payload_size} bytes, got: #{payload.bytesize} bytes. " \
"Try using a shorter nonce or a larger RSA key (minimum 2048-bit recommended).",
)
end
end
def self.encrypt!(public_key, data, padding: nil)
padding_mode = padding == "oaep" ? "oaep" : "pkcs1"
public_key.encrypt(data, { "rsa_padding_mode" => padding_mode })
rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::RSAError
raise Discourse::InvalidParameters.new(:public_key)
end
end