0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 08:06:54 +08:00
discourse/app/models/user_api_key.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

122 lines
3.8 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserApiKey < ActiveRecord::Base
self.ignored_columns = [
"client_id", # TODO: Add post-migration to remove column after 3.4.0 stable release (not before early 2025)
"application_name", # TODO: Add post-migration to remove column after 3.4.0 stable release (not before early 2025)
]
REVOKE_MATCHER = RouteMatcher.new(actions: "user_api_keys#revoke", methods: :post, params: [:id])
belongs_to :user
belongs_to :client, class_name: "UserApiKeyClient", foreign_key: "user_api_key_client_id"
has_many :scopes, class_name: "UserApiKeyScope", dependent: :destroy
scope :active,
-> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.zone.now) }
scope :with_key, ->(key) { where(key_hash: ApiKey.hash_key(key)) }
after_initialize :generate_key
def generate_key
if !key_hash
@key ||= SecureRandom.hex
self.key_hash = ApiKey.hash_key(@key)
end
end
def key
unless key_available?
raise ApiKey::KeyAccessError.new "API key is only accessible immediately after creation"
end
@key
end
def key_available?
@key.present?
end
def ensure_allowed!(env)
raise Discourse::InvalidAccess.new if !allow?(env)
end
def update_last_used(client_id)
update_args = { last_used_at: Time.zone.now }
if client_id.present? && client_id != client.client_id
new_client =
UserApiKeyClient.create!(client_id: client_id, application_name: client.application_name)
update_args[:user_api_key_client_id] = new_client.id
end
update_columns(**update_args)
end
# Scopes allowed to be requested by external services
def self.allowed_scopes
Set.new(SiteSetting.allow_user_api_key_scopes.split("|"))
end
def self.available_scopes
@available_scopes ||= Set.new(UserApiKeyScopes.all_scopes.keys.map(&:to_s))
end
def has_push?
scopes.any? { |s| s.name == "push" || s.name == "notifications" } && push_url.present? &&
SiteSetting.allowed_user_api_push_urls.include?(push_url)
end
def expired?
expires_at.present? && expires_at <= Time.zone.now
end
def self.push_clients_for(user)
return [] if SiteSetting.allow_user_api_key_scopes.split("|").exclude?("push")
return [] if SiteSetting.allowed_user_api_push_urls.blank?
user
.user_api_keys
.active
.joins(:scopes, :client)
.where("user_api_key_scopes.name IN ('push', 'notifications')")
.where("push_url IS NOT NULL AND push_url <> ''")
.where("position(push_url IN ?) > 0", SiteSetting.allowed_user_api_push_urls)
.order("user_api_key_clients.client_id ASC")
.pluck("user_api_key_clients.client_id, user_api_keys.push_url")
end
def allow?(env)
scopes.any? { |s| s.permits?(env) } || is_revoke_self_request?(env)
end
private
def revoke_self_matcher
REVOKE_MATCHER.with_allowed_param_values({ "id" => [nil, id.to_s] })
end
def is_revoke_self_request?(env)
revoke_self_matcher.match?(env: env)
end
end
# == Schema Information
#
# Table name: user_api_keys
#
# id :integer not null, primary key
# expires_at :datetime
# key_hash :string not null
# last_used_at :datetime not null
# push_url :string
# revoked_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# user_api_key_client_id :bigint
# user_id :integer not null
#
# Indexes
#
# index_user_api_keys_on_client_id (client_id) UNIQUE
# index_user_api_keys_on_key_hash (key_hash) UNIQUE
# index_user_api_keys_on_user_api_key_client_id (user_api_key_client_id)
# index_user_api_keys_on_user_id (user_id)
#