@taazkareem/clickup-mcp-server 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * OAuth2 Authentication Service
6
+ *
7
+ * Handles OAuth2 authentication flow with ClickUp API:
8
+ * - Authorization URL generation
9
+ * - Token exchange and refresh
10
+ * - User information retrieval
11
+ * - Token validation and management
12
+ */
13
+ import axios from 'axios';
14
+ import { Logger } from '../../logger.js';
15
+ /**
16
+ * OAuth2 service for ClickUp authentication
17
+ */
18
+ export class OAuth2Service {
19
+ constructor(config) {
20
+ this.config = {
21
+ baseUrl: 'https://api.clickup.com/api/v2',
22
+ scopes: ['read', 'write'],
23
+ ...config
24
+ };
25
+ this.logger = new Logger('OAuth2Service');
26
+ // Create HTTP client for OAuth2 operations
27
+ this.client = axios.create({
28
+ baseURL: this.config.baseUrl,
29
+ timeout: 30000,
30
+ headers: {
31
+ 'Content-Type': 'application/json'
32
+ }
33
+ });
34
+ this.logger.info('OAuth2 service initialized', {
35
+ clientId: this.config.clientId,
36
+ redirectUri: this.config.redirectUri,
37
+ scopes: this.config.scopes
38
+ });
39
+ }
40
+ /**
41
+ * Generate OAuth2 authorization URL
42
+ * @param state Optional state parameter for CSRF protection
43
+ * @returns Authorization URL for user redirection
44
+ */
45
+ generateAuthorizationUrl(state) {
46
+ const params = new URLSearchParams({
47
+ client_id: this.config.clientId,
48
+ redirect_uri: this.config.redirectUri,
49
+ response_type: 'code',
50
+ scope: this.config.scopes?.join(' ') || 'read write'
51
+ });
52
+ if (state) {
53
+ params.append('state', state);
54
+ }
55
+ const authUrl = `https://app.clickup.com/api?${params.toString()}`;
56
+ this.logger.info('Generated authorization URL', {
57
+ clientId: this.config.clientId,
58
+ redirectUri: this.config.redirectUri,
59
+ state: state || 'none'
60
+ });
61
+ return authUrl;
62
+ }
63
+ /**
64
+ * Exchange authorization code for access token
65
+ * @param code Authorization code from ClickUp callback
66
+ * @returns OAuth2 authentication result with tokens and user info
67
+ */
68
+ async exchangeCodeForToken(code) {
69
+ try {
70
+ this.logger.info('Exchanging authorization code for token', { code: code.substring(0, 10) + '...' });
71
+ // Exchange code for token
72
+ const tokenResponse = await this.client.post('/oauth/token', {
73
+ client_id: this.config.clientId,
74
+ client_secret: this.config.clientSecret,
75
+ code: code,
76
+ redirect_uri: this.config.redirectUri,
77
+ grant_type: 'authorization_code'
78
+ });
79
+ const tokens = tokenResponse.data;
80
+ this.logger.info('Successfully exchanged code for token', {
81
+ tokenType: tokens.token_type,
82
+ expiresIn: tokens.expires_in,
83
+ hasRefreshToken: !!tokens.refresh_token
84
+ });
85
+ // Get user information using the access token
86
+ const userInfo = await this.getUserInfo(tokens.access_token);
87
+ if (!userInfo.success || !userInfo.user) {
88
+ return {
89
+ success: false,
90
+ error: {
91
+ message: 'Failed to retrieve user information',
92
+ details: userInfo.error
93
+ }
94
+ };
95
+ }
96
+ return {
97
+ success: true,
98
+ tokens,
99
+ user: userInfo.user
100
+ };
101
+ }
102
+ catch (error) {
103
+ this.logger.error('Failed to exchange code for token', {
104
+ error: error.message,
105
+ status: error.response?.status,
106
+ data: error.response?.data
107
+ });
108
+ return {
109
+ success: false,
110
+ error: {
111
+ message: 'OAuth2 token exchange failed',
112
+ code: 'TOKEN_EXCHANGE_FAILED',
113
+ details: error.response?.data || error.message
114
+ }
115
+ };
116
+ }
117
+ }
118
+ /**
119
+ * Refresh access token using refresh token
120
+ * @param refreshToken Refresh token from previous authentication
121
+ * @returns New token response or error
122
+ */
123
+ async refreshToken(refreshToken) {
124
+ try {
125
+ this.logger.info('Refreshing access token');
126
+ const response = await this.client.post('/oauth/token', {
127
+ client_id: this.config.clientId,
128
+ client_secret: this.config.clientSecret,
129
+ refresh_token: refreshToken,
130
+ grant_type: 'refresh_token'
131
+ });
132
+ const tokens = response.data;
133
+ this.logger.info('Successfully refreshed token', {
134
+ tokenType: tokens.token_type,
135
+ expiresIn: tokens.expires_in
136
+ });
137
+ return {
138
+ success: true,
139
+ tokens
140
+ };
141
+ }
142
+ catch (error) {
143
+ this.logger.error('Failed to refresh token', {
144
+ error: error.message,
145
+ status: error.response?.status
146
+ });
147
+ return {
148
+ success: false,
149
+ error: {
150
+ message: 'Token refresh failed',
151
+ code: 'TOKEN_REFRESH_FAILED',
152
+ details: error.response?.data || error.message
153
+ }
154
+ };
155
+ }
156
+ }
157
+ /**
158
+ * Get user information using access token
159
+ * @param accessToken Valid access token
160
+ * @returns User information or error
161
+ */
162
+ async getUserInfo(accessToken) {
163
+ try {
164
+ this.logger.info('Retrieving user information');
165
+ const response = await this.client.get('/user', {
166
+ headers: {
167
+ 'Authorization': `Bearer ${accessToken}`
168
+ }
169
+ });
170
+ const userData = response.data;
171
+ const user = userData.user || userData;
172
+ this.logger.info('Successfully retrieved user information', {
173
+ userId: user.id,
174
+ username: user.username,
175
+ email: user.email
176
+ });
177
+ return {
178
+ success: true,
179
+ user
180
+ };
181
+ }
182
+ catch (error) {
183
+ this.logger.error('Failed to retrieve user information', {
184
+ error: error.message,
185
+ status: error.response?.status
186
+ });
187
+ return {
188
+ success: false,
189
+ error: {
190
+ message: 'Failed to retrieve user information',
191
+ code: 'USER_INFO_FAILED',
192
+ details: error.response?.data || error.message
193
+ }
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Validate access token by making a test API call
199
+ * @param accessToken Token to validate
200
+ * @returns Whether the token is valid
201
+ */
202
+ async validateToken(accessToken) {
203
+ try {
204
+ const result = await this.getUserInfo(accessToken);
205
+ return result.success;
206
+ }
207
+ catch (error) {
208
+ this.logger.warn('Token validation failed', { error });
209
+ return false;
210
+ }
211
+ }
212
+ /**
213
+ * Revoke access token
214
+ * @param accessToken Token to revoke
215
+ * @returns Success status
216
+ */
217
+ async revokeToken(accessToken) {
218
+ try {
219
+ this.logger.info('Revoking access token');
220
+ await this.client.post('/oauth/revoke', {
221
+ token: accessToken,
222
+ client_id: this.config.clientId,
223
+ client_secret: this.config.clientSecret
224
+ });
225
+ this.logger.info('Successfully revoked token');
226
+ return true;
227
+ }
228
+ catch (error) {
229
+ this.logger.error('Failed to revoke token', {
230
+ error: error.message,
231
+ status: error.response?.status
232
+ });
233
+ return false;
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * Simple Session Management Service
6
+ *
7
+ * Handles user session management for OAuth2 authentication:
8
+ * - In-memory session storage with TTL
9
+ * - Token management and refresh
10
+ * - Session validation and cleanup
11
+ * - Automatic expiration handling
12
+ */
13
+ import { Logger } from '../../logger.js';
14
+ /**
15
+ * Simple in-memory session management service
16
+ */
17
+ export class SessionService {
18
+ constructor() {
19
+ this.sessions = new Map();
20
+ this.logger = new Logger('SessionService');
21
+ this.logger.info('Simple session service initialized (in-memory)');
22
+ // Start cleanup interval (every 10 minutes)
23
+ this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), 10 * 60 * 1000);
24
+ }
25
+ /**
26
+ * Cleanup resources
27
+ */
28
+ destroy() {
29
+ if (this.cleanupInterval) {
30
+ clearInterval(this.cleanupInterval);
31
+ }
32
+ this.sessions.clear();
33
+ this.logger.info('Session service destroyed');
34
+ }
35
+ /**
36
+ * Create a new user session
37
+ * @param sessionData Session creation data
38
+ * @returns Created session or error
39
+ */
40
+ async createSession(sessionData) {
41
+ try {
42
+ this.logger.info('Creating new session', {
43
+ clickupUserId: sessionData.clickupUserId,
44
+ tokenType: sessionData.tokens.token_type
45
+ });
46
+ // Generate unique session ID
47
+ const sessionId = this.generateSessionId();
48
+ // Calculate expiration time
49
+ const expiresAt = new Date(Date.now() + (sessionData.tokens.expires_in * 1000));
50
+ const now = new Date();
51
+ // Create session object
52
+ const session = {
53
+ id: sessionId,
54
+ clickupUserId: sessionData.clickupUserId,
55
+ accessToken: sessionData.tokens.access_token,
56
+ refreshToken: sessionData.tokens.refresh_token,
57
+ tokenType: sessionData.tokens.token_type,
58
+ expiresAt,
59
+ createdAt: now,
60
+ lastAccessedAt: now
61
+ };
62
+ // Store in memory
63
+ this.sessions.set(sessionId, session);
64
+ this.logger.info('Session created successfully', {
65
+ sessionId: session.id,
66
+ clickupUserId: sessionData.clickupUserId,
67
+ expiresAt: session.expiresAt.toISOString()
68
+ });
69
+ return {
70
+ success: true,
71
+ data: session
72
+ };
73
+ }
74
+ catch (error) {
75
+ this.logger.error('Failed to create session', {
76
+ error: error.message,
77
+ sessionData
78
+ });
79
+ return {
80
+ success: false,
81
+ error: {
82
+ message: 'Failed to create session',
83
+ code: 'SESSION_CREATION_FAILED',
84
+ details: error.message
85
+ }
86
+ };
87
+ }
88
+ }
89
+ /**
90
+ * Get session by ID
91
+ * @param sessionId Session ID
92
+ * @returns Session or null if not found/expired
93
+ */
94
+ async getSession(sessionId) {
95
+ try {
96
+ this.logger.debug('Getting session', { sessionId });
97
+ const session = this.sessions.get(sessionId);
98
+ if (!session) {
99
+ return {
100
+ success: true,
101
+ data: null
102
+ };
103
+ }
104
+ // Check if session is expired
105
+ if (session.expiresAt <= new Date()) {
106
+ this.logger.debug('Session expired, removing', { sessionId });
107
+ this.sessions.delete(sessionId);
108
+ return {
109
+ success: true,
110
+ data: null
111
+ };
112
+ }
113
+ // Update last accessed time
114
+ session.lastAccessedAt = new Date();
115
+ return {
116
+ success: true,
117
+ data: session
118
+ };
119
+ }
120
+ catch (error) {
121
+ this.logger.error('Failed to get session', {
122
+ error: error.message,
123
+ sessionId
124
+ });
125
+ return {
126
+ success: false,
127
+ error: {
128
+ message: 'Failed to get session',
129
+ code: 'SESSION_RETRIEVAL_FAILED',
130
+ details: error.message
131
+ }
132
+ };
133
+ }
134
+ }
135
+ /**
136
+ * Update session tokens (after refresh)
137
+ * @param sessionId Session ID
138
+ * @param tokens New token data
139
+ * @returns Updated session or error
140
+ */
141
+ async updateSessionTokens(sessionId, tokens) {
142
+ try {
143
+ this.logger.info('Updating session tokens', { sessionId });
144
+ const session = this.sessions.get(sessionId);
145
+ if (!session) {
146
+ return {
147
+ success: false,
148
+ error: {
149
+ message: 'Session not found',
150
+ code: 'SESSION_NOT_FOUND'
151
+ }
152
+ };
153
+ }
154
+ // Update session with new tokens
155
+ session.accessToken = tokens.access_token;
156
+ if (tokens.refresh_token) {
157
+ session.refreshToken = tokens.refresh_token;
158
+ }
159
+ session.tokenType = tokens.token_type;
160
+ session.expiresAt = new Date(Date.now() + (tokens.expires_in * 1000));
161
+ session.lastAccessedAt = new Date();
162
+ this.logger.info('Session tokens updated successfully', {
163
+ sessionId,
164
+ newExpiresAt: session.expiresAt.toISOString()
165
+ });
166
+ return {
167
+ success: true,
168
+ data: session
169
+ };
170
+ }
171
+ catch (error) {
172
+ this.logger.error('Failed to update session tokens', {
173
+ error: error.message,
174
+ sessionId
175
+ });
176
+ return {
177
+ success: false,
178
+ error: {
179
+ message: 'Failed to update session tokens',
180
+ code: 'SESSION_UPDATE_FAILED',
181
+ details: error.message
182
+ }
183
+ };
184
+ }
185
+ }
186
+ /**
187
+ * Delete session
188
+ * @param sessionId Session ID
189
+ * @returns Success status
190
+ */
191
+ async deleteSession(sessionId) {
192
+ try {
193
+ this.logger.info('Deleting session', { sessionId });
194
+ const deleted = this.sessions.delete(sessionId);
195
+ this.logger.info('Session deleted successfully', { sessionId, deleted });
196
+ return {
197
+ success: true,
198
+ data: deleted
199
+ };
200
+ }
201
+ catch (error) {
202
+ this.logger.error('Failed to delete session', {
203
+ error: error.message,
204
+ sessionId
205
+ });
206
+ return {
207
+ success: false,
208
+ error: {
209
+ message: 'Failed to delete session',
210
+ code: 'SESSION_DELETION_FAILED',
211
+ details: error.message
212
+ }
213
+ };
214
+ }
215
+ }
216
+ /**
217
+ * Delete all sessions for a user
218
+ * @param clickupUserId ClickUp user ID
219
+ * @returns Success status
220
+ */
221
+ async deleteUserSessions(clickupUserId) {
222
+ try {
223
+ this.logger.info('Deleting all sessions for user', { clickupUserId });
224
+ let deletedCount = 0;
225
+ for (const [sessionId, session] of this.sessions.entries()) {
226
+ if (session.clickupUserId === clickupUserId) {
227
+ this.sessions.delete(sessionId);
228
+ deletedCount++;
229
+ }
230
+ }
231
+ this.logger.info('All user sessions deleted successfully', {
232
+ clickupUserId,
233
+ deletedCount
234
+ });
235
+ return {
236
+ success: true,
237
+ data: true
238
+ };
239
+ }
240
+ catch (error) {
241
+ this.logger.error('Failed to delete user sessions', {
242
+ error: error.message,
243
+ clickupUserId
244
+ });
245
+ return {
246
+ success: false,
247
+ error: {
248
+ message: 'Failed to delete user sessions',
249
+ code: 'USER_SESSIONS_DELETION_FAILED',
250
+ details: error.message
251
+ }
252
+ };
253
+ }
254
+ }
255
+ /**
256
+ * Get all active sessions for a user
257
+ * @param clickupUserId ClickUp user ID
258
+ * @returns List of active sessions
259
+ */
260
+ async getUserSessions(clickupUserId) {
261
+ try {
262
+ this.logger.debug('Getting user sessions', { clickupUserId });
263
+ const userSessions = [];
264
+ const now = new Date();
265
+ for (const [sessionId, session] of this.sessions.entries()) {
266
+ if (session.clickupUserId === clickupUserId) {
267
+ // Check if session is expired
268
+ if (session.expiresAt <= now) {
269
+ this.sessions.delete(sessionId);
270
+ }
271
+ else {
272
+ userSessions.push(session);
273
+ }
274
+ }
275
+ }
276
+ return {
277
+ success: true,
278
+ data: userSessions
279
+ };
280
+ }
281
+ catch (error) {
282
+ this.logger.error('Failed to get user sessions', {
283
+ error: error.message,
284
+ clickupUserId
285
+ });
286
+ return {
287
+ success: false,
288
+ error: {
289
+ message: 'Failed to get user sessions',
290
+ code: 'USER_SESSIONS_RETRIEVAL_FAILED',
291
+ details: error.message
292
+ }
293
+ };
294
+ }
295
+ }
296
+ /**
297
+ * Clean up expired sessions
298
+ * @private
299
+ */
300
+ async cleanupExpiredSessions() {
301
+ try {
302
+ this.logger.debug('Starting expired session cleanup');
303
+ const now = new Date();
304
+ let cleanedCount = 0;
305
+ for (const [sessionId, session] of this.sessions.entries()) {
306
+ if (session.expiresAt <= now) {
307
+ this.sessions.delete(sessionId);
308
+ cleanedCount++;
309
+ }
310
+ }
311
+ if (cleanedCount > 0) {
312
+ this.logger.info('Cleaned up expired sessions', {
313
+ cleanedCount,
314
+ remainingSessions: this.sessions.size
315
+ });
316
+ }
317
+ }
318
+ catch (error) {
319
+ this.logger.error('Failed to cleanup expired sessions', { error });
320
+ }
321
+ }
322
+ /**
323
+ * Generate unique session ID
324
+ * @private
325
+ */
326
+ generateSessionId() {
327
+ return 'sess_' + Math.random().toString(36).substring(2, 15) +
328
+ Math.random().toString(36).substring(2, 15) +
329
+ Date.now().toString(36);
330
+ }
331
+ /**
332
+ * Get session count (for monitoring)
333
+ */
334
+ getSessionCount() {
335
+ return this.sessions.size;
336
+ }
337
+ }