Semantic diff: User API key auth becomes a device authorization flow.
Start from the semantically split full diff, not from alphabetic files. Each section explains what changed, why that slice matters, and then shows the Pierre-rendered diff with notes attached to the relevant files.
expires_at
Redis-backed grants
ERB → Ember authorization screens
Rake smoke client
Creation, polling, activation, approve, deny, and activation POST.
Grant, store, validator, crypto, code registry, user activation, and flow commands.
Device requests live briefly in Redis; authorized payloads are retained for at most one minute.
New hidden site setting gates expires_in_seconds.
Behavioral contract
Before
- Client opens browser or redirect-capable auth flow.
- Server-rendered ERB pages own
new,otp, andshow. - User API keys have no first-class requested expiry field.
- No code-entry flow for CLIs/headless clients.
After
- Headless client calls
POST /user-api-key/device.json. - User approves in Ember activation UI with code/request-token safeguards.
- Client polls
/device/poll.jsonand receives encrypted payload once. - Keys can carry
expires_at, exposed in payload and preferences.
Changed system surfaces
Execution story: what actually happens
POST /user-api-key/device.jsonCreateRequestGrantStore + CodeRegistryUserActivationAuthorize / DenyPollUserApiKey.activeRedis objects introduced
user_api_key:device:<64-hex-device-code> # serialized Grant user_api_key:device:code:<ABCD-2345> # user code → device code user_api_key:device:request:<8-char-token> # request token → device code user_api_key:device:lock:<64-hex-device-code> # short authorization lock
Poll state machine
pending → { status: "authorization_pending" }
authorized → consume grant, return { status: "authorized", payload }
denied → { status: "access_denied" }
missing/bad → { status: "expired_token" }
locked → { status: "authorization_pending" }
What to understand
Reviewer lens
Representative files
Risk heatmap
Open review questions
expires_at == now?DEVICE_AUTHORIZED_PAYLOAD_TTL?expires_at?d-otp now normalizes normal input, not only paste. Is every existing consumer happy with that?Why these risks rank high
This PR touches auth, key material, Redis one-time delivery, CSRF exemptions on JSON endpoints, and persistent key expiry. The implementation is sensibly decomposed; the dangerous parts are not “lots of files”, they are lifecycle edges: expiry, races, replay, stale Redis indexes, and user-binding.
Suggested human reading order
Pick an item
Click a file on the left. This is the piece GitHub does not give you: a route through the change ordered by conceptual dependency, not alphabetic file path.
// evidence appears here
Evidence lenses
+ post "/user-api-key/device" => "user_api_keys#create_device_request" + post "/user-api-key/device/poll" => "user_api_keys#poll_device_request" + get "/user-api-key/activate" => "user_api_keys#activate" + post "/user-api-key/activate" => "user_api_keys#activate" + post "/user-api-key/device/authorize" => "user_api_keys#authorize_device_request" + post "/user-api-key/device/deny" => "user_api_keys#deny_device_request"
class UserApiKey::DeviceAuth
DEVICE_AUTH_TTL = 10.minutes
DEVICE_AUTH_INTERVAL = 5
DEVICE_AUTHORIZED_PAYLOAD_TTL = 1.minute
DEVICE_CODE_REDIS_PREFIX = "user_api_key:device:".freeze
DEVICE_USER_CODE_REDIS_PREFIX = "user_api_key:device:code:".freeze
DEVICE_REQUEST_REDIS_PREFIX = "user_api_key:device:request:".freeze
DEVICE_AUTHORIZATION_LOCK_REDIS_PREFIX = "user_api_key:device:lock:".freeze
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
DEVICE_CODE_REGEX = /\A\h{64}\z/
DEVICE_REQUEST_TOKEN_REGEX = /\A[-_A-Za-z0-9]{8}\z/
endclass AddExpiresAtToUserApiKeys < ActiveRecord::Migration[8.0]
def change
add_column :user_api_keys, :expires_at, :datetime
end
end
scope :active,
-> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.zone.now) }
def expired?
expires_at.present? && expires_at <= Time.zone.now
endbin/rake user_api_key:device_auth \ SITE=http://localhost:3000 \ SCOPES=read,write \ EXPIRES_IN=1d # Prints verification URL + user code, polls /device/poll.json, # decrypts payload with generated RSA private key, verifies nonce, # optionally checks /session/current.json using the returned key.
diff --git a/app/controllers/user_api_keys_controller.rb b/app/controllers/user_api_keys_controller.rb @@ class UserApiKeysController < ApplicationController - layout "no_ember" - requires_login only: %i[create create_otp revoke undo_revoke] + requires_login only: %i[create create_otp revoke undo_revoke authorize_device_request deny_device_request] + skip_before_action :verify_authenticity_token, only: %i[create_device_request poll_device_request] + before_action :set_device_auth_no_store, only: %i[new otp create create_otp create_device_request poll_device_request activate authorize_device_request deny_device_request] @@ def new - head :ok, auth_api_version: AUTH_API_VERSION + head :ok, auth_api_version: AUTH_API_VERSION, auth_api_device_code: "true" @@ added endpoint + def create_device_request + ensure_json_request! + rate_limit_device_request_creation + UserApiKey::DeviceAuth::CreateRequest.call(service_params) do + on_success do |device_request:| + render json: { device_code:, user_code:, verification_uri:, expires_in:, interval: } + end + end + end
Semantic diff: the primary view
This is now the main artifact. The whole PR is split into ten conceptual boundaries, not file path. Each semantic group has reviewer notes, then the actual Pierre-rendered diffs. The goal is to teach the change while you read the code — not before, not after, inside the review surface.