Skip to content

Commit b0199f1

Browse files
fix: improve auth-related error handling (#1477)
1 parent 4c40437 commit b0199f1

File tree

7 files changed

+234
-13
lines changed

7 files changed

+234
-13
lines changed

.changeset/nine-lilies-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"task-master-ai": patch
3+
---
4+
5+
Improve auth-related error handling

apps/cli/src/utils/error-handler.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
* Provides consistent error formatting and debug mode detection
44
*/
55

6+
import {
7+
AuthenticationError,
8+
isSupabaseAuthError,
9+
AUTH_ERROR_MESSAGES
10+
} from '@tm/core';
611
import chalk from 'chalk';
712

813
/**
@@ -36,6 +41,28 @@ export function displayError(
3641
const sanitized = error.getSanitizedDetails();
3742
console.error(chalk.red(`\n${sanitized.message}`));
3843

44+
// Show stack trace in debug mode or if forced
45+
if ((isDebugMode() || options.forceStack) && error.stack) {
46+
console.error(chalk.gray('\nStack trace:'));
47+
console.error(chalk.gray(error.stack));
48+
}
49+
} else if (error instanceof AuthenticationError) {
50+
// Handle AuthenticationError with clean message (no "Error:" prefix)
51+
console.error(chalk.red(`\n${error.message}`));
52+
53+
// Show stack trace in debug mode or if forced
54+
if ((isDebugMode() || options.forceStack) && error.stack) {
55+
console.error(chalk.gray('\nStack trace:'));
56+
console.error(chalk.gray(error.stack));
57+
}
58+
} else if (isSupabaseAuthError(error)) {
59+
// Handle raw Supabase auth errors with user-friendly messages
60+
const code = error.code;
61+
const userMessage = code
62+
? AUTH_ERROR_MESSAGES[code] || error.message
63+
: error.message;
64+
console.error(chalk.red(`\n${userMessage}`));
65+
3966
// Show stack trace in debug mode or if forced
4067
if ((isDebugMode() || options.forceStack) && error.stack) {
4168
console.error(chalk.gray('\nStack trace:'));

packages/tm-core/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export {
9696
type LocalOnlyCommand
9797
} from './modules/auth/index.js';
9898

99+
// Auth error utilities (shared with CLI)
100+
export {
101+
isSupabaseAuthError,
102+
AUTH_ERROR_MESSAGES,
103+
isRecoverableStaleSessionError
104+
} from './modules/auth/index.js';
105+
99106
// Brief types
100107
export type { Brief } from './modules/briefs/types.js';
101108
export type { TagWithStats } from './modules/briefs/services/brief-service.js';

packages/tm-core/src/modules/auth/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,12 @@ export {
3838
LOCAL_ONLY_COMMANDS,
3939
type LocalOnlyCommand
4040
} from './constants.js';
41+
42+
// Auth error utilities (shared with CLI)
43+
export {
44+
isSupabaseAuthError,
45+
AUTH_ERROR_MESSAGES,
46+
RECOVERABLE_STALE_SESSION_ERRORS,
47+
isRecoverableStaleSessionError,
48+
toAuthenticationError
49+
} from './utils/index.js';
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Shared authentication error utilities
3+
* These utilities are used by both tm-core (Supabase client) and CLI (error handler)
4+
*/
5+
6+
import { isAuthError, type AuthError } from '@supabase/supabase-js';
7+
import { AuthenticationError } from '../types.js';
8+
9+
/**
10+
* Check if an error is a Supabase auth error.
11+
* Uses Supabase's public isAuthError helper for stable identification.
12+
*/
13+
export function isSupabaseAuthError(
14+
error: unknown
15+
): error is AuthError & { code?: string } {
16+
return isAuthError(error);
17+
}
18+
19+
/**
20+
* User-friendly error messages for common Supabase auth error codes
21+
* Note: refresh_token_not_found and refresh_token_already_used are expected
22+
* during MFA flows and should not trigger these messages in that context.
23+
*/
24+
export const AUTH_ERROR_MESSAGES: Record<string, string> = {
25+
refresh_token_not_found:
26+
'Your session has expired. Please log in again with: task-master login',
27+
refresh_token_already_used:
28+
'Your session has expired (token was already used). Please log in again with: task-master login',
29+
invalid_refresh_token:
30+
'Your session has expired (invalid token). Please log in again with: task-master login',
31+
session_expired:
32+
'Your session has expired. Please log in again with: task-master login',
33+
user_not_found:
34+
'User account not found. Please log in again with: task-master login',
35+
invalid_credentials:
36+
'Invalid credentials. Please log in again with: task-master login'
37+
};
38+
39+
/**
40+
* Error codes caused by stale sessions that can be recovered from
41+
* by clearing the session storage and retrying.
42+
*
43+
* These errors occur when there's a stale session with an invalid refresh token
44+
* and Supabase tries to use it during authentication. The fix is to clear
45+
* the stale session and retry the operation.
46+
*/
47+
export const RECOVERABLE_STALE_SESSION_ERRORS = [
48+
'refresh_token_not_found',
49+
'refresh_token_already_used'
50+
] as const;
51+
52+
/**
53+
* Check if an error is caused by a stale session and can be recovered
54+
* by clearing the session storage and retrying.
55+
*/
56+
export function isRecoverableStaleSessionError(error: unknown): boolean {
57+
if (!isSupabaseAuthError(error)) return false;
58+
return RECOVERABLE_STALE_SESSION_ERRORS.includes(
59+
(error.code || '') as (typeof RECOVERABLE_STALE_SESSION_ERRORS)[number]
60+
);
61+
}
62+
63+
/**
64+
* Convert a Supabase auth error to a user-friendly AuthenticationError
65+
*/
66+
export function toAuthenticationError(
67+
error: AuthError,
68+
defaultMessage: string
69+
): AuthenticationError {
70+
const code = error.code;
71+
const userMessage = code
72+
? AUTH_ERROR_MESSAGES[code] || `${defaultMessage}: ${error.message}`
73+
: `${defaultMessage}: ${error.message}`;
74+
75+
// Map Supabase error codes to our AuthErrorCode
76+
let authErrorCode:
77+
| 'REFRESH_FAILED'
78+
| 'NOT_AUTHENTICATED'
79+
| 'INVALID_CREDENTIALS' = 'REFRESH_FAILED';
80+
if (
81+
code === 'refresh_token_not_found' ||
82+
code === 'refresh_token_already_used' ||
83+
code === 'invalid_refresh_token' ||
84+
code === 'session_expired' ||
85+
code === 'user_not_found'
86+
) {
87+
authErrorCode = 'NOT_AUTHENTICATED';
88+
} else if (code === 'invalid_credentials') {
89+
authErrorCode = 'INVALID_CREDENTIALS';
90+
}
91+
92+
return new AuthenticationError(userMessage, authErrorCode, error);
93+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Auth utilities exports
3+
*/
4+
export {
5+
isSupabaseAuthError,
6+
AUTH_ERROR_MESSAGES,
7+
RECOVERABLE_STALE_SESSION_ERRORS,
8+
isRecoverableStaleSessionError,
9+
toAuthenticationError
10+
} from './auth-error-utils.js';

packages/tm-core/src/modules/integration/clients/supabase-client.ts

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
import { getLogger } from '../../../common/logger/index.js';
1212
import { SupabaseSessionStorage } from '../../auth/services/supabase-session-storage.js';
1313
import { AuthenticationError } from '../../auth/types.js';
14+
import {
15+
isSupabaseAuthError,
16+
isRecoverableStaleSessionError,
17+
toAuthenticationError
18+
} from '../../auth/utils/index.js';
1419

1520
export class SupabaseAuthClient {
1621
private static instance: SupabaseAuthClient | null = null;
@@ -98,7 +103,10 @@ export class SupabaseAuthClient {
98103
} = await client.auth.getSession();
99104

100105
if (error) {
101-
this.logger.warn('Failed to restore session:', error);
106+
// MFA-expected errors are normal during auth flows - don't log warnings
107+
if (!isRecoverableStaleSessionError(error)) {
108+
this.logger.warn('Failed to restore session:', error);
109+
}
102110
return null;
103111
}
104112

@@ -108,7 +116,14 @@ export class SupabaseAuthClient {
108116

109117
return session;
110118
} catch (error) {
111-
this.logger.error('Error initializing session:', error);
119+
// MFA-expected errors (refresh_token_not_found, etc.) are normal during auth flows
120+
if (isRecoverableStaleSessionError(error)) {
121+
this.logger.debug('Session not available (expected during MFA flow)');
122+
} else if (isSupabaseAuthError(error)) {
123+
this.logger.warn('Session expired or invalid');
124+
} else {
125+
this.logger.error('Error initializing session:', error);
126+
}
112127
return null;
113128
}
114129
}
@@ -213,13 +228,23 @@ export class SupabaseAuthClient {
213228
} = await client.auth.getSession();
214229

215230
if (error) {
216-
this.logger.warn('Failed to get session:', error);
231+
// MFA-expected errors are normal during auth flows - don't log warnings
232+
if (!isRecoverableStaleSessionError(error)) {
233+
this.logger.warn('Failed to get session:', error);
234+
}
217235
return null;
218236
}
219237

220238
return session;
221239
} catch (error) {
222-
this.logger.error('Error getting session:', error);
240+
// MFA-expected errors (refresh_token_not_found, etc.) are normal during auth flows
241+
if (isRecoverableStaleSessionError(error)) {
242+
this.logger.debug('Session not available (expected during MFA flow)');
243+
} else if (isSupabaseAuthError(error)) {
244+
this.logger.warn('Session expired or invalid');
245+
} else {
246+
this.logger.error('Error getting session:', error);
247+
}
223248
return null;
224249
}
225250
}
@@ -241,10 +266,8 @@ export class SupabaseAuthClient {
241266

242267
if (error) {
243268
this.logger.error('Failed to refresh session:', error);
244-
throw new AuthenticationError(
245-
`Failed to refresh session: ${error.message}`,
246-
'REFRESH_FAILED'
247-
);
269+
// Use user-friendly error message for known Supabase auth errors
270+
throw toAuthenticationError(error, 'Failed to refresh session');
248271
}
249272

250273
if (session) {
@@ -257,6 +280,11 @@ export class SupabaseAuthClient {
257280
throw error;
258281
}
259282

283+
// Handle raw Supabase auth errors that might be thrown
284+
if (isSupabaseAuthError(error)) {
285+
throw toAuthenticationError(error, 'Session refresh failed');
286+
}
287+
260288
throw new AuthenticationError(
261289
`Failed to refresh session: ${(error as Error).message}`,
262290
'REFRESH_FAILED'
@@ -341,12 +369,36 @@ export class SupabaseAuthClient {
341369
}
342370
}
343371

372+
/**
373+
* Handle recoverable stale session errors by clearing storage and retrying.
374+
* Returns the result of the retry if applicable, or null if no retry was attempted.
375+
*/
376+
private async handleRecoverableError(
377+
error: unknown,
378+
isRetry: boolean,
379+
retryFn: () => Promise<Session>
380+
): Promise<Session | null> {
381+
if (!isRetry && isRecoverableStaleSessionError(error)) {
382+
this.logger.debug(
383+
'MFA-expected error during token verification, clearing stale session and retrying'
384+
);
385+
await this.sessionStorage.clear();
386+
return retryFn();
387+
}
388+
return null;
389+
}
390+
344391
/**
345392
* Verify a one-time token and create a session
346393
* Used for CLI authentication with pre-generated tokens
394+
*
395+
* Note: If MFA is enabled and there's a stale session, Supabase might throw
396+
* refresh_token_not_found errors. We handle this by clearing the stale session
397+
* and retrying once.
347398
*/
348-
async verifyOneTimeCode(token: string): Promise<Session> {
399+
async verifyOneTimeCode(token: string, isRetry = false): Promise<Session> {
349400
const client = this.getClient();
401+
const retryFn = () => this.verifyOneTimeCode(token, true);
350402

351403
try {
352404
this.logger.info('Verifying authentication token...');
@@ -359,11 +411,18 @@ export class SupabaseAuthClient {
359411
});
360412

361413
if (error) {
362-
this.logger.error('Failed to verify token:', error);
363-
throw new AuthenticationError(
364-
`Failed to verify token: ${error.message}`,
365-
'INVALID_CODE'
414+
// If this is an MFA-expected error (like refresh_token_not_found),
415+
// it might be due to a stale session interfering. Clear and retry once.
416+
const retryResult = await this.handleRecoverableError(
417+
error,
418+
isRetry,
419+
retryFn
366420
);
421+
if (retryResult) return retryResult;
422+
423+
this.logger.error('Failed to verify token:', error);
424+
// Use user-friendly error message for known Supabase auth errors
425+
throw toAuthenticationError(error, 'Failed to verify token');
367426
}
368427

369428
if (!data?.session) {
@@ -380,6 +439,17 @@ export class SupabaseAuthClient {
380439
throw error;
381440
}
382441

442+
// Handle raw Supabase auth errors that might be thrown
443+
if (isSupabaseAuthError(error)) {
444+
const retryResult = await this.handleRecoverableError(
445+
error,
446+
isRetry,
447+
retryFn
448+
);
449+
if (retryResult) return retryResult;
450+
throw toAuthenticationError(error, 'Token verification failed');
451+
}
452+
383453
throw new AuthenticationError(
384454
`Token verification failed: ${(error as Error).message}`,
385455
'CODE_AUTH_FAILED'

0 commit comments

Comments
 (0)