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

  1. Send back the exact state string returned by POST /api/oauth/preflight (the full JSON envelope) as the state field of /api/oauth/callback.
  2. Do not re-encode or truncate the state between preflight and callback; treat it as an opaque string.
  3. Log the received stateStr length — anything other than 36 after envelope unwrap fails.
  4. 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

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.