Skip to content

Commit 1db15fd

Browse files
khaliqgantclaude
andcommitted
Fix race conditions in CLI auth credential fetching
Critical fixes for OAuth auth reliability: 1. /creds endpoint now checks for token existence, not just status - Status='success' can be set before credentials are extracted - Added hasToken field to error response for debugging 2. Added error passthrough in /creds endpoint - Returns errorHint and recoverable for auth failures - Allows frontend to show actionable error messages 3. Added retry loop for credential fetching in cloud API - 5 retries with 1s delay (5s total wait) - Handles race between OAuth completion and credential extraction - Returns immediately on auth error (no retry) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 8fde587 commit 1db15fd

File tree

2 files changed

+82
-22
lines changed

2 files changed

+82
-22
lines changed

src/cloud/api/onboarding.ts

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -343,30 +343,67 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request,
343343
session.status = 'success';
344344
}
345345

346-
// Fetch credentials from workspace
346+
// Fetch credentials from workspace with retry
347+
// Credentials may not be immediately available after OAuth completes
347348
if (!accessToken) {
348-
try {
349-
const credsResponse = await fetch(
350-
`${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`
351-
);
352-
if (credsResponse.ok) {
353-
const creds = await credsResponse.json() as {
354-
token?: string;
355-
refreshToken?: string;
356-
expiresAt?: string;
349+
const MAX_CREDS_RETRIES = 5;
350+
const CREDS_RETRY_DELAY = 1000; // 1 second between retries
351+
352+
for (let attempt = 1; attempt <= MAX_CREDS_RETRIES; attempt++) {
353+
try {
354+
console.log(`[onboarding] Fetching credentials from workspace (attempt ${attempt}/${MAX_CREDS_RETRIES})`);
355+
const credsResponse = await fetch(
356+
`${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`
357+
);
358+
359+
if (credsResponse.ok) {
360+
const creds = await credsResponse.json() as {
361+
token?: string;
362+
refreshToken?: string;
363+
tokenExpiresAt?: string;
364+
};
365+
accessToken = creds.token;
366+
refreshToken = creds.refreshToken;
367+
if (creds.tokenExpiresAt) {
368+
tokenExpiresAt = new Date(creds.tokenExpiresAt);
369+
}
370+
console.log('[onboarding] Fetched credentials from workspace:', {
371+
hasToken: !!accessToken,
372+
hasRefreshToken: !!refreshToken,
373+
attempt,
374+
});
375+
break; // Success, exit retry loop
376+
}
377+
378+
// Check if it's an error state (not just "not ready yet")
379+
const errorBody = await credsResponse.json().catch(() => ({})) as {
380+
status?: string;
381+
error?: string;
382+
errorHint?: string;
383+
recoverable?: boolean;
357384
};
358-
accessToken = creds.token;
359-
refreshToken = creds.refreshToken;
360-
if (creds.expiresAt) {
361-
tokenExpiresAt = new Date(creds.expiresAt);
385+
386+
if (errorBody.status === 'error') {
387+
// Auth failed, don't retry
388+
console.error('[onboarding] Auth failed in workspace:', errorBody);
389+
return res.status(400).json({
390+
error: errorBody.error || 'Authentication failed',
391+
errorHint: errorBody.errorHint,
392+
recoverable: errorBody.recoverable,
393+
});
394+
}
395+
396+
// If not ready yet and we have more retries, wait and try again
397+
if (attempt < MAX_CREDS_RETRIES) {
398+
console.log(`[onboarding] Credentials not ready yet, retrying in ${CREDS_RETRY_DELAY}ms...`);
399+
await new Promise(resolve => setTimeout(resolve, CREDS_RETRY_DELAY));
400+
}
401+
} catch (err) {
402+
console.error(`[onboarding] Failed to get credentials from workspace (attempt ${attempt}):`, err);
403+
if (attempt < MAX_CREDS_RETRIES) {
404+
await new Promise(resolve => setTimeout(resolve, CREDS_RETRY_DELAY));
362405
}
363-
console.log('[onboarding] Fetched credentials from workspace:', {
364-
hasToken: !!accessToken,
365-
hasRefreshToken: !!refreshToken,
366-
});
367406
}
368-
} catch (err) {
369-
console.error('[onboarding] Failed to get credentials from workspace:', err);
370407
}
371408
}
372409
}

src/daemon/api.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,13 +371,36 @@ export class DaemonApi extends EventEmitter {
371371
if (!session) {
372372
return { status: 404, body: { error: 'Session not found' } };
373373
}
374-
if (session.status !== 'success') {
375-
return { status: 400, body: { error: 'Auth not complete', status: session.status } };
374+
// Check for error state first
375+
if (session.status === 'error') {
376+
return {
377+
status: 400,
378+
body: {
379+
error: session.error || 'Authentication failed',
380+
errorHint: session.errorHint,
381+
recoverable: session.recoverable,
382+
status: session.status,
383+
},
384+
};
385+
}
386+
// Check if auth is complete AND we have credentials
387+
// Status can be 'success' before credentials are extracted (race condition)
388+
if (session.status !== 'success' || !session.token) {
389+
return {
390+
status: 400,
391+
body: {
392+
error: 'Auth not complete or credentials not yet available',
393+
status: session.status,
394+
hasToken: !!session.token,
395+
},
396+
};
376397
}
377398
return {
378399
status: 200,
379400
body: {
380401
token: session.token,
402+
refreshToken: session.refreshToken,
403+
tokenExpiresAt: session.tokenExpiresAt,
381404
provider: session.provider,
382405
},
383406
};

0 commit comments

Comments
 (0)