Repository navigation
fix(mcp): log why an OAuth-mode request was refused, without telling the client - #310
Merged
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 nodefaultRole).What changes
POST /admin/refreshwhen 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.authfor 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_tokensays that a lone tokenless request is how a client normally starts, before it signs in.@kozou/core/auth:KozouAuthErrorcarries areason(new exported typeAuthFailureReason), classified from the jose error class. The message returned to the caller is unchanged, and the REST surface does not read the reason.reasonis not enumerable, likemessage, soJSON.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.Token does not specify a role (no "role" claim).(the configured claim name). REST keepsToken does not specify a role and no default role is configured.Both
kozou mcp --httpandkozou devserve MCP throughstartHttpServer, so both get the lines.Unchanged
WWW-Authenticatechallenges.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 atools/calllacked; 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 typedRecord<RefusalCode, …>places every code in a test.kozou(test/mcp-refusal-log.test.ts):kozou mcp --httprun 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.no_tokenas a failure; a line status that disagrees with the response; makingreasonenumerable; foldingambiguous_keyback intounknown_key; measuring windows onDate.now; droppingunref(); removing a case from the shared request table; restoring the REST-styleauth.jwt.audiencewording. 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.keycloak.e2e.test.ts) now printsno_token,role_missing(a user with no role attribute),role_not_allowedandaudience_missinglines for tokens a real Keycloak 26 minted.Not covered
jwks_unavailablethroughJWKSTimeout) is not exercised: jose's timeout is 5 s and not configurable from kozou. The otherjwks_unavailablecauses (a non-200 response, a body that is not JSON, a malformed key set) are.kozou mcp --httpdoes 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 devcloses it and writes the count.