Description
POST /oauth2/introspect authenticates the caller but never checks whether the presented token
belongs to that caller. Any client holding valid credentials can introspect any token issued
by the deployment, including tokens belonging to other clients and to admin users.
Root cause
backend/internal/oauth/oauth2/introspect/init.go:56-58 correctly wraps the route in
clientauth.ClientAuthMiddleware, so the caller must authenticate. But the handler never reads the
authenticated client back out of the request context — handler.go:47 calls:
h.service.IntrospectToken(ctx, token, tokenTypeHint)
The authenticated client is never passed down, and service.IntrospectToken has no client
parameter, so no ownership comparison is possible anywhere in the call chain.
Reproduction
Two confidential apps A and B, both client_secret_basic + client_credentials. Mint a token for
A, then introspect it while authenticating as B:
POST /oauth2/introspect -u 'qa-b6-client-B:<secretB>' -d token=<A's access token>
HTTP 200
{"active":true,"client_id":"qa-b6-client-A","token_type":"Bearer","exp":1786594915,
"iat":1786591315,"nbf":1786591315,"sub":"019ff923-e7d0-73c5-a137-507823b4e38b",
"aud":"qa-b6-client-A","iss":"https://localhost:8090","jti":"019ff924-078d-7f28-88a3-f8355678f097"}
The response is byte-identical to A introspecting its own token.
A second demonstration: the same throwaway client successfully introspected the CONSOLE admin
user's access token, recovering scope: "openid system", sub and jti.
Controls confirm authentication itself is enforced, so ownership is the only gap:
- unauthenticated →
400 {"error":"invalid_request","error_description":"Missing client_id parameter"}
- wrong secret →
401 {"error":"invalid_client",...}
Expected
RFC 7662 §2.1 requires the endpoint to determine whether the caller is authorized for the token.
The service should compare the authenticated client against the token's client_id (or its
audience / resource server), returning {"active": false} for tokens the caller does not own.
Impact
Security-relevant. Any client credential in the deployment becomes an oracle for enumerating
metadata about arbitrary tokens — subject identifiers, scopes, audiences, expiry and jti —
including privileged admin tokens.
Description
POST /oauth2/introspectauthenticates the caller but never checks whether the presented tokenbelongs to that caller. Any client holding valid credentials can introspect any token issued
by the deployment, including tokens belonging to other clients and to admin users.
Root cause
backend/internal/oauth/oauth2/introspect/init.go:56-58correctly wraps the route inclientauth.ClientAuthMiddleware, so the caller must authenticate. But the handler never reads theauthenticated client back out of the request context —
handler.go:47calls:The authenticated client is never passed down, and
service.IntrospectTokenhas no clientparameter, so no ownership comparison is possible anywhere in the call chain.
Reproduction
Two confidential apps A and B, both
client_secret_basic+client_credentials. Mint a token forA, then introspect it while authenticating as B:
The response is byte-identical to A introspecting its own token.
A second demonstration: the same throwaway client successfully introspected the CONSOLE admin
user's access token, recovering
scope: "openid system",subandjti.Controls confirm authentication itself is enforced, so ownership is the only gap:
400 {"error":"invalid_request","error_description":"Missing client_id parameter"}401 {"error":"invalid_client",...}Expected
RFC 7662 §2.1 requires the endpoint to determine whether the caller is authorized for the token.
The service should compare the authenticated client against the token's
client_id(or itsaudience / resource server), returning
{"active": false}for tokens the caller does not own.Impact
Security-relevant. Any client credential in the deployment becomes an oracle for enumerating
metadata about arbitrary tokens — subject identifiers, scopes, audiences, expiry and
jti—including privileged admin tokens.