Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
91eb41b
Add Slack access review scopes
aureliensibiril Apr 8, 2026
d0fedcf
feat(connector): add scope parsing utilities
aureliensibiril Apr 8, 2026
62bab0f
fix(connector): relax Slack incoming webhook requirement
aureliensibiril Apr 8, 2026
71f6364
feat(connector): support OAuth2 scope preservation and incremental auth
aureliensibiril Apr 8, 2026
6390b15
feat(coredata): add widest-scope connector loader
aureliensibiril Apr 8, 2026
5340953
feat(probo): validate reconnects and preserve dropped token fields
aureliensibiril Apr 8, 2026
08f163f
refactor(console): rewrite /connectors/initiate to union scopes
aureliensibiril Apr 8, 2026
816b930
fix(slack): select widest-scope connector in sender
aureliensibiril Apr 8, 2026
1def3a4
Remove unused request param from InitiateWithState
aureliensibiril Apr 10, 2026
cc6d271
Remove unused ScopesCover function
aureliensibiril Apr 10, 2026
c6a5d98
Drop grouped brackets for single declarations
aureliensibiril Apr 10, 2026
8d70cf1
Stop leaking internal error in initiate handler
aureliensibiril Apr 10, 2026
a95e2f4
Replace deprecated useMutationWithToasts hook
aureliensibiril Apr 10, 2026
c7ec1bc
Validate Slack OAuth2 token response
aureliensibiril Apr 10, 2026
72478ef
Drop redundant connector fields from sample config
aureliensibiril Apr 13, 2026
05781d1
Address code review feedback
aureliensibiril Apr 13, 2026
3d36d93
Replace panic with error handling in connector handlers
aureliensibiril Apr 13, 2026
a3bb542
Add tenant scoping to LoadAllByCookieBannerID
aureliensibiril Apr 13, 2026
d990d56
Fix arrow-parens lint error in GoogleWorkspaceConnector
aureliensibiril Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ import {
IconSettingsGear2,
Input,
useDialogRef,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { graphql, useFragment } from "react-relay";
import { graphql, useFragment, useMutation } from "react-relay";

import type { GoogleWorkspaceConnectorDeleteMutation } from "#/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql";
import type { GoogleWorkspaceConnectorFragment$key } from "#/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql";
import type { GoogleWorkspaceConnectorUpdateSCIMBridgeMutation } from "#/__generated__/iam/GoogleWorkspaceConnectorUpdateSCIMBridgeMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";

const googleWorkspaceConnectorFragment = graphql`
Expand Down Expand Up @@ -85,27 +85,20 @@ export function GoogleWorkspaceConnector(props: {

const organizationId = useOrganizationId();
const { __, dateTimeFormat } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const excludedUserNamesDialogRef = useDialogRef();

const [newUser, setNewUser] = useState("");

const [deleteSCIMConfiguration, isDeleting]
= useMutationWithToasts<GoogleWorkspaceConnectorDeleteMutation>(
= useMutation<GoogleWorkspaceConnectorDeleteMutation>(
deleteSCIMConfigurationMutation,
{
successMessage: __("Google Workspace disconnected successfully"),
errorMessage: __("Failed to disconnect Google Workspace"),
},
);

const [updateSCIMBridge, isUpdating]
= useMutationWithToasts<GoogleWorkspaceConnectorUpdateSCIMBridgeMutation>(
= useMutation<GoogleWorkspaceConnectorUpdateSCIMBridgeMutation>(
updateSCIMBridgeMutation,
{
successMessage: __("Excluded user names updated successfully"),
errorMessage: __("Failed to update excluded user names"),
},
);

const handleConnect = () => {
Expand All @@ -131,9 +124,29 @@ export function GoogleWorkspaceConnector(props: {
scimConfigurationId: scimConfigurationId,
},
},
onCompleted: () => {
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors.map(e => e.message).join(", "),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Google Workspace disconnected successfully"),
variant: "success",
});
dialogRef.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
updater: (store) => {
const organizationRecord = store.get(organizationId);
if (organizationRecord) {
Expand All @@ -156,6 +169,28 @@ export function GoogleWorkspaceConnector(props: {
excludedUserNames: newList,
},
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors.map(e => e.message).join(", "),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Excluded user names updated successfully"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
});
};

Expand Down
72 changes: 0 additions & 72 deletions cfg/dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,122 +94,50 @@ probod:
config:
client-id: "your-slack-client-id"
client-secret: "your-slack-client-secret"
redirect-uri: "https://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://slack.com/oauth/v2/authorize"
token-url: "https://slack.com/api/oauth.v2.access"

scopes:
- "chat:write"
- "channels:join"
- "incoming-webhook"
- "users:read"
- "users:read.email"
settings:
signing-secret: "your-slack-signing-secret"
- provider: "GOOGLE_WORKSPACE"
protocol: "oauth2"
config:
client-id: "your-google-client-id.apps.googleusercontent.com"
client-secret: "your-google-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://accounts.google.com/o/oauth2/v2/auth"
token-url: "https://oauth2.googleapis.com/token"

scopes:
- "https://www.googleapis.com/auth/admin.directory.user.readonly"
- "https://www.googleapis.com/auth/admin.directory.userschema.readonly"
- "https://www.googleapis.com/auth/admin.directory.group.member.readonly"
- "https://www.googleapis.com/auth/admin.directory.customer.readonly"
extra-auth-params:
access_type: "offline"
prompt: "consent"
- provider: "LINEAR"
protocol: "oauth2"
config:
client-id: "your-linear-client-id"
client-secret: "your-linear-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://linear.app/oauth/authorize"
token-url: "https://api.linear.app/oauth/token"

scopes:
- "read"
- "write"
- provider: "BREX"
protocol: "oauth2"
config:
client-id: "your-brex-client-id"
client-secret: "your-brex-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://accounts-api.brex.com/oauth2/default/v1/authorize"
token-url: "https://accounts-api.brex.com/oauth2/default/v1/token"

scopes:
- "openid"
- "offline_access"
- provider: "HUBSPOT"
protocol: "oauth2"
config:
client-id: "your-hubspot-client-id"
client-secret: "your-hubspot-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://app.hubspot.com/oauth/authorize"
token-url: "https://api.hubapi.com/oauth/v1/token"

scopes:
- "settings.users.read"
- provider: "DOCUSIGN"
protocol: "oauth2"
config:
client-id: "your-docusign-client-id"
client-secret: "your-docusign-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://account-d.docusign.com/oauth/auth"
token-url: "https://account-d.docusign.com/oauth/token"

scopes:
- "signature"
token-endpoint-auth: "basic-form"
- provider: "NOTION"
protocol: "oauth2"
config:
client-id: "your-notion-client-id"
client-secret: "your-notion-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://api.notion.com/v1/oauth/authorize"
token-url: "https://api.notion.com/v1/oauth/token"

extra-auth-params:
owner: "user"
token-endpoint-auth: "basic-json"
- provider: "GITHUB"
protocol: "oauth2"
config:
client-id: "your-github-client-id"
client-secret: "your-github-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://github.com/login/oauth/authorize"
token-url: "https://github.com/login/oauth/access_token"

scopes:
- "read:org"
- provider: "SENTRY"
protocol: "oauth2"
config:
client-id: "your-sentry-client-id"
client-secret: "your-sentry-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://sentry.io/oauth/authorize/"
token-url: "https://sentry.io/oauth/token/"

scopes:
- "org:read"
- "member:read"
- provider: "INTERCOM"
protocol: "oauth2"
config:
client-id: "your-intercom-client-id"
client-secret: "your-intercom-client-secret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://app.intercom.com/oauth"
token-url: "https://api.intercom.io/auth/eagle/token"
1 change: 1 addition & 0 deletions pkg/accessreview/drivers/oauth2_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var providerOAuth2Scopes = map[coredata.ConnectorProvider][]string{
coredata.ConnectorProviderBrex: {"openid", "offline_access"},
coredata.ConnectorProviderDocuSign: {"signature"},
coredata.ConnectorProviderLinear: {"read"},
coredata.ConnectorProviderSlack: {"users:read", "users:read.email"},
coredata.ConnectorProviderGoogleWorkspace: {
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.group.member.readonly",
Expand Down
4 changes: 4 additions & 0 deletions pkg/connector/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func (c *APIKeyConnection) Type() ProtocolType {
return ProtocolAPIKey
}

func (c *APIKeyConnection) Scopes() []string {
return nil
}

func (c *APIKeyConnection) Client(ctx context.Context) (*http.Client, error) {
transport := &oauth2Transport{
token: c.APIKey,
Expand Down
8 changes: 8 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ type (
// a different set of scopes (e.g. SCIM bridge vs access review).
InitiateOptions struct {
Scopes []string
// IncludeGrantedScopes is honored only when the provider has
// SupportsIncrementalAuth=true.
IncludeGrantedScopes bool
// ConnectorID, when set, marks this flow as a reconnect of an
// existing connector: the callback updates the row in place
// instead of creating a new one.
ConnectorID string
}

Connector interface {
Expand All @@ -42,6 +49,7 @@ type (
Connection interface {
Type() ProtocolType
Client(ctx context.Context) (*http.Client, error)
Scopes() []string

json.Unmarshaler
json.Marshaler
Expand Down
62 changes: 42 additions & 20 deletions pkg/connector/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,18 +107,17 @@ 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)
return c.InitiateWithState(ctx, stateData, opts)
}

// InitiateWithState generates an OAuth2 authorization URL with a custom state.
Expand All @@ -125,7 +126,6 @@ func (c *OAuth2Connector) InitiateWithState(
ctx context.Context,
stateData OAuth2State,
opts InitiateOptions,
r *http.Request,
) (string, error) {
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
if err != nil {
Expand All @@ -141,8 +141,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)
}

Expand Down Expand Up @@ -225,11 +235,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)
Expand Down Expand Up @@ -335,6 +353,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)
}
Expand Down
Loading
Loading