Skip to content

Commit 633f0e0

Browse files
RFC 8707 - resource indicator
1 parent 9f5a4d5 commit 633f0e0

File tree

5 files changed

+28
-3
lines changed

5 files changed

+28
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "resource" TEXT;
3+
4+
-- AlterTable
5+
ALTER TABLE "OAuthToken" ADD COLUMN "resource" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ model ChatAccess {
493493
// @see: https://datatracker.ietf.org/doc/html/rfc6749
494494

495495
/// A registered OAuth2 client application (e.g. Claude Desktop, Cursor).
496-
/// Created via dynamic client registration (RFC 7591) at POST /api/oauth/register.
496+
/// Created via dynamic client registration (RFC 7591) at POST /api/ee/oauth/register.
497497
model OAuthClient {
498498
id String @id @default(cuid())
499499
name String
@@ -515,6 +515,7 @@ model OAuthAuthorizationCode {
515515
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
516516
redirectUri String
517517
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
518+
resource String? // RFC 8707: canonical URI of the target resource server
518519
expiresAt DateTime
519520
createdAt DateTime @default(now())
520521
}
@@ -527,6 +528,7 @@ model OAuthToken {
527528
userId String
528529
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
529530
scope String @default("")
531+
resource String? // RFC 8707: canonical URI of the target resource server
530532
expiresAt DateTime
531533
createdAt DateTime @default(now())
532534
lastUsedAt DateTime?

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface AuthorizePageProps {
1717
code_challenge_method?: string;
1818
response_type?: string;
1919
state?: string;
20+
resource?: string;
2021
}>;
2122
}
2223

@@ -26,7 +27,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
2627
}
2728

2829
const params = await searchParams;
29-
const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state } = params;
30+
const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, resource } = params;
3031

3132
// Validate required parameters. Per spec, do NOT redirect on client errors —
3233
// show an error page instead to avoid open redirect vulnerabilities.
@@ -67,6 +68,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
6768
userId: session!.user.id,
6869
redirectUri: redirect_uri!,
6970
codeChallenge: code_challenge!,
71+
resource: resource ?? null,
7072
});
7173

7274
const callbackUrl = new URL(redirect_uri!);

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'server-only';
22

33
import { prisma } from '@/prisma';
44
import { generateOAuthToken, hashSecret } from '@sourcebot/shared';
5+
import { Prisma } from '@prisma/client';
56
import crypto from 'crypto';
67

78
const AUTH_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
@@ -16,11 +17,13 @@ export async function generateAndStoreAuthCode({
1617
userId,
1718
redirectUri,
1819
codeChallenge,
20+
resource,
1921
}: {
2022
clientId: string;
2123
userId: string;
2224
redirectUri: string;
2325
codeChallenge: string;
26+
resource: string | null;
2427
}): Promise<string> {
2528
const rawCode = crypto.randomBytes(32).toString('hex');
2629
const codeHash = hashSecret(rawCode);
@@ -32,6 +35,7 @@ export async function generateAndStoreAuthCode({
3235
userId,
3336
redirectUri,
3437
codeChallenge,
38+
resource,
3539
expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS),
3640
},
3741
});
@@ -46,11 +50,13 @@ export async function verifyAndExchangeCode({
4650
clientId,
4751
redirectUri,
4852
codeVerifier,
53+
resource,
4954
}: {
5055
rawCode: string;
5156
clientId: string;
5257
redirectUri: string;
5358
codeVerifier: string;
59+
resource: string | null;
5460
}): Promise<{ token: string; expiresIn: number } | { error: string; errorDescription: string }> {
5561
const codeHash = hashSecret(rawCode);
5662

@@ -85,10 +91,15 @@ export async function verifyAndExchangeCode({
8591
return { error: 'invalid_grant', errorDescription: 'PKCE code verifier is invalid.' };
8692
}
8793

94+
// RFC 8707: if a resource was bound to the auth code, the token request must present the same value.
95+
if (authCode.resource !== null && authCode.resource !== resource) {
96+
return { error: 'invalid_target', errorDescription: 'resource parameter does not match the value bound to the authorization code.' };
97+
}
98+
8899
// Single-use: delete the auth code before issuing token.
89100
// Handle concurrent consume attempts gracefully.
90101
try {
91-
await prisma.oAuthAuthorizationCode.delete({ where: { codeHash } });
102+
await prisma.oAuthAuthorizationCode.delete({ where: { codeHash } });
92103
} catch (error) {
93104
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
94105
return { error: 'invalid_grant', errorDescription: 'Authorization code has already been used.' };
@@ -103,6 +114,7 @@ export async function verifyAndExchangeCode({
103114
hash,
104115
clientId,
105116
userId: authCode.userId,
117+
resource: authCode.resource,
106118
expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
107119
},
108120
});

packages/web/src/withAuthV2.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ export const getAuthenticatedUser = async () => {
122122

123123
// OAuth access token (sourcebot-oauth-<hex>)
124124
if (bearerToken.startsWith("sourcebot-oauth-")) {
125+
if (!hasEntitlement('oauth')) {
126+
return undefined;
127+
}
128+
125129
const secret = bearerToken.slice("sourcebot-oauth-".length);
126130
const hash = hashSecret(secret);
127131
const oauthToken = await __unsafePrisma.oAuthToken.findUnique({

0 commit comments

Comments
 (0)