diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index 8fdc350ce0..c3da72894f 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -25,6 +25,8 @@ import { invalidClientIdMismatchMessage } from '@kody-internal/shared/oauth-mess import { getUsernameFormatValidationError } from '#app/username.ts' import { getPkceValidationError } from '#worker/oauth-pkce.ts' import { oauthPaths } from '#app/oauth-paths.ts' +import { getAppBaseUrl } from '#app/app-base-url.ts' +import { mcpResourcePath } from './mcp-auth.ts' export { oauthPaths } @@ -214,7 +216,28 @@ function createSetCookieHeaders(cookies: Array) { return hasCookie ? headers : undefined } -async function resolveAuthRequest(helpers: OAuthHelpers, request: Request) { +function defaultMcpResourceForAuthRequest( + authRequest: AuthRequest, + request: Request, + env: Env, +) { + // Gemini Spark (and some other MCP hosts) omit RFC 8707 `resource` on + // authorize. Without a grant resource, token exchange can mint a token with + // no audience, and `/mcp` rejects it. Default to the MCP resource when the + // client did not send one; explicit `resource` values are left unchanged. + if (authRequest.resource !== undefined) return + const origin = getAppBaseUrl({ + env, + requestUrl: request.url, + }) + authRequest.resource = `${origin}${mcpResourcePath}` +} + +async function resolveAuthRequest( + helpers: OAuthHelpers, + request: Request, + env: Env, +) { try { const authRequest = await helpers.parseAuthRequest(request) if (!authRequest.clientId || !authRequest.redirectUri) { @@ -227,6 +250,7 @@ async function resolveAuthRequest(helpers: OAuthHelpers, request: Request) { if (!client) { return { error: 'Unknown OAuth client.' } } + defaultMcpResourceForAuthRequest(authRequest, request, env) return { authRequest, client } } catch (error) { const message = @@ -593,7 +617,7 @@ export async function loadOAuthAuthorizeData( env: Env, ): Promise { const helpers = getOAuthHelpers(env) - const resolution = await resolveAuthRequest(helpers, request) + const resolution = await resolveAuthRequest(helpers, request, env) if ('error' in resolution) { const { allowClientReset, setCookie } = await resolveAuthorizeInfoResetState( @@ -736,7 +760,7 @@ export async function handleAuthorizeRequest( return handleResetClientRequest(request, env, helpers, requestIp) } - const resolution = await resolveAuthRequest(helpers, request) + const resolution = await resolveAuthRequest(helpers, request, env) if ('error' in resolution) { return respondAuthorizeError( request, diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index 41d135a8fb..1188fa5c97 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -56,6 +56,25 @@ const claudeClient: ClientInfo = { clientName: 'Claude', tokenEndpointAuthMethod: 'none', } +// Gemini Spark custom MCP apps omit RFC 8707 `resource` on authorize. +const geminiAuthorizeUrl = + 'https://heykody.dev/oauth/authorize?response_type=code&client_id=QuXak4ugdtPZncjp&redirect_uri=https%3A%2F%2Foauth-redirect.googleusercontent.com%2Fr%2Fuser_bound_custom-mcp-106664623666703652842-heykody_dev&scope=profile+email&code_challenge=uMIae0HtFCz1_e_KxVlJ_W2oz1eT87iC0h4WkBB4tEc&code_challenge_method=S256&state=gemini-demo-state' +const geminiAuthRequestWithoutResource: AuthRequest = { + responseType: 'code', + clientId: 'QuXak4ugdtPZncjp', + redirectUri: + 'https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-106664623666703652842-heykody_dev', + scope: ['profile', 'email'], + state: 'gemini-demo-state', + codeChallenge: 'uMIae0HtFCz1_e_KxVlJ_W2oz1eT87iC0h4WkBB4tEc', + codeChallengeMethod: 'S256', +} +const geminiClient: ClientInfo = { + clientId: geminiAuthRequestWithoutResource.clientId, + redirectUris: [geminiAuthRequestWithoutResource.redirectUri], + clientName: 'Gemini', + tokenEndpointAuthMethod: 'none', +} function createHelpers(overrides: Partial = {}): OAuthHelpers { return { @@ -500,6 +519,46 @@ test('Claude-shaped authorize requests render and approve without throwing', asy expect(capturedOptions?.request.scope).toEqual(['profile', 'email']) }) +test('Gemini-shaped authorize requests default resource to /mcp when omitted', async () => { + let capturedOptions: CompleteAuthorizationOptions | null = null + const helpers = createHelpers({ + // Return a fresh object each time so the defaulting mutation stays local. + parseAuthRequest: async () => ({ ...geminiAuthRequestWithoutResource }), + lookupClient: async () => geminiClient, + async completeAuthorization(options) { + capturedOptions = options + return { + redirectTo: + 'https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-106664623666703652842-heykody_dev?code=demo&state=gemini-demo-state', + } + }, + }) + const postResponse = await handleAuthorizeRequest( + new Request(geminiAuthorizeUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + decision: 'approve', + email: 'user@example.com', + password: 'password123', + }), + }), + createEnv(helpers, await createDatabase('password123')), + ) + + expect(postResponse.status).toBe(200) + await expect(postResponse.json()).resolves.toEqual({ + ok: true, + redirectTo: + 'https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-106664623666703652842-heykody_dev?code=demo&state=gemini-demo-state', + }) + expect(capturedOptions?.request.resource).toBe('https://heykody.dev/mcp') + expect(geminiAuthRequestWithoutResource.resource).toBeUndefined() +}) + test('session approval uses database user id when cookie email is stale', async () => { const currentEmail = `changed-oauth-${crypto.randomUUID()}@example.com` const userId = await seedWorkerUser(currentEmail, 'password123')