fix: resolve login issues for users with CLI authentication blocked by browsers or firewalls (#1492)

This commit is contained in:
Ralph Khreish
2025-12-08 22:42:39 +01:00
committed by GitHub
parent 0e908be43a
commit 071dfc6be9
7 changed files with 451 additions and 340 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Fix login issues for users whose browsers or firewalls were blocking CLI authentication

4
context7.json Normal file
View File

@@ -0,0 +1,4 @@
{
"url": "https://context7.com/eyaltoledano/claude-task-master",
"public_key": "pk_52Na55p8REi9c5jSFszav"
}

View File

@@ -16,7 +16,6 @@ export type {
AuthCredentials, AuthCredentials,
OAuthFlowOptions, OAuthFlowOptions,
AuthConfig, AuthConfig,
CliData,
UserContext, UserContext,
MFAVerificationResult MFAVerificationResult
} from './types.js'; } from './types.js';

View File

@@ -1,11 +1,14 @@
/** /**
* OAuth 2.0 Authorization Code Flow service * OAuth 2.0 Authorization Code Flow service
*
* Uses backend PKCE flow with E2E encryption:
* - CLI generates RSA keypair and sends public key to backend
* - Backend manages PKCE params (code_verifier never leaves server)
* - Backend encrypts tokens with CLI's public key before storage
* - CLI decrypts tokens with private key (tokens never stored in plaintext on server)
*/ */
import crypto from 'crypto';
import http from 'http';
import os from 'os'; import os from 'os';
import { URL } from 'url';
import type { Session } from '@supabase/supabase-js'; import type { Session } from '@supabase/supabase-js';
import { TASKMASTER_VERSION } from '../../../common/constants/index.js'; import { TASKMASTER_VERSION } from '../../../common/constants/index.js';
import { getLogger } from '../../../common/logger/index.js'; import { getLogger } from '../../../common/logger/index.js';
@@ -16,10 +19,41 @@ import {
type AuthConfig, type AuthConfig,
type AuthCredentials, type AuthCredentials,
AuthenticationError, AuthenticationError,
type CliData,
type MFAChallenge, type MFAChallenge,
type OAuthFlowOptions type OAuthFlowOptions
} from '../types.js'; } from '../types.js';
import {
type AuthKeyPair,
type EncryptedTokenPayload,
decryptTokens,
generateKeyPair
} from '../utils/cli-crypto.js';
/**
* Response from POST /api/auth/cli/start
*/
interface StartFlowResponse {
success: boolean;
flow_id?: string;
verification_url?: string;
expires_at?: string;
poll_interval?: number;
error?: string;
message?: string;
}
/**
* Response from GET /api/auth/cli/status
*/
interface FlowStatusResponse {
success: boolean;
status?: 'pending' | 'authenticating' | 'complete' | 'failed' | 'expired';
encrypted_tokens?: EncryptedTokenPayload;
user_id?: string;
error?: string;
error_description?: string;
message?: string;
}
export class OAuthService { export class OAuthService {
private logger = getLogger('OAuthService'); private logger = getLogger('OAuthService');
@@ -27,9 +61,7 @@ export class OAuthService {
private supabaseClient: SupabaseAuthClient; private supabaseClient: SupabaseAuthClient;
private baseUrl: string; private baseUrl: string;
private authorizationUrl: string | null = null; private authorizationUrl: string | null = null;
private originalState: string | null = null; private keyPair: AuthKeyPair | null = null;
private authorizationReady: Promise<void> | null = null;
private resolveAuthorizationReady: (() => void) | null = null;
constructor( constructor(
contextStore: ContextStore, contextStore: ContextStore,
@@ -44,6 +76,11 @@ export class OAuthService {
/** /**
* Start OAuth 2.0 Authorization Code Flow with browser handling * Start OAuth 2.0 Authorization Code Flow with browser handling
*
* Uses secure backend PKCE flow where:
* - CLI calls backend to start flow
* - Backend manages PKCE params (code_verifier never leaves server)
* - CLI polls backend for completion
*/ */
async authenticate(options: OAuthFlowOptions = {}): Promise<AuthCredentials> { async authenticate(options: OAuthFlowOptions = {}): Promise<AuthCredentials> {
const { const {
@@ -56,54 +93,13 @@ export class OAuthService {
} = options; } = options;
try { try {
// Start the OAuth flow (starts local server) return await this.authenticateWithBackendPKCE({
const authPromise = this.startFlow(timeout); openBrowser,
timeout,
// Wait for server to be ready and URL to be generated onAuthUrl,
if (this.authorizationReady) { onWaitingForAuth,
await this.authorizationReady; onSuccess
} });
// Get the authorization URL
const authUrl = this.getAuthorizationUrl();
if (!authUrl) {
throw new AuthenticationError(
'Failed to generate authorization URL',
'URL_GENERATION_FAILED'
);
}
// Notify about the auth URL
if (onAuthUrl) {
onAuthUrl(authUrl);
}
// Open browser if callback provided
if (openBrowser) {
try {
await openBrowser(authUrl);
this.logger.debug('Browser opened successfully with URL:', authUrl);
} catch (error) {
// Log the error but don't throw - user can still manually open the URL
this.logger.warn('Failed to open browser automatically:', error);
}
}
// Notify that we're waiting for authentication
if (onWaitingForAuth) {
onWaitingForAuth();
}
// Wait for authentication to complete
const credentials = await authPromise;
// Notify success
if (onSuccess) {
onSuccess(credentials);
}
return credentials;
} catch (error) { } catch (error) {
const authError = const authError =
error instanceof AuthenticationError error instanceof AuthenticationError
@@ -125,305 +121,302 @@ export class OAuthService {
} }
/** /**
* Start the OAuth flow (internal implementation) * Authenticate using backend-managed PKCE flow with E2E encryption
*
* This is the secure flow where:
* 1. CLI generates RSA keypair for E2E encryption
* 2. CLI calls POST /api/auth/cli/start with public key
* 3. Backend generates PKCE params and stores them with public key
* 4. CLI opens browser with verification URL
* 5. User authenticates in browser
* 6. Backend exchanges code for session, encrypts tokens with public key
* 7. CLI polls GET /api/auth/cli/status until complete
* 8. CLI decrypts tokens with private key
*/ */
private async startFlow(timeout = 300000): Promise<AuthCredentials> { private async authenticateWithBackendPKCE(
const state = this.generateState(); options: OAuthFlowOptions
): Promise<AuthCredentials> {
const {
openBrowser,
timeout = 300000,
onAuthUrl,
onWaitingForAuth,
onSuccess
} = options;
// Store the original state for verification // Step 1: Generate keypair for E2E encryption
this.originalState = state; this.keyPair = generateKeyPair();
this.logger.debug('Generated RSA keypair for E2E encryption');
// Create a promise that will resolve when the server is ready // Step 2: Start the flow on the backend with our public key
this.authorizationReady = new Promise<void>((resolve) => { const startResponse = await this.startBackendFlow();
this.resolveAuthorizationReady = resolve;
});
return new Promise((resolve, reject) => { if (!startResponse.success || !startResponse.flow_id) {
let timeoutId: NodeJS.Timeout; throw new AuthenticationError(
// Create local HTTP server for OAuth callback startResponse.message || 'Failed to start authentication flow',
const server = http.createServer(); 'START_FLOW_FAILED'
// Start server on localhost only, bind to port 0 for automatic port assignment
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const port = address.port;
const callbackUrl = `http://localhost:${port}/callback`;
// Set up request handler after we know the port
server.on('request', async (req, res) => {
const url = new URL(req.url!, `http://127.0.0.1:${port}`);
if (url.pathname === '/callback') {
await this.handleCallback(
url,
res,
server,
resolve,
reject,
timeoutId
); );
} else {
// Handle other paths (favicon, etc.)
res.writeHead(404);
res.end();
} }
const { flow_id, verification_url, poll_interval = 2 } = startResponse;
// Store the auth URL
this.authorizationUrl = verification_url || null;
// Notify about the auth URL
if (onAuthUrl && verification_url) {
onAuthUrl(verification_url);
}
// Step 3: Open browser with verification URL
if (openBrowser && verification_url) {
try {
await openBrowser(verification_url);
this.logger.debug(
'Browser opened successfully with URL:',
verification_url
);
} catch (error) {
this.logger.warn('Failed to open browser automatically:', error);
}
}
// Notify that we're waiting for authentication
if (onWaitingForAuth) {
onWaitingForAuth();
}
// Step 4: Poll for completion
const credentials = await this.pollForCompletion(
flow_id,
poll_interval * 1000,
timeout
);
// Set the session in Supabase client
// Note: Only set session if we have a valid refresh token
// Supabase requires a valid refresh_token to manage token lifecycle
if (!credentials.refreshToken) {
this.logger.warn(
'No refresh token received from server - session refresh will not work'
);
}
const session: Session = {
access_token: credentials.token,
refresh_token: credentials.refreshToken ?? '',
expires_in: credentials.expiresAt
? Math.floor(
(new Date(credentials.expiresAt).getTime() - Date.now()) / 1000
)
: 3600,
token_type: 'bearer',
user: {
id: credentials.userId,
email: credentials.email,
app_metadata: {},
user_metadata: {},
aud: 'authenticated',
created_at: ''
}
};
await this.supabaseClient.setSession(session);
// Save user info to context store
this.contextStore.saveContext({
userId: credentials.userId,
email: credentials.email
}); });
// Prepare CLI data object (server handles OAuth/PKCE) // Check if MFA is required
const cliData: CliData = { await this.checkAndThrowIfMFARequired();
callback: callbackUrl,
state: state, // Notify success
if (onSuccess) {
onSuccess(credentials);
}
return credentials;
}
/**
* Start a new authentication flow on the backend
*/
private async startBackendFlow(): Promise<StartFlowResponse> {
const startUrl = `${this.baseUrl}/api/auth/cli/start`;
if (!this.keyPair) {
throw new AuthenticationError(
'Keypair not generated before starting flow',
'INTERNAL_ERROR'
);
}
try {
const response = await fetch(startUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': `TaskMasterCLI/${this.getCliVersion()}`
},
body: JSON.stringify({
name: 'Task Master CLI', name: 'Task Master CLI',
version: this.getCliVersion(), version: this.getCliVersion(),
device: os.hostname(), device: os.hostname(),
user: os.userInfo().username, user: os.userInfo().username,
platform: os.platform(), platform: os.platform(),
timestamp: Date.now() public_key: this.keyPair.publicKey
})
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
message?: string;
}; };
throw new AuthenticationError(
// Build authorization URL for CLI-specific sign-in page errorData.message || `HTTP ${response.status}`,
const authUrl = new URL(`${this.baseUrl}/auth/cli/sign-in`); 'START_FLOW_FAILED'
// Encode CLI data as base64
const cliParam = Buffer.from(JSON.stringify(cliData)).toString(
'base64'
);
// Set the single CLI parameter with all encoded data
authUrl.searchParams.append('cli', cliParam);
// Store auth URL for browser opening
this.authorizationUrl = authUrl.toString();
this.logger.info(
`OAuth session started - ${cliData.name} v${cliData.version} on port ${port}`
);
this.logger.debug('CLI data:', cliData);
// Signal that the server is ready and URL is available
if (this.resolveAuthorizationReady) {
this.resolveAuthorizationReady();
this.resolveAuthorizationReady = null;
}
});
// Set timeout for authentication
timeoutId = setTimeout(() => {
if (server.listening) {
server.close();
// Clean up the readiness promise if still pending
if (this.resolveAuthorizationReady) {
this.resolveAuthorizationReady();
this.resolveAuthorizationReady = null;
}
reject(
new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT')
); );
} }
}, timeout);
});
}
/** return (await response.json()) as StartFlowResponse;
* Handle OAuth callback
*/
private async handleCallback(
url: URL,
res: http.ServerResponse,
server: http.Server,
resolve: (value: AuthCredentials) => void,
reject: (error: any) => void,
timeoutId?: NodeJS.Timeout
): Promise<void> {
// Server now returns tokens directly instead of code
const type = url.searchParams.get('type');
const returnedState = url.searchParams.get('state');
const accessToken = url.searchParams.get('access_token');
const refreshToken = url.searchParams.get('refresh_token');
const expiresIn = url.searchParams.get('expires_in');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Server handles displaying success/failure, just close connection
res.writeHead(200);
res.end();
if (error) {
if (server.listening) {
server.close();
}
reject(
new AuthenticationError(
errorDescription || error || 'Authentication failed',
'OAUTH_ERROR'
)
);
return;
}
// Verify state parameter for CSRF protection
if (returnedState !== this.originalState) {
if (server.listening) {
server.close();
}
reject(
new AuthenticationError('Invalid state parameter', 'INVALID_STATE')
);
return;
}
// Handle authorization code for PKCE flow
const code = url.searchParams.get('code');
this.logger.info(`Code: ${code}, type: ${type}`);
if (code && type === 'pkce_callback') {
try {
this.logger.info('Received authorization code for PKCE flow');
const session = await this.supabaseClient.exchangeCodeForSession(code);
// Save user info to context store
this.contextStore.saveContext({
userId: session.user.id,
email: session.user.email
});
// Check if MFA is required for this user
// This will throw MFA_REQUIRED error if MFA verification is needed
await this.checkAndThrowIfMFARequired();
// Calculate expiration - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
if (tokenExpiryMinutes) {
const minutes = parseInt(tokenExpiryMinutes);
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
} else {
expiresAt = session.expires_at
? new Date(session.expires_at * 1000).toISOString()
: undefined;
}
// Return credentials for backward compatibility
const authData: AuthCredentials = {
token: session.access_token,
refreshToken: session.refresh_token,
userId: session.user.id,
email: session.user.email,
expiresAt,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
if (server.listening) {
server.close();
}
// Clear timeout since authentication succeeded
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(authData);
return;
} catch (error) { } catch (error) {
if (server.listening) { if (error instanceof AuthenticationError) {
server.close(); throw error;
}
reject(error);
return;
}
} }
// Handle direct token response from server (legacy flow) // Network errors indicate backend is unreachable
if ( this.logger.warn('Failed to reach backend for PKCE flow:', error);
accessToken && throw new AuthenticationError(
(type === 'oauth_success' || type === 'session_transfer') 'Unable to reach authentication server',
) { 'BACKEND_UNREACHABLE',
try { error
this.logger.info(
`\n\n==============================================\n Received tokens via ${type}\n==============================================\n`
); );
// Create a session with the tokens and set it in Supabase client
// This automatically saves the session to session.json via SupabaseSessionStorage
const session: Session = {
access_token: accessToken,
refresh_token: refreshToken || '',
expires_in: expiresIn ? parseInt(expiresIn) : 0,
token_type: 'bearer',
user: null as any // Will be populated by setSession
};
// Set the session in Supabase client
await this.supabaseClient.setSession(session);
// Get user info from the session
const user = await this.supabaseClient.getUser();
// Save user info to context store
this.contextStore.saveContext({
userId: user?.id || 'unknown',
email: user?.email
});
// Check if MFA is required for this user
// This will throw MFA_REQUIRED error if MFA verification is needed
await this.checkAndThrowIfMFARequired();
// Calculate expiration time - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
if (tokenExpiryMinutes) {
const minutes = parseInt(tokenExpiryMinutes);
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
} else {
expiresAt = expiresIn
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
: undefined;
}
// Return credentials for backward compatibility
const authData: AuthCredentials = {
token: accessToken,
refreshToken: refreshToken || undefined,
userId: user?.id || 'unknown',
email: user?.email,
expiresAt,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
if (server.listening) {
server.close();
}
// Clear timeout since authentication succeeded
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(authData);
} catch (error) {
if (server.listening) {
server.close();
}
reject(error);
}
} else {
if (server.listening) {
server.close();
}
reject(new AuthenticationError('No access token received', 'NO_TOKEN'));
} }
} }
/** /**
* Generate state for OAuth flow * Poll the backend for flow completion
*/ */
private generateState(): string { private async pollForCompletion(
return crypto.randomBytes(32).toString('base64url'); flowId: string,
pollInterval: number,
timeout: number
): Promise<AuthCredentials> {
const statusUrl = `${this.baseUrl}/api/auth/cli/status?flow_id=${flowId}`;
const startTime = Date.now();
if (!this.keyPair) {
throw new AuthenticationError(
'Keypair not available for decryption',
'INTERNAL_ERROR'
);
}
while (Date.now() - startTime < timeout) {
try {
const response = await fetch(statusUrl, {
method: 'GET',
headers: {
'User-Agent': `TaskMasterCLI/${this.getCliVersion()}`
}
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
message?: string;
};
if (response.status === 404) {
throw new AuthenticationError(
'Authentication flow expired or not found',
'FLOW_NOT_FOUND'
);
}
throw new AuthenticationError(
errorData.message || `HTTP ${response.status}`,
'POLL_FAILED'
);
}
const data = (await response.json()) as FlowStatusResponse;
if (!data.success) {
throw new AuthenticationError(
data.message || 'Failed to check status',
'POLL_FAILED'
);
}
switch (data.status) {
case 'complete': {
// Decrypt tokens using our private key
if (!data.encrypted_tokens) {
throw new AuthenticationError(
'Server returned no encrypted tokens',
'MISSING_TOKENS'
);
}
const tokens = decryptTokens(
data.encrypted_tokens,
this.keyPair.privateKey
);
this.logger.debug('Successfully decrypted authentication tokens');
return {
token: tokens.access_token,
refreshToken: tokens.refresh_token,
userId: tokens.user_id,
email: tokens.email,
expiresAt: tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
: undefined,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
}
case 'failed':
throw new AuthenticationError(
data.error_description || data.error || 'Authentication failed',
'OAUTH_FAILED'
);
case 'expired':
throw new AuthenticationError(
'Authentication flow expired',
'AUTH_TIMEOUT'
);
case 'pending':
case 'authenticating':
// Still waiting, continue polling
this.logger.debug(
`Flow status: ${data.status}, continuing to poll`
);
break;
default:
this.logger.warn(`Unknown flow status: ${data.status}`);
}
} catch (error) {
if (error instanceof AuthenticationError) {
throw error;
}
// Log network errors but continue polling
this.logger.debug('Poll request failed, will retry:', error);
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT');
} }
/** /**

View File

@@ -50,17 +50,6 @@ export interface AuthConfig {
configFile: string; configFile: string;
} }
export interface CliData {
callback: string;
state: string;
name: string;
version: string;
device?: string;
user?: string;
platform?: string;
timestamp?: number;
}
/** /**
* MFA challenge information * MFA challenge information
*/ */
@@ -95,9 +84,7 @@ export type AuthErrorCode =
| 'OAUTH_ERROR' | 'OAUTH_ERROR'
| 'OAUTH_CANCELED' | 'OAUTH_CANCELED'
| 'URL_GENERATION_FAILED' | 'URL_GENERATION_FAILED'
| 'INVALID_STATE'
| 'NO_TOKEN' | 'NO_TOKEN'
| 'TOKEN_EXCHANGE_FAILED'
| 'INVALID_CREDENTIALS' | 'INVALID_CREDENTIALS'
| 'NO_REFRESH_TOKEN' | 'NO_REFRESH_TOKEN'
| 'NOT_AUTHENTICATED' | 'NOT_AUTHENTICATED'
@@ -114,11 +101,19 @@ export type AuthErrorCode =
| 'CODE_EXCHANGE_FAILED' | 'CODE_EXCHANGE_FAILED'
| 'SESSION_SET_FAILED' | 'SESSION_SET_FAILED'
| 'CODE_AUTH_FAILED' | 'CODE_AUTH_FAILED'
| 'INVALID_CODE'
| 'MFA_REQUIRED' | 'MFA_REQUIRED'
| 'MFA_REQUIRED_INCOMPLETE' | 'MFA_REQUIRED_INCOMPLETE'
| 'MFA_VERIFICATION_FAILED' | 'MFA_VERIFICATION_FAILED'
| 'INVALID_MFA_CODE'; | 'INVALID_MFA_CODE'
// PKCE flow errors
| 'BACKEND_UNREACHABLE'
| 'START_FLOW_FAILED'
| 'POLL_FAILED'
| 'FLOW_NOT_FOUND'
// E2E encryption errors
| 'INTERNAL_ERROR'
| 'MISSING_TOKENS'
| 'DECRYPTION_FAILED';
/** /**
* Authentication error class * Authentication error class

View File

@@ -0,0 +1,107 @@
/**
* E2E Encryption Utilities for CLI Authentication
*
* Uses hybrid encryption (RSA + AES-256-GCM):
* - CLI generates RSA keypair
* - Server encrypts tokens with AES, then encrypts AES key with CLI's public key
* - CLI decrypts AES key with private key, then decrypts tokens
*/
import crypto from 'crypto';
import { AuthenticationError } from '../types.js';
/**
* Encrypted token payload from server
*/
export interface EncryptedTokenPayload {
encrypted_key: string; // AES key encrypted with RSA (base64)
encrypted_data: string; // Tokens encrypted with AES-256-GCM (base64)
iv: string; // AES-GCM initialization vector (base64)
auth_tag: string; // AES-GCM authentication tag (base64)
}
/**
* Decrypted token data
*/
export interface DecryptedTokens {
access_token: string;
refresh_token?: string;
expires_in?: number;
user_id: string;
email?: string;
}
/**
* RSA keypair for E2E encryption
*/
export interface AuthKeyPair {
publicKey: string; // PEM format
privateKey: string; // PEM format
}
/**
* Generate RSA keypair for E2E encryption
* Uses 2048-bit keys which is the minimum secure size
*/
export function generateKeyPair(): AuthKeyPair {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
return { publicKey, privateKey };
}
/**
* Decrypt tokens received from server
*
* @param payload - Encrypted payload from server
* @param privateKeyPem - CLI's private key in PEM format
* @returns Decrypted token data
*/
export function decryptTokens(
payload: EncryptedTokenPayload,
privateKeyPem: string
): DecryptedTokens {
try {
// Decode base64 values
const encryptedKey = Buffer.from(payload.encrypted_key, 'base64');
const encryptedData = Buffer.from(payload.encrypted_data, 'base64');
const iv = Buffer.from(payload.iv, 'base64');
const authTag = Buffer.from(payload.auth_tag, 'base64');
// Decrypt AES key using RSA private key
const aesKey = crypto.privateDecrypt(
{
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encryptedKey
);
// Decrypt tokens using AES-256-GCM
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final()
]);
return JSON.parse(decrypted.toString('utf8')) as DecryptedTokens;
} catch (error) {
throw new AuthenticationError(
`Token decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
'DECRYPTION_FAILED',
error
);
}
}

View File

@@ -8,3 +8,11 @@ export {
isRecoverableStaleSessionError, isRecoverableStaleSessionError,
toAuthenticationError toAuthenticationError
} from './auth-error-utils.js'; } from './auth-error-utils.js';
export {
type EncryptedTokenPayload,
type DecryptedTokens,
type AuthKeyPair,
generateKeyPair,
decryptTokens
} from './cli-crypto.js';