Skip to content

bug(auth/generic): introspection response parser fails on Google tokeninfo because exp is parsed as int64 but returned as a JSON string #3240

@cbcoutinho

Description

@cbcoutinho

Toolbox version

us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:1.2.0 (also reproduces on main as of 2026-05-15)

What happens

Configuring an authService with type: generic, mcpEnabled: true, and Google as the OIDC provider per the docs at docs/en/documentation/configuration/authentication/generic.md:

kind: authService
name: claude-ai
type: generic
audience: "<client_id>.apps.googleusercontent.com"
authorizationServer: https://accounts.google.com
introspectionEndpoint: https://www.googleapis.com/oauth2/v3/tokeninfo
introspectionMethod: GET
introspectionParamName: access_token
mcpEnabled: true
scopesRequired:
  - openid
  - email

Every authenticated MCP request from claude.ai (which sends a Google OAuth opaque access token) returns HTTP 500 with this in the toolbox logs:

ERROR "unexpected error during MCP auth validation"
failed to parse introspection response: json: cannot unmarshal string into Go struct field .exp of type int64

Why

Google's https://www.googleapis.com/oauth2/v3/tokeninfo returns numeric fields as JSON strings:

{
  "aud": "1234-abcd.apps.googleusercontent.com",
  "sub": "...",
  "scope": "openid https://www.googleapis.com/auth/userinfo.email",
  "exp": "1747345200",
  "expires_in": "3599",
  "email": "...",
  "email_verified": "true"
}

But internal/auth/generic/generic.go:367 declares the parser struct as:

var introspectResp struct {
    Active   *bool           `json:"active"`
    Scope    string          `json:"scope"`
    Aud      json.RawMessage `json:"aud"`
    Audience json.RawMessage `json:"audience"`
    Exp      int64           `json:"exp"`
    Iss      string          `json:"iss"`
}

Exp int64 cannot accept the quoted string, so unmarshaling fails before the audience/scope checks can even run. The Google example in the official docs therefore can't actually authenticate any real Google access token end-to-end.

Reproduction

  1. Deploy toolbox to Cloud Run with mcpEnabled: true + the auth config above.
  2. Connect claude.ai (or any MCP client that uses MCP OAuth) and complete the Google sign-in.
  3. claude.ai sends Authorization: Bearer <google_access_token> to /mcp.
  4. Toolbox returns HTTP 500 with the unmarshal error in logs.

Suggested fix

Use json.Number for the numeric fields, then convert to int64 with .Int64():

var introspectResp struct {
    Active   *bool           `json:"active"`
    Scope    string          `json:"scope"`
    Aud      json.RawMessage `json:"aud"`
    Audience json.RawMessage `json:"audience"`
    Exp      json.Number     `json:"exp"`
    Iss      string          `json:"iss"`
}

json.Number accepts both raw numbers and number-as-string. Combined with decoder.UseNumber() (or by switching from json.Unmarshal to a json.Decoder with that option), this round-trips both shapes safely. Same fix applies if/when iat, nbf, or expires_in are added to the struct.

Workaround for now

A 30-line Go Cloud Function in front of tokeninfo that coerces the offending fields to numbers before returning. Toolbox's introspectionEndpoint points at the proxy. Happy to share the snippet if useful — would rather see this fixed upstream and drop it.

Why this matters

The Google example is the only worked path in the docs for end-to-end MCP OAuth with Google as the IdP. Anyone following it on a stock toolbox image hits this on the first authenticated request, and the failure mode (HTTP 500 with no client-visible error message) makes it look like a misconfiguration on the client side.

Metadata

Metadata

Assignees

Labels

type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions