Skip to content

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-tokens mints 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/connect serves two flows behind one route name, dispatched on whether the body carries a connection_token: ConnectSystemConnectionController::initiate() (create a pending connection in the caller's own tenant — what the connector-widget's generateToken() call genuinely needs) and finalize() (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 name column 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/disconnect and /uninstall stay reachable by every token regardless of scope. They are outside the widget prefix, authenticated by AuthenticateWidgetToken alone, 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_id path (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() sends tenant_id unconditionally and only attaches a Bearer header if (this.internalToken) — which is empty whenever initialize()'s bootstrap didn't match a canHaveSupportUsers() 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:

  • EnsureWidgetTokenIsAuthorizedForRoute sits in the widget route group, after AuthenticateWidgetToken (which now also stores the resolved token record on the request as widget_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.
  • EnsureConnectionTokenIsAuthorizedForRoute is the connection-guard counterpart. It is appended to the whole api middleware group, immediately after auth:connection (see bootstrap/app.php) — not attached to individual routes. A first pass attached it only to /connect and /access-tokens, which a second review round correctly flagged as not actually closed by default: every other connection-guarded route (/me, /systems, /system-connections, and any added later) would authenticate via the group's auth:connection but 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 to routes/api.php later is closed to the connector-widget scope by default, the same property the widget-group middleware already had. SystemConnectionTokenGuard exposes the token behind a resolved connection (resolvedToken()) so this middleware can read it without a second query. It authorizes: /me, /systems, /system-connections outright (read-only, and exactly what a token straight out of the bootstrap flow is proven to need by ConnectionTokenAuthenticationE2eTest), and /connect without a connection_token in the body — the initiate half. Finalize is always rejected for this scope, and /access-tokens is closed entirely, regardless of which connection it targets — neither is on any allow-list. Routes outside its concern — the widget group, /disconnect, /uninstall (all three authenticate via AuthenticateWidgetToken, a different guard, but the bearer token is still resolvable through the connection guard too, since both guards query the same table). /register (genuinely unauthenticated) and webhooks/{connection} — strip it via withoutMiddleware(), the same way they already strip auth:connection itself. Without stripping it from the widget group 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.