Skip to content

Commit 70bf92f

Browse files
wip
1 parent 6e14b50 commit 70bf92f

12 files changed

Lines changed: 161 additions & 90 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "scope" TEXT NOT NULL DEFAULT '';

packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql

Lines changed: 0 additions & 10 deletions
This file was deleted.

packages/db/prisma/schema.prisma

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ model OAuthAuthorizationCode {
653653
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
654654
redirectUri String
655655
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
656+
scope String @default("")
656657
resource String? // RFC 8707: canonical URI of the target resource server
657658
dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding
658659
expiresAt DateTime
@@ -666,7 +667,7 @@ model OAuthRefreshToken {
666667
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
667668
userId String
668669
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
669-
scope String @default("mcp")
670+
scope String @default("")
670671
resource String? // RFC 8707
671672
dpopJkt String? // RFC 9449
672673
expiresAt DateTime
@@ -682,7 +683,7 @@ model OAuthToken {
682683
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
683684
userId String
684685
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
685-
scope String @default("mcp")
686+
scope String @default("")
686687
resource String? // RFC 8707: canonical URI of the target resource server
687688
dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding
688689
expiresAt DateTime

packages/web/src/app/oauth/authorize/components/consentScreen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { approveAuthorization, denyAuthorization } from '@/ee/features/oauth/actions';
4-
import { isPermittedRedirectUrl } from '@/ee/features/oauth/constants';
4+
import { isPermittedRedirectUrl } from '@/ee/features/oauth/utils';
55
import { LoadingButton } from '@/components/ui/loading-button';
66
import { isServiceError } from '@/lib/utils';
77
import { ClientIcon } from './clientIcon';

packages/web/src/app/oauth/authorize/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ConsentScreen } from './components/consentScreen';
44
import { __unsafePrisma } from '@/prisma';
55
import { hasEntitlement } from '@/lib/entitlements';
66
import { redirect } from 'next/navigation';
7-
import { resolveGrantedOAuthScopes } from '@/ee/features/oauth/constants';
7+
import { resolveGrantedOAuthScopes } from '@/ee/features/oauth/utils';
88
import { isValidDpopJkt } from '@/ee/features/oauth/dpop';
99

1010
export const dynamic = 'force-dynamic';

packages/web/src/ee/features/oauth/actions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import { sew } from "@/middleware/sew";
44
import { generateAndStoreAuthCode } from '@/ee/features/oauth/server';
55
import { withAuth } from '@/middleware/withAuth';
6-
import { resolveGrantedOAuthScopes, UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants';
6+
import { UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants';
7+
import { formatOAuthScopeString, resolveGrantedOAuthScopes } from '@/ee/features/oauth/utils';
78
import { isValidDpopJkt } from '@/ee/features/oauth/dpop';
89
import { ErrorCode } from '@/lib/errorCodes';
910
import type { ServiceError } from '@/lib/serviceError';
@@ -77,6 +78,7 @@ export const approveAuthorization = async ({
7778
userId: user.id,
7879
redirectUri,
7980
codeChallenge,
81+
scope: formatOAuthScopeString(grantedScopes.scopes),
8082
resource,
8183
dpopJkt,
8284
});

packages/web/src/ee/features/oauth/constants.ts

Lines changed: 5 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,72 +3,10 @@ export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this
33

44
export const UNPERMITTED_SCHEMES = /^(javascript|data|vbscript):/i;
55

6+
export const SOURCEBOT_OAUTH_SCOPES = [
7+
"mcp"
8+
];
9+
export type SourcebotOauthScope = (typeof SOURCEBOT_OAUTH_SCOPES)[number];
10+
611
export const SOURCEBOT_MCP_OAUTH_SCOPE = 'mcp';
712
export const DEFAULT_SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const;
8-
export const SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const;
9-
10-
const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
11-
12-
export function parseOAuthScopeString(scope: string | null | undefined): string[] {
13-
if (!scope) {
14-
return [];
15-
}
16-
17-
return [...new Set(scope.split(/\s+/).map((token) => token.trim()).filter(Boolean))];
18-
}
19-
20-
export function formatOAuthScopeString(scopes: readonly string[]): string {
21-
return scopes.join(' ');
22-
}
23-
24-
export function hasRequiredOAuthScopes(tokenScopes: readonly string[], requiredScopes: readonly string[]): boolean {
25-
const tokenScopeSet = new Set(tokenScopes);
26-
return requiredScopes.every((scope) => tokenScopeSet.has(scope));
27-
}
28-
29-
export function resolveGrantedOAuthScopes(requestedScope: string | null | undefined): { scopes: string[] } | { error: 'invalid_scope'; errorDescription: string } {
30-
const requestedScopes = parseOAuthScopeString(requestedScope);
31-
32-
for (const scope of requestedScopes) {
33-
if (!OAUTH_SCOPE_TOKEN_REGEX.test(scope)) {
34-
return {
35-
error: 'invalid_scope',
36-
errorDescription: `Invalid OAuth scope token: ${scope}.`,
37-
};
38-
}
39-
}
40-
41-
const supportedScopeSet = new Set<string>([...SOURCEBOT_OAUTH_SCOPES]);
42-
for (const scope of requestedScopes) {
43-
if (!supportedScopeSet.has(scope)) {
44-
return {
45-
error: 'invalid_scope',
46-
errorDescription: `Unsupported OAuth scope: ${scope}.`,
47-
};
48-
}
49-
}
50-
51-
return {
52-
scopes: requestedScopes.length > 0
53-
? requestedScopes
54-
: [...DEFAULT_SOURCEBOT_OAUTH_SCOPES],
55-
};
56-
}
57-
58-
/**
59-
* Returns true if the URL is permitted for use as a redirect target.
60-
* Allows relative paths starting with /oauth/complete and http(s) URLs.
61-
* Returns false for dangerous schemes like javascript:, data:, vbscript:.
62-
*/
63-
export function isPermittedRedirectUrl(url: string): boolean {
64-
if (url.startsWith('/oauth/complete')) {
65-
return true;
66-
}
67-
68-
try {
69-
const parsed = new URL(url);
70-
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
71-
} catch {
72-
return false;
73-
}
74-
}

packages/web/src/ee/features/oauth/server.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test, vi, beforeEach, describe } from 'vitest';
22
import { MOCK_REFRESH_TOKEN, MOCK_USER_WITH_ACCOUNTS, prisma } from '@/__mocks__/prisma';
3-
import { verifyAndExchangeCode, verifyAndRotateRefreshToken, revokeToken } from './server';
3+
import { generateAndStoreAuthCode, verifyAndExchangeCode, verifyAndRotateRefreshToken, revokeToken } from './server';
44
import { SOURCEBOT_MCP_OAUTH_SCOPE } from './constants';
55

66
vi.mock('@/prisma', async () => {
@@ -31,6 +31,7 @@ const VALID_AUTH_CODE = {
3131
redirectUri: 'http://localhost:9999/callback',
3232
// SHA-256('myverifier') base64url
3333
codeChallenge: 'Eb223qLjTQNFkRjCVsrDbsBk5ycPKwHdbHNRX99tTeQ',
34+
scope: SOURCEBOT_MCP_OAUTH_SCOPE,
3435
resource: null,
3536
dpopJkt: null,
3637
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
@@ -44,6 +45,39 @@ beforeEach(() => {
4445
prisma.$transaction.mockResolvedValue([] as any);
4546
});
4647

48+
// ---------------------------------------------------------------------------
49+
// generateAndStoreAuthCode
50+
// ---------------------------------------------------------------------------
51+
52+
describe('generateAndStoreAuthCode', () => {
53+
test('stores the granted scope with the authorization code', async () => {
54+
prisma.oAuthAuthorizationCode.create.mockResolvedValue(VALID_AUTH_CODE);
55+
56+
const code = await generateAndStoreAuthCode({
57+
clientId: 'test-client-id',
58+
userId: MOCK_USER_WITH_ACCOUNTS.id,
59+
redirectUri: 'http://localhost:9999/callback',
60+
codeChallenge: 'challenge',
61+
scope: 'mcp other',
62+
resource: 'https://sourcebot.test/api/mcp',
63+
dpopJkt: 'dpop-thumbprint',
64+
});
65+
66+
expect(code).toEqual(expect.any(String));
67+
expect(prisma.oAuthAuthorizationCode.create).toHaveBeenCalledWith({
68+
data: expect.objectContaining({
69+
clientId: 'test-client-id',
70+
userId: MOCK_USER_WITH_ACCOUNTS.id,
71+
redirectUri: 'http://localhost:9999/callback',
72+
codeChallenge: 'challenge',
73+
scope: 'mcp other',
74+
resource: 'https://sourcebot.test/api/mcp',
75+
dpopJkt: 'dpop-thumbprint',
76+
}),
77+
});
78+
});
79+
});
80+
4781
// ---------------------------------------------------------------------------
4882
// verifyAndExchangeCode
4983
// ---------------------------------------------------------------------------
@@ -78,6 +112,33 @@ describe('verifyAndExchangeCode', () => {
78112
});
79113
});
80114

115+
test('uses the scope bound to the authorization code when issuing tokens', async () => {
116+
prisma.oAuthAuthorizationCode.findUnique.mockResolvedValue({
117+
...VALID_AUTH_CODE,
118+
scope: 'mcp other',
119+
});
120+
prisma.oAuthAuthorizationCode.delete.mockResolvedValue(VALID_AUTH_CODE);
121+
prisma.oAuthToken.create.mockResolvedValue({} as never);
122+
prisma.oAuthRefreshToken.create.mockResolvedValue({} as never);
123+
124+
const result = await verifyAndExchangeCode({
125+
rawCode: VALID_CODE_HASH,
126+
clientId: 'test-client-id',
127+
redirectUri: 'http://localhost:9999/callback',
128+
codeVerifier: 'myverifier',
129+
resource: null,
130+
dpopJkt: null,
131+
});
132+
133+
expect(result).toMatchObject({ scope: 'mcp other' });
134+
expect(prisma.oAuthToken.create).toHaveBeenCalledWith({
135+
data: expect.objectContaining({ scope: 'mcp other' }),
136+
});
137+
expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({
138+
data: expect.objectContaining({ scope: 'mcp other' }),
139+
});
140+
});
141+
81142
test('returns invalid_grant if code is not found', async () => {
82143
prisma.oAuthAuthorizationCode.findUnique.mockResolvedValue(null);
83144

packages/web/src/ee/features/oauth/server.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
OAUTH_REFRESH_TOKEN_PREFIX,
1212
} from '@sourcebot/shared';
1313
import crypto from 'crypto';
14-
import { DEFAULT_SOURCEBOT_OAUTH_SCOPES, formatOAuthScopeString } from './constants';
14+
import { DEFAULT_SOURCEBOT_OAUTH_SCOPES } from './constants';
15+
import { formatOAuthScopeString } from './utils';
1516

1617
const DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING = formatOAuthScopeString(DEFAULT_SOURCEBOT_OAUTH_SCOPES);
1718

@@ -22,13 +23,15 @@ export async function generateAndStoreAuthCode({
2223
userId,
2324
redirectUri,
2425
codeChallenge,
26+
scope,
2527
resource,
2628
dpopJkt,
2729
}: {
2830
clientId: string;
2931
userId: string;
3032
redirectUri: string;
3133
codeChallenge: string;
34+
scope: string;
3235
resource: string | null;
3336
dpopJkt: string | null;
3437
}): Promise<string> {
@@ -42,6 +45,7 @@ export async function generateAndStoreAuthCode({
4245
userId,
4346
redirectUri,
4447
codeChallenge,
48+
scope,
4549
resource,
4650
dpopJkt,
4751
expiresAt: new Date(Date.now() + env.OAUTH_AUTHORIZATION_CODE_TTL_SECONDS * 1000),
@@ -123,6 +127,7 @@ export async function verifyAndExchangeCode({
123127

124128
const { token, hash } = generateOAuthToken();
125129
const { token: refreshToken, hash: refreshHash } = generateOAuthRefreshToken();
130+
const scope = authCode.scope;
126131
const tokenDpopJkt = authCode.dpopJkt ?? dpopJkt;
127132

128133
await __unsafePrisma.$transaction([
@@ -131,7 +136,7 @@ export async function verifyAndExchangeCode({
131136
hash,
132137
clientId,
133138
userId: authCode.userId,
134-
scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING,
139+
scope,
135140
resource: authCode.resource,
136141
dpopJkt: tokenDpopJkt,
137142
expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000),
@@ -142,15 +147,15 @@ export async function verifyAndExchangeCode({
142147
hash: refreshHash,
143148
clientId,
144149
userId: authCode.userId,
145-
scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING,
150+
scope,
146151
resource: authCode.resource,
147152
dpopJkt: tokenDpopJkt,
148153
expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000),
149154
},
150155
}),
151156
]);
152157

153-
return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING, dpopJkt: tokenDpopJkt };
158+
return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope, dpopJkt: tokenDpopJkt };
154159
}
155160

156161
// Verifies a refresh token, rotates it, and issues a new access token + refresh token.

packages/web/src/ee/features/oauth/constants.test.ts renamed to packages/web/src/ee/features/oauth/utils.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { expect, test, describe } from 'vitest';
22
import {
33
SOURCEBOT_MCP_OAUTH_SCOPE,
44
UNPERMITTED_SCHEMES,
5+
} from './constants';
6+
import {
57
hasRequiredOAuthScopes,
68
isPermittedRedirectUrl,
79
parseOAuthScopeString,
810
resolveGrantedOAuthScopes,
9-
} from './constants';
11+
} from './utils';
1012

1113
describe('OAuth scopes', () => {
1214
test('parses and deduplicates space-delimited scope strings', () => {

0 commit comments

Comments
 (0)