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

313 lines
8.9 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey::DeviceAuth::UserActivation
class Result
attr_reader :status, :grant, :device_code, :request_token, :debug_reason
def initialize(status:, grant: nil, device_code: nil, request_token: nil, debug_reason: nil)
@status = status
@grant = grant
@device_code = device_code
@request_token = request_token
@debug_reason = debug_reason
end
end
def initialize(user:, session:, request_id: nil)
@user = user
@request_id = request_id
@approval_tokens = UserApiKey::DeviceAuth::ApprovalTokenStore.new(session: session, user: user)
end
def preview_request_token(request_token)
if !UserApiKey::DeviceAuth::CodeRegistry.valid_request_token?(request_token)
return(
trace_and_return_result(
"device_auth.activation.preview.failed",
expired_result("invalid_request_token", request_token: request_token),
)
)
end
grant = UserApiKey::DeviceAuth::CodeRegistry.load_by_request_token(request_token)
if (reason = unavailable_reason_for(grant))
return(
trace_and_return_result(
"device_auth.activation.preview.failed",
expired_result(reason, grant: grant, request_token: request_token),
)
)
end
trace_and_return_result(
"device_auth.activation.preview.succeeded",
Result.new(status: :success, grant: grant, request_token: request_token),
)
end
def find_manual_code(code)
user_code = UserApiKey::DeviceAuth::CodeRegistry.normalize_user_code(code)
if user_code.blank?
return(
trace_and_return_result(
"device_auth.activation.manual_code.failed",
Result.new(status: :invalid_code, debug_reason: "invalid_user_code_format"),
user_code: code,
)
)
end
grant = UserApiKey::DeviceAuth::CodeRegistry.load_by_user_code(user_code)
if grant.blank?
return(
trace_and_return_result(
"device_auth.activation.manual_code.failed",
Result.new(status: :invalid_code, debug_reason: "user_code_not_found"),
user_code: user_code,
)
)
end
if (reason = unavailable_reason_for(grant))
return(
trace_and_return_result(
"device_auth.activation.manual_code.failed",
expired_result(reason, grant: grant),
user_code: user_code,
)
)
end
trace_and_return_result(
"device_auth.activation.manual_code.succeeded",
Result.new(status: :success, grant: grant),
user_code: user_code,
)
end
def create_approval_token!(grant)
device_code = grant.device_code
approval_token = nil
failure_reason = nil
UserApiKey::DeviceAuth::GrantStore.with_lock!(
device_code,
operation: "device_auth.activation.approval_token",
request_id: request_id,
) do
stored_grant = UserApiKey::DeviceAuth::GrantStore.load(device_code)
if (failure_reason = unavailable_reason_for(stored_grant))
next
end
unless stored_grant.bind_to_user!(user)
failure_reason = "bound_to_other_user"
next
end
UserApiKey::DeviceAuth::GrantStore.save!(
stored_grant,
ttl: UserApiKey::DeviceAuth::GrantStore.ttl_for_update(device_code),
)
approval_token = @approval_tokens.create!(device_code)
end
if approval_token.present?
UserApiKey::DeviceAuth.trace(
"device_auth.activation.approval_token.succeeded",
request_id: request_id,
user_id: user&.id,
device_code: device_code,
approval_token: approval_token,
)
else
UserApiKey::DeviceAuth.trace(
"device_auth.activation.approval_token.failed",
request_id: request_id,
reason: failure_reason || "approval_token_not_created",
user_id: user&.id,
device_code: device_code,
)
end
approval_token
rescue Discourse::InvalidAccess
UserApiKey::DeviceAuth.trace(
"device_auth.activation.approval_token.failed",
request_id: request_id,
reason: "lock_busy",
user_id: user&.id,
device_code: device_code,
)
nil
end
def resolve_authorize_device_code(request_token:, user_code:, approval_token:)
if request_token.present?
resolve_request_authorize_device_code(request_token, user_code)
else
resolve_approval_token_device_code(approval_token)
end
end
def resolve_deny_device_code(request_token:, approval_token:)
if request_token.present?
return(
trace_and_return_result(
"device_auth.activation.resolve_deny.failed",
expired_result("deny_requires_approval_token", request_token: request_token),
)
)
end
resolve_approval_token_device_code(approval_token)
end
def delete_approval_token(token)
@approval_tokens.delete!(token)
UserApiKey::DeviceAuth.trace(
"device_auth.activation.approval_token.deleted",
request_id: request_id,
user_id: user&.id,
approval_token: token,
)
end
private
attr_reader :user, :request_id
def resolve_request_authorize_device_code(request_token, user_code)
grant = UserApiKey::DeviceAuth::CodeRegistry.load_by_request_token(request_token)
if (reason = unavailable_reason_for(grant))
return(
trace_and_return_result(
"device_auth.activation.resolve_request.failed",
expired_result(reason, grant: grant, request_token: request_token),
)
)
end
result = nil
UserApiKey::DeviceAuth::GrantStore.with_lock!(
grant.device_code,
operation: "device_auth.activation.resolve_request",
request_id: request_id,
) do
grant = UserApiKey::DeviceAuth::CodeRegistry.load_by_request_token(request_token)
if (reason = unavailable_reason_for(grant))
result = expired_result(reason, grant: grant, request_token: request_token)
next
end
unless UserApiKey::DeviceAuth::CodeRegistry.user_code_matches_grant?(user_code, grant)
result =
Result.new(
status: :invalid_code,
grant: grant,
request_token: request_token,
debug_reason: "user_code_mismatch",
)
next
end
if !grant.bind_to_user!(user)
result = expired_result("bound_to_other_user", grant: grant, request_token: request_token)
next
end
UserApiKey::DeviceAuth::GrantStore.save!(
grant,
ttl: UserApiKey::DeviceAuth::GrantStore.ttl_for_update(grant.device_code),
)
result =
Result.new(
status: :success,
grant: grant,
device_code: grant.device_code,
request_token: request_token,
)
end
if result.present?
trace_and_return_result(
(
if result.status == :success
"device_auth.activation.resolve_request.succeeded"
else
"device_auth.activation.resolve_request.failed"
end
),
result,
user_code: user_code,
)
else
trace_and_return_result(
"device_auth.activation.resolve_request.failed",
expired_result("empty_lock_result", grant: grant, request_token: request_token),
user_code: user_code,
)
end
rescue Discourse::InvalidAccess
trace_and_return_result(
"device_auth.activation.resolve_request.failed",
expired_result("lock_busy", grant: grant, request_token: request_token),
user_code: user_code,
)
end
def resolve_approval_token_device_code(approval_token)
device_code = @approval_tokens.device_code_for(approval_token)
if device_code.blank?
return(
trace_and_return_result(
"device_auth.activation.resolve_approval_token.failed",
expired_result("approval_token_invalid"),
approval_token: approval_token,
)
)
end
trace_and_return_result(
"device_auth.activation.resolve_approval_token.succeeded",
Result.new(status: :success, device_code: device_code),
approval_token: approval_token,
)
end
def unavailable_reason_for(grant)
return "grant_missing" if grant.blank?
return "grant_not_pending" if !grant.pending?
return "user_missing" if user.blank?
"bound_to_other_user" if grant.bound_to_another_user?(user)
end
def expired_result(debug_reason, grant: nil, request_token: nil)
Result.new(
status: :expired_code,
grant: grant,
request_token: request_token,
debug_reason: debug_reason,
)
end
def trace_and_return_result(event, result, **payload)
UserApiKey::DeviceAuth.trace(
event,
**{
request_id: request_id,
reason: result.debug_reason,
status: result.status,
user_id: user&.id,
client_id: result.grant&.client_id,
device_code: result.device_code || result.grant&.device_code,
request_token: result.request_token,
}.merge(payload),
)
result
end
end