feat(connector): scope-preserving OAuth2 reconnect#1012
Open
aureliensibiril wants to merge 19 commits intogetprobo:mainfrom
Open
feat(connector): scope-preserving OAuth2 reconnect#1012aureliensibiril wants to merge 19 commits intogetprobo:mainfrom
aureliensibiril wants to merge 19 commits intogetprobo:mainfrom
Conversation
ffb8b25 to
56e7a54
Compare
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>
63342ea to
d990d56
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
(org, provider)pair — tiebreak by most recentupdated_at. Fixes the latent "oldest wins" bug in the Slack sender where re-installing Slack to switch channels silently did nothing.ConnectorService.Reconnecttakes aReconnectConnectorRequestand verifies the loaded row's organization, provider and protocol inside the same transaction, blocking cross-org/cross-provider corruption via a craftedconnector_idin the initiate URL.include_granted_scopes=trueand skipprompt=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.gopickedconnectors[0]ordered bycreated_at ASC. A staleconnector_idin 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:
feat(connector): add scope parsing utilities—ParseScopeString/FormatScopeString/UnionScopes/ScopesCover, tolerant of GitHub's comma-separated form.fix(connector): relax Slack incoming webhook requirement— access-review Slack flows no longer fail at token parsing.feat(connector): support OAuth2 scope preservation and incremental auth—Connection.Scopes(),OAuth2State.RequestedScopesfallback,SupportsIncrementalAuth, conditionalinclude_granted_scopes/prompt=consent.feat(coredata): add widest-scope connector loader—LoadOneByOrganizationIDAndProviderwithScopes()-driven selection.feat(probo): validate reconnects and preserve dropped token fields—ReconnectConnectorRequest+ in-tx validation +preserveConnectionFieldsfor refresh token and Slack webhook.refactor(console): rewrite /connectors/initiate to union scopes— extracted handler, scope union,InitiateOptions.ConnectorIDpassed explicitly instead of mutatingr.URL.fix(slack): select widest-scope connector in sender— replacesLoadAll[0]with the widest-scope loader insendMessageandupdateMessage.The earlier commit
Add Slack access review scopes(1-line fix to theproviderOAuth2Scopesmap) is carried along — it was not in the squash-merged state of #997.Test plan
go build ./...cleango vet ./...cleango test ./pkg/connector/... ./pkg/coredata/...— all passing (scopes_test.go,slack_test.go, newoauth2_test.gosubtests,connector_test.goforconnectorScopeCount)include_granted_scopes=trueand a delta consent screen, and a single connector row updated in place.#general, then reconnect to#audit. Expect subsequent compliance events to route to#audit(the latent "oldest wins" bug being fixed).Reconnecton the access source. Expect a fresh token exchange (no short-circuit on stored scope coverage).connector_idblocked: craftorganization_id=OrgA&connector_id=<OrgB's id>in the initiate URL. Expect a 400 at callback time (caught insideReconnect).Out of scope
createAccessSource/updateAccessSourcecross-orgconnector_idvalidation — flagged as a follow-up security PR (see design decision §6 in the plan).Organization.slackConnectionsresolver 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
connector_id;InitiateOptions.IncludeGrantedScopesenables incremental auth (Google addsinclude_granted_scopes=trueand skipsprompt=consent).scope, we fall back to requested scopes from state; addedConnection.Scopes()and scope helpers (ParseScopeString,FormatScopeString,UnionScopes), plus a widest‑scope connector selector used byGetByOrganizationIDAndProviderand the Slack sender.ReconnectConnectorRequestvalidates org/provider/protocol in‑transaction and preserves missing fields (refresh token, Slack webhook settings) during reconnect.Bug Fixes
incoming_webhookoptional, validatedok/error, required access token, and selected the widest‑scope/most‑recent connector with clearer errors.cfg/dev.yamldrops redundant OAuth2 fields; Console usesuseMutation+useToast.Written for commit d990d56. Summary will update on new commits.