ruvnet/ruflo · error · OAuthError

protocol

protocol

Error message

oauth error: ${body.error} — ${body.error_description}

What it means

The token endpoint answered non-ok with a standard OAuth error body {error, error_description}; it is rethrown as OAuthError code 'protocol' with oauthError/oauthDescription preserved (e.g. invalid_grant, invalid_client, access_denied). This is the server formally rejecting the grant — the message names which OAuth error occurred.

Source

Thrown at v3/@claude-flow/security/src/oauth/client.ts:80

  url.searchParams.set('redirect_uri', redirectUri);
  url.searchParams.set('scope', SCOPE);
  url.searchParams.set('state', state);
  url.searchParams.set('code_challenge', codeChallenge);
  url.searchParams.set('code_challenge_method', 'S256');
  return url.toString();
}

async function parseTokenResponse(resp: Response): Promise<TokenResponse> {
  if (resp.ok) {
    try {
      return (await resp.json()) as TokenResponse;
    } catch {
      throw new OAuthError('unexpected response shape from the server', 'unexpected_shape');
    }
  }
  try {
    const body = (await resp.json()) as OAuthErrorBody;
    throw new OAuthError(
      `oauth error: ${body.error} — ${body.error_description}`,
      'protocol',
      body.error,
      body.error_description,
    );
  } catch (e) {
    if (e instanceof OAuthError) throw e;
    throw new OAuthError('unexpected response shape from the server', 'unexpected_shape');
  }
}

async function postForm(path: string, form: Record<string, string>, base = authBaseUrl()): Promise<TokenResponse> {
  let resp: Response;
  try {
    resp = await fetch(`${base}${path}`, {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams(form).toString(),

View on GitHub (pinned to fa13ee4ad6)

Solutions

  1. Inspect e.oauthError — invalid_grant means restart the full authorize flow; access_denied means the user declined; invalid_client indicates client_id/registration problems
  2. Never reuse an authorization code: persist it durably, exchange exactly once, claim before retrying
  3. Keep the code_verifier from the session that generated the challenge
  4. When refresh fails, fall back to re-authorization to obtain a new refresh_token rather than retrying the dead one

Example fix

// before
const tokens = await exchangeCode(code, verifier, redirectUri); // retried after timeout → oauth error: invalid_grant

// after
if (!exchangeClaimed) {
  exchangeClaimed = true; // exactly-once guard before first attempt
  const tokens = await exchangeCode(code, verifier, redirectUri);
}
Defensive patterns

Strategy: try-catch

Type guard

function isOAuthProtocolError(e: unknown, oauthError?: string): boolean {
  return e instanceof Error && e.name === 'OAuthError'
    && (e as { code?: string }).code === 'protocol'
    && (oauthError === undefined || (e as { oauthError?: string }).oauthError === oauthError);
}

Try / catch

try {
  return await exchangeCode(code, verifier, redirectUri);
} catch (e) {
  if (isOAuthProtocolError(e, 'invalid_grant')) {
    return await restartAuthorizationFlow(); // fresh authorize + PKCE pair
  }
  if (isOAuthProtocolError(e)) {
    throw new Error(`OAuth rejected the grant: ${(e as { oauthError?: string }).oauthError}`);
  }
  throw e;
}

Prevention

When it happens

Trigger: exchangeCode() with an already-used or expired authorization code; PKCE code_verifier that doesn't match the challenge; refreshAccessToken() with a revoked or rotated refresh token; a redirect_uri or client_id that differs from registration.

Common situations: Retrying the code exchange after a crash/timeout reuses the one-time code; refresh tokens revoked by logout or rotation; delays consuming a code past its short expiry; clock skew on the client.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/2b95a5634ef62a9b. Report an issue: GitHub.