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,199 @@
|
||||
require "rails_helper"
|
||||
|
||||
# Signing a client in, end to end: the client asks for a code, a person approves it on
|
||||
# the host's page, the client's next poll carries the token away, and the token then
|
||||
# works as a bearer credential on the read-only API.
|
||||
RSpec.describe "Device sign-in", type: :request do
|
||||
let(:owner) { create(:test_owner) }
|
||||
|
||||
# The identity seam is off by default. Configuring the subject class is what turns
|
||||
# the whole flow on — including whether it exists at all.
|
||||
def configure_identity!(verification: "/devices")
|
||||
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
|
||||
allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification)
|
||||
end
|
||||
|
||||
describe "when the host configured no client identity" do
|
||||
it "has no device endpoint at all" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "says so in the service descriptor rather than by erroring" do
|
||||
get "/api/service"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["auth"]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "the full flow" do
|
||||
before { configure_identity! }
|
||||
|
||||
it "issues a code pair a person can read off a screen" do
|
||||
post "/api/auth/device", params: { client_name: "Zsolt's laptop" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["deviceCode"]).to be_present
|
||||
# Grouped and free of I/O/0/1, because it is typed by hand into a browser.
|
||||
expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/)
|
||||
expect(json["verificationUrl"]).to eq("http://www.example.com/devices")
|
||||
expect(json["interval"]).to eq(5)
|
||||
end
|
||||
|
||||
it "keeps the client waiting until somebody approves" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
device_code = JSON.parse(response.body)["deviceCode"]
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: device_code }
|
||||
|
||||
expect(JSON.parse(response.body)).to eq("state" => "pending")
|
||||
end
|
||||
|
||||
it "hands over the token on the first poll after approval" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
body = JSON.parse(response.body)
|
||||
expect(body["state"]).to eq("approved")
|
||||
expect(body["token"]).to be_present
|
||||
end
|
||||
|
||||
# The plain token is never stored, so it cannot be handed out twice. A client that
|
||||
# loses it starts again — which is cheaper than a database full of live secrets.
|
||||
it "does not repeat the token on a second poll" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
body = JSON.parse(response.body)
|
||||
expect(body["state"]).to eq("approved")
|
||||
expect(body).not_to have_key("token")
|
||||
end
|
||||
|
||||
it "reports a denied grant as denied" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrantService.new.deny(user_code: json["userCode"])
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
expect(JSON.parse(response.body)["state"]).to eq("denied")
|
||||
end
|
||||
|
||||
it "reports an expired grant as expired" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrant.last.update!(expires_at: 1.minute.ago)
|
||||
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
|
||||
expect(JSON.parse(response.body)["state"]).to eq("expired")
|
||||
end
|
||||
|
||||
it "404s an unknown device code" do
|
||||
post "/api/auth/device/token", params: { device_code: "nope" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "will not approve the same code twice" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
code = JSON.parse(response.body)["userCode"]
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner)
|
||||
|
||||
expect { WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner) }
|
||||
.to raise_error(WarpEngine::DeviceGrantService::UnknownCode)
|
||||
end
|
||||
|
||||
it "accepts the user code however a person typed it" do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
code = JSON.parse(response.body)["userCode"]
|
||||
|
||||
grant = WarpEngine::DeviceGrantService.new.approve(
|
||||
user_code: code.downcase.delete("-"), subject: owner
|
||||
)
|
||||
|
||||
expect(grant).to be_approved
|
||||
end
|
||||
end
|
||||
|
||||
describe "the issued token" do
|
||||
before { configure_identity! }
|
||||
|
||||
let(:token) do
|
||||
post "/api/auth/device", params: { client_name: "laptop" }
|
||||
json = JSON.parse(response.body)
|
||||
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||
JSON.parse(response.body)["token"]
|
||||
end
|
||||
|
||||
it "belongs to the subject who approved it, and may only read the catalog" do
|
||||
token
|
||||
record = WarpEngine::ApplicationToken.last
|
||||
|
||||
expect(record.owner).to eq(owner)
|
||||
expect(record.scopes).to eq([ "catalog" ])
|
||||
end
|
||||
|
||||
# A publishing token must not become a client token by accident, and vice versa:
|
||||
# the scope is what separates them, and the catalog endpoint requires its own.
|
||||
it "is not accepted as a publishing credential" do
|
||||
token
|
||||
|
||||
expect(WarpEngine::ApplicationToken.authenticate(token, required_scope: "update")).to be_nil
|
||||
end
|
||||
|
||||
it "identifies the subject on a catalog request" do
|
||||
create(:software)
|
||||
seen = nil
|
||||
policy = Class.new do
|
||||
def initialize(sink) = @sink = sink
|
||||
def visible_software_scope(subject: nil) = WarpEngine::Software.all
|
||||
def access_for(software:, subject: nil)
|
||||
@sink.call(subject)
|
||||
WarpEngine::Access::OPEN
|
||||
end
|
||||
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
|
||||
end.new(->(s) { seen = s })
|
||||
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
|
||||
|
||||
get "/api/software", headers: { "Authorization" => "Bearer #{token}" }
|
||||
|
||||
expect(seen).to eq(owner)
|
||||
end
|
||||
|
||||
it "is ignored when it is not a bearer credential" do
|
||||
value = token
|
||||
|
||||
get "/api/software", headers: { "Authorization" => value }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(WarpEngine::ApplicationToken.last.last_used_at).to be_nil
|
||||
end
|
||||
|
||||
it "stops working once revoked" do
|
||||
value = token
|
||||
|
||||
delete "/api/auth/token", headers: { "Authorization" => "Bearer #{value}" }
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(WarpEngine::ApplicationToken.authenticate(value, required_scope: "catalog")).to be_nil
|
||||
end
|
||||
|
||||
it "refuses to revoke without a token" do
|
||||
delete "/api/auth/token"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
require "rails_helper"
|
||||
|
||||
# The descriptor is how a client stops being built for one particular store: everything
|
||||
# it used to have compiled in — is there a sign-in, where does it live, can titles be
|
||||
# gated — is answered here instead.
|
||||
RSpec.describe "GET /api/service", type: :request do
|
||||
after { WarpEngine::AccessPolicy.reset! }
|
||||
|
||||
it "names the engine and its version" do
|
||||
get "/api/service"
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["engine"]).to eq("warp_engine")
|
||||
expect(json["version"]).to eq(WarpEngine::VERSION)
|
||||
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
|
||||
end
|
||||
|
||||
it "reports an open catalog as ungated and offering no sign-in" do
|
||||
get "/api/service"
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["catalog"]).to eq("gated" => false)
|
||||
expect(json["auth"]).to be_nil
|
||||
end
|
||||
|
||||
it "reports a configured policy as a catalog that can gate" do
|
||||
policy = Class.new do
|
||||
def visible_software_scope(subject: nil) = WarpEngine::Software.all
|
||||
def access_for(software:, subject: nil) = WarpEngine::Access::OPEN
|
||||
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
|
||||
end.new
|
||||
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
|
||||
|
||||
get "/api/service"
|
||||
|
||||
expect(JSON.parse(response.body)["catalog"]).to eq("gated" => true)
|
||||
end
|
||||
|
||||
describe "with a client identity configured" do
|
||||
before do
|
||||
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
|
||||
allow(WarpEngine.config).to receive(:identity_verification_url).and_return("/devices")
|
||||
end
|
||||
|
||||
it "describes the device flow, so a client needs no addresses of its own" do
|
||||
get "/api/service"
|
||||
|
||||
auth = JSON.parse(response.body)["auth"]
|
||||
expect(auth["schemes"]).to eq([ "bearer" ])
|
||||
expect(auth["device"]["authorizeUrl"]).to eq("http://www.example.com/api/auth/device")
|
||||
expect(auth["device"]["tokenUrl"]).to eq("http://www.example.com/api/auth/device/token")
|
||||
expect(auth["device"]["revokeUrl"]).to eq("http://www.example.com/api/auth/token")
|
||||
expect(auth["device"]["interval"]).to eq(5)
|
||||
end
|
||||
|
||||
# A host that configured a bare path should not have to know its own hostname; one
|
||||
# that put the approval page on another domain should keep it.
|
||||
it "makes a configured path absolute against the request" do
|
||||
get "/api/service"
|
||||
|
||||
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
|
||||
.to eq("http://www.example.com/devices")
|
||||
end
|
||||
|
||||
it "leaves an absolute verification URL alone" do
|
||||
allow(WarpEngine.config).to receive(:identity_verification_url)
|
||||
.and_return("https://accounts.example.org/devices")
|
||||
|
||||
get "/api/service"
|
||||
|
||||
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
|
||||
.to eq("https://accounts.example.org/devices")
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user