import { type OAuthHelpers } from '@cloudflare/workers-oauth-provider'
import { Hono } from 'hono'
import { verifyClerkToken, getClerkUser } from './clerk'
import { type Env } from './types'
import {
log,
encodeAuthState,
decodeAuthState,
cleanupExpiredRequests,
type AuthSession,
} from './utils'
// Create Hono app with appropriate bindings
const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>()
/**
* GET /authorize - Entry point for OAuth authorization flow
*/
app.get('/authorize', async (c) => {
log.info('Starting /authorize request')
try {
// Clean up expired requests periodically
await cleanupExpiredRequests(c.env.OAUTH_KV)
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
log.info('Parsed OAuth request', { clientId: oauthReqInfo.clientId })
const { clientId } = oauthReqInfo
if (!clientId) {
log.error('Missing client ID in request')
return c.json(
{ error: 'invalid_request', error_description: 'Missing client_id' },
400,
)
}
// Look up client to verify it exists
const clientInfo = await c.env.OAUTH_PROVIDER.lookupClient(clientId)
if (!clientInfo) {
log.error('Client not found', { clientId })
return c.json(
{ error: 'invalid_client', error_description: 'Client not found' },
400,
)
}
log.info('Retrieved client info', { clientName: clientInfo.clientName })
// Create minimal auth session in KV
const sessionId = crypto.randomUUID()
const authSession: AuthSession = {
sessionId,
clientId,
timestamp: Date.now(),
expiresAt: Date.now() + 600000, // 10 minutes
}
await c.env.OAUTH_KV.put(
`auth_session:${sessionId}`,
JSON.stringify(authSession),
{ expirationTtl: 600 },
)
// Encode the full auth request and session ID in state
const state = await encodeAuthState(
{
sessionId,
authRequest: oauthReqInfo,
},
c.env.CLERK_SECRET_KEY,
)
log.info('Redirecting to app for authentication', {
sessionId,
clientId: oauthReqInfo.clientId,
})
// Build app auth URL
const url = new URL(c.req.url)
const baseUrl = `${url.protocol}//${url.host}`
const appUrl = c.env.APP_URL
const authUrl = new URL('/auth/mcp', appUrl)
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('callback_url', `${baseUrl}/callback`)
authUrl.searchParams.set(
'client_name',
clientInfo.clientName || 'Unknown Client',
)
return Response.redirect(authUrl.toString(), 302)
} catch (error) {
log.error('Error in /authorize', {
error: error instanceof Error ? error.message : String(error),
})
return c.json(
{
error: 'server_error',
error_description: 'Authorization request failed',
},
500,
)
}
})
/**
* GET /callback - Handle callback from app with clerk_token
*/
app.get('/callback', async (c) => {
try {
const url = new URL(c.req.url)
const clerkToken = url.searchParams.get('clerk_token')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
log.info('Received callback from app', {
hasToken: !!clerkToken,
hasState: !!state,
error,
})
if (error) {
log.info('User denied authorization in app', { error })
return c.html(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Denied</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 2rem; max-width: 600px; margin: 0 auto; text-align: center; }
.error { color: #dc2626; margin: 2rem 0; }
</style>
</head>
<body>
<h1>Authorization Denied</h1>
<div class="error">You have denied access to the MCP client.</div>
<p>You can close this window and try again if needed.</p>
</body>
</html>
`)
}
if (!clerkToken || !state) {
log.error('Missing required parameters in callback', {
hasToken: !!clerkToken,
hasState: !!state,
})
return c.json(
{
error: 'invalid_request',
error_description: 'Missing required parameters',
},
400,
)
}
// Decode and verify the state
const stateData = await decodeAuthState(state, c.env.CLERK_SECRET_KEY)
if (!stateData) {
log.error('Invalid or tampered state parameter')
return c.json(
{
error: 'invalid_request',
error_description: 'Invalid state parameter',
},
400,
)
}
const { sessionId, authRequest: mcpAuthRequest } = stateData
// Verify session still exists and is valid
const sessionData = await c.env.OAUTH_KV.get(`auth_session:${sessionId}`)
if (!sessionData) {
log.error('Auth session not found or expired', { sessionId })
return c.json(
{
error: 'invalid_request',
error_description: 'Auth session expired',
},
400,
)
}
const authSession: AuthSession = JSON.parse(sessionData)
// Verify session hasn't expired
if (Date.now() > authSession.expiresAt) {
log.error('Auth session expired', { sessionId })
await c.env.OAUTH_KV.delete(`auth_session:${sessionId}`)
return c.json(
{
error: 'invalid_request',
error_description: 'Auth session expired',
},
400,
)
}
log.info('Retrieved and validated auth session', {
clientId: mcpAuthRequest.clientId,
})
// Verify the clerk_token with Clerk API to get user info
let clerkSession
try {
clerkSession = await verifyClerkToken(clerkToken, c.env.CLERK_SECRET_KEY)
} catch (error) {
log.error('Invalid Clerk token', {
sessionId,
error: error instanceof Error ? error.message : String(error),
})
return c.json(
{
error: 'invalid_grant',
error_description: 'Invalid authentication token',
},
401,
)
}
// Get user information from Clerk
const user = await getClerkUser(clerkSession.userId, c.env.CLERK_SECRET_KEY)
const userProps = {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
imageUrl: user.imageUrl,
},
sessionToken: clerkToken,
sessionId: clerkSession.id,
expiresAt: new Date(clerkSession.expireAt).toISOString(),
appUrl: c.env.APP_URL,
}
// Complete the OAuth authorization
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
request: mcpAuthRequest,
userId: userProps.user.id,
metadata: {
label: `${userProps.user.firstName} ${userProps.user.lastName}`,
clerkToken,
},
scope: mcpAuthRequest.scope,
props: userProps,
})
log.info('OAuth authorization completed, redirecting', {
userId: userProps.user.id,
clientId: mcpAuthRequest.clientId,
})
// Clean up the auth session
await c.env.OAUTH_KV.delete(`auth_session:${sessionId}`)
return Response.redirect(redirectTo)
} catch (error) {
log.error('Error in callback handler', {
error: error instanceof Error ? error.message : String(error),
})
return c.json(
{
error: 'server_error',
error_description: 'Callback processing failed',
},
500,
)
}
})
export { app as AuthHandler }