Skip to content

feat(connector): scope-preserving OAuth2 reconnect#1012

Open
aureliensibiril wants to merge 19 commits intogetprobo:mainfrom
aureliensibiril:aureliensibiril/connectors-improvements
Open

feat(connector): scope-preserving OAuth2 reconnect#1012
aureliensibiril wants to merge 19 commits intogetprobo:mainfrom
aureliensibiril:aureliensibiril/connectors-improvements

Conversation

@aureliensibiril
Copy link
Copy Markdown
Contributor

@aureliensibiril aureliensibiril commented Apr 8, 2026

Summary

  • Reconnecting an OAuth2 connector now requests the union of previously granted and newly requested scopes, so a SCIM reconnect never drops the access-review scopes (and vice versa). Portable across every provider.
  • The widest-scope row wins when multiple connectors exist for the same (org, provider) pair — tiebreak by most recent updated_at. Fixes the latent "oldest wins" bug in the Slack sender where re-installing Slack to switch channels silently did nothing.
  • Defense-in-depth validation on reconnect: ConnectorService.Reconnect takes a ReconnectConnectorRequest and verifies the loaded row's organization, provider and protocol inside the same transaction, blocking cross-org/cross-provider corruption via a crafted connector_id in the initiate URL.
  • Google Workspace reuse flows now carry include_granted_scopes=true and skip prompt=consent, so the user sees the delta consent screen instead of a full re-consent. Refresh tokens and Slack webhook settings are preserved through reconnect when the provider omits them.

Why

Users re-connecting Google Workspace for a new SCIM scope were silently losing the access-review scopes (and the reverse). Slack re-installs to switch channels silently broke because pkg/slack/sender.go picked connectors[0] ordered by created_at ASC. A stale connector_id in the initiate URL could target a connector in a sibling organization inside the same tenant.

All three are fixed at the runtime entry points (initiate handler, Slack sender, ConnectorService.GetByOrganizationIDAndProvider) without a DB schema change. Legacy duplicate rows remain in place and are consolidated forward via auto-reconnect.

Commits

Small, buildable, conventional commits:

  1. feat(connector): add scope parsing utilitiesParseScopeString/FormatScopeString/UnionScopes/ScopesCover, tolerant of GitHub's comma-separated form.
  2. fix(connector): relax Slack incoming webhook requirement — access-review Slack flows no longer fail at token parsing.
  3. feat(connector): support OAuth2 scope preservation and incremental authConnection.Scopes(), OAuth2State.RequestedScopes fallback, SupportsIncrementalAuth, conditional include_granted_scopes/prompt=consent.
  4. feat(coredata): add widest-scope connector loaderLoadOneByOrganizationIDAndProvider with Scopes()-driven selection.
  5. feat(probo): validate reconnects and preserve dropped token fieldsReconnectConnectorRequest + in-tx validation + preserveConnectionFields for refresh token and Slack webhook.
  6. refactor(console): rewrite /connectors/initiate to union scopes — extracted handler, scope union, InitiateOptions.ConnectorID passed explicitly instead of mutating r.URL.
  7. fix(slack): select widest-scope connector in sender — replaces LoadAll[0] with the widest-scope loader in sendMessage and updateMessage.

The earlier commit Add Slack access review scopes (1-line fix to the providerOAuth2Scopes map) is carried along — it was not in the squash-merged state of #997.

Test plan

  • go build ./... clean
  • go vet ./... clean
  • go test ./pkg/connector/... ./pkg/coredata/... — all passing (scopes_test.go, slack_test.go, new oauth2_test.go subtests, connector_test.go for connectorScopeCount)
  • Manual smoke — Google Workspace SCIM + access review: connect Google Workspace for SCIM (4 scopes), then click "Add Access Source → Google Workspace". Expect the user to go through the full OAuth flow with include_granted_scopes=true and a delta consent screen, and a single connector row updated in place.
  • Manual smoke — Slack re-install to switch channels: connect Slack to #general, then reconnect to #audit. Expect subsequent compliance events to route to #audit (the latent "oldest wins" bug being fixed).
  • Manual smoke — explicit reconnect on a revoked token: revoke the Google token at the provider, click Reconnect on the access source. Expect a fresh token exchange (no short-circuit on stored scope coverage).
  • Manual smoke — cross-org connector_id blocked: craft organization_id=OrgA&connector_id=<OrgB's id> in the initiate URL. Expect a 400 at callback time (caught inside Reconnect).

Out of scope

  • Broader createAccessSource/updateAccessSource cross-org connector_id validation — flagged as a follow-up security PR (see design decision §6 in the plan).
  • Consolidating Organization.slackConnections resolver duplicates — the sender picks the right row but the compliance page UI may still list legacy duplicates.

Summary by cubic

Adds scope‑preserving OAuth2 reconnects with an initiate flow that unions scopes and supports incremental auth; Slack now picks the correct connector and validates token responses. Handlers return clean HTTP errors, and cookie category queries are tenant‑scoped.

  • New Features

    • Initiate unions stored + requested scopes and supports explicit reconnect via connector_id; InitiateOptions.IncludeGrantedScopes enables incremental auth (Google adds include_granted_scopes=true and skips prompt=consent).
    • If a token response omits scope, we fall back to requested scopes from state; added Connection.Scopes() and scope helpers (ParseScopeString, FormatScopeString, UnionScopes), plus a widest‑scope connector selector used by GetByOrganizationIDAndProvider and the Slack sender.
    • ReconnectConnectorRequest validates org/provider/protocol in‑transaction and preserves missing fields (refresh token, Slack webhook settings) during reconnect.
  • Bug Fixes

    • Slack: added access‑review scopes, made incoming_webhook optional, validated ok/error, required access token, and selected the widest‑scope/most‑recent connector with clearer errors.
    • Connector handlers no longer panic and now log + return generic 4xx/5xx responses (no leaked internals); cookie categories loading is tenant‑scoped; cfg/dev.yaml drops redundant OAuth2 fields; Console uses useMutation + useToast.

Written for commit d990d56. Summary will update on new commits.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 16 files

@aureliensibiril aureliensibiril force-pushed the aureliensibiril/connectors-improvements branch from ffb8b25 to 56e7a54 Compare April 13, 2026 16:27
Slack has two OAuth2 use cases in the app: the compliance page
integration (chat:write, channels:join, incoming-webhook) and the
access review driver that lists workspace members via users.list
(users:read, users:read.email). The per-caller scope refactor
already handled the compliance page but missed the access review
path, leaving it silently broken (zero scopes → missing_scope from
Slack API).

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
ParseScopeString, FormatScopeString, UnionScopes and ScopesCover
encapsulate OAuth2 scope-set arithmetic. ParseScopeString accepts
both the RFC 6749 space-separated form and GitHub's comma-separated
non-compliant form in one pass, so callers can stay format-agnostic.

These primitives are the foundation for scope-preserving reconnect:
later commits compute the union of stored and requested scopes so
a reconnect never drops a previously granted scope.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
ParseSlackTokenResponse treated the incoming_webhook field as
mandatory, which blocked any Slack OAuth2 flow that did not request
the incoming-webhook scope. Access review Slack connects only ask
for users:read and users:read.email and would fail at token parsing.

Treat incoming_webhook as optional: populate SlackSettings when it
is present, leave them empty otherwise. The existing compliance-page
webhook URL is preserved through Reconnect in a later commit.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Extend the OAuth2 connector so a reconnect can request the union of
previously granted and newly requested scopes without losing either.
Four related changes:

- Connection gains Scopes() []string so callers no longer need a type
  switch to reach the scope set. OAuth2Connection and APIKeyConnection
  implement it; SlackConnection inherits via embedding.
- OAuth2State carries RequestedScopes and CompleteWithState falls
  back to it when the provider omits the scope field (RFC 6749 §5.1
  allows this when granted equals requested). Without the fallback
  the stored Scope would be empty and the next reconnect would have
  no diff base.
- providerDefinition gains SupportsIncrementalAuth, set only for
  Google Workspace. When the flag is true and the caller passes
  InitiateOptions.IncludeGrantedScopes, the auth URL carries
  include_granted_scopes=true and the prompt=consent param is
  dropped so reuse flows see only the delta consent screen.
- InitiateOptions gains ConnectorID so the reconnect case is passed
  explicitly instead of relying on the caller to mutate r.URL.Query.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
LoadOneByOrganizationIDAndProvider returns the effective OAuth2
connector for an (organization, provider) pair. When legacy rows
leave multiple candidates behind (pre-auto-reconnect installs), it
picks the row with the widest stored scope set and tiebreaks by
most recent updated_at.

This is correct for Google Workspace where the SCIM bridge's
4-scope row must win over the access review's 2-scope row so the
caller always sees a token that can run SCIM operations. The
selector reads the decrypted scope set through Connection.Scopes,
so it is robust against token-refresh updated_at churn that would
mislead a pure SQL ORDER BY.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Reconnect now takes a ReconnectConnectorRequest carrying the expected
OrganizationID and Provider. It validates inside the same transaction
that the loaded connector belongs to the requested org, provider and
OAUTH2 protocol before mutating the row. This blocks cross-org and
cross-provider corruption via a crafted connector_id reaching the
OAuth callback through the HMAC-signed state token.

preserveConnectionFields copies fields from the existing connection
onto the new one when the new one omits them:

- OAuth2 refresh_token: Google drops it on incremental-auth reuse
  when prompt=consent is skipped.
- Slack webhook URL, channel and channel ID: access review Slack
  reconnects without the incoming-webhook scope return a token
  response with no incoming_webhook field.

GetByOrganizationIDAndProvider now routes through the widest-scope
coredata loader, and GetWithConnection exposes a by-ID load that
returns the fully decrypted connector so the initiate handler can
read the stored scope set.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The initiate handler now looks up the existing connector for the
target (organization, provider) pair, reads its stored scope set
through Connection.Scopes, and unions it with the scopes the caller
passed in the query string. The union is what gets requested on the
OAuth authorization URL, so reconnects never drop a previously
granted scope.

When an existing connector is found the handler also flags the
flow as a reconnect via InitiateOptions.ConnectorID, so the
OAuth2 state carries the id and the callback updates the row in
place. When the provider supports it (Google Workspace), the auth
URL also carries include_granted_scopes=true and the user sees
only the delta on the consent screen.

There is no short-circuit: every initiate click runs the full
OAuth flow even if stored scopes already cover the request, because
scope coverage is an unsafe proxy for token liveness. Revoked
tokens or leftover connectors from deleted access sources would
otherwise be silently reused.

The handler body is extracted to its own file to keep NewMux
readable.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
sendMessage and updateMessage used LoadAllByOrganizationIDProtocol
AndProvider and then picked connectors[0], which is ordered by
created_at ASC. On an organization with multiple Slack installs
only the oldest install ever received messages — re-installing
Slack to switch channels silently broke because the sort kept
the old row winning.

Switch to LoadOneByOrganizationIDAndProvider, which picks the
widest-scope row with updated_at DESC as the tiebreak. For Slack
the scope sets are typically identical across installs, so the
effective behavior becomes "most recent install wins" — the
expected behavior.

While here, prefix the remaining error messages in this file with
"cannot" to match the project convention.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The r *http.Request parameter was not read inside
InitiateWithState — all test call sites passed nil.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The 500 response was wrapping the underlying error with
%w, exposing internal details to the client. Log the
full error, return a generic message.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Switch to useMutation + useToast with onCompleted and
onError callbacks, matching the pattern used across
the rest of the console app.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Check the ok and error fields from Slack's token
response and reject flows with a missing access token.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
ApplyProviderDefaults now sets redirect-uri, auth-url,
token-url, extra-auth-params, token-endpoint-auth, and
scopes at startup. Only client-id, client-secret, and
provider-specific settings are needed in the YAML.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
- Return 400 instead of panicking on invalid organization_id
- Use generic error message for internal failures
- Drop duplicate validation from initiate handler (kept in tx)
- Make preserveConnectionFields mutate in place
- Remove as type assertions in GoogleWorkspaceConnector
- Use sort.Slice instead of sort.SliceStable
- Consistent error prefixes in Slack sender

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The connector initiate and complete HTTP handlers used panic for
operational errors (network, DB, provider failures). No recovery
middleware exists on the console chi router, so these panics
produced incomplete responses instead of proper HTTP 500 errors.

Use the same log-and-render pattern already established in
loadExistingConnector error handling.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Every other LoadAll* method in coredata takes a Scoper parameter
for tenant isolation. LoadAllByCookieBannerID was the only one
that omitted it, making the isolation invariant depend entirely
on callers first loading the banner with a scoped query.

Add scope.SQLFragment to the WHERE clause to match the pattern
used by the paginated sibling LoadByCookieBannerID.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
@aureliensibiril aureliensibiril force-pushed the aureliensibiril/connectors-improvements branch from 63342ea to d990d56 Compare April 13, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant