WarpEngine: let the host say who a browser is
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) <noreply@anthropic.com>
This commit is contained in:
@@ -220,6 +220,9 @@ Rails.application.config.to_prepare do
|
|||||||
# none: /api/auth/* answers 404 and GET /api/service reports auth: null.
|
# none: /api/auth/* answers 404 and GET /api/service reports auth: null.
|
||||||
# c.access_token_owner_class = "User"
|
# c.access_token_owner_class = "User"
|
||||||
# c.identity_verification_url = "/devices"
|
# 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
|
# If your app's own models reference catalog images, register them so the
|
||||||
# admin Images page counts them as "in use":
|
# 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
|
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.
|
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
|
The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, sent as
|
||||||
`Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out),
|
`Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out),
|
||||||
and the admin lists both kinds of token and the sign-ins behind them.
|
and the admin lists both kinds of token and the sign-ins behind them.
|
||||||
|
|||||||
@@ -20,10 +20,20 @@ module WarpEngine
|
|||||||
@current_access_token = resolve_access_token
|
@current_access_token = resolve_access_token
|
||||||
end
|
end
|
||||||
|
|
||||||
# Whoever that token belongs to — the host's own object. nil when the request is
|
# Whoever the request is on behalf of — the host's own object, or nil.
|
||||||
# anonymous, when the token is unknown, or when no subject class is configured.
|
#
|
||||||
|
# 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
|
def current_subject
|
||||||
current_access_token&.owner
|
return @current_subject if defined?(@current_subject)
|
||||||
|
|
||||||
|
@current_subject = current_access_token&.owner || resolve_host_subject
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_access_token
|
def resolve_access_token
|
||||||
@@ -41,6 +51,19 @@ module WarpEngine
|
|||||||
record
|
record
|
||||||
end
|
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
|
def bearer_token
|
||||||
header = request.headers["Authorization"].to_s
|
header = request.headers["Authorization"].to_s
|
||||||
return nil unless header.start_with?("Bearer ")
|
return nil unless header.start_with?("Bearer ")
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ Rails.application.config.to_prepare do
|
|||||||
# It calls WarpEngine::DeviceGrantService#approve. A path is made absolute
|
# It calls WarpEngine::DeviceGrantService#approve. A path is made absolute
|
||||||
# against the request, so you need not know your own hostname (default "/devices").
|
# against the request, so you need not know your own hostname (default "/devices").
|
||||||
# c.identity_verification_url = "/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_ttl = 600 # seconds a pending code lives
|
||||||
# c.device_code_interval = 5 # seconds a client is told to wait between polls
|
# c.device_code_interval = 5 # seconds a client is told to wait between polls
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ module WarpEngine
|
|||||||
# code. Path or absolute URL; nil falls back to "/devices". The page is the host's
|
# 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
|
# because approving needs a session, a login and HTML — none of which is the
|
||||||
# engine's business.
|
# 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
|
# device_code_ttl / device_code_interval: how long a pending device code lives, and
|
||||||
# how often a client is told to poll for it.
|
# how often a client is told to poll for it.
|
||||||
# woodpecker_url / woodpecker_api_token / woodpecker_repo_owner:
|
# woodpecker_url / woodpecker_api_token / woodpecker_repo_owner:
|
||||||
@@ -63,6 +69,7 @@ module WarpEngine
|
|||||||
:storage_adapter,
|
:storage_adapter,
|
||||||
:access_policy,
|
:access_policy,
|
||||||
:access_token_owner_class,
|
:access_token_owner_class,
|
||||||
|
:subject_resolver,
|
||||||
:identity_verification_url,
|
:identity_verification_url,
|
||||||
:device_code_ttl,
|
:device_code_ttl,
|
||||||
:device_code_interval
|
:device_code_interval
|
||||||
@@ -86,6 +93,7 @@ module WarpEngine
|
|||||||
@storage_adapter = :local
|
@storage_adapter = :local
|
||||||
@access_policy = :open
|
@access_policy = :open
|
||||||
@access_token_owner_class = nil
|
@access_token_owner_class = nil
|
||||||
|
@subject_resolver = nil
|
||||||
@identity_verification_url = nil
|
@identity_verification_url = nil
|
||||||
@device_code_ttl = 600
|
@device_code_ttl = 600
|
||||||
@device_code_interval = 5
|
@device_code_interval = 5
|
||||||
|
|||||||
@@ -196,4 +196,50 @@ RSpec.describe "Device sign-in", type: :request do
|
|||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user