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
- Complete exercise 4 step 1 as instructed (or load the solution)
- Connect an MCP client and trigger any tool that calls the DB (e.g. list entries)
- Observe a
401 Unauthorized in the dev server logs — but the error message is swallowed by the DBClient, making it invisible without further investigation
- 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...
}
}
}
- 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?
Description
After completing the token wiring in exercise 4 step 1 (passing
authInfo.tokentogetClient()), all calls to the DB API athttp://localhost:7788/db-apireturn a401 Unauthorizedwith:This happens in both the problem and solution versions of exercises 4 and 5. The code matches the solution exactly.
Steps to Reproduce
401 Unauthorizedin the dev server logs — but the error message is swallowed by the DBClient, making it invisible without further investigationconsole.logstatements inside the@epic-web/epicme-db-clientpackage source and rebuild it locally:Investigation
The token itself is valid — introspection against
http://localhost:7788/oauth/introspectionreturns200 OKandactive: true. Adding a log aroundresolveAuthInfoconfirms:{ "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
audclaim. The DB API appears to validate the token's audience and rejects tokens that lack one.The
handleOAuthProtectedResourceRequestcorrectly advertises the MCP server URL as the resource:So the
resourceparameter is being sent during the OAuth flow, but the local auth server (localhost:7788) is not stamping it as theaudclaim 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
audclaim matching the requested resource, and the DB API should accept them. Or alternatively, theDBClientpackage should surface the401error with the fullWWW-Authenticateheader message so learners can debug without having to patch anode_modulespackage.Actual Behaviour
Tokens have no
audclaim. The DB API rejects them withInvalid audience. The error is silent without manual instrumentation of theDBClientsource.Workaround
Remove the resource from the request in the workers/app.ts before sending it to the OauthProvider
Environment
Question
Is the local auth server at
localhost:7788expected to support theresourceparameter (RFC 8707) and stampaudin issued tokens? If not, how should the DB API's audience check be satisfied in a local dev environment?