Skip to content

fix(mcp): log why an OAuth-mode request was refused, without telling the client - #310

Merged
takashi-matsuyama merged 1 commit into
mainfrom
fix/mcp-oauth-refusal-log
Oct 19, 2026
Merged

takashi-matsuyama merged 1 commit into
mainfrom
fix/mcp-oauth-refusal-log

Conversation

@takashi-matsuyama

Copy link
Copy Markdown
Member

Why

In MCP OAuth resource-server mode, a refused token left no record on the server. A client such as Claude Code shows only "needs authentication", the 401 body deliberately never names the check that failed, and kozou printed nothing. So an operator could not tell an issuer mismatch from an expired token, a missing role claim, or a role outside allowedRoles. The 403 for a token without a role claim also pointed at a remedy this surface does not have ("no default role is configured": MCP OAuth has no defaultRole).

What changes

  • One stderr line per refusal, naming the check. Every 401/403 on the MCP endpoint (and on POST /admin/refresh when enabled) writes
    <prefix> auth <status> <code>: <explanation>, for example
    [kozou mcp] auth 403 role_missing: the token carries no "role" claim. This endpoint has no default role: the role must come from the token, so assign it in the authorization server.
    Codes: no_token, malformed_header, malformed_token, algorithm_not_allowed, unknown_key, ambiguous_key, jwks_unavailable, bad_signature, expired, not_yet_valid, issuer_mismatch, issuer_missing, audience_mismatch, audience_missing, claims_invalid, other_token_error, role_missing, role_unreadable, role_not_allowed, insufficient_scope.
    A line holds fixed text, configuration names (the role claim's name, a configured scope's name) and the code; a setting is named as it appears in the MCP auth settings (server.mcp.http.auth for the kozou CLI), never as the REST block's key. It never holds the token, a claim value, a header, an address or the request path. no_token says that a lone tokenless request is how a client normally starts, before it signs in.
  • Capped per reason. At most 5 lines per reason per 60 s, measured on a monotonic clock. Further refusals are counted and reported in one line when the window closes (or when the server closes), so a flood of bad tokens cannot bury another reason's line or grow the log without bound.
  • @kozou/core/auth: KozouAuthError carries a reason (new exported type AuthFailureReason), classified from the jose error class. The message returned to the caller is unchanged, and the REST surface does not read the reason. reason is not enumerable, like message, so JSON.stringify(err) or { ...err } does not hand it to a client. The type's doc states its stability: reasons may be added in a minor release; within a major none is renamed, removed or given a different meaning.
  • MCP 403 for a missing role: now Token does not specify a role (no "role" claim). (the configured claim name). REST keeps Token does not specify a role and no default role is configured.

Both kozou mcp --http and kozou dev serve MCP through startHttpServer, so both get the lines.

Unchanged

  • 401 bodies and WWW-Authenticate challenges.
  • Every other 403 body.
  • REST behaviour and messages.
  • A JWKS that cannot be connected to at all (refused, DNS, TLS) still answers 500 and logs the existing request failed: line, as before; it is not a refusal and gets no reason.

Verification

  • pnpm -r run build, pnpm -r typecheck, pnpm run lint, pnpm -r --if-present test (with testcontainers): all green locally.
  • @kozou/core: each jose failure maps to its reason, and every message is unchanged.
  • @kozou/mcp (test/refusalLog.test.ts): for each reason, the response status and exactly one line with that status and code; no planted claim value, token, token segment or header on stderr; the scope a tools/call lacked; the three key-set reasons against a loopback JWKS; the cap (with an injected clock), a wall-clock step, the unref-ed timer and the flush on close. An inventory typed Record<RefusalCode, …> places every code in a test.
  • kozou (test/mcp-refusal-log.test.ts): kozou mcp --http run as a process, with an HS256 config and with a JWKS config: one line per reason, in order, and no value on the process's stderr.
  • Negative controls, each restored (and rebuilt) before the next: removing the log call; writing the refusal message to stderr; putting the Authorization header into a line; swapping two reason mappings in core; putting the role name into the reason; raising the cap; never scheduling the window-end count; restoring the shared 403 text; wording no_token as a failure; a line status that disagrees with the response; making reason enumerable; folding ambiguous_key back into unknown_key; measuring windows on Date.now; dropping unref(); removing a case from the shared request table; restoring the REST-style auth.jwt.audience wording. Each one turned at least one of the new tests red. Adding a new code to the union without a status, an explanation and a place in the test inventory does not compile.
  • The real-Keycloak suite (keycloak.e2e.test.ts) now prints no_token, role_missing (a user with no role attribute), role_not_allowed and audience_missing lines for tokens a real Keycloak 26 minted.

Not covered

  • A JWKS fetch timeout (jwks_unavailable through JWKSTimeout) is not exercised: jose's timeout is 5 s and not configurable from kozou. The other jwks_unavailable causes (a non-200 response, a body that is not JSON, a malformed key set) are.
  • kozou mcp --http does not close the server on SIGTERM/SIGINT, so a count still held back when the process stops is not written (the lines already written stay). kozou dev closes it and writes the count.
  • The REST surface still logs nothing on a refusal.

…the client

In OAuth resource-server mode a refused token left no record on the server,
so an operator whose client showed only "needs authentication" could not tell
an issuer mismatch from an expired token from a missing role claim.

- @kozou/core/auth: KozouAuthError carries a `reason` (new exported type
  AuthFailureReason) naming the check that refused. It is not enumerable, so
  serialising the error does not hand it to a client; the message returned to
  the caller is unchanged, and the REST surface does not read the reason.
  Reasons may be added in a minor release, never renamed or removed within a
  major.
- @kozou/mcp: every 401/403 writes one stderr line,
  `<prefix> auth <status> <code>: <explanation>`, built from fixed text and
  configuration names only: never the token, a claim value or a header. At
  most 5 lines per reason per 60 s, measured on a monotonic clock; the rest
  are counted and reported in one line when the window closes, or when the
  server closes.
- @kozou/mcp: the 403 for a token with no role claim no longer says "no
  default role is configured", a remedy this surface does not have. It now
  reads `Token does not specify a role (no "role" claim).` REST keeps its
  message.
@takashi-matsuyama
takashi-matsuyama merged commit 85211f8 into main Oct 19, 2026
21 checks passed
takashi-matsuyama added a commit that referenced this pull request Oct 19, 2026
#311)

Bump all packages 1.20.1 -> 1.21.0 and the pinned image tags (README,
quickstart + scaffold docker-compose). Ships the change already on main since
v1.20.1:

- #310 in OAuth resource-server mode, every 401 from the MCP endpoint, and
  every 403 over the token's role or scope (also from `POST /admin/refresh`
  when it is enabled), writes one stderr line naming the check that refused
  the request: `<prefix> auth <status> <code>: <explanation>`. Until now a
  refused token left no record on the server, and a client such as Claude Code
  shows only "needs authentication", so an operator could not tell an issuer
  mismatch from an expired token, a missing role claim or a role outside
  `allowedRoles`. A line holds fixed text, the code and configuration names
  only - never the token, a claim value, a header, an address or the request
  path. `no_token` says that a lone request without a token is how a client
  normally starts, before it signs in. At most 5 lines per reason are written
  in each 60-second window; the rest are counted and reported in one line when
  the window closes or the server closes. A 403 from the DNS-rebinding guard
  (a `Host` or `Origin` it does not accept) is not a token refusal and writes
  no line. `kozou mcp --http` and `kozou dev` both write the lines.
- #310 `KozouAuthError` carries a `reason`, typed by the new
  `AuthFailureReason` exported from `@kozou/core/auth`, and its constructor
  takes the reason as an optional third argument. The property is not
  enumerable, so serialising the error does not hand it to a client. Reasons
  may be added in a minor release; within a major none is renamed, removed or
  given a different meaning. The message returned to the caller is unchanged,
  and the REST surface does not read the reason.
- #310 the MCP 403 for a token with no role claim now reads
  `Token does not specify a role (no "role" claim).`, naming the configured
  claim. It used to say "no default role is configured", a remedy MCP OAuth
  mode does not have. REST keeps its message.

Behaviour changes for embedders and operators:

- New stderr output in OAuth mode: one line per request refused for its token,
  up to the cap above, including the first request a client sends before it
  signs in.
- The body of the MCP 403 for a token with no role claim changes as above.
  Other 401 and 403 bodies, the `WWW-Authenticate` challenges, and REST are
  unchanged.
- `@kozou/core/auth` exports a new type, `AuthFailureReason`; `KozouAuthError`
  has a new `reason` property and an optional third constructor argument.

Not covered: `kozou mcp --http` does not close the server on SIGTERM or SIGINT,
so a count still held back when the process stops is not written (the lines
already written stay). A JWKS that cannot be reached at all (refused, DNS,
TLS) still answers 500 with the existing `request failed:` line. The REST
surface still logs nothing on a refusal.
@takashi-matsuyama
takashi-matsuyama deleted the fix/mcp-oauth-refusal-log branch October 19, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant