mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
fix: resolve login issues for users with CLI authentication blocked by browsers or firewalls (#1492)
This commit is contained in:
5
.changeset/six-eels-send.md
Normal file
5
.changeset/six-eels-send.md
Normal 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
4
context7.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"url": "https://context7.com/eyaltoledano/claude-task-master",
|
||||||
|
"public_key": "pk_52Na55p8REi9c5jSFszav"
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ export type {
|
|||||||
AuthCredentials,
|
AuthCredentials,
|
||||||
OAuthFlowOptions,
|
OAuthFlowOptions,
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
CliData,
|
|
||||||
UserContext,
|
UserContext,
|
||||||
MFAVerificationResult
|
MFAVerificationResult
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
107
packages/tm-core/src/modules/auth/utils/cli-crypto.ts
Normal file
107
packages/tm-core/src/modules/auth/utils/cli-crypto.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user