{"record":{"id":"bfebb896d52a13fb","repo":"gotify/server","slug":"token-exchange-failed-w","errorCode":null,"errorMessage":"token exchange failed: %w","messagePattern":"token exchange failed: %w","errorType":"http","errorClass":null,"httpStatus":401,"severity":"error","filePath":"api/oidc.go","lineNumber":387,"sourceCode":"//\t        $ref: \"#/definitions/Error\"\nfunc (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {\n\tvar req model.OIDCExternalTokenRequest\n\tif err := ctx.ShouldBindJSON(&req); err != nil {\n\t\tctx.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\tsession, ok := a.popPendingSession(req.State)\n\tif !ok {\n\t\tctx.AbortWithError(http.StatusBadRequest, errors.New(\"unknown or expired state\"))\n\t\treturn\n\t}\n\texchangeOpts := []rp.CodeExchangeOpt{\n\t\trp.CodeExchangeOpt(rp.WithURLParam(\"redirect_uri\", session.RedirectURI)),\n\t\trp.WithCodeVerifier(req.CodeVerifier),\n\t}\n\ttokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx.Request.Context(), req.Code, a.Provider, exchangeOpts...)\n\tif err != nil {\n\t\tctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf(\"token exchange failed: %w\", err))\n\t\treturn\n\t}\n\tinfo, err := rp.Userinfo[*oidc.UserInfo](ctx.Request.Context(), tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), a.Provider)\n\tif err != nil {\n\t\tctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf(\"failed to get user info: %w\", err))\n\t\treturn\n\t}\n\tuser, status, resolveErr := a.resolveUser(tokens.IDTokenClaims, info)\n\tif resolveErr != nil {\n\t\tctx.AbortWithError(status, resolveErr)\n\t\treturn\n\t}\n\tclient, err := a.createClient(session.ClientName, user.ID)\n\tif err != nil {\n\t\tctx.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\tctx.JSON(http.StatusOK, &model.OIDCExternalTokenResponse{","sourceCodeStart":369,"sourceCodeEnd":405,"githubUrl":"https://github.com/gotify/server/blob/14bfc256276775c425f988d621dccfe705de18ac/api/oidc.go#L369-L405","documentation":"During the OIDC authorization-code flow in ExternalTokenHandler, `rp.CodeExchange` (from zitadel/oidc rp package) failed to exchange the authorization code (plus PKCE verifier) for tokens at the provider's token endpoint. The handler wraps the underlying error with 'token exchange failed: %w' and returns 401.","triggerScenarios":"POST to the external token endpoint with an invalid/expired/already-used `code`, a wrong `code_verifier` (PKCE mismatch), a mismatched `redirect_uri`, or the provider's token endpoint being unreachable/misconfigured.","commonSituations":"Replaying an authorization code (codes are single-use); user taking too long so the code expired; redirect_uri differing from the one used in the authorize request; client secret/issuer config changed; clock skew.","solutions":["Ensure the authorization code is fresh and used exactly once","Send the identical redirect_uri used in the /authorize request","Verify the PKCE code_verifier matches the code_challenge from step 1","Check OIDC provider issuer/client-id/secret configuration","Inspect the wrapped cause (`%w`) in logs for the provider's exact error"],"exampleFix":"// before\nrp.WithCodeVerifier(storedOtherVerifier)\n// after\nrp.WithCodeVerifier(session.CodeVerifier) // same verifier that generated the code_challenge","handlingStrategy":"try-catch","validationCode":"// before exchanging, verify session state\nif (!session || !session.CodeVerifier || !req.Code) throw new Error('missing code or PKCE verifier');\nif (req.RedirectURI !== session.RedirectURI) throw new Error('redirect_uri mismatch');","typeGuard":null,"tryCatchPattern":"try {\n  tokens = await exchange(code, verifier, redirectURI);\n} catch (err) {\n  if (isInvalidGrant(err)) return startNewLoginFlow(); // code expired/used\n  throw err;\n}","preventionTips":["Use authorization codes immediately — they are single-use and short-lived","Send the exact redirect_uri used in /authorize","Store and pass the same PKCE verifier that created the challenge","Keep provider issuer/secret config in sync"],"tags":["oidc","oauth2","http","authentication","pkce"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"14bfc256276775c425f988d621dccfe705de18ac","analyzedAt":"2026-09-05T12:52:36.781Z","contentChangedAt":"2026-09-05T12:52:36.781Z","schemaVersion":2},"datasetVersion":"2026-09-12T17:17:11.597Z"}