Skip to content

Commit 9c50a7c

Browse files
committed
Change the approach to enable the scope by default if it is found during discovery. Still allow an admin to remove the scope if they want.
1 parent 13e8df6 commit 9c50a7c

9 files changed

Lines changed: 27 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- Fixed issue where using multiple identity providers of the same type (e.g., gitlab) would result in unexpected behaviours. [#1177](https://github.com/sourcebot-dev/sourcebot/pull/1177)
3030
- Fixed a race condition where large repositories could be indexed twice within a single reindex interval. [#1298](https://github.com/sourcebot-dev/sourcebot/pull/1298)
3131
- Upgraded `shell-quote` to `^1.8.4`. [#1299](https://github.com/sourcebot-dev/sourcebot/pull/1299)
32-
- [EE] Fixed MCP OAuth connectors (e.g. Atlassian) rejecting the authorization request when `offline_access` was not explicitly enabled by an admin. `offline_access` is now always included in the requested scope and is enabled by default in the admin UI. [#1292](https://github.com/sourcebot-dev/sourcebot/pull/1292)
32+
- [EE] Fixed MCP OAuth connectors (e.g. Atlassian) rejecting the authorization request when `offline_access` was not explicitly enabled by an admin. The OAuth scopes dialog now pre-selects `offline_access` when the connector offers it; admins can still untick it to opt out of refresh tokens. [#1292](https://github.com/sourcebot-dev/sourcebot/pull/1292)
3333

3434
## [5.0.1] - 2026-06-04
3535

docs/docs/features/ask/connectors.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ Changing connector scopes requires all users to re-authenticate with that connec
8181
</Warning>
8282

8383
<Note>
84-
`offline_access` is enabled by default when offered by the connector. It is required for token refresh, so Sourcebot includes it regardless of whether an admin ticks the box.
84+
`offline_access` is pre-selected when offered by the connector because token refresh requires it. You can untick it to opt out of refresh tokens, but users will need to re-authenticate every time their access token expires. Some connectors (such as Atlassian) reject authorization entirely without it.
8585
</Note>
8686

8787
## Tool Permissions

packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpBu
2727
import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard";
2828
import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata";
2929
import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys";
30-
import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes } from "@/ee/features/chat/mcp/oauthScopeUtils";
30+
import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from "@/ee/features/chat/mcp/oauthScopeUtils";
3131
import { pluralize } from "@/features/chat/mcp/utils";
3232
import { cn, isServiceError } from "@/lib/utils";
3333
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -440,6 +440,13 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
440440
setCustomOAuthScopeInput("");
441441
};
442442

443+
// Pre-select offline_access so admins can see the scope token refresh depends on;
444+
// they can still untick it to opt out of refresh tokens.
445+
const initializeOAuthScopeSelection = (discoveredOAuthScopes: string[]) => {
446+
setSelectedOAuthScopes(discoveredOAuthScopes.includes(OFFLINE_ACCESS_SCOPE) ? [OFFLINE_ACCESS_SCOPE] : []);
447+
setCustomOAuthScopeInput("");
448+
};
449+
443450
const handleCloseClientCredentialsDialog = () => {
444451
setIsClientCredentialsDialogOpen(false);
445452
setPendingClientCredentialsServer(null);
@@ -572,7 +579,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
572579

573580
const discoveredOAuthScopes = normalizeMcpRequestedOAuthScopes(dcrSupport.oauthScopesSupported);
574581
if (dcrSupport.isKnown && !dcrSupport.supportsDcr) {
575-
resetOAuthScopeInputs();
582+
initializeOAuthScopeSelection(discoveredOAuthScopes);
576583
setPendingClientCredentialsServer({
577584
name: displayName,
578585
serverUrl: normalizedServerUrl,
@@ -584,7 +591,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
584591
}
585592

586593
if (discoveredOAuthScopes.length > 0) {
587-
resetOAuthScopeInputs();
594+
initializeOAuthScopeSelection(discoveredOAuthScopes);
588595
setPendingOAuthScopeSelectionServer({
589596
name: displayName,
590597
serverUrl: normalizedServerUrl,

packages/web/src/app/api/(server)/ee/askmcp/callback/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe('GET /api/ee/askmcp/callback', () => {
150150
const state = createMcpOAuthState('state-1', '/settings/workspaceAskAgent');
151151
mocks.mcpAuth.mockImplementation(async (provider) => {
152152
expect('saveClientInformation' in provider).toBe(false);
153-
expect(provider.clientMetadata.scope).toBe('offline_access repo');
153+
expect(provider.clientMetadata.scope).toBe('repo');
154154
await provider.invalidateCredentials('all');
155155
const error = new Error('invalid_client client_secret=client-secret refresh_token=refresh-token');
156156
Object.assign(error, {

packages/web/src/app/api/(server)/ee/askmcp/connect/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ describe('POST /api/ee/askmcp/connect', () => {
148148
mocks.mcpAuth.mockImplementation(async (provider, options) => {
149149
expect('saveClientInformation' in provider).toBe(true);
150150
expect(provider.saveClientInformation).toEqual(expect.any(Function));
151-
expect(provider.clientMetadata.scope).toBe('offline_access repo');
151+
expect(provider.clientMetadata.scope).toBe('repo');
152152
expect(options.fetchFn).toEqual(expect.any(Function));
153153

154154
await provider.saveClientInformation({ client_id: 'client-1' });

packages/web/src/ee/features/chat/mcp/oauthScopeUtils.test.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,18 @@ describe('buildMcpOAuthScopeEntries', () => {
3131
]);
3232
});
3333

34-
test('enables offline_access by default even when not in requestedOAuthScopes', () => {
34+
test('does not special-case offline_access; it is enabled only when requested', () => {
3535
const entries = buildMcpOAuthScopeEntries({
36-
availableOAuthScopes: ['offline_access', 'read', 'write'],
36+
availableOAuthScopes: ['offline_access', 'read'],
3737
requestedOAuthScopes: [],
3838
});
3939

4040
expect(entries).toEqual([
41-
{ scope: 'offline_access', enabled: true },
41+
{ scope: 'offline_access', enabled: false },
4242
{ scope: 'read', enabled: false },
43-
{ scope: 'write', enabled: false },
4443
]);
4544
});
4645

47-
test('does not add offline_access when it is absent from available scopes', () => {
48-
const entries = buildMcpOAuthScopeEntries({
49-
availableOAuthScopes: ['read', 'write'],
50-
requestedOAuthScopes: [],
51-
});
52-
53-
expect(entries.find((e) => e.scope === 'offline_access')).toBeUndefined();
54-
});
55-
5646
test('merges requested scopes not in available scopes into the output', () => {
5747
const entries = buildMcpOAuthScopeEntries({
5848
availableOAuthScopes: ['read'],

packages/web/src/ee/features/chat/mcp/oauthScopeUtils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import type { McpServerOAuthScopeEntry } from './types';
33
export const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
44

55
// Required for the refresh_token grant that all clients declare. Providers such as
6-
// Atlassian only honour that grant when this scope is included in the authorization request.
6+
// Atlassian only honour that grant when this scope is included in the authorization request,
7+
// so the admin UI pre-selects it whenever the connector advertises it. Admins can still
8+
// untick it to opt out of refresh tokens.
79
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
810

911
export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] {
@@ -54,8 +56,7 @@ export function buildMcpOAuthScopeEntries({
5456

5557
return normalizedAvailableOAuthScopes.map((scope) => ({
5658
scope,
57-
// Force-enabled regardless of admin selection — see OFFLINE_ACCESS_SCOPE.
58-
enabled: scope === OFFLINE_ACCESS_SCOPE || requestedScopeSet.has(scope),
59+
enabled: requestedScopeSet.has(scope),
5960
}));
6061
}
6162

packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.test.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,26 +79,18 @@ describe('PrismaOAuthClientProvider modes', () => {
7979
});
8080

8181
describe('PrismaOAuthClientProvider client metadata', () => {
82-
test('always includes offline_access even when no scopes were requested', () => {
82+
test('omits scope when no scopes were requested', () => {
8383
const provider = createProvider();
8484

85-
expect(provider.clientMetadata.scope).toBe('offline_access');
85+
expect(provider.clientMetadata.scope).toBeUndefined();
8686
});
8787

88-
test('includes offline_access alongside normalized requested scopes', () => {
88+
test('includes normalized requested scopes', () => {
8989
const provider = createProvider(createPrismaMock(), {
9090
requestedOAuthScopes: [' repo ', 'read:user', 'repo'],
9191
});
9292

93-
expect(provider.clientMetadata.scope).toBe('offline_access read:user repo');
94-
});
95-
96-
test('does not duplicate offline_access when already present in requested scopes', () => {
97-
const provider = createProvider(createPrismaMock(), {
98-
requestedOAuthScopes: ['offline_access', 'read:user'],
99-
});
100-
101-
expect(provider.clientMetadata.scope).toBe('offline_access read:user');
93+
expect(provider.clientMetadata.scope).toBe('read:user repo');
10294
});
10395
});
10496

packages/web/src/ee/features/chat/mcp/prismaOAuthClientProvider.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { McpServerClientInfoSource, type PrismaClient } from '@sourcebot/db';
99
import { encryptOAuthToken, decryptOAuthToken, createLogger } from '@sourcebot/shared';
1010
import { __unsafePrisma } from '@/prisma';
1111
import { createMcpOAuthState } from './mcpOAuthReturnTo';
12-
import { normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from './oauthScopeUtils';
12+
import { normalizeMcpRequestedOAuthScopes } from './oauthScopeUtils';
1313

1414
type McpOAuthPrismaClient = Pick<PrismaClient, 'mcpServer' | 'userMcpServer'>;
1515
const logger = createLogger('mcp-oauth-client-provider');
@@ -113,14 +113,7 @@ export class PrismaOAuthClientProvider implements OAuthClientProvider {
113113
this.userId = userId;
114114
this.callbackUrl = callbackUrl;
115115
this.callbackReturnTo = callbackReturnTo;
116-
// Always inject offline_access (see OFFLINE_ACCESS_SCOPE). We do so unconditionally rather
117-
// than checking the provider's advertised scopes because oauthScopesSupported is not plumbed
118-
// through to this constructor; the tradeoff (a benign unknown-scope rejection on strict
119-
// providers) is the same as the existing behaviour of always declaring refresh_token.
120-
this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes([
121-
...requestedOAuthScopes,
122-
OFFLINE_ACCESS_SCOPE,
123-
]);
116+
this.requestedOAuthScopes = normalizeMcpRequestedOAuthScopes(requestedOAuthScopes);
124117

125118
if (allowClientRegistration) {
126119
this.saveClientInformation = async (info: OAuthClientInformation) => {

0 commit comments

Comments
 (0)