toeverything/AFFiNE · error · InvalidOauthCallbackState
invalid_oauth_callback_state
invalid_oauth_callback_state
Error message
Invalid callback state parameter.
What it means
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).
Source
Thrown at packages/backend/server/src/plugins/oauth/service.ts:96
code: string;
stateStr: string;
clientNonce?: string;
rawBody?: Buffer;
}): Promise<VerifyCallbackResult> {
let stateStr = input.stateStr;
let rawState: { state: string; provider?: string } | null = null;
if (typeof stateStr === 'string' && stateStr.length > 36) {
try {
const parsed = OAuthStateEnvelopeSchema.safeParse(JSON.parse(stateStr));
if (parsed.success) {
rawState = parsed.data;
stateStr = rawState.state;
}
} catch {} // noop
}
if (typeof stateStr !== 'string' || !this.isValidState(stateStr)) {
throw new InvalidOauthCallbackState();
}
const state = await this.getOAuthState(stateStr);
if (!state) throw new OauthStateExpired();
if (!state.token) state.token = stateStr;
if (
state.provider === OAuthProviderName.Apple &&
rawState &&
state.client &&
state.client !== 'web'
) {
return {
type: 'handoff',
code: input.code,
provider: rawState.provider,
state,
stateToken: stateStr,View on GitHub (pinned to b4c8548c09)
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.
Example fix
// before (custom client)
await fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state: stateUuidOnly }) });
// after
const { url } = await (await fetch('/api/oauth/preflight', { method: 'POST', body: JSON.stringify({ provider, client, client_nonce }) })).json();
// ...user completes login, IdP redirects with code + state (the envelope)...
await fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state: envelopeFromIdpRedirect, client_nonce }) }); Defensive patterns
Strategy: validation
Validate before calling
// Client-side guard before posting the callback
function isValidStateEnvelope(stateStr: unknown): boolean {
if (typeof stateStr !== 'string' || stateStr.length === 0) return false;
try {
const parsed = JSON.parse(stateStr);
return typeof parsed.state === 'string' && parsed.state.length === 36;
} catch {
return stateStr.length === 36;
}
}
if (!isValidStateEnvelope(stateFromIdpRedirect)) throw new Error('state corrupted in redirect'); Try / catch
try {
const res = await fetch('/api/oauth/callback', { method: 'POST', body: JSON.stringify({ code, state, client_nonce }) });
} catch (err) {
if (err.code === 'invalid_oauth_callback_state') {
// restart the flow from /api/oauth/preflight with a fresh state
}
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/b11d19a676a5f9c1.
Report an issue: GitHub.