Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions packages/worker/src/oauth-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -214,7 +216,28 @@ function createSetCookieHeaders(cookies: Array<string | null | undefined>) {
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) {
Expand All @@ -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 =
Expand Down Expand Up @@ -593,7 +617,7 @@ export async function loadOAuthAuthorizeData(
env: Env,
): Promise<OAuthAuthorizeDataResult> {
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(
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions packages/worker/src/oauth-handlers.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): OAuthHelpers {
return {
Expand Down Expand Up @@ -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')
Expand Down
Loading