WarpEngine 0.5.0: a catalog that can say a title is not yours
A desktop client reading /api/software had no way to learn that a title costs money. There was nothing in the response to say so, no way to sign in, and no way to be told "you do not own this" — so a store with paid titles could only hand the client a 403 at download time and let it guess why. The fix belongs here rather than in the client. A client serves more than one store, so anything it knows about a particular one has to arrive from that store's own API; a rule compiled into the client is a rule that breaks every other catalog it reads. Three seams, each following the storage adapter's shape — documented contract, default that is byte for byte the old behaviour, one config key to replace it: - **access policy** — visible_software_scope / access_for / authorize_download. Every catalog entry now carries an `access` block (gated, entitled, price, purchaseUrl, webUrl) and both /api/download and /file/* ask before serving. The vocabulary is deliberately generic: a word from one host's domain would make every client that reads it specific to that host. - **client sign-in** — the device authorization grant (RFC 8628), over the host's own user model. The approval page stays the host's, because approving needs a session and HTML. Tokens are ApplicationTokens with a `catalog` scope, so publishing and reading stay separable. - **service descriptor** — GET /api/service says what this deployment is and whether it has a sign-in at all, which is how a client stops guessing. With no policy and no subject class configured — every deployment today — the API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and the 187 pre-existing examples pass untouched. A policy that raises is treated as a refusal, not permission. An artifact served because the gatekeeper crashed is the one failure mode this must not have, so a broken policy empties the catalog and denies the download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
module WarpEngine
|
||||
# Who the caller is, on the read-only side of the API.
|
||||
#
|
||||
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
|
||||
# pipeline write to the catalog", this one asks "whose library am I looking at". The
|
||||
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
|
||||
# and a catalog with no policy configured never needs one.
|
||||
#
|
||||
# The credential is a bearer token, because that is what a client can carry: it has no
|
||||
# cookie jar and no browser session.
|
||||
module SubjectAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# The ApplicationToken behind the request, or nil.
|
||||
def current_access_token
|
||||
return @current_access_token if defined?(@current_access_token)
|
||||
|
||||
@current_access_token = resolve_access_token
|
||||
end
|
||||
|
||||
# Whoever that token belongs to — the host's own object. nil when the request is
|
||||
# anonymous, when the token is unknown, or when no subject class is configured.
|
||||
def current_subject
|
||||
current_access_token&.owner
|
||||
end
|
||||
|
||||
def resolve_access_token
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
token = bearer_token
|
||||
return nil if token.blank?
|
||||
|
||||
record = WarpEngine::ApplicationToken.authenticate(
|
||||
token, required_scope: WarpEngine::ApplicationToken::CATALOG_SCOPE
|
||||
)
|
||||
return nil if record.nil?
|
||||
|
||||
record.touch_last_used!
|
||||
record
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
header = request.headers["Authorization"].to_s
|
||||
return nil unless header.start_with?("Bearer ")
|
||||
|
||||
header.delete_prefix("Bearer ").strip.presence
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, client side (RFC 8628).
|
||||
#
|
||||
# Both actions are unauthenticated, and have to be: the whole point of the flow is
|
||||
# that the caller has no credential yet. What protects it is that a device code is
|
||||
# useless until a signed-in person approves it on the host's own page.
|
||||
class Api::Auth::DevicesController < ApiController
|
||||
before_action :ensure_identity_configured
|
||||
|
||||
resource_description do
|
||||
short "Device sign-in"
|
||||
end
|
||||
|
||||
api :POST, "/api/auth/device", "Start a device sign-in and get a code pair"
|
||||
param :client_name, String, required: false, desc: "What to call this device in the person's account"
|
||||
returns code: 200, desc: "The code pair and where to take it"
|
||||
error code: 404, desc: "This deployment has no client sign-in"
|
||||
def create
|
||||
grant = service.request(client_name: params[:client_name])
|
||||
|
||||
render json: {
|
||||
deviceCode: grant.device_code,
|
||||
userCode: grant.formatted_user_code,
|
||||
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||
interval: WarpEngine.config.device_code_interval.to_i,
|
||||
expiresIn: (grant.expires_at - Time.current).to_i
|
||||
}
|
||||
end
|
||||
|
||||
api :POST, "/api/auth/device/token", "Poll a device sign-in for its token"
|
||||
param :device_code, String, required: true, desc: "The device code from POST /api/auth/device"
|
||||
returns code: 200, desc: "state is one of pending, approved, denied, expired"
|
||||
error code: 404, desc: "No such device code, or no client sign-in here"
|
||||
def token
|
||||
state, plain = service.poll(device_code: params[:device_code])
|
||||
|
||||
# The token rides on the one poll that finds the grant newly approved; a client
|
||||
# that loses it starts the flow again. Keeping a plain token around to hand out
|
||||
# twice would mean storing it, which is the thing this design avoids.
|
||||
body = { state: state.to_s }
|
||||
body[:token] = plain if plain.present?
|
||||
render json: body
|
||||
rescue WarpEngine::DeviceGrantService::UnknownCode
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def service
|
||||
@service ||= WarpEngine::DeviceGrantService.new
|
||||
end
|
||||
|
||||
# A deployment with no configured subject class has no sign-in at all, and says so
|
||||
# the same way GET /api/service does — by not offering it.
|
||||
def ensure_identity_configured
|
||||
return if WarpEngine.identity_configured?
|
||||
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module WarpEngine
|
||||
# Signing out: a client throws away its own token.
|
||||
#
|
||||
# Revocation is a soft delete on the ApplicationToken, so the record of which device
|
||||
# signed in and when survives it. The person's own list of devices — where somebody
|
||||
# revokes a token for a laptop they no longer have — is the host's page, because it
|
||||
# needs a session and a browser.
|
||||
class Api::Auth::TokensController < ApiController
|
||||
resource_description do
|
||||
short "Client tokens"
|
||||
end
|
||||
|
||||
api :DELETE, "/api/auth/token", "Revoke the bearer token this request carries"
|
||||
returns code: 204, desc: "Revoked"
|
||||
error code: 401, desc: "No usable bearer token on the request"
|
||||
def destroy
|
||||
token = current_access_token
|
||||
return head(:unauthorized) if token.nil?
|
||||
|
||||
WarpEngine::DeviceGrantService.new.revoke(token: token)
|
||||
head :no_content
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ module WarpEngine
|
||||
returns code: 200, desc: "File binary data"
|
||||
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
|
||||
error code: 400, desc: "Path is blank"
|
||||
error code: 403, desc: "The access policy refused this caller"
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
path = params[:path]
|
||||
@@ -19,7 +20,9 @@ module WarpEngine
|
||||
path: path,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
referer: request.referer
|
||||
referer: request.referer,
|
||||
subject: current_subject,
|
||||
request: request
|
||||
)
|
||||
|
||||
if location.nil?
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
module WarpEngine
|
||||
# What this deployment is and what it can do, in one unauthenticated request.
|
||||
#
|
||||
# This is how a client stops guessing. Before it existed, everything a client knew
|
||||
# about a store was compiled into the client — which endpoints to call, whether
|
||||
# signing in was a thing here, where to send somebody who wanted to buy something.
|
||||
# Every one of those is a property of the *server*, and a client that carries them
|
||||
# can only ever serve the one store it was built for.
|
||||
class Api::ServiceController < ApiController
|
||||
resource_description do
|
||||
short "Service descriptor"
|
||||
end
|
||||
|
||||
api :GET, "/api/service", "What this WarpEngine deployment offers"
|
||||
desc <<~DESC
|
||||
Public on purpose: a client reads this *before* it can have a credential.
|
||||
|
||||
`auth` is null where the host has configured no client identity — the catalog is
|
||||
open, there is nobody to sign in as, and a client should not offer to. Where it is
|
||||
present, `auth.device` describes the device authorization grant a client with no
|
||||
browser of its own uses to sign in.
|
||||
|
||||
`catalog.gated` says whether any title here can require an entitlement. A client
|
||||
can render a store that never gates differently from one that sometimes does,
|
||||
without having to read the whole catalog first to find out.
|
||||
DESC
|
||||
returns code: 200, desc: "The descriptor" do
|
||||
property :engine, String, desc: "Always 'warp_engine'"
|
||||
property :version, String, desc: "Engine version, same value as the WarpEngine-Version header"
|
||||
property :catalog, Hash, desc: "Catalog properties" do
|
||||
property :gated, :boolean, desc: "Whether titles here can require an entitlement"
|
||||
end
|
||||
property :auth, Hash, desc: "How to sign in, or null where there is no sign-in"
|
||||
end
|
||||
def show
|
||||
render json: {
|
||||
engine: "warp_engine",
|
||||
version: WarpEngine::VERSION,
|
||||
catalog: { gated: !WarpEngine::AccessPolicy.open? },
|
||||
auth: auth_descriptor
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_descriptor
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
service = WarpEngine::DeviceGrantService.new
|
||||
{
|
||||
schemes: [ "bearer" ],
|
||||
device: {
|
||||
authorizeUrl: warp_engine.api_auth_device_url,
|
||||
tokenUrl: warp_engine.api_auth_device_token_url,
|
||||
revokeUrl: warp_engine.api_auth_token_url,
|
||||
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||
interval: WarpEngine.config.device_code_interval.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -56,7 +56,7 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
def index
|
||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
|
||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id], subject: current_subject)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,7 +34,7 @@ module WarpEngine
|
||||
end
|
||||
error code: 404, desc: "No highlighted software found"
|
||||
def index
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
|
||||
if result
|
||||
render json: result
|
||||
else
|
||||
|
||||
@@ -11,6 +11,10 @@ module WarpEngine
|
||||
# client needs the version most when something came back wrong.
|
||||
before_action :set_version_header
|
||||
|
||||
# Every read-only endpoint may be called with a bearer token; none of them requires
|
||||
# one. See WarpEngine::SubjectAuthentication.
|
||||
include WarpEngine::SubjectAuthentication
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||
@@ -28,6 +32,10 @@ module WarpEngine
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from WarpEngine::DownloadService::Denied do
|
||||
render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_version_header
|
||||
|
||||
@@ -9,9 +9,12 @@ module WarpEngine
|
||||
param :path, String, required: true, desc: "File path"
|
||||
returns code: 200, desc: "File binary data"
|
||||
returns code: 301, desc: "Redirect to file URL"
|
||||
error code: 403, desc: "The access policy refused this caller"
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
result = WarpEngine::FileService.new.show(WarpEngine::FileShowInputDto.new(path: params[:path]))
|
||||
result = WarpEngine::FileService.new.show(
|
||||
WarpEngine::FileShowInputDto.new(path: params[:path]), subject: current_subject
|
||||
)
|
||||
case result.type
|
||||
when :redirect then redirect_to result.url, status: :moved_permanently
|
||||
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
|
||||
|
||||
Reference in New Issue
Block a user