From 76427bab91f5c727e3eb4ea08c674c348455f01d Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 19 Aug 2026 10:36:30 +0200 Subject: [PATCH] WarpEngine: let the host say who a browser is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access policy asks who the caller is, and until now only a bearer token could answer. That is what a desktop client carries — but a person clicking a download link on the site carries a session instead, and the engine has no idea what a session is. So a host that gated its catalog found its own signed-in visitors refused at /api/download, which is a regression the shadowed route used to hide. c.subject_resolver is a callable taking the Rack request and returning the host's subject: `->(request) { request.env["warden"]&.user }` for a Devise app. Unset — every deployment today — a non-bearer request stays anonymous, exactly as before. A resolver that raises is logged and treated as anonymous, because a broken one turning every read into a 500 is worse than an anonymous request. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 18 ++++++++ .../warp_engine/subject_authentication.rb | 29 ++++++++++-- .../install/templates/initializer.rb | 5 ++ lib/warp_engine/configuration.rb | 8 ++++ spec/requests/device_auth_spec.rb | 46 +++++++++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8dc0104..f754887 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,9 @@ Rails.application.config.to_prepare do # none: /api/auth/* answers 404 and GET /api/service reports auth: null. # c.access_token_owner_class = "User" # c.identity_verification_url = "/devices" + # + # How to recognise a caller with a session instead of a bearer token. + # c.subject_resolver = ->(request) { request.env["warden"]&.user } # If your app's own models reference catalog images, register them so the # admin Images page counts them as "in use": @@ -465,6 +468,21 @@ The flow: 4. the client's next `POST /api/auth/device/token` carries the token away. It is handed over exactly once and never stored in the clear afterwards. +### Recognising a browser + +A bearer token is what a *client* carries; a browser carries a session, and the +engine has no idea what a session is. A host that wants its signed-in visitors +recognised on these endpoints too — so that clicking a download link on the site +works the same way the client's download does — says how: + +```ruby +c.subject_resolver = ->(request) { request.env["warden"]&.user } +``` + +Without one, a request with no bearer token is anonymous, which is what the +read-only API always did. A resolver that raises is logged and treated as +anonymous rather than taking the request down with it. + The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, sent as `Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out), and the admin lists both kinds of token and the sign-ins behind them. diff --git a/app/controllers/concerns/warp_engine/subject_authentication.rb b/app/controllers/concerns/warp_engine/subject_authentication.rb index 0de8e4b..9e2e74e 100644 --- a/app/controllers/concerns/warp_engine/subject_authentication.rb +++ b/app/controllers/concerns/warp_engine/subject_authentication.rb @@ -20,10 +20,20 @@ module WarpEngine @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. + # Whoever the request is on behalf of — the host's own object, or nil. + # + # Two ways to be somebody, tried in that order. A bearer token is what a client + # carries. A *browser* carries a session instead, and the engine has no idea what a + # session is here — so a host that wants its signed-in visitors recognised on these + # endpoints supplies a resolver: + # + # c.subject_resolver = ->(request) { request.env["warden"]&.user } + # + # Without one, a browser is simply anonymous, which is what it always was. def current_subject - current_access_token&.owner + return @current_subject if defined?(@current_subject) + + @current_subject = current_access_token&.owner || resolve_host_subject end def resolve_access_token @@ -41,6 +51,19 @@ module WarpEngine record end + # A resolver that raises must not take the request with it: it runs on every read + # endpoint, and a broken one would turn the whole API into 500s rather than into + # anonymous requests, which is the honest fallback. + def resolve_host_subject + resolver = WarpEngine.config.subject_resolver + return nil if resolver.nil? + + resolver.call(request) + rescue StandardError => e + Rails.logger.error("[WarpEngine::SubjectAuthentication] subject_resolver #{e.class}: #{e.message}") + nil + end + def bearer_token header = request.headers["Authorization"].to_s return nil unless header.start_with?("Bearer ") diff --git a/lib/generators/warp_engine/install/templates/initializer.rb b/lib/generators/warp_engine/install/templates/initializer.rb index e160600..e90a1bd 100644 --- a/lib/generators/warp_engine/install/templates/initializer.rb +++ b/lib/generators/warp_engine/install/templates/initializer.rb @@ -60,6 +60,11 @@ Rails.application.config.to_prepare do # It calls WarpEngine::DeviceGrantService#approve. A path is made absolute # against the request, so you need not know your own hostname (default "/devices"). # c.identity_verification_url = "/devices" + # A browser has a session rather than a bearer token, and the engine cannot + # read one. Say how, and your signed-in visitors are recognised on the + # read-only endpoints too: + # c.subject_resolver = ->(request) { request.env["warden"]&.user } + # c.device_code_ttl = 600 # seconds a pending code lives # c.device_code_interval = 5 # seconds a client is told to wait between polls diff --git a/lib/warp_engine/configuration.rb b/lib/warp_engine/configuration.rb index 1e8790c..dc371be 100644 --- a/lib/warp_engine/configuration.rb +++ b/lib/warp_engine/configuration.rb @@ -40,6 +40,12 @@ module WarpEngine # code. Path or absolute URL; nil falls back to "/devices". The page is the host's # because approving needs a session, a login and HTML — none of which is the # engine's business. + # subject_resolver: how to recognise a caller that is not carrying a bearer token — + # a browser with a session, typically. A callable taking the Rack request and + # returning the host's own subject object, or nil: + # c.subject_resolver = ->(request) { request.env["warden"]&.user } + # nil (default) makes every non-bearer request anonymous, which is what the + # read-only API always did. # device_code_ttl / device_code_interval: how long a pending device code lives, and # how often a client is told to poll for it. # woodpecker_url / woodpecker_api_token / woodpecker_repo_owner: @@ -63,6 +69,7 @@ module WarpEngine :storage_adapter, :access_policy, :access_token_owner_class, + :subject_resolver, :identity_verification_url, :device_code_ttl, :device_code_interval @@ -86,6 +93,7 @@ module WarpEngine @storage_adapter = :local @access_policy = :open @access_token_owner_class = nil + @subject_resolver = nil @identity_verification_url = nil @device_code_ttl = 600 @device_code_interval = 5 diff --git a/spec/requests/device_auth_spec.rb b/spec/requests/device_auth_spec.rb index 4583810..e709d67 100644 --- a/spec/requests/device_auth_spec.rb +++ b/spec/requests/device_auth_spec.rb @@ -196,4 +196,50 @@ RSpec.describe "Device sign-in", type: :request do expect(response).to have_http_status(:unauthorized) end end + + # A browser carries a session, not a bearer token, and the engine has no idea what a + # session is. A host that wants its signed-in visitors recognised says how. + describe "the host's own subject resolver" do + let(:seen) { [] } + let(:policy) do + sink = seen + Class.new do + def initialize(sink) = @sink = sink + def visible_software_scope(subject: nil) = WarpEngine::Software.all + def access_for(software:, subject: nil) + @sink << subject + WarpEngine::Access::OPEN + end + def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN + end.new(sink) + end + + before do + create(:software) + allow(WarpEngine.config).to receive(:access_policy).and_return(policy) + end + + it "is asked when there is no bearer token" do + allow(WarpEngine.config).to receive(:subject_resolver).and_return(->(_request) { owner }) + + get "/api/software" + + expect(seen).to eq([ owner ]) + end + + it "leaves the request anonymous when the host configured none" do + get "/api/software" + + expect(seen).to eq([ nil ]) + end + + it "answers anonymously rather than erroring when the resolver breaks" do + allow(WarpEngine.config).to receive(:subject_resolver).and_return(->(_request) { raise "boom" }) + + get "/api/software" + + expect(response).to have_http_status(:ok) + expect(seen).to eq([ nil ]) + end + end end