Skip to content

Commit 649bfbf

Browse files
Allow DPoP-bound OAuth access tokens (#1395)
* feat(web): support DPoP-bound OAuth tokens * docs: add DPoP changelog entry * Use request context for auth helpers * Clarify DPoP scheme guard * feedback
1 parent a369cfb commit 649bfbf

20 files changed

Lines changed: 882 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- [EE] Added mermaid diagram rendering to Ask Sourcebot answers, with pan/zoom, copy/export, in-thread deep links, and an interleaved right-panel view. [#1369](https://github.com/sourcebot-dev/sourcebot/pull/1369)
1717
- [EE] Added a context-window usage gauge to the Ask Sourcebot chat details, showing how much of the selected model's context window each turn occupies. Window sizes are resolved from the models.dev catalog. [#1370](https://github.com/sourcebot-dev/sourcebot/pull/1370)
1818
- Added language model input-modality and document capability resolution, automatically resolved from the models.dev catalog (falls back to text-only for uncatalogued/self-hosted models). [#1372](https://github.com/sourcebot-dev/sourcebot/pull/1372)
19+
- [EE] Added DPoP sender-constrained OAuth tokens for MCP clients. [#1395](https://github.com/sourcebot-dev/sourcebot/pull/1395)
1920

2021
### Fixed
2122
- Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "dpopJkt" TEXT;
2+
3+
ALTER TABLE "OAuthRefreshToken" ADD COLUMN "dpopJkt" TEXT;
4+
5+
ALTER TABLE "OAuthToken" ADD COLUMN "dpopJkt" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ model OAuthAuthorizationCode {
654654
redirectUri String
655655
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
656656
resource String? // RFC 8707: canonical URI of the target resource server
657+
dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding
657658
expiresAt DateTime
658659
createdAt DateTime @default(now())
659660
}
@@ -667,6 +668,7 @@ model OAuthRefreshToken {
667668
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
668669
scope String @default("")
669670
resource String? // RFC 8707
671+
dpopJkt String? // RFC 9449
670672
expiresAt DateTime
671673
createdAt DateTime @default(now())
672674
@@ -682,6 +684,7 @@ model OAuthToken {
682684
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
683685
scope String @default("")
684686
resource String? // RFC 8707: canonical URI of the target resource server
687+
dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding
685688
expiresAt DateTime
686689
createdAt DateTime @default(now())
687690
lastUsedAt DateTime?

packages/web/src/__mocks__/prisma.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[]
5555
userId: MOCK_USER_WITH_ACCOUNTS.id,
5656
scope: '',
5757
resource: null,
58+
dpopJkt: null,
5859
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now
5960
createdAt: new Date(),
6061
lastUsedAt: null,
@@ -67,8 +68,9 @@ export const MOCK_REFRESH_TOKEN: OAuthRefreshToken = {
6768
userId: MOCK_USER_WITH_ACCOUNTS.id,
6869
scope: '',
6970
resource: null,
71+
dpopJkt: null,
7072
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days from now
7173
createdAt: new Date(),
7274
}
7375

74-
export const userScopedPrismaClientExtension = vi.fn();
76+
export const userScopedPrismaClientExtension = vi.fn();

packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
22
import { env } from '@sourcebot/shared';
33
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
44
import { hasEntitlement } from '@/lib/entitlements';
5+
import { SUPPORTED_DPOP_SIGNING_ALGS } from '@/ee/features/oauth/dpop';
56

67
// RFC 8414: OAuth 2.0 Authorization Server Metadata
78
// @see: https://datatracker.ietf.org/doc/html/rfc8414
@@ -26,6 +27,7 @@ export const GET = oauthApiHandler(async () => {
2627
grant_types_supported: ['authorization_code', 'refresh_token'],
2728
code_challenge_methods_supported: ['S256'],
2829
token_endpoint_auth_methods_supported: ['none'],
30+
dpop_signing_alg_values_supported: SUPPORTED_DPOP_SIGNING_ALGS,
2931
service_documentation: 'https://docs.sourcebot.dev',
3032
});
3133
});

packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@ export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { p
3737
authorization_servers: [
3838
issuer
3939
],
40+
bearer_methods_supported: ['header'],
4041
});
4142
});

packages/web/src/app/api/(server)/ee/mcp/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ async function mcpErrorResponse(error: ServiceError): Promise<Response> {
2222
const response = serviceErrorResponse(error);
2323
if (error.statusCode === StatusCodes.UNAUTHORIZED && await hasEntitlement('oauth')) {
2424
const issuer = env.AUTH_URL.replace(/\/$/, '');
25-
response.headers.set(
25+
response.headers.append(
2626
'WWW-Authenticate',
2727
`Bearer realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`
2828
);
29+
response.headers.append(
30+
'WWW-Authenticate',
31+
`DPoP realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`
32+
);
2933
}
3034
return response;
3135
}

packages/web/src/app/api/(server)/ee/oauth/token/route.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { env } from '@sourcebot/shared';
44
import { NextRequest } from 'next/server';
55
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
66
import { hasEntitlement } from '@/lib/entitlements';
7+
import { DPOP_PROOF_HEADER, DPOP_TOKEN_TYPE, verifyDpopProof } from '@/ee/features/oauth/dpop';
78

89
// OAuth 2.0 Token Endpoint
910
// Supports grant_type=authorization_code with PKCE (RFC 7636).
@@ -30,6 +31,20 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
3031
);
3132
}
3233

34+
const dpopProof = request.headers.get(DPOP_PROOF_HEADER);
35+
const dpopProofResult = dpopProof
36+
? await verifyDpopProof({ request, proof: dpopProof })
37+
: null;
38+
39+
if (dpopProofResult && !dpopProofResult.ok) {
40+
return Response.json(
41+
{ error: dpopProofResult.error, error_description: dpopProofResult.errorDescription },
42+
{ status: 400 }
43+
);
44+
}
45+
46+
const dpopJkt = dpopProofResult?.ok ? dpopProofResult.jkt : null;
47+
3348
if (grantType === 'authorization_code') {
3449
const code = formData.get('code');
3550
const redirectUri = formData.get('redirect_uri');
@@ -48,6 +63,7 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
4863
redirectUri: redirectUri.toString(),
4964
codeVerifier: codeVerifier.toString(),
5065
resource: resource ? resource.toString() : null,
66+
dpopJkt,
5167
});
5268

5369
if ('error' in result) {
@@ -60,7 +76,7 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
6076
return Response.json({
6177
access_token: result.token,
6278
refresh_token: result.refreshToken,
63-
token_type: 'Bearer',
79+
token_type: result.dpopJkt ? DPOP_TOKEN_TYPE : 'Bearer',
6480
expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS,
6581
scope: '',
6682
});
@@ -80,6 +96,7 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
8096
rawRefreshToken: rawRefreshToken.toString(),
8197
clientId: clientId.toString(),
8298
resource: resource ? resource.toString() : null,
99+
dpopJkt,
83100
});
84101

85102
if ('error' in result) {
@@ -92,7 +109,7 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
92109
return Response.json({
93110
access_token: result.token,
94111
refresh_token: result.refreshToken,
95-
token_type: 'Bearer',
112+
token_type: result.dpopJkt ? DPOP_TOKEN_TYPE : 'Bearer',
96113
expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS,
97114
scope: '',
98115
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface ConsentScreenProps {
1818
redirectUri: string;
1919
codeChallenge: string;
2020
resource: string | null;
21+
dpopJkt: string | null;
2122
state: string | undefined;
2223
userEmail: string;
2324
}
@@ -29,6 +30,7 @@ export function ConsentScreen({
2930
redirectUri,
3031
codeChallenge,
3132
resource,
33+
dpopJkt,
3234
state,
3335
userEmail,
3436
}: ConsentScreenProps) {
@@ -43,7 +45,7 @@ export function ConsentScreen({
4345
const onApprove = async () => {
4446
captureEvent('wa_oauth_authorization_approved', { clientId, clientName });
4547
setPending('approve');
46-
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, state });
48+
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, dpopJkt, state });
4749
if (!isServiceError(result)) {
4850
if (!isPermittedRedirectUrl(result)) {
4951
toast({ description: `❌ Redirect URL is not permitted.` });

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +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 { isValidDpopJkt } from '@/ee/features/oauth/dpop';
78

89
export const dynamic = 'force-dynamic';
910

@@ -16,6 +17,7 @@ interface AuthorizePageProps {
1617
response_type?: string;
1718
state?: string;
1819
resource?: string | string[];
20+
dpop_jkt?: string | string[];
1921
}>;
2022
}
2123

@@ -25,13 +27,14 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
2527
}
2628

2729
const params = await searchParams;
28-
const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, resource: _resource } = params;
30+
const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, resource: _resource, dpop_jkt: _dpopJkt } = params;
2931

3032
// RFC 8707 allows multiple resource parameters to indicate a token intended for multiple resources.
3133
// Sourcebot only supports a single resource (the MCP endpoint), so we take the first value.
3234
//
3335
// @see: https://www.rfc-editor.org/rfc/rfc8707.html#section-2-2.2
3436
const resource = Array.isArray(_resource) ? _resource[0] : _resource;
37+
const dpopJkt = Array.isArray(_dpopJkt) ? _dpopJkt[0] : _dpopJkt;
3538

3639
// Validate required parameters. Per spec, do NOT redirect on client errors —
3740
// show an error page instead to avoid open redirect vulnerabilities.
@@ -47,6 +50,10 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
4750
return <ErrorPage message={`Unsupported code_challenge_method: ${code_challenge_method}. Only "S256" is supported.`} />;
4851
}
4952

53+
if (dpopJkt && !isValidDpopJkt(dpopJkt)) {
54+
return <ErrorPage message="Invalid dpop_jkt parameter." />;
55+
}
56+
5057
const client = await __unsafePrisma.oAuthClient.findUnique({ where: { id: client_id } });
5158

5259
if (!client) {
@@ -74,6 +81,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
7481
redirectUri={redirect_uri!}
7582
codeChallenge={code_challenge!}
7683
resource={resource ?? null}
84+
dpopJkt={dpopJkt ?? null}
7785
state={state}
7886
userEmail={session!.user.email!}
7987
/>

0 commit comments

Comments
 (0)