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
- Inspect e.oauthError — invalid_grant means restart the full authorize flow; access_denied means the user declined; invalid_client indicates client_id/registration problems
- Never reuse an authorization code: persist it durably, exchange exactly once, claim before retrying
- Keep the code_verifier from the session that generated the challenge
- 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
- Exchange authorization codes exactly once — claim durably before the first attempt
- Keep code_verifier with the session that created the challenge
- On refresh-token protocol errors, re-authorize instead of retrying the dead token
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
- state mismatch — the OAuth callback did not match the reques
- Cognitum auth service returned an unexpected response: ${e.m
- profile "${profile}" has no persisted refresh token and its
- Cognitum refresh response did not contain an access token
- MCP initialization failed: ${initResponse.error.message}
AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18).
Data as JSON: /api/errors/2b95a5634ef62a9b.
Report an issue: GitHub.