Reproduction of a bug in Go's golang.org/x/oauth2 library.
When AuthStyleUnknown triggers the auth style probe, the first token request's error is silently discarded.
When Endpoint.AuthStyle is AuthStyleUnknown (the default), RetrieveToken probes the token endpoint by trying two credential delivery methods:
- Client credentials in the
Authorizationheader (AuthStyleInHeader) - If that fails for any reason, retry with credentials in form params (
AuthStyleInParams)
The second attempt overwrites the error from the first:
token, err := doTokenRoundTrip(ctx, req)
if err != nil && needsAuthStyleProbe {
// If we get an error, assume the server wants the
// clientID & clientSecret in a different form.
authStyle = AuthStyleInParams // the second way we'll try
req, _ = newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle)
token, err = doTokenRoundTrip(ctx, req)
}The probe works correctly when the provider rejects the wrong auth style without consuming the authorization code: the first attempt fails harmlessly, the second succeeds, and the style is cached for future requests.
The bug surfaces when:
- The provider accepts header auth (the first method the probe tries)
- The request fails for a reason unrelated to auth style (misconfiguration, expired key, invalid grant, etc.)
- The provider consumes the authorization code during the failed request
- The probe retries with params auth, which also fails
- Only the second error is returned; the first error is silently discarded
The caller sees a misleading error (e.g., "code already redeemed") instead of the actual problem (e.g., "missing signing key").
Configuration errors are most likely during initial setup, which is when users need clear error messages the most, and when this bug is most likely to bite.
Two self-contained reproductions, each in their own directory:
mock-test/: Mock token endpoint, no external dependencies.entra-test/: Live reproduction against Microsoft Entra ID. Requires an Azure account and OpenTofu. Seeentra-test/README.mdfor setup.
Quick demo with the mock:
cd mock-test && go run .-> Request 1: credentials in Authorization header
-> Request 2: credentials in form params (probe fallback)
Exchange error: oauth2: "invalid_grant" "Authorization code has already been redeemed"
The mock returns "signing key not configured" on the first request (header auth) and "code already redeemed" on the second (params fallback). Only the second error reaches the caller.
Approaches for RetrieveToken when both probe attempts fail:
Use errors.Join so the caller sees both.
This is what #786 suggests.
token, err := doTokenRoundTrip(ctx, req)
if err != nil && needsAuthStyleProbe {
+ headerErr := err
authStyle = AuthStyleInParams
req, _ = newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle)
token, err = doTokenRoundTrip(ctx, req)
+ if err != nil {
+ err = errors.Join(headerErr, err)
+ }
}Return both errors wrapped with context about the probe sequence.
token, err := doTokenRoundTrip(ctx, req)
if err != nil && needsAuthStyleProbe {
+ headerErr := err
authStyle = AuthStyleInParams
req, _ = newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle)
token, err = doTokenRoundTrip(ctx, req)
+ if err != nil {
+ err = fmt.Errorf("auth style probe failed: header: %w; params: %w", headerErr, err)
+ }
}Both approaches change the error that flows through oauth2.Exchange() to callers.
err.Error()string: Changes with both approaches.errors.As(err, &oauth2.RetrieveError{}): Currently unwraps to the second error'sRetrieveError. With either fix, it unwraps to the first (real) error instead. This is the point, but it's a behavior change for anyone keying on the fallback response.
Require callers to set AuthStyle explicitly.
This would be a breaking change, but #718 and #535 show other problems caused by the probe.
This was discovered while configuring SSO for Portainer with Microsoft Entra ID. The token request failed due to a missing signing key in the app registration (AADSTS50146), but the only error surfaced in Portainer's logs was that the authorization code had already been redeemed (AADSTS54005).
The Portainer team shipped an AuthStyle configuration option as a workaround, allowing users to skip the probe entirely.
A request to set AuthStyle explicitly on the library's Azure endpoint was closed as not planned, since Entra ID supports both header and params auth per RFC 6749.
That's correct for style detection, but it doesn't address the error swallowing when the correctly-styled request fails for other reasons.
See journal.md for a first-person account of debugging this.
Upstream:
- golang/oauth2#786: Swallowed exception for header attempt if auth style is unknown
- golang/oauth2#718: Auth style detection breaks on token refresh
- golang/oauth2#535: Request to set AuthStyle on Azure endpoint (closed, not planned)
RetrieveTokensource
Portainer:
- portainer/portainer discussions#9924: Original report of misleading error
- portainer/portainer#11610: PR adding AuthStyle config option as workaround
- Portainer's oauth2 usage:
api/oauth/oauth.go