{"record":{"id":"6e5f7a1cde2efd95","repo":"siyuan-note/siyuan","slug":"oauth-issuer-mismatch","errorCode":null,"errorMessage":"OAuth issuer mismatch","messagePattern":"OAuth issuer mismatch","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/mcp/client/oauth.go","lineNumber":600,"sourceCode":"\t}\n\toauthFlows.Unlock()\n}\n\nfunc CompleteMCPOAuth(flowID, code, state, callbackError, issuer string) error {\n\toauthFlows.Lock()\n\tflow := oauthFlows.items[flowID]\n\tif flow == nil || time.Now().After(flow.Expires) {\n\t\tdelete(oauthFlows.items, flowID)\n\t\toauthFlows.Unlock()\n\t\treturn fmt.Errorf(\"OAuth flow is missing or expired\")\n\t}\n\tif state != flow.State {\n\t\toauthFlows.Unlock()\n\t\treturn fmt.Errorf(\"OAuth state mismatch\")\n\t}\n\tif issuer != \"\" && issuer != flow.Issuer {\n\t\toauthFlows.Unlock()\n\t\treturn fmt.Errorf(\"OAuth issuer mismatch\")\n\t}\n\tdelete(oauthFlows.items, flowID)\n\toauthFlows.Unlock()\n\tselect {\n\tcase flow.Result <- oauthCallbackResult{Code: code, State: state, Error: callbackError}:\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"OAuth callback was already handled\")\n\t}\n}\n\nfunc IsLoopbackCallback(remoteAddr string) bool {\n\thost, _, err := net.SplitHostPort(remoteAddr)\n\tif err != nil {\n\t\treturn false\n\t}\n\tip := net.ParseIP(host)\n\treturn ip != nil && ip.IsLoopback()","sourceCodeStart":582,"sourceCodeEnd":618,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/251596fc0de2f9528c00c224252fd073a99973f4/kernel/mcp/client/oauth.go#L582-L618","documentation":"An optional `issuer` argument supplied to `CompleteMCPOAuth` does not equal the `Issuer` bound to the stored flow. The check only fires when a non-empty `issuer` is passed; its purpose is to bind a callback to a specific authorization server so that a token issued by a different (e.g. spoofed or multi-tenant) issuer is rejected.","triggerScenarios":"The callback handler is invoked with an `issuer` string that disagrees with the issuer recorded at flow-creation time. This happens when metadata discovery resolved to a different issuer URL, or when the caller hard-codes an issuer that no longer matches the provider's advertised `issuer` claim.","commonSituations":"Provider changed its issuer URL after a regional failover or tenant migration. The caller passes the token endpoint URL as the issuer instead of the metadata `issuer` field. A multi-tenant IdP returns tenant-specific issuer values that vary per authorization.","solutions":["Re-run OAuth metadata discovery for the provider and pass the freshly discovered `issuer` (or omit the `issuer` argument to skip the check).","Verify the `issuer` passed to the callback matches exactly (scheme, host, port, trailing slash) the issuer stored when the flow was created.","If the provider legitimately rotates issuers, clear stored credentials and start a new flow so the bound issuer is refreshed."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// Re-fetch the issuer from metadata discovery and pass it, or omit it to skip the check.\ndiscovered, err := discoverOAuthMetadata(ctx, serverID)\nif err != nil {\n    return err\n}\n// Pass discovered.Issuer (or \"\") to CompleteMCPOAuth.\nreturn CompleteMCPOAuth(flowID, code, state, callbackError, discovered.Issuer)","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Always source the issuer from the provider's metadata `issuer` field, not the token endpoint URL.","Omit the issuer argument when issuer binding is not required.","Restart the flow if the provider legitimately rotates its issuer."],"tags":["oauth","mcp","security","identity-provider"],"backgroundTag":null,"analyzedSha":"251596fc0de2f9528c00c224252fd073a99973f4","analyzedAt":"2026-08-12T21:18:37.123Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}