{"record":{"id":"1a6e11ce7642b9fb","repo":"semaphoreui/semaphore","slug":"oidc-sign-in-failed-could-not-exchange-authorizat","errorCode":null,"errorMessage":"OIDC sign-in failed: could not exchange authorization code. Contact your administrator.","messagePattern":"OIDC sign-in failed: could not exchange authorization code\\. Contact your administrator\\.","errorType":"http","errorClass":null,"httpStatus":401,"severity":"error","filePath":"api/login.go","lineNumber":905,"sourceCode":"\t\thttp.Error(w, \"Failed to initialize OIDC provider. Contact your administrator.\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tprovider, ok := util.Config.OidcProviders[pid]\n\tif !ok {\n\t\tlog.Error(fmt.Errorf(\"no such provider: %s\", pid))\n\t\thttp.Error(w, \"Unknown OIDC provider.\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\tverifier := _oidc.Verifier(&oidc.Config{ClientID: oauth.ClientID})\n\n\tcode := r.URL.Query().Get(\"code\")\n\n\toauth2Token, err := oauth.Exchange(ctx, code)\n\tif err != nil {\n\t\tlog.Error(err.Error())\n\t\thttp.Error(w, \"OIDC sign-in failed: could not exchange authorization code. Contact your administrator.\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\tvar claims claimResult\n\n\t// Extract the ID Token from OAuth2 token.\n\trawIDToken, ok := oauth2Token.Extra(\"id_token\").(string)\n\n\tif ok && rawIDToken != \"\" {\n\t\tvar idToken *oidc.IDToken\n\t\t// Parse and verify ID Token payload.\n\t\tidToken, err = verifier.Verify(ctx, rawIDToken)\n\n\t\tif err == nil {\n\t\t\tclaims, err = claimOidcToken(idToken, provider)\n\t\t}\n\t} else {\n\t\tvar userInfo *oidc.UserInfo","sourceCodeStart":887,"sourceCodeEnd":923,"githubUrl":"https://github.com/semaphoreui/semaphore/blob/1774ccb71a0a8b82eb74ea24c23ac9ab713de2fa/api/login.go#L887-L923","documentation":"oidcRedirect exchanges the ?code query parameter for OAuth2 tokens via oauth.Exchange(ctx, code). If the exchange fails, the handler returns HTTP 401 with this message. The real cause (expired/used code, bad client credentials, redirect_uri mismatch) is only in the server log.","triggerScenarios":"Authorization code already redeemed (browser refresh/retry), code expired, client_id/client_secret mismatch with the IdP, redirect_uri not exactly matching the registered callback, IdP token endpoint unreachable.","commonSituations":"Users double-clicking or refreshing the callback; wrong callback URL registered in the IdP app config; rotated client secret not updated in Semaphore config; clock skew invalidating tokens.","solutions":["Read the underlying error in server logs from oauth.Exchange","Have the user restart sign-in (codes are single-use and short-lived)","Verify client_id, client_secret, and redirect_uri exactly match the IdP application settings","Check server clock sync (NTP) and IdP token endpoint reachability","Ensure no proxy rewrites the callback URL path/query"],"exampleFix":"// before (IdP app)\nredirect_uri: https://semaphore.example.com/api/auth/oidc/keycloak/cb\n// after - must match callback route exactly\nredirect_uri: https://semaphore.example.com/api/auth/oidc/keycloak/callback","handlingStrategy":"try-catch","validationCode":"// pre-check alignment of registered redirect URI with the route\nif registeredRedirectURI != callbackURL(r) {\n    return errors.New(\"redirect_uri mismatch with IdP app config\")\n}","typeGuard":null,"tryCatchPattern":"oauth2Token, err := oauth.Exchange(ctx, code)\nif err != nil {\n    log.Errorf(\"code exchange failed: %v\", err)\n    // redirect user to restart login instead of a dead-end 401\n    http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)\n    return\n}","preventionTips":["Never reuse or refresh callback URLs (codes are single-use)","Keep client_id/secret/redirect_uri in sync with the IdP app config","Run NTP on the server to avoid clock-skew token failures","Disable caching on the callback route so refreshes don't replay the code"],"tags":["oidc","oauth2","http-401"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"1774ccb71a0a8b82eb74ea24c23ac9ab713de2fa","analyzedAt":"2026-09-07T11:00:33.293Z","contentChangedAt":"2026-09-07T11:00:33.293Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}