Skip to content

Commit e694dd3

Browse files
author
Rajat
committed
code review comments fixes
1 parent 8d5da87 commit e694dd3

6 files changed

Lines changed: 86 additions & 9 deletions

File tree

apps/api/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ CIMD, while existing clients can use Dynamic Client Registration (DCR); static
315315
pre-registered clients are also supported. OAuth tool access is
316316
default-deny using the scope map in `src/mcp/policy.ts`. Team API keys have full
317317
tool access to their fixed team, while browser session cookies are rejected.
318+
OAuth authorization requests must include an explicit, non-empty `scope` so a
319+
missing value cannot expand to the client's complete capability set.
318320
319321
MCP tools live in `src/mcp/tools/*` and cover contacts, templates, sequences,
320322
ESP settings (both the default-ESP singleton tools and the multi-ESP

apps/api/docs/mcp-2026-07-28-migration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ OAuth calls must contain the tool's required scope. Team API keys currently
178178
have full access to their fixed team because scoped team keys are not yet a
179179
SendLit API-key product feature.
180180

181+
Dynamic registration and discovery use identity-only default scopes. All MCP
182+
scopes remain requestable capabilities, but `/oauth2/authorize` rejects a
183+
missing or empty `scope` rather than allowing Better Auth to substitute the
184+
client's complete capability set.
185+
181186
The supported scope families are contacts, templates, media, sequences,
182187
emails, settings, ESP configuration, teams, API keys, feedback, delivery
183188
events, and suppressions, with read/write or read/send separation as

apps/api/src/auth/better-auth-mcp.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,58 @@ vi.mock("../organization/queries", () => ({
1919

2020
describe("Better Auth MCP authorization metadata", () => {
2121
let auth: (typeof import("./better-auth.js"))["auth"];
22+
let defaultScopes: (typeof import("./better-auth.js"))["OAUTH_CLIENT_DEFAULT_SCOPES"];
23+
let requestableScopes: (typeof import("./better-auth.js"))["OAUTH_CLIENT_REQUESTABLE_SCOPES"];
2224

2325
beforeAll(async () => {
2426
vi.stubEnv(
2527
"BETTER_AUTH_SECRET",
2628
"test-only-secret-with-at-least-thirty-two-characters",
2729
);
2830
vi.stubEnv("API_PUBLIC_URL", "https://sendlit.test");
29-
({ auth } = await import("./better-auth.js"));
31+
const betterAuthModule = await import("./better-auth.js");
32+
auth = betterAuthModule.auth;
33+
defaultScopes = betterAuthModule.OAUTH_CLIENT_DEFAULT_SCOPES;
34+
requestableScopes = betterAuthModule.OAUTH_CLIENT_REQUESTABLE_SCOPES;
3035
});
3136

37+
it("keeps registration defaults identity-only while allowing MCP scopes", () => {
38+
expect(defaultScopes).toEqual(["openid", "profile", "email"]);
39+
expect(requestableScopes).toEqual(
40+
expect.arrayContaining(["offline_access", ...MCP_SCOPES_SUPPORTED]),
41+
);
42+
expect(defaultScopes).not.toEqual(
43+
expect.arrayContaining([...MCP_SCOPES_SUPPORTED]),
44+
);
45+
});
46+
47+
it.each(["", " "])(
48+
"rejects authorization without an explicit scope (%j)",
49+
async (scope) => {
50+
const query = new URLSearchParams({
51+
response_type: "code",
52+
client_id: "test-client",
53+
redirect_uri: "https://client.example/callback",
54+
code_challenge: "test-code-challenge",
55+
code_challenge_method: "S256",
56+
});
57+
if (scope.length > 0) query.set("scope", scope);
58+
59+
const response = await auth.handler(
60+
new Request(
61+
`https://sendlit.test/api/auth/oauth2/authorize?${query}`,
62+
),
63+
);
64+
65+
expect(response.status).toBe(400);
66+
await expect(response.json()).resolves.toMatchObject({
67+
error: "invalid_scope",
68+
error_description:
69+
"OAuth authorization requests must include an explicit scope.",
70+
});
71+
},
72+
);
73+
3274
it("advertises CIMD, DCR, issuer protection, and all enforced MCP scopes", async () => {
3375
const response = await auth.handler(
3476
new Request(

apps/api/src/auth/better-auth.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { betterAuth } from "better-auth";
2+
import { APIError, createAuthMiddleware } from "better-auth/api";
23
import { drizzleAdapter } from "better-auth/adapters/drizzle";
34
import { emailOTP } from "better-auth/plugins/email-otp";
45
import { jwt } from "better-auth/plugins/jwt";
@@ -52,13 +53,19 @@ export const mcpResourceUrl = `${authBaseUrl}/mcp`;
5253
* single source of truth shared between `oauthProvider({ validAudiences })`
5354
* below and `resolve-auth.ts`'s bearer verification. */
5455
export const validOAuthAudiences = [authBaseUrl, mcpResourceUrl];
55-
const supportedOAuthScopes = [
56+
export const OAUTH_CLIENT_DEFAULT_SCOPES = [
5657
"openid",
5758
"profile",
5859
"email",
60+
] as const;
61+
export const OAUTH_CLIENT_REQUESTABLE_SCOPES = [
5962
"offline_access",
6063
...MCP_SCOPES_SUPPORTED,
6164
] as const;
65+
const supportedOAuthScopes = [
66+
...OAUTH_CLIENT_DEFAULT_SCOPES,
67+
...OAUTH_CLIENT_REQUESTABLE_SCOPES,
68+
] as const;
6269

6370
/** Where an MCP client should discover `mcpResourceUrl`'s protected-resource
6471
* metadata (RFC 9728: `<origin>/.well-known/oauth-protected-resource<path>`).
@@ -172,6 +179,25 @@ export const auth = betterAuth({
172179
},
173180
},
174181
},
182+
hooks: {
183+
before: createAuthMiddleware(async (context) => {
184+
if (context.path !== "/oauth2/authorize") return;
185+
186+
const scope = context.query?.scope;
187+
if (typeof scope === "string" && scope.trim().length > 0) return;
188+
189+
// Better Auth persists the union of registration defaults and
190+
// allowed scopes as the client's capability set, then treats that
191+
// complete set as requested when `scope` is omitted. Require an
192+
// explicit choice so a scope-less request can never become a
193+
// full-access MCP grant.
194+
throw new APIError("BAD_REQUEST", {
195+
error: "invalid_scope",
196+
error_description:
197+
"OAuth authorization requests must include an explicit scope.",
198+
});
199+
}),
200+
},
175201
plugins: [
176202
emailOTP({
177203
async sendVerificationOTP({ email, otp }) {
@@ -199,12 +225,12 @@ export const auth = betterAuth({
199225
allowUnauthenticatedDynamicClientRegistration: true,
200226
scopes: supportedOAuthScopes,
201227
validAudiences: validOAuthAudiences,
202-
// CIMD documents such as VS Code's describe redirect URIs and
203-
// grant types but do not predeclare SendLit-specific scopes.
204-
// Allow the client to request the scopes it presents to the
205-
// user at authorization time; per-tool enforcement remains
206-
// default-deny in the MCP policy registry.
207-
clientRegistrationDefaultScopes: supportedOAuthScopes,
228+
// Registration establishes capabilities, not grants. Keep the
229+
// default set identity-only and allow clients to explicitly
230+
// request the narrower MCP scope set they present at consent.
231+
clientRegistrationDefaultScopes: OAUTH_CLIENT_DEFAULT_SCOPES,
232+
clientRegistrationAllowedScopes:
233+
OAUTH_CLIENT_REQUESTABLE_SCOPES,
208234
}),
209235
// The authorization request's RFC 8707 `resource` value must
210236
// resolve to a persisted provider resource. Seed the sole current

apps/docs/content/docs/developers/authentication.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ HTTPS metadata-document URL. Modern clients should prefer CIMD; DCR remains
2626
available for existing clients. Clients must use Authorization Code
2727
with S256 PKCE and request only the MCP scopes they need; the complete scope
2828
list and tool groups are documented on the [MCP server page](/developers/mcp).
29+
Authorization requests must include an explicit `scope`; SendLit rejects an
30+
omitted or empty value instead of granting the client's complete capability set.
2931
Unauthenticated DCR is rate-limited to 20 requests per IP per minute.
3032
For a multi-team account, SendLit asks the user to choose the team during the
3133
OAuth flow; the resulting token is restricted to that chosen team.

apps/docs/content/docs/developers/mcp.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ GET /.well-known/openid-configuration
4242

4343
OAuth authorization, consent, token, introspection, revocation, and userinfo endpoints are provided under `/api/auth/oauth2/*`. SendLit supports pre-registered clients, Client ID Metadata Documents (CIMD), and Dynamic Client Registration (DCR). For CIMD, the OAuth `client_id` is the HTTPS URL of the client's metadata document. Modern clients should prefer CIMD; DCR remains available for existing clients such as MCP Inspector.
4444

45-
Use Authorization Code with S256 PKCE, request only the scopes your client needs, and keep any refresh token in the client's secure credential store. Every authorization requires a SendLit user to sign in, select a team when necessary, and approve consent.
45+
Use Authorization Code with S256 PKCE, request only the scopes your client needs, and keep any refresh token in the client's secure credential store. The authorization request must include an explicit, non-empty `scope`; SendLit does not turn a missing value into a full-access grant. Every authorization requires a SendLit user to sign in, select a team when necessary, and approve consent.
4646

4747
API keys always select one fixed team and currently grant the complete MCP tool set for that team. Dashboard session cookies and `X-Sendlit-Team-Id` are not accepted as MCP authentication.
4848

0 commit comments

Comments
 (0)