Skip to content

Commit ebab4d9

Browse files
fatmcgavclaude
andcommitted
fix(web): always request offline_access for MCP refresh_token grant
Atlassian (and other providers) only honour the `refresh_token` grant when `offline_access` is included in the authorization scope. The client was already declaring `refresh_token` in `clientMetadata.grant_types` but never injecting `offline_access` into `requestedOAuthScopes`, so the /authorize request was incomplete and Atlassian rejected it. - `PrismaOAuthClientProvider` now appends `OFFLINE_ACCESS_SCOPE` before normalization so it appears in both `clientMetadata.scope` and the /authorize request. Injection is unconditional (matching the existing behaviour of always declaring `refresh_token`); a comment explains the tradeoff vs checking `oauthScopesSupported`. - `buildMcpOAuthScopeEntries` defaults `offline_access` to enabled when present in available scopes so the admin UI reflects what will be sent. - New `oauthScopeUtils.test.ts` covers the default-enabled behaviour and general normalization/filtering helpers. - Updated `prismaOAuthClientProvider.test.ts` to assert `offline_access` is always present and that it is not duplicated when already supplied. - Added a note to the connectors doc explaining why `offline_access` is pre-ticked in the OAuth scopes UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d02f61c commit ebab4d9

5 files changed

Lines changed: 149 additions & 7 deletions

File tree

docs/docs/features/ask/connectors.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ Owners can change connector scopes at any time from **Settings → Workspace →
8080
Changing connector scopes requires all users to re-authenticate with that connector.
8181
</Warning>
8282

83+
<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.
85+
</Note>
86+
8387
## Tool Permissions
8488

8589
Owners can configure how Ask Sourcebot may use each tool exposed by a connector. Changes take effect immediately and do not require users to re-authenticate.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, test } from 'vitest';
2+
import {
3+
buildMcpOAuthScopeEntries,
4+
normalizeMcpRequestedOAuthScopes,
5+
getEnabledMcpOAuthScopeNames,
6+
} from './oauthScopeUtils';
7+
8+
describe('normalizeMcpRequestedOAuthScopes', () => {
9+
test('deduplicates, trims, and sorts scopes', () => {
10+
expect(normalizeMcpRequestedOAuthScopes([' repo ', 'read:user', 'repo'])).toEqual([
11+
'read:user',
12+
'repo',
13+
]);
14+
});
15+
16+
test('returns an empty array for empty input', () => {
17+
expect(normalizeMcpRequestedOAuthScopes([])).toEqual([]);
18+
});
19+
20+
test('filters blank strings', () => {
21+
expect(normalizeMcpRequestedOAuthScopes(['', ' ', 'read'])).toEqual(['read']);
22+
});
23+
});
24+
25+
describe('buildMcpOAuthScopeEntries', () => {
26+
test('enables scopes that are in requestedOAuthScopes', () => {
27+
const entries = buildMcpOAuthScopeEntries({
28+
availableOAuthScopes: ['read', 'write'],
29+
requestedOAuthScopes: ['read'],
30+
});
31+
32+
expect(entries).toEqual([
33+
{ scope: 'read', enabled: true },
34+
{ scope: 'write', enabled: false },
35+
]);
36+
});
37+
38+
test('enables offline_access by default when present in available scopes', () => {
39+
const entries = buildMcpOAuthScopeEntries({
40+
availableOAuthScopes: ['offline_access', 'read'],
41+
requestedOAuthScopes: ['read'],
42+
});
43+
44+
expect(entries).toEqual([
45+
{ scope: 'offline_access', enabled: true },
46+
{ scope: 'read', enabled: true },
47+
]);
48+
});
49+
50+
test('enables offline_access by default even when not in requestedOAuthScopes', () => {
51+
const entries = buildMcpOAuthScopeEntries({
52+
availableOAuthScopes: ['offline_access', 'read', 'write'],
53+
requestedOAuthScopes: [],
54+
});
55+
56+
expect(entries).toEqual([
57+
{ scope: 'offline_access', enabled: true },
58+
{ scope: 'read', enabled: false },
59+
{ scope: 'write', enabled: false },
60+
]);
61+
});
62+
63+
test('does not add offline_access when it is absent from available scopes', () => {
64+
const entries = buildMcpOAuthScopeEntries({
65+
availableOAuthScopes: ['read', 'write'],
66+
requestedOAuthScopes: [],
67+
});
68+
69+
expect(entries.find((e) => e.scope === 'offline_access')).toBeUndefined();
70+
});
71+
72+
test('merges requested scopes not in available scopes into the output', () => {
73+
const entries = buildMcpOAuthScopeEntries({
74+
availableOAuthScopes: ['read'],
75+
requestedOAuthScopes: ['write'],
76+
});
77+
78+
expect(entries).toEqual([
79+
{ scope: 'read', enabled: false },
80+
{ scope: 'write', enabled: true },
81+
]);
82+
});
83+
84+
test('returns sorted, deduplicated entries', () => {
85+
const entries = buildMcpOAuthScopeEntries({
86+
availableOAuthScopes: ['write', 'read', 'write'],
87+
requestedOAuthScopes: ['write', 'write'],
88+
});
89+
90+
expect(entries).toEqual([
91+
{ scope: 'read', enabled: false },
92+
{ scope: 'write', enabled: true },
93+
]);
94+
});
95+
});
96+
97+
describe('getEnabledMcpOAuthScopeNames', () => {
98+
test('returns only enabled scopes, sorted and deduplicated', () => {
99+
const scopes = getEnabledMcpOAuthScopeNames([
100+
{ scope: 'write', enabled: false },
101+
{ scope: 'read', enabled: true },
102+
{ scope: 'offline_access', enabled: true },
103+
]);
104+
105+
expect(scopes).toEqual(['offline_access', 'read']);
106+
});
107+
108+
test('returns an empty array when no scopes are enabled', () => {
109+
const scopes = getEnabledMcpOAuthScopeNames([
110+
{ scope: 'read', enabled: false },
111+
]);
112+
113+
expect(scopes).toEqual([]);
114+
});
115+
});

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import type { McpServerOAuthScopeEntry } from './types';
22

33
export const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
44

5+
// 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.
7+
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
8+
59
export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] {
610
return [...new Set(oauthScopes.map((scope) => scope.trim()).filter(Boolean))]
711
.sort();
@@ -50,7 +54,9 @@ export function buildMcpOAuthScopeEntries({
5054

5155
return normalizedAvailableOAuthScopes.map((scope) => ({
5256
scope,
53-
enabled: requestedScopeSet.has(scope),
57+
// offline_access is enabled by default because all clients declare the refresh_token
58+
// grant; an admin who leaves it unticked would produce a broken authorization request.
59+
enabled: scope === OFFLINE_ACCESS_SCOPE || requestedScopeSet.has(scope),
5460
}));
5561
}
5662

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

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

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

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

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

93-
expect(provider.clientMetadata.scope).toBe('read:user repo');
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');
94102
});
95103
});
96104

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

Lines changed: 11 additions & 2 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 } from './oauthScopeUtils';
12+
import { normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from './oauthScopeUtils';
1313

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

118127
if (allowClientRegistration) {
119128
this.saveClientInformation = async (info: OAuthClientInformation) => {

0 commit comments

Comments
 (0)