Skip to content

Commit 90a5afe

Browse files
fatmcgavclaudejsourcebot
authored
fix(web): always request offline_access for MCP refresh_token grant (#1292)
* fix(web): 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. If the offline_access scope is discovered upon adding a connector, it is automatically selected to be included in the scopes. Admins can still remove this scope. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add `CHANGELOG.md` entry for `offline_access` MCP fix [#1292] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * clean up comments to be more concise * clean up tests to keep only the higher value ones * 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. * Add support for a tooltip warning admins that this scope is required for refresh tokens. * Update scopes page to warn admin when offline scope is the only selected scope * Update changelog and docs * include links to openid documentation for offline access scope * Also show warning when scopes are discovered but none are selected --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com>
1 parent 1387c46 commit 90a5afe

5 files changed

Lines changed: 134 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +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 authorization when `offline_access` was not enabled. When adding a connector, the scopes dialog now pre-selects `offline_access` (admins can untick it) and warns when it is the only selected scope. [#1292](https://github.com/sourcebot-dev/sourcebot/pull/1292)
3233

3334
## [5.0.1] - 2026-06-04
3435

docs/docs/features/ask/connectors.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Owners can configure which OAuth scopes users authorize when connecting to a con
5656

5757
Sourcebot checks the connector for discoverable scopes and shows them as options. You can also add custom scopes.
5858

59+
When you select scopes, most providers grant only what you request, so include the resource scopes the connector's tools need.
60+
5961
<div className="max-w-sm mx-auto">
6062
<Frame>
6163
<img
@@ -80,6 +82,11 @@ Owners can change connector scopes at any time from **Settings → Workspace →
8082
Changing connector scopes requires all users to re-authenticate with that connector.
8183
</Warning>
8284

85+
<Note>
86+
`offline_access` is pre-selected when you add a connector that offers it because token refresh requires it. You can deselect 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.
87+
For more information, see the [OpenID Connect `offline_access` documentation](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess).
88+
</Note>
89+
8390
## Tool Permissions
8491

8592
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.
@@ -121,4 +128,3 @@ You can see all available connectors on this page. After you connect one, you ca
121128
</Frame>
122129
</div>
123130

124-

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ import { Label } from "@/components/ui/label";
2222
import { Separator } from "@/components/ui/separator";
2323
import { Skeleton } from "@/components/ui/skeleton";
2424
import { Textarea } from "@/components/ui/textarea";
25+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
2526
import { checkMcpServerDynamicClientRegistration, createMcpServer, createStaticOAuthMcpServer, deleteMcpServer, updateMcpServerOAuthScopes } from "@/ee/features/chat/mcp/actions";
2627
import { ConnectMcpButton } from "@/ee/features/chat/mcp/components/connectMcpButton";
2728
import { ConnectorCard } from "@/ee/features/chat/mcp/components/connectorCard";
2829
import { useMcpToolMetadata } from "@/ee/features/chat/mcp/hooks/useMcpToolMetadata";
2930
import { invalidateMcpConfigurationQueries, mcpQueryKeys } from "@/ee/features/chat/mcp/queryKeys";
30-
import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes } from "@/ee/features/chat/mcp/oauthScopeUtils";
31+
import { buildMcpOAuthScopeEntries, getMcpRequestedOAuthScopes, normalizeMcpRequestedOAuthScopes, OFFLINE_ACCESS_SCOPE } from "@/ee/features/chat/mcp/oauthScopeUtils";
3132
import { pluralize } from "@/features/chat/mcp/utils";
3233
import { cn, isServiceError } from "@/lib/utils";
3334
import { useQuery, useQueryClient } from "@tanstack/react-query";
34-
import { AlertTriangleIcon, CableIcon, CopyIcon, KeyRoundIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react";
35+
import { AlertTriangleIcon, CableIcon, CopyIcon, InfoIcon, KeyRoundIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon, WrenchIcon } from "lucide-react";
3536
import { PrefabConnectorPopover } from "@/ee/features/chat/mcp/components/prefabConnectorPopover";
3637
import Markdown from "react-markdown";
3738
import { getStaticOAuthDescription, type PrefabMcpServer } from "@/ee/features/chat/mcp/prefabMcpServers";
@@ -89,6 +90,11 @@ function OAuthScopesInput({
8990
const [oauthScopeSearchInput, setOAuthScopeSearchInput] = useState("");
9091
const selectedOAuthScopeSet = new Set(selectedOAuthScopes);
9192
const requestedOAuthScopes = getMcpRequestedOAuthScopes(selectedOAuthScopes, customOAuthScopeInput);
93+
const hasDiscoveredResourceScopes = discoveredOAuthScopes.some((scope) => scope !== OFFLINE_ACCESS_SCOPE);
94+
const isOfflineAccessOnly = requestedOAuthScopes.length === 1
95+
&& requestedOAuthScopes[0] === OFFLINE_ACCESS_SCOPE
96+
&& hasDiscoveredResourceScopes;
97+
const isNoScopesSelected = requestedOAuthScopes.length === 0 && hasDiscoveredResourceScopes;
9298
const filteredOAuthScopes = useMemo(() => {
9399
const query = oauthScopeSearchInput.trim().toLowerCase();
94100
if (!query) {
@@ -154,6 +160,18 @@ function OAuthScopesInput({
154160
aria-label={`Request ${scope}`}
155161
/>
156162
<span className="break-all font-mono text-xs">{scope}</span>
163+
{scope === OFFLINE_ACCESS_SCOPE && (
164+
<Tooltip>
165+
<TooltipTrigger asChild>
166+
<span className="shrink-0 text-muted-foreground" onClick={(event) => event.preventDefault()}>
167+
<InfoIcon className="h-3.5 w-3.5" />
168+
</span>
169+
</TooltipTrigger>
170+
<TooltipContent className="max-w-64">
171+
Required for refresh tokens. Without this scope, users must re-authenticate whenever their access token expires, and some connectors reject authorization entirely.
172+
</TooltipContent>
173+
</Tooltip>
174+
)}
157175
</label>
158176
{onRemoveOAuthScope && (
159177
<Button
@@ -188,6 +206,15 @@ function OAuthScopesInput({
188206
className="min-h-20 resize-y font-mono text-sm"
189207
/>
190208
</div>
209+
210+
{(isOfflineAccessOnly || isNoScopesSelected) && (
211+
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
212+
<AlertTriangleIcon className="h-3.5 w-3.5 shrink-0 text-yellow-600 dark:text-yellow-400" />
213+
{isOfflineAccessOnly
214+
? "Only offline_access is selected. Without any resource scopes, the connector may not be able to access anything."
215+
: "No scopes are selected. Without any resource scopes, the connector may not be able to access anything."}
216+
</p>
217+
)}
191218
</div>
192219
);
193220
}
@@ -440,6 +467,13 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
440467
setCustomOAuthScopeInput("");
441468
};
442469

470+
// Pre-select offline_access so admins can see the scope token refresh depends on;
471+
// they can still untick it to opt out of refresh tokens.
472+
const initializeOAuthScopeSelection = (discoveredOAuthScopes: string[]) => {
473+
setSelectedOAuthScopes(discoveredOAuthScopes.includes(OFFLINE_ACCESS_SCOPE) ? [OFFLINE_ACCESS_SCOPE] : []);
474+
setCustomOAuthScopeInput("");
475+
};
476+
443477
const handleCloseClientCredentialsDialog = () => {
444478
setIsClientCredentialsDialogOpen(false);
445479
setPendingClientCredentialsServer(null);
@@ -572,7 +606,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
572606

573607
const discoveredOAuthScopes = normalizeMcpRequestedOAuthScopes(dcrSupport.oauthScopesSupported);
574608
if (dcrSupport.isKnown && !dcrSupport.supportsDcr) {
575-
resetOAuthScopeInputs();
609+
initializeOAuthScopeSelection(discoveredOAuthScopes);
576610
setPendingClientCredentialsServer({
577611
name: displayName,
578612
serverUrl: normalizedServerUrl,
@@ -584,7 +618,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
584618
}
585619

586620
if (discoveredOAuthScopes.length > 0) {
587-
resetOAuthScopeInputs();
621+
initializeOAuthScopeSelection(discoveredOAuthScopes);
588622
setPendingOAuthScopeSelectionServer({
589623
name: displayName,
590624
serverUrl: normalizedServerUrl,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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('filters blank strings', () => {
17+
expect(normalizeMcpRequestedOAuthScopes(['', ' ', 'read'])).toEqual(['read']);
18+
});
19+
});
20+
21+
describe('buildMcpOAuthScopeEntries', () => {
22+
test('enables scopes that are in requestedOAuthScopes', () => {
23+
const entries = buildMcpOAuthScopeEntries({
24+
availableOAuthScopes: ['read', 'write'],
25+
requestedOAuthScopes: ['read'],
26+
});
27+
28+
expect(entries).toEqual([
29+
{ scope: 'read', enabled: true },
30+
{ scope: 'write', enabled: false },
31+
]);
32+
});
33+
34+
test('does not special-case offline_access; it is enabled only when requested', () => {
35+
const entries = buildMcpOAuthScopeEntries({
36+
availableOAuthScopes: ['offline_access', 'read'],
37+
requestedOAuthScopes: [],
38+
});
39+
40+
expect(entries).toEqual([
41+
{ scope: 'offline_access', enabled: false },
42+
{ scope: 'read', enabled: false },
43+
]);
44+
});
45+
46+
test('merges requested scopes not in available scopes into the output', () => {
47+
const entries = buildMcpOAuthScopeEntries({
48+
availableOAuthScopes: ['read'],
49+
requestedOAuthScopes: ['write'],
50+
});
51+
52+
expect(entries).toEqual([
53+
{ scope: 'read', enabled: false },
54+
{ scope: 'write', enabled: true },
55+
]);
56+
});
57+
58+
test('returns sorted, deduplicated entries', () => {
59+
const entries = buildMcpOAuthScopeEntries({
60+
availableOAuthScopes: ['write', 'read', 'write'],
61+
requestedOAuthScopes: ['write', 'write'],
62+
});
63+
64+
expect(entries).toEqual([
65+
{ scope: 'read', enabled: false },
66+
{ scope: 'write', enabled: true },
67+
]);
68+
});
69+
});
70+
71+
describe('getEnabledMcpOAuthScopeNames', () => {
72+
test('returns only enabled scopes, sorted and deduplicated', () => {
73+
const scopes = getEnabledMcpOAuthScopeNames([
74+
{ scope: 'write', enabled: false },
75+
{ scope: 'read', enabled: true },
76+
{ scope: 'offline_access', enabled: true },
77+
]);
78+
79+
expect(scopes).toEqual(['offline_access', 'read']);
80+
});
81+
});

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ 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+
// so the admin UI pre-selects it whenever the connector advertises it. Admins can still
8+
// untick it to opt out of refresh tokens.
9+
// See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
10+
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
11+
512
export function normalizeMcpRequestedOAuthScopes(oauthScopes: string[]): string[] {
613
return [...new Set(oauthScopes.map((scope) => scope.trim()).filter(Boolean))]
714
.sort();

0 commit comments

Comments
 (0)