{"record":{"id":"72b01c018392fc31","repo":"siyuan-note/siyuan","slug":"oidc-nonce-does-not-match","errorCode":null,"errorMessage":"OIDC nonce does not match","messagePattern":"OIDC nonce does not match","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/model/oidc_provider/provider.go","lineNumber":108,"sourceCode":"\nfunc (p *Provider) Exchange(ctx context.Context, code, codeVerifier, nonce string) (map[string]any, error) {\n\ttoken, err := p.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"exchange OIDC authorization code failed: %w\", err)\n\t}\n\tif p.kind == conf.OIDCProviderGitHub {\n\t\treturn exchangeGitHubClaims(ctx, token)\n\t}\n\trawIDToken, ok := token.Extra(\"id_token\").(string)\n\tif !ok || rawIDToken == \"\" {\n\t\treturn nil, errors.New(\"OIDC response does not contain an ID token\")\n\t}\n\tidToken, err := p.verifier.Verify(ctx, rawIDToken)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"verify OIDC ID token failed: %w\", err)\n\t}\n\tif idToken.Nonce != nonce {\n\t\treturn nil, errors.New(\"OIDC nonce does not match\")\n\t}\n\tclaims := map[string]any{}\n\tif err = idToken.Claims(&claims); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode OIDC claims failed: %w\", err)\n\t}\n\treturn claims, nil\n}\n\nfunc newGitHub(config *conf.OIDC, redirectURL string) *Provider {\n\tscopes := append([]string{}, config.Scopes...)\n\tif len(scopes) == 0 || isDefaultOIDCScopes(scopes) {\n\t\tscopes = []string{\"read:user\", \"user:email\"}\n\t} else {\n\t\tfiltered := scopes[:0]\n\t\tfor _, scope := range scopes {\n\t\t\tif scope != oidc.ScopeOpenID && scope != \"profile\" && scope != \"email\" {\n\t\t\t\tfiltered = append(filtered, scope)\n\t\t\t}","sourceCodeStart":90,"sourceCodeEnd":126,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/251596fc0de2f9528c00c224252fd073a99973f4/kernel/model/oidc_provider/provider.go#L90-L126","documentation":"Thrown by Provider.Exchange() when idToken.Nonce does not equal the nonce argument. The nonce is a single-use random string sent in the authorization request and embedded in the id_token to prevent replay/token-injection attacks. A mismatch indicates the token was not issued for this specific login attempt.","triggerScenarios":"Calling Exchange(ctx, code, codeVerifier, nonce) where the nonce extracted from the verified id_token differs from the nonce passed in. This occurs when: the nonce was not persisted correctly between AuthURL generation and the callback, a different nonce was used in AuthURL vs Exchange, or an attacker attempted a token injection/replay.","commonSituations":"The nonce was generated for AuthURL but stored in a session/cookie that expired or was cleared before the callback, so a fresh/empty nonce is compared. The server restarted between generating the auth URL and handling the callback, losing the in-memory nonce. Multiple concurrent login flows mixed up nonces. A genuine replay attack attempt (rare but this is the security guard catching it).","solutions":["Ensure the nonce generated in AuthURL is persisted (session, signed cookie, or database) and correctly retrieved in the callback handler.","Verify the nonce passed to Exchange is the exact same value passed to AuthURL for this login session.","Use a per-session nonce store that survives the redirect (e.g., signed HTTP-only cookie rather than in-memory map).","If the server restarts mid-login, the user must re-initiate the OIDC flow to get a new nonce."],"exampleFix":"// before\nnonce := generateNonce()\nauthURL := provider.AuthURL(state, nonce, codeVerifier)\n// ... redirect user ...\n// On callback:\nclaims, err := provider.Exchange(ctx, code, codeVerifier, generateNonce()) // fresh nonce!\n\n// after\nnonce := generateNonce()\nsession.Set(\"oidc_nonce\", nonce)\nsession.Save()\nauthURL := provider.AuthURL(state, nonce, codeVerifier)\n// ... redirect user ...\n// On callback:\nstoredNonce := session.Get(\"oidc_nonce\").(string)\nclaims, err := provider.Exchange(ctx, code, codeVerifier, storedNonce)","handlingStrategy":"validation","validationCode":"// Ensure nonce is persisted and retrieved correctly\nif nonce == \"\" {\n    return errors.New(\"nonce is empty; cannot verify OIDC response\")\n}\n// Compare with stored nonce from the auth request\nstoredNonce := session.Get(\"oidc_nonce\")\nif storedNonce != nonce {\n    return errors.New(\"nonce mismatch detected before exchange\")\n}","typeGuard":"func isValidNonce(stored, received string) bool {\n    return stored != \"\" && received != \"\" && stored == received\n}","tryCatchPattern":"claims, err := provider.Exchange(ctx, code, codeVerifier, nonce)\nif err != nil && strings.Contains(err.Error(), \"nonce does not match\") {\n    // Session lost the nonce or potential replay — force re-authentication\n    session.Delete(\"oidc_nonce\")\n    http.Redirect(w, r, \"/api/oidc/login\", http.StatusTemporaryRedirect)\n    return\n}","preventionTips":["Store the nonce in a signed HTTP-only cookie that survives the redirect.","Never generate a new nonce in the callback handler; always use the one from the auth request.","Clear the nonce after successful exchange to prevent reuse.","Use per-session nonce storage, not a global map."],"tags":["oidc","authentication","security","nonce","replay-attack","session"],"backgroundTag":null,"analyzedSha":"251596fc0de2f9528c00c224252fd073a99973f4","analyzedAt":"2026-08-12T21:18:37.123Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}