Skip to content

Bug: invalid_token / Invalid audience from DB API in exercises 4 & 5 #9

@alinaMihai

Description

@alinaMihai

Description

After completing the token wiring in exercise 4 step 1 (passing authInfo.token to getClient()), all calls to the DB API at http://localhost:7788/db-api return a 401 Unauthorized with:

www-authenticate: Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"

This happens in both the problem and solution versions of exercises 4 and 5. The code matches the solution exactly.

Steps to Reproduce

  1. Complete exercise 4 step 1 as instructed (or load the solution)
  2. Connect an MCP client and trigger any tool that calls the DB (e.g. list entries)
  3. Observe a 401 Unauthorized in the dev server logs — but the error message is swallowed by the DBClient, making it invisible without further investigation
  4. To surface the actual error, you need to add console.log statements inside the @epic-web/epicme-db-client package source and rebuild it locally:
// node_modules/@epic-web/epicme-db-client/src/index.ts (or similar)
export class DBClient {
  constructor(baseUrl: string, oauthToken?: string) {
    this.baseUrl = baseUrl.replace(/\/$/, '')
    this.oauthToken = oauthToken
    console.log('DBClient initialized with baseUrl:', this.baseUrl, oauthToken)
  }

  private async makeRequest<T>(method: string, params: Record<string, any> = {}): Promise<T> {
    console.log(`${this.baseUrl}/db-api`, this.oauthToken)
    const response = await fetch(`${this.baseUrl}/db-api`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: this.oauthToken ? `Bearer ${this.oauthToken}` : '',
      },
      body: JSON.stringify({ method, params }),
    })
    console.log({ response })
    if (!response.ok) {
      // error handling...
    }
  }
}
  1. After rebuilding, the logs reveal:
DBClient initialized with baseUrl: http://localhost:7788 3:qdm0Cy78O_vjSdOo:QgZT_Y7QhmLf2hQehihkCTUOxsDMwwX2
http://localhost:7788/db-api 3:qdm0Cy78O_vjSdOo:QgZT_Y7QhmLf2hQehihkCTUOxsDMwwX2
{
  response: Response {
    status: 401,
    statusText: 'Unauthorized',
    headers: Headers {
      'www-authenticate': 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
    },
    url: 'http://localhost:7788/db-api'
  }
}

Investigation

The token itself is valid — introspection against http://localhost:7788/oauth/introspection returns 200 OK and active: true. Adding a log around resolveAuthInfo confirms:

{
  "active": true,
  "client_id": "p0wz4_gXdlED7_D2",
  "scope": "user:read entries:read entries:write tags:read tags:write",
  "sub": "3",
  "exp": 1772043655,
  "iat": 1772040055
}

However, the introspection response contains no aud claim. The DB API appears to validate the token's audience and rejects tokens that lack one.

The handleOAuthProtectedResourceRequest correctly advertises the MCP server URL as the resource:

export async function handleOAuthProtectedResourceRequest(request: Request) {
  const resourceServerUrl = new URL('/mcp', request.url)
  return Response.json({
    resource: resourceServerUrl.toString(),
    authorization_servers: [EPIC_ME_AUTH_SERVER_URL],
  })
}

So the resource parameter is being sent during the OAuth flow, but the local auth server (localhost:7788) is not stamping it as the aud claim in issued tokens. As a result, the DB API's audience check always fails.

Expected Behaviour

Tokens issued by the local auth server should include an aud claim matching the requested resource, and the DB API should accept them. Or alternatively, the DBClient package should surface the 401 error with the full WWW-Authenticate header message so learners can debug without having to patch a node_modules package.

Actual Behaviour

Tokens have no aud claim. The DB API rejects them with Invalid audience. The error is silent without manual instrumentation of the DBClient source.

Workaround

Remove the resource from the request in the workers/app.ts before sending it to the OauthProvider


/**
 * Strip the `resource` field from the body of POST /oauth/token requests.
 *
 * The MCP client sends `resource=http://localhost:56000/mcp` (its own URL)
 * in the token exchange request. The library stores this as the token's
 * `audience` and then requires every subsequent API call to come from that
 * exact host — which is impossible since API calls go to the Worker host.
 *
 * By removing `resource` here, no audience is set on the token and the
 * library skips audience validation entirely.
 */
async function stripResourceFromTokenRequest(request: Request): Promise<Request> {
	const url = new URL(request.url)
	const isTokenEndpoint =
		url.pathname === '/oauth/token' && request.method === 'POST'

	if (!isTokenEndpoint) return request

	const contentType = request.headers.get('content-type') ?? ''
	if (!contentType.includes('application/x-www-form-urlencoded')) return request

	const body = await request.text()
	const params = new URLSearchParams(body)
	params.delete('resource')

	return new Request(request, {
		body: params.toString(),
	})
}

export default {
	fetch: withCors({
		getCorsHeaders: (request) => {
			if (request.url.includes('/.well-known')) {
				return {
					'Access-Control-Allow-Origin': '*',
					'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
					'Access-Control-Allow-Headers': 'mcp-protocol-version',
				}
			}
		},
		handler: async (request: Request, env: Env, ctx: ExecutionContext) => {
			const cleanRequest = await stripResourceFromTokenRequest(request)
			return oauthProvider.fetch(cleanRequest, env, ctx)
		},
	}),
} satisfies ExportedHandler<Env>

Environment

Question

Is the local auth server at localhost:7788 expected to support the resource parameter (RFC 8707) and stamp aud in issued tokens? If not, how should the DB API's audience check be satisfied in a local dev environment?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions