{"record":{"id":"b11d19a676a5f9c1","repo":"toeverything/AFFiNE","slug":"invalid-oauth-callback-state","errorCode":"invalid_oauth_callback_state","errorMessage":"Invalid callback state parameter.","messagePattern":"Invalid callback state parameter\\.","errorType":"exception","errorClass":"InvalidOauthCallbackState","httpStatus":400,"severity":"error","filePath":"packages/backend/server/src/plugins/oauth/service.ts","lineNumber":96,"sourceCode":"    code: string;\n    stateStr: string;\n    clientNonce?: string;\n    rawBody?: Buffer;\n  }): Promise<VerifyCallbackResult> {\n    let stateStr = input.stateStr;\n    let rawState: { state: string; provider?: string } | null = null;\n    if (typeof stateStr === 'string' && stateStr.length > 36) {\n      try {\n        const parsed = OAuthStateEnvelopeSchema.safeParse(JSON.parse(stateStr));\n        if (parsed.success) {\n          rawState = parsed.data;\n          stateStr = rawState.state;\n        }\n      } catch {} // noop\n    }\n\n    if (typeof stateStr !== 'string' || !this.isValidState(stateStr)) {\n      throw new InvalidOauthCallbackState();\n    }\n\n    const state = await this.getOAuthState(stateStr);\n    if (!state) throw new OauthStateExpired();\n    if (!state.token) state.token = stateStr;\n\n    if (\n      state.provider === OAuthProviderName.Apple &&\n      rawState &&\n      state.client &&\n      state.client !== 'web'\n    ) {\n      return {\n        type: 'handoff',\n        code: input.code,\n        provider: rawState.provider,\n        state,\n        stateToken: stateStr,","sourceCodeStart":78,"sourceCodeEnd":114,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/plugins/oauth/service.ts#L78-L114","documentation":"InvalidOauthCallbackState thrown at packages/backend/server/src/plugins/oauth/service.ts:96 when the state posted to /api/oauth/callback is not, after unwrapping, a 36-character UUID. AFFiNE's preflight returns a JSON envelope {state, client, provider, pkce} whose inner state is a 36-char UUID stored server-side; verifyCallback unwraps envelopes longer than 36 chars via OAuthStateEnvelopeSchema and then requires exactly 36 chars (isValidState).","triggerScenarios":"POST /api/oauth/callback with a state that is empty, truncated, double-encoded, a raw non-envelope string, or an envelope whose inner state is not the 36-char UUID; client echoing the IdP's query-string state through a URL-decode that corrupts it; tampered or fabricated state.","commonSituations":"Client sends only the inner state UUID when using the envelope flow incorrectly, or re-serializes the envelope (key order/quotes) so JSON.parse or schema parse fails and the raw >36 string is then rejected; mobile deep-link handoff losing part of the state; test scripts posting arbitrary state.","solutions":["Send back the exact state string returned by POST /api/oauth/preflight (the full JSON envelope) as the state field of /api/oauth/callback.","Do not re-encode or truncate the state between preflight and callback; treat it as an opaque string.","Log the received stateStr length — anything other than 36 after envelope unwrap fails.","If writing a custom client, run preflight, keep the envelope, and post {code, state, client_nonce} unchanged."],"exampleFix":"// before (custom client)\nawait fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state: stateUuidOnly }) });\n\n// after\nconst { url } = await (await fetch('/api/oauth/preflight', { method: 'POST', body: JSON.stringify({ provider, client, client_nonce }) })).json();\n// ...user completes login, IdP redirects with code + state (the envelope)...\nawait fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state: envelopeFromIdpRedirect, client_nonce }) });","handlingStrategy":"validation","validationCode":"// Client-side guard before posting the callback\nfunction isValidStateEnvelope(stateStr: unknown): boolean {\n  if (typeof stateStr !== 'string' || stateStr.length === 0) return false;\n  try {\n    const parsed = JSON.parse(stateStr);\n    return typeof parsed.state === 'string' && parsed.state.length === 36;\n  } catch {\n    return stateStr.length === 36;\n  }\n}\nif (!isValidStateEnvelope(stateFromIdpRedirect)) throw new Error('state corrupted in redirect');","typeGuard":null,"tryCatchPattern":"try {\n  const res = await fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state, client_nonce }) });\n} catch (err) {\n  if (err.code === 'invalid_oauth_callback_state') {\n    // restart the flow from /api/oauth/preflight with a fresh state\n  }\n}","preventionTips":["Treat the preflight state as an opaque string; never re-serialize or trim it.","Carry the state through the IdP redirect without URL double-encoding.","If the state looks corrupted, silently restart login instead of posting garbage."],"tags":["oauth","state-parameter","callback","authentication"],"backgroundTag":"oauth-invalid-callback-state","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}