0038. Scope the connector-widget token to an allow-listed route set¶
Date: 2026-10-01 Status: Accepted
Context¶
POST /system-connections/connect mints a long-lived, non-expiring access-purpose token and
hands it straight to the connector-widget, which re-broadcasts it to the embedding page's own JS
via the conflux-initialized event. Before this decision, every access token authenticated
identically regardless of who held it: AuthenticateWidgetToken resolved any valid token to a
SystemConnection for every route in the widget group, and the standard connection guard
(auth:connection / SystemConnectionTokenGuard) did the same for every other authenticated
route — /me, /systems, /system-connections, POST /system-connections/connect, and
POST /system-connections/{connection}/access-tokens. A token's name (connector-widget vs
plugin-widget) was recorded at mint time but never read again.
That was tolerable while the widget group only exposed configuration, status and sync-batch
reads. CFX-226 needs an endpoint where a consuming platform (e.g. User Platform) can submit its
own callback credentials during widget setup (CFX-259 adds POST /widget/external-credentials
for this), and the project's direction is to keep adding widget routes — customer, order and
product endpoints are expected. The connector-widget token is the one token in this system that
lives outside Conflux, embedded in a third party's frontend JS, so its blast radius needed
minimizing before any such route exists for it to reach by default.
A pre-launch security review of the CFX-259 draft (nothing live yet, so free to close this properly rather than patch around it) surfaced that a widget-route allow-list alone was not enough:
POST /system-connections/{connection}/access-tokensmints a new access token for any connection in the caller's tenant. An unrestricted connector-widget token could mint itself a fresh connector-widget token for a sibling connection — e.g. a shop holding WooCommerce or PrestaShop credentials — and, combined with the new external-credentials endpoint, overwrite that shop's credentials entirely.POST /system-connections/connectserves two flows behind one route name, dispatched on whether the body carries aconnection_token:ConnectSystemConnectionController::initiate()(create a pending connection in the caller's own tenant — what the connector-widget'sgenerateToken()call genuinely needs) andfinalize()(claim an arbitrary plugin-initiated pending connection, by its token, into the caller's tenant). Allow-listing the route by name alone would have let a connector-widget token reach both.- A free-text
namecolumn is also the wrong thing to gate on: nothing stops a future call site from passing a typo'd string that matches neither known value and falls through unscoped.
Two closely related, more nuanced points the same review raised were deliberately not folded
into this decision, after checking the actual code and the connector widget's frontend
(ConfluxConnector.vue) rather than assuming:
POST /system-connections/disconnectand/uninstallstay reachable by every token regardless of scope. They are outside thewidgetprefix, authenticated byAuthenticateWidgetTokenalone, and — confirmed with the person who owns this decision — the connector-widget is meant to be able to disconnect/uninstall the host connection; this is intentional, not an oversight./connect's unauthenticated-tenant_idpath (anyone who obtains a tenant's UUID can create pending connections in it with no Bearer token at all) looks like a bug but is load-bearing:generateToken()sendstenant_idunconditionally and only attaches a Bearer headerif (this.internalToken)— which is empty wheneverinitialize()'s bootstrap didn't match acanHaveSupportUsers()system, i.e. for any embedding platform that isn't one of Conflux's own internal systems. Closing it requires redesigning how that call authenticates, not a validation tweak, and risks breaking that flow. Tracked separately, not part of this decision.
Decision¶
Two new, parallel middlewares — one per guard the connector-widget token can reach:
EnsureWidgetTokenIsAuthorizedForRoutesits in thewidgetroute group, afterAuthenticateWidgetToken(which now also stores the resolved token record on the request aswidget_connection_token, not just the connection). Allow-lists exactly the five routes in that group. Because it sits on the whole group, a route added there later inherits the check automatically — closed by default, not by someone remembering to add it per route.EnsureConnectionTokenIsAuthorizedForRouteis theconnection-guard counterpart. It is appended to the wholeapimiddleware group, immediately afterauth:connection(seebootstrap/app.php) — not attached to individual routes. A first pass attached it only to/connectand/access-tokens, which a second review round correctly flagged as not actually closed by default: every otherconnection-guarded route (/me,/systems,/system-connections, and any added later) would authenticate via the group'sauth:connectionbut never run the scope check at all, unless someone remembered to attach it — a deny-list in effect, the opposite of what this ADR claims. Appending it to the group fixes that: a route added toroutes/api.phplater is closed to the connector-widget scope by default, the same property the widget-group middleware already had.SystemConnectionTokenGuardexposes the token behind a resolved connection (resolvedToken()) so this middleware can read it without a second query. It authorizes:/me,/systems,/system-connectionsoutright (read-only, and exactly what a token straight out of the bootstrap flow is proven to need byConnectionTokenAuthenticationE2eTest), and/connectwithout aconnection_tokenin the body — the initiate half. Finalize is always rejected for this scope, and/access-tokensis closed entirely, regardless of which connection it targets — neither is on any allow-list. Routes outside its concern — thewidgetgroup,/disconnect,/uninstall(all three authenticate viaAuthenticateWidgetToken, a different guard, but the bearer token is still resolvable through theconnectionguard too, since both guards query the same table)./register(genuinely unauthenticated) andwebhooks/{connection}— strip it viawithoutMiddleware(), the same way they already stripauth:connectionitself. Without stripping it from thewidgetgroup specifically, every widget route would reject a connector-widget token outright, since none of them are on this middleware's own, unrelated allow-list.
Both middlewares key their check on a typed App\Enums\AccessTokenScope (ConnectorWidget /
PluginWidget), read from a new, dedicated scope column on system_connection_tokens —
deliberately not a cast on the existing name column. name is a free-text label with no
enforced shape (the system_connection_access_tokens legacy migration copied arbitrary values
into it, and the model's own docs note admin-preview as a surviving legacy value); casting it
directly to a backed enum would throw on any row outside the handful of known values.
ConnectionTokenIssuerService::issueAccessToken() takes an optional ?AccessTokenScope $scope
and derives name from it when $name is not separately given, so a caller sets one typed
argument and the two columns can never silently drift apart. Only ConnectorWidget and
PluginWidget are defined; every other case (the examples-widget preview token, legacy rows, or
no scope at all) is null and unrestricted by either middleware — only the connector-widget scope
has a reason to be restricted today.
Both allow-lists are plain, closed-by-default checks in their respective middleware, not a
deny-list or a declarative per-route-per-scope matrix: this is one scope with one well-understood
job today, and the project avoids designing abstractions ahead of a second real case (see
.ai/architecture rules). A route added to either surface later is unreachable by the
connector-widget scope until someone explicitly adds it.
Consequences¶
A route added to the widget group, or a new route anywhere else in routes/api.php, is
unreachable by the connector-widget scope until someone explicitly extends
EnsureWidgetTokenIsAuthorizedForRoute::CONNECTOR_WIDGET_ALLOWED_ROUTES or
EnsureConnectionTokenIsAuthorizedForRoute::CONNECTOR_WIDGET_ALLOWED_ROUTES — a forgotten addition
fails closed (403) rather than open, in both places, with no third option (a route that forgets to
strip the connection-guard middleware where it genuinely doesn't apply fails closed too, as the
widget group would have without its explicit strip). The plugin-widget scope, and any token with
no scope at all, is unaffected unless this scheme is deliberately extended to cover it too. Having
the identical pattern live in two middleware classes, one per guard, is a deliberate
near-duplication rather than a shared abstraction — the two guards resolve tokens differently
enough (widget_connection_token request attribute vs SystemConnectionTokenGuard::
resolvedToken()) that forcing a shared implementation now would cost more than it saves against
two real cases. If a third guard or a third scope value needs the same treatment, generalize then.
A new route added to routes/api.php that needs to be exempt from the connection-guard check
entirely (because it authenticates some other way, or not at all) must remember to add
EnsureConnectionTokenIsAuthorizedForRoute::class to its own withoutMiddleware() call, the same
way it would already need to strip auth:connection for that case — there is no mechanism that
infers this automatically from stripping auth:connection alone.
POST /widget/external-credentials additionally rejects (422) when the submitted credentials
verify against a different external account than the connection already has — a connector-widget
token minted for one external workspace must not be able to silently re-point a connection that
already represents another. This is an acceptance-criterion-level rule for that endpoint, not a
route-scoping concern, but is recorded here since it closes the same class of cross-account risk
this ADR is about.