Skip to content

Commit f8e9355

Browse files
wip - move things into EE and also improve authorization page
1 parent 0851626 commit f8e9355

File tree

14 files changed

+203
-58
lines changed

14 files changed

+203
-58
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "OAuthClient" ADD COLUMN "logoUri" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ model ChatAccess {
497497
model OAuthClient {
498498
id String @id @default(cuid())
499499
name String
500+
logoUri String?
500501
redirectUris String[]
501502
createdAt DateTime @default(now())
502503

packages/shared/src/entitlements.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ const entitlements = [
4040
"permission-syncing",
4141
"github-app",
4242
"chat-sharing",
43-
"org-management"
43+
"org-management",
44+
"oauth",
4445
] as const;
4546
export type Entitlement = (typeof entitlements)[number];
4647

@@ -58,6 +59,7 @@ const entitlementsByPlan: Record<Plan, Entitlement[]> = {
5859
"github-app",
5960
"chat-sharing",
6061
"org-management",
62+
"oauth",
6163
],
6264
"self-hosted:enterprise-unlimited": [
6365
"anonymous-access",
@@ -70,6 +72,7 @@ const entitlementsByPlan: Record<Plan, Entitlement[]> = {
7072
"github-app",
7173
"chat-sharing",
7274
"org-management",
75+
"oauth",
7376
],
7477
} as const;
7578

packages/web/next.config.mjs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,18 @@ const nextConfig = {
2424
source: "/ingest/decide",
2525
destination: `https://us.i.posthog.com/decide`,
2626
},
27-
// Expose OAuth discovery documents at canonical RFC paths (without /api prefix)
27+
// Expose OAuth discovery documents at canonical RFC paths (without /api/ee prefix)
2828
// so MCP clients and OAuth tools can find them via standard discovery.
29+
//
30+
// RFC 8414: /.well-known/oauth-authorization-server
2931
{
3032
source: "/.well-known/oauth-authorization-server",
31-
destination: "/api/.well-known/oauth-authorization-server",
33+
destination: "/api/ee/.well-known/oauth-authorization-server",
3234
},
35+
// RFC 9728: path-specific form /.well-known/oauth-protected-resource/{resource-path}
3336
{
34-
source: "/.well-known/oauth-protected-resource",
35-
destination: "/api/.well-known/oauth-protected-resource",
37+
source: "/.well-known/oauth-protected-resource/:path*",
38+
destination: "/api/ee/.well-known/oauth-protected-resource/:path*",
3639
},
3740
];
3841
},

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

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

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@ import { apiHandler } from '@/lib/apiHandler';
22
import { env } from '@sourcebot/shared';
33

44
// RFC 8414: OAuth 2.0 Authorization Server Metadata
5+
// @note: we do not gate on entitlements here. That is handled in the /register,
6+
// /token, and /revoke routes.
57
// @see: https://datatracker.ietf.org/doc/html/rfc8414
68
export const GET = apiHandler(async () => {
79
const issuer = env.AUTH_URL.replace(/\/$/, '');
810

911
return Response.json({
1012
issuer,
1113
authorization_endpoint: `${issuer}/oauth/authorize`,
12-
token_endpoint: `${issuer}/api/oauth/token`,
13-
registration_endpoint: `${issuer}/api/oauth/register`,
14-
revocation_endpoint: `${issuer}/api/oauth/revoke`,
14+
token_endpoint: `${issuer}/api/ee/oauth/token`,
15+
registration_endpoint: `${issuer}/api/ee/oauth/register`,
16+
revocation_endpoint: `${issuer}/api/ee/oauth/revoke`,
1517
response_types_supported: ['code'],
1618
grant_types_supported: ['authorization_code'],
1719
code_challenge_methods_supported: ['S256'],
1820
token_endpoint_auth_methods_supported: ['none'],
1921
});
20-
}, { track: false });
22+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { apiHandler } from '@/lib/apiHandler';
2+
import { env } from '@sourcebot/shared';
3+
import { NextRequest } from 'next/server';
4+
5+
// RFC 9728: OAuth 2.0 Protected Resource Metadata (path-specific form)
6+
// For a resource at /api/mcp, the well-known URI is /.well-known/oauth-protected-resource/api/mcp.
7+
// @note: we do not gate on entitlements here. That is handled in the /register,
8+
// /token, and /revoke routes.
9+
// @see: https://datatracker.ietf.org/doc/html/rfc9728#section-3
10+
const PROTECTED_RESOURCES = new Set([
11+
'api/mcp'
12+
]);
13+
14+
export const GET = apiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
15+
const { path } = await params;
16+
const resourcePath = path.join('/');
17+
18+
if (!PROTECTED_RESOURCES.has(resourcePath)) {
19+
return Response.json(
20+
{ error: 'not_found', error_description: `No protected resource metadata found for path: ${resourcePath}` },
21+
{ status: 404 }
22+
);
23+
}
24+
25+
const issuer = env.AUTH_URL.replace(/\/$/, '');
26+
27+
return Response.json({
28+
resource: `${issuer}/${resourcePath}`,
29+
authorization_servers: [
30+
issuer
31+
],
32+
});
33+
});

packages/web/src/app/api/(server)/oauth/register/route.ts renamed to packages/web/src/app/api/(server)/ee/oauth/register/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { apiHandler } from '@/lib/apiHandler';
22
import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
33
import { prisma } from '@/prisma';
4+
import { hasEntitlement } from '@sourcebot/shared';
45
import { NextRequest } from 'next/server';
56
import { z } from 'zod';
67

@@ -9,17 +10,25 @@ import { z } from 'zod';
910
const registerRequestSchema = z.object({
1011
client_name: z.string().min(1),
1112
redirect_uris: z.array(z.string().url()).min(1),
13+
logo_uri: z.string().nullish(),
1214
});
1315

1416
export const POST = apiHandler(async (request: NextRequest) => {
17+
if (!hasEntitlement('oauth')) {
18+
return Response.json(
19+
{ error: 'access_denied', error_description: 'OAuth is not available on this plan.' },
20+
{ status: 403 }
21+
);
22+
}
23+
1524
const body = await request.json();
1625
const parsed = registerRequestSchema.safeParse(body);
1726

1827
if (!parsed.success) {
1928
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
2029
}
2130

22-
const { client_name, redirect_uris } = parsed.data;
31+
const { client_name, redirect_uris, logo_uri } = parsed.data;
2332

2433
// Reject wildcard redirect URIs per security best practices
2534
if (redirect_uris.some((uri) => uri.includes('*'))) {
@@ -32,6 +41,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
3241
const client = await prisma.oAuthClient.create({
3342
data: {
3443
name: client_name,
44+
logoUri: logo_uri ?? null,
3545
redirectUris: redirect_uris,
3646
},
3747
});
@@ -40,6 +50,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
4050
{
4151
client_id: client.id,
4252
client_name: client.name,
53+
...(client.logoUri && { logo_uri: client.logoUri }),
4354
redirect_uris: client.redirectUris,
4455
},
4556
{ status: 201 }

packages/web/src/app/api/(server)/oauth/revoke/route.ts renamed to packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { revokeToken } from '@/features/oauth/server';
22
import { apiHandler } from '@/lib/apiHandler';
3+
import { hasEntitlement } from '@sourcebot/shared';
34
import { NextRequest } from 'next/server';
45

56
// RFC 7009: OAuth 2.0 Token Revocation
67
// Always returns 200 regardless of whether the token existed.
78
// @see: https://datatracker.ietf.org/doc/html/rfc7009
89
export const POST = apiHandler(async (request: NextRequest) => {
10+
if (!hasEntitlement('oauth')) {
11+
return Response.json(
12+
{ error: 'access_denied', error_description: 'OAuth is not available on this plan.' },
13+
{ status: 403 }
14+
);
15+
}
16+
917
const formData = await request.formData();
1018
const token = formData.get('token');
1119

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { verifyAndExchangeCode, ACCESS_TOKEN_TTL_SECONDS } from '@/features/oauth/server';
22
import { apiHandler } from '@/lib/apiHandler';
3+
import { hasEntitlement } from '@sourcebot/shared';
34
import { NextRequest } from 'next/server';
45

56
// OAuth 2.0 Token Endpoint
67
// Supports grant_type=authorization_code with PKCE (RFC 7636).
78
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
89
export const POST = apiHandler(async (request: NextRequest) => {
10+
if (!hasEntitlement('oauth')) {
11+
return Response.json(
12+
{ error: 'access_denied', error_description: 'OAuth is not available on this plan.' },
13+
{ status: 403 }
14+
);
15+
}
16+
917
const formData = await request.formData();
1018

1119
const grantType = formData.get('grant_type');

0 commit comments

Comments
 (0)