feat(connector): support OAuth2 scope preservation and incremental auth

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>
This commit is contained in:
Aurélien Sibiril
2026-04-09 01:02:49 +02:00
parent 62bab0f732
commit 71f6364df0
5 changed files with 304 additions and 22 deletions

View File

@@ -41,20 +41,22 @@ import (
type (
OAuth2Connector struct {
ClientID string
ClientSecret string
RedirectURI string
AuthURL string
TokenURL string
ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google)
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
ClientID string
ClientSecret string
RedirectURI string
AuthURL string
TokenURL string
ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google)
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
SupportsIncrementalAuth bool
}
OAuth2State struct {
OrganizationID string `json:"oid"`
Provider string `json:"provider"`
ContinueURL string `json:"continue,omitempty"`
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
OrganizationID string `json:"oid"`
Provider string `json:"provider"`
ContinueURL string `json:"continue,omitempty"`
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
RequestedScopes []string `json:"scopes,omitempty"`
}
OAuth2Connection struct {
@@ -105,16 +107,15 @@ func (c *OAuth2Connector) Initiate(
r *http.Request,
) (string, error) {
stateData := OAuth2State{
OrganizationID: organizationID.String(),
Provider: provider,
OrganizationID: organizationID.String(),
Provider: provider,
ConnectorID: opts.ConnectorID,
RequestedScopes: opts.Scopes,
}
if r != nil {
if continueURL := r.URL.Query().Get("continue"); continueURL != "" {
stateData.ContinueURL = continueURL
}
if connectorID := r.URL.Query().Get("connector_id"); connectorID != "" {
stateData.ConnectorID = connectorID
}
}
return c.InitiateWithState(ctx, stateData, opts, r)
}
@@ -141,8 +142,18 @@ func (c *OAuth2Connector) InitiateWithState(
authCodeQuery.Set("scope", strings.Join(opts.Scopes, " "))
}
// Add any extra auth params (e.g., access_type=offline, prompt=consent for Google)
incrementalAuth := c.SupportsIncrementalAuth && opts.IncludeGrantedScopes
if incrementalAuth {
authCodeQuery.Set("include_granted_scopes", "true")
}
// Skip prompt=consent when doing incremental auth so the user sees
// only the delta, not a full re-consent. First-install flows keep it
// because IncludeGrantedScopes is false there.
for k, v := range c.ExtraAuthParams {
if incrementalAuth && k == "prompt" && v == "consent" {
continue
}
authCodeQuery.Set(k, v)
}
@@ -225,11 +236,19 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
return nil, nil, fmt.Errorf("cannot decode token response: %w", err)
}
grantedScope := rawToken.Scope
if grantedScope == "" {
// RFC 6749 §5.1: scope is OPTIONAL when identical to the
// requested scope. Fall back to what we asked for so
// subsequent reconnect diffs have a meaningful base.
grantedScope = FormatScopeString(payload.Data.RequestedScopes)
}
oauth2Conn := OAuth2Connection{
AccessToken: rawToken.AccessToken,
RefreshToken: rawToken.RefreshToken,
TokenType: rawToken.TokenType,
Scope: rawToken.Scope,
Scope: grantedScope,
}
// Convert expires_in (seconds) to expires_at (absolute time)
@@ -335,6 +354,10 @@ func (c *OAuth2Connection) Type() ProtocolType {
return ProtocolOAuth2
}
func (c *OAuth2Connection) Scopes() []string {
return ParseScopeString(c.Scope)
}
func (c *OAuth2Connection) Client(ctx context.Context) (*http.Client, error) {
return c.ClientWithOptions(ctx)
}