|
1 | 1 | import { betterAuth } from "better-auth"; |
| 2 | +import { APIError, createAuthMiddleware } from "better-auth/api"; |
2 | 3 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; |
3 | 4 | import { emailOTP } from "better-auth/plugins/email-otp"; |
4 | 5 | import { jwt } from "better-auth/plugins/jwt"; |
@@ -52,13 +53,19 @@ export const mcpResourceUrl = `${authBaseUrl}/mcp`; |
52 | 53 | * single source of truth shared between `oauthProvider({ validAudiences })` |
53 | 54 | * below and `resolve-auth.ts`'s bearer verification. */ |
54 | 55 | export const validOAuthAudiences = [authBaseUrl, mcpResourceUrl]; |
55 | | -const supportedOAuthScopes = [ |
| 56 | +export const OAUTH_CLIENT_DEFAULT_SCOPES = [ |
56 | 57 | "openid", |
57 | 58 | "profile", |
58 | 59 | "email", |
| 60 | +] as const; |
| 61 | +export const OAUTH_CLIENT_REQUESTABLE_SCOPES = [ |
59 | 62 | "offline_access", |
60 | 63 | ...MCP_SCOPES_SUPPORTED, |
61 | 64 | ] as const; |
| 65 | +const supportedOAuthScopes = [ |
| 66 | + ...OAUTH_CLIENT_DEFAULT_SCOPES, |
| 67 | + ...OAUTH_CLIENT_REQUESTABLE_SCOPES, |
| 68 | +] as const; |
62 | 69 |
|
63 | 70 | /** Where an MCP client should discover `mcpResourceUrl`'s protected-resource |
64 | 71 | * metadata (RFC 9728: `<origin>/.well-known/oauth-protected-resource<path>`). |
@@ -172,6 +179,25 @@ export const auth = betterAuth({ |
172 | 179 | }, |
173 | 180 | }, |
174 | 181 | }, |
| 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 | + }, |
175 | 201 | plugins: [ |
176 | 202 | emailOTP({ |
177 | 203 | async sendVerificationOTP({ email, otp }) { |
@@ -199,12 +225,12 @@ export const auth = betterAuth({ |
199 | 225 | allowUnauthenticatedDynamicClientRegistration: true, |
200 | 226 | scopes: supportedOAuthScopes, |
201 | 227 | 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, |
208 | 234 | }), |
209 | 235 | // The authorization request's RFC 8707 `resource` value must |
210 | 236 | // resolve to a persisted provider resource. Seed the sole current |
|
0 commit comments