Skip to content

fix(oauth): 401 on expired token at MCP transport (incl. tools/list)#135

Merged
BorisTyshkevich merged 1 commit into
mainfrom
fix-oauth-expired-token-401
Jun 2, 2026
Merged

fix(oauth): 401 on expired token at MCP transport (incl. tools/list)#135
BorisTyshkevich merged 1 commit into
mainfrom
fix-oauth-expired-token-401

Conversation

@BorisTyshkevich
Copy link
Copy Markdown
Collaborator

Problem

altinity-mcp issues no refresh tokens (#115 — CIMD clients re-authorize on expiry). When claude.ai's access token expires it does not disconnect cleanly: it gets stuck showing a single stale tool instead of re-authenticating (suspected claude.ai bug, anthropics/claude-code#46328).

Root cause

The MCP auth middleware createMCPAuthInjector only checked that the bearer token was non-empty — it never inspected exp. So an expired token sailed through tools/list (HTTP 200 + stale/static tools) and only failed later inside tools/call, surfacing as a ClickHouse error rather than a transport-level 401. The client never saw a signal to re-authorize.

Fix

Add an exp-only check in the middleware: decode the JWT payload without verifying the signature and reject when exp is in the past (60s clock skew), returning the existing 401 + WWW-Authenticate: Bearer error="invalid_token", error_description="OAuth token expired". This fires on every MCP method, including tools/list, pushing the client straight to re-auth.

Design notes:

  • Signature / iss / aud / scope validation stays delegated to the CH-side ch-jwt-verify sidecar per query — no JWKS hop added to the hot path, preserving the deliberate design in the prior middleware comment.
  • Opaque / non-JWT tokens soft-pass unchanged (forward-mode safe).
  • grant_types_supported already advertises only authorization_code (locked by TestOAuthASMetadataShape) — no metadata change needed.

Changes

  • pkg/server/server_client.gounverifiedExp (decode-only) + OAuthTokenExpired method + 60s skew constant.
  • cmd/altinity-mcp/oauth_server.gocreateMCPAuthInjector rejects expired tokens via writeOAuthError(ErrOAuthTokenExpired).
  • cmd/altinity-mcp/oauth_regression_test.go — invert the old "expired token reaches handler" assumption; add an expired→401+WWW-Authenticate transport test (sends a tools/list body) plus opaque-soft-pass and unexpired-pass cases.
  • pkg/server/server_client_test.go — unit tests for unverifiedExp / OAuthTokenExpired (expired, unexpired, clock-skew boundary, opaque, no-exp, float exp, malformed).

Verification

go test ./... green.

Deployed fix-oauth-expired-token-401-1ee854f to the live github-mcp (broker:true, Google IdP) and verified against https://mcp.demo.altinity.cloud/mcp/github:

Case Result
Expired JWT → tools/list 401 + error="invalid_token", "OAuth token expired"
Unexpired JWT 200 (passes middleware) ✅
Opaque token 200 (soft-pass) ✅
Missing token 401 (pre-existing) ✅
Fresh real Google OAuth via claude.ai connector token accepted, identity resolved ✅ — happy path unbroken

🤖 Generated with Claude Code

altinity-mcp issues no refresh tokens (#115). When claude.ai's access
token expires it gets stuck showing a single stale tool instead of
re-authorizing (anthropics/claude-code#46328), because the MCP auth
middleware only checked the token was non-empty: an expired token sailed
through tools/list (200 + stale tools) and only failed later inside
tools/call as a ClickHouse error, never as a transport-level 401.

Add an exp-only check in createMCPAuthInjector: decode the JWT payload
without verifying the signature and reject when exp is in the past (with
60s clock skew), returning the existing 401 + WWW-Authenticate: Bearer
error="invalid_token". This pushes the client to re-authorize on every
method including tools/list. Signature/iss/aud/scope validation stays
delegated to the CH-side ch-jwt-verify sidecar per query, so no JWKS hop
is added to the hot path. Opaque/non-JWT tokens soft-pass unchanged
(forward-mode safe).

grant_types_supported already advertises only authorization_code, so no
metadata change is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BorisTyshkevich BorisTyshkevich merged commit 15843f8 into main Jun 2, 2026
4 checks passed
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