promptarchitect 0.6.0

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,556 @@
1
+ /**
2
+ * PromptArchitect Authentication Service
3
+ *
4
+ * Manages user authentication for the VS Code extension.
5
+ * Uses VS Code's built-in authentication API with a custom provider
6
+ * that connects to our backend.
7
+ */
8
+
9
+ import * as vscode from 'vscode';
10
+
11
+ const AUTH_PROVIDER_ID = 'promptarchitect';
12
+ const AUTH_PROVIDER_LABEL = 'PromptArchitect';
13
+ const SCOPES = ['user:read', 'prompts:write'];
14
+
15
+ export interface UserSession {
16
+ id: string;
17
+ accessToken: string;
18
+ account: {
19
+ id: string;
20
+ email: string;
21
+ displayName: string;
22
+ };
23
+ expiresAt?: number;
24
+ }
25
+
26
+ export interface AuthState {
27
+ isAuthenticated: boolean;
28
+ session: UserSession | null;
29
+ error: string | null;
30
+ }
31
+
32
+ // API Response types
33
+ interface CheckEmailResponse {
34
+ exists: boolean;
35
+ }
36
+
37
+ interface AuthResponse {
38
+ sessionId: string;
39
+ accessToken: string;
40
+ expiresIn?: number;
41
+ user: {
42
+ id: string;
43
+ email: string;
44
+ displayName?: string;
45
+ };
46
+ }
47
+
48
+ interface AuthErrorResponse {
49
+ message?: string;
50
+ error?: string;
51
+ }
52
+
53
+ export class AuthService implements vscode.Disposable {
54
+ private static instance: AuthService;
55
+ private _session: vscode.AuthenticationSession | null = null;
56
+ private _onDidChangeAuth = new vscode.EventEmitter<boolean>();
57
+ public readonly onDidChangeAuth = this._onDidChangeAuth.event;
58
+
59
+ private context: vscode.ExtensionContext;
60
+ private apiEndpoint: string;
61
+ private disposables: vscode.Disposable[] = [];
62
+
63
+ private constructor(context: vscode.ExtensionContext, apiEndpoint: string) {
64
+ this.context = context;
65
+ this.apiEndpoint = apiEndpoint;
66
+
67
+ // Listen for session changes
68
+ this.disposables.push(
69
+ vscode.authentication.onDidChangeSessions((e) => {
70
+ if (e.provider.id === AUTH_PROVIDER_ID) {
71
+ this.checkAuthStatus();
72
+ }
73
+ })
74
+ );
75
+ }
76
+
77
+ static getInstance(context: vscode.ExtensionContext, apiEndpoint: string): AuthService {
78
+ if (!AuthService.instance) {
79
+ AuthService.instance = new AuthService(context, apiEndpoint);
80
+ }
81
+ return AuthService.instance;
82
+ }
83
+
84
+ /**
85
+ * Check if user is currently authenticated
86
+ */
87
+ async isAuthenticated(): Promise<boolean> {
88
+ // First check stored session
89
+ const storedSession = this.context.globalState.get<UserSession>('authSession');
90
+ if (storedSession && storedSession.expiresAt && storedSession.expiresAt > Date.now()) {
91
+ return true;
92
+ }
93
+
94
+ // Check with API
95
+ try {
96
+ const session = await this.getSession(false);
97
+ return session !== null;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Get current session or prompt for login
105
+ */
106
+ async getSession(createIfNone: boolean = true): Promise<UserSession | null> {
107
+ // Check stored session first
108
+ const storedSession = this.context.globalState.get<UserSession>('authSession');
109
+ if (storedSession && storedSession.expiresAt && storedSession.expiresAt > Date.now()) {
110
+ return storedSession;
111
+ }
112
+
113
+ if (!createIfNone) {
114
+ return null;
115
+ }
116
+
117
+ // Prompt for login
118
+ return this.signIn();
119
+ }
120
+
121
+ /**
122
+ * Get access token for API requests
123
+ */
124
+ async getAccessToken(): Promise<string | null> {
125
+ const session = await this.getSession(false);
126
+ return session?.accessToken || null;
127
+ }
128
+
129
+ /**
130
+ * Sign in user - opens browser for OAuth flow
131
+ */
132
+ async signIn(): Promise<UserSession | null> {
133
+ const choice = await vscode.window.showInformationMessage(
134
+ '🔐 Welcome to PromptArchitect! Please sign in or create an account.',
135
+ { modal: true },
136
+ 'Sign In',
137
+ 'Create Account'
138
+ );
139
+
140
+ if (!choice) {
141
+ return null;
142
+ }
143
+
144
+ if (choice === 'Create Account') {
145
+ vscode.env.openExternal(vscode.Uri.parse('https://promptarchitectlabs.com/'));
146
+ return null;
147
+ }
148
+
149
+ // Sign In Flow
150
+ const providerChoice = await vscode.window.showQuickPick(
151
+ [
152
+ { label: '$(globe) Sign in with Google', value: 'google' },
153
+ { label: '$(github) Sign in with GitHub', value: 'github' },
154
+ { label: '$(mail) Sign in with Email', value: 'email' },
155
+ ],
156
+ {
157
+ placeHolder: 'Select a sign-in method',
158
+ title: 'Sign In to PromptArchitect',
159
+ }
160
+ );
161
+
162
+ if (!providerChoice) {
163
+ return null;
164
+ }
165
+
166
+ try {
167
+ const provider = providerChoice.value as 'google' | 'github' | 'email';
168
+
169
+ // For email, collect credentials
170
+ if (provider === 'email') {
171
+ return this.signInWithEmail();
172
+ }
173
+
174
+ // For OAuth providers, open browser
175
+ return this.signInWithOAuth(provider);
176
+ } catch (error) {
177
+ vscode.window.showErrorMessage(`Sign in failed: ${error}`);
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Sign in with email/password
184
+ */
185
+ private async signInWithEmail(): Promise<UserSession | null> {
186
+ const email = await vscode.window.showInputBox({
187
+ prompt: 'Enter your email address',
188
+ placeHolder: 'you@example.com',
189
+ ignoreFocusOut: true,
190
+ validateInput: (value) => {
191
+ if (!value || !value.includes('@')) {
192
+ return 'Please enter a valid email address';
193
+ }
194
+ return null;
195
+ },
196
+ });
197
+
198
+ if (!email) return null;
199
+
200
+ const password = await vscode.window.showInputBox({
201
+ prompt: 'Enter your password',
202
+ password: true,
203
+ ignoreFocusOut: true,
204
+ validateInput: (value) => {
205
+ if (!value || value.length < 6) {
206
+ return 'Password must be at least 6 characters';
207
+ }
208
+ return null;
209
+ },
210
+ });
211
+
212
+ if (!password) return null;
213
+
214
+ // Check if this is a new user
215
+ const isNewUser = await this.checkIfNewUser(email);
216
+
217
+ if (isNewUser) {
218
+ const confirm = await vscode.window.showInformationMessage(
219
+ `No account found for ${email}. Create a new account?`,
220
+ { modal: true },
221
+ 'Create Account',
222
+ 'Cancel'
223
+ );
224
+
225
+ if (confirm !== 'Create Account') {
226
+ return null;
227
+ }
228
+
229
+ return this.createAccount(email, password);
230
+ }
231
+
232
+ return this.authenticateWithEmail(email, password);
233
+ }
234
+
235
+ /**
236
+ * Check if email is registered
237
+ */
238
+ private async checkIfNewUser(email: string): Promise<boolean> {
239
+ try {
240
+ const response = await fetch(`${this.apiEndpoint}/auth/check-email`, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ email }),
244
+ });
245
+ const data = await response.json() as CheckEmailResponse;
246
+ return !data.exists;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Authenticate with email/password
254
+ */
255
+ private async authenticateWithEmail(email: string, password: string): Promise<UserSession | null> {
256
+ try {
257
+ const response = await fetch(`${this.apiEndpoint}/auth/login`, {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ email, password, client: 'vscode' }),
261
+ });
262
+
263
+ if (!response.ok) {
264
+ const error = await response.json() as AuthErrorResponse;
265
+ throw new Error(error.message || 'Authentication failed');
266
+ }
267
+
268
+ const data = await response.json() as AuthResponse;
269
+ const session: UserSession = {
270
+ id: data.sessionId,
271
+ accessToken: data.accessToken,
272
+ account: {
273
+ id: data.user.id,
274
+ email: data.user.email,
275
+ displayName: data.user.displayName || email.split('@')[0],
276
+ },
277
+ expiresAt: Date.now() + (data.expiresIn || 86400) * 1000,
278
+ };
279
+
280
+ // Store session
281
+ await this.context.globalState.update('authSession', session);
282
+ this._onDidChangeAuth.fire(true);
283
+
284
+ vscode.window.showInformationMessage(`✅ Welcome back, ${session.account.displayName}!`);
285
+ return session;
286
+ } catch (error) {
287
+ vscode.window.showErrorMessage(`Login failed: ${error}`);
288
+ return null;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Create a new account
294
+ */
295
+ private async createAccount(email: string, password: string): Promise<UserSession | null> {
296
+ // Get display name
297
+ const displayName = await vscode.window.showInputBox({
298
+ prompt: 'Choose a display name',
299
+ placeHolder: 'Your Name',
300
+ ignoreFocusOut: true,
301
+ value: email.split('@')[0],
302
+ });
303
+
304
+ if (!displayName) return null;
305
+
306
+ try {
307
+ const response = await fetch(`${this.apiEndpoint}/auth/register`, {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json' },
310
+ body: JSON.stringify({ email, password, displayName, client: 'vscode' }),
311
+ });
312
+
313
+ if (!response.ok) {
314
+ const error = await response.json() as AuthErrorResponse;
315
+ throw new Error(error.message || 'Registration failed');
316
+ }
317
+
318
+ const data = await response.json() as AuthResponse;
319
+ const session: UserSession = {
320
+ id: data.sessionId,
321
+ accessToken: data.accessToken,
322
+ account: {
323
+ id: data.user.id,
324
+ email: data.user.email,
325
+ displayName: data.user.displayName || displayName,
326
+ },
327
+ expiresAt: Date.now() + (data.expiresIn || 86400) * 1000,
328
+ };
329
+
330
+ // Store session
331
+ await this.context.globalState.update('authSession', session);
332
+ this._onDidChangeAuth.fire(true);
333
+
334
+ vscode.window.showInformationMessage(`🎉 Account created! Welcome, ${session.account.displayName}!`);
335
+ return session;
336
+ } catch (error) {
337
+ vscode.window.showErrorMessage(`Registration failed: ${error}`);
338
+ return null;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Sign in with OAuth (Google/GitHub)
344
+ */
345
+ private async signInWithOAuth(provider: 'google' | 'github'): Promise<UserSession | null> {
346
+ // Generate state for CSRF protection
347
+ const state = this.generateRandomString(32);
348
+ await this.context.globalState.update('authState', state);
349
+
350
+ // Build OAuth URL
351
+ // Use the official callback URL scheme
352
+ const callbackUri = await vscode.env.asExternalUri(
353
+ vscode.Uri.parse(`${vscode.env.uriScheme}://merabylabs.promptarchitect/callback`)
354
+ );
355
+
356
+ // Use frontend URL for auth
357
+ const authUrl = new URL(`https://promptarchitectlabs.com/vscode-auth`);
358
+ authUrl.searchParams.set('state', state);
359
+ authUrl.searchParams.set('redirect_uri', callbackUri.toString(true)); // Use true to skip encoding
360
+ authUrl.searchParams.set('client', 'vscode');
361
+ authUrl.searchParams.set('provider', provider);
362
+
363
+ // Open browser for OAuth
364
+ await vscode.env.openExternal(vscode.Uri.parse(authUrl.toString()));
365
+
366
+ // Wait for callback (with timeout)
367
+ return new Promise((resolve) => {
368
+ const timeout = setTimeout(() => {
369
+ disposable.dispose();
370
+ resolve(null);
371
+ }, 120000); // 2 minute timeout
372
+
373
+ const disposable = vscode.window.registerUriHandler({
374
+ handleUri: async (uri) => {
375
+ clearTimeout(timeout);
376
+ disposable.dispose();
377
+
378
+ if (uri.path === '/callback') {
379
+ const query = new URLSearchParams(uri.query);
380
+ const returnedState = query.get('state');
381
+ const code = query.get('code');
382
+ const token = query.get('token');
383
+ const uid = query.get('uid');
384
+ const email = query.get('email');
385
+ const displayName = query.get('displayName');
386
+ const error = query.get('error');
387
+
388
+ if (error) {
389
+ vscode.window.showErrorMessage(`Authentication failed: ${error}`);
390
+ resolve(null);
391
+ return;
392
+ }
393
+
394
+ // Verify state
395
+ const savedState = this.context.globalState.get<string>('authState');
396
+ if (returnedState !== savedState) {
397
+ vscode.window.showErrorMessage('Authentication failed: Invalid state');
398
+ resolve(null);
399
+ return;
400
+ }
401
+
402
+ if (token) {
403
+ // Implicit flow from frontend
404
+ const session: UserSession = {
405
+ id: uid || 'user',
406
+ accessToken: token,
407
+ account: {
408
+ id: uid || 'user',
409
+ email: email || 'user@example.com',
410
+ displayName: displayName || 'User',
411
+ },
412
+ expiresAt: Date.now() + 3600 * 1000,
413
+ };
414
+
415
+ await this.context.globalState.update('authSession', session);
416
+ this._onDidChangeAuth.fire(true);
417
+ vscode.window.showInformationMessage(`✅ Welcome, ${session.account.displayName}!`);
418
+ resolve(session);
419
+ } else if (code) {
420
+ const session = await this.exchangeCodeForSession(code, provider);
421
+ resolve(session);
422
+ } else {
423
+ resolve(null);
424
+ }
425
+ }
426
+ },
427
+ });
428
+ });
429
+ }
430
+
431
+ /**
432
+ * Exchange OAuth code for session
433
+ */
434
+ private async exchangeCodeForSession(code: string, provider: string): Promise<UserSession | null> {
435
+ try {
436
+ const response = await fetch(`${this.apiEndpoint}/auth/callback/${provider}`, {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/json' },
439
+ body: JSON.stringify({ code, client: 'vscode' }),
440
+ });
441
+
442
+ if (!response.ok) {
443
+ throw new Error('Failed to exchange code');
444
+ }
445
+
446
+ const data = await response.json() as AuthResponse;
447
+ const session: UserSession = {
448
+ id: data.sessionId,
449
+ accessToken: data.accessToken,
450
+ account: {
451
+ id: data.user.id,
452
+ email: data.user.email,
453
+ displayName: data.user.displayName || 'User',
454
+ },
455
+ expiresAt: Date.now() + (data.expiresIn || 86400) * 1000,
456
+ };
457
+
458
+ await this.context.globalState.update('authSession', session);
459
+ this._onDidChangeAuth.fire(true);
460
+
461
+ vscode.window.showInformationMessage(`✅ Welcome, ${session.account.displayName}!`);
462
+ return session;
463
+ } catch (error) {
464
+ vscode.window.showErrorMessage(`Authentication failed: ${error}`);
465
+ return null;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Sign out the current user
471
+ */
472
+ async signOut(): Promise<void> {
473
+ const session = await this.getSession(false);
474
+
475
+ if (session) {
476
+ try {
477
+ // Revoke session on server
478
+ await fetch(`${this.apiEndpoint}/auth/logout`, {
479
+ method: 'POST',
480
+ headers: {
481
+ 'Content-Type': 'application/json',
482
+ 'Authorization': `Bearer ${session.accessToken}`,
483
+ },
484
+ });
485
+ } catch {
486
+ // Ignore errors during logout
487
+ }
488
+ }
489
+
490
+ // Clear stored session
491
+ await this.context.globalState.update('authSession', undefined);
492
+ await this.context.globalState.update('authState', undefined);
493
+ this._session = null;
494
+ this._onDidChangeAuth.fire(false);
495
+
496
+ vscode.window.showInformationMessage('👋 Signed out successfully');
497
+ }
498
+
499
+ /**
500
+ * Get current user info
501
+ */
502
+ async getCurrentUser(): Promise<UserSession['account'] | null> {
503
+ const session = await this.getSession(false);
504
+ return session?.account || null;
505
+ }
506
+
507
+ /**
508
+ * Check auth status and update internal state
509
+ */
510
+ private async checkAuthStatus(): Promise<void> {
511
+ const isAuth = await this.isAuthenticated();
512
+ this._onDidChangeAuth.fire(isAuth);
513
+ }
514
+
515
+ /**
516
+ * Generate random string for CSRF state
517
+ */
518
+ private generateRandomString(length: number): string {
519
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
520
+ let result = '';
521
+ for (let i = 0; i < length; i++) {
522
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
523
+ }
524
+ return result;
525
+ }
526
+
527
+ /**
528
+ * Show auth required message and prompt to sign in
529
+ */
530
+ async requireAuth(): Promise<boolean> {
531
+ const isAuth = await this.isAuthenticated();
532
+
533
+ if (isAuth) {
534
+ return true;
535
+ }
536
+
537
+ const result = await vscode.window.showWarningMessage(
538
+ '🔐 Authentication Required\n\nSign in to continue using PromptArchitect. Your account enables prompt history, preferences, and access to all features.',
539
+ { modal: true },
540
+ 'Sign In',
541
+ 'Create Account'
542
+ );
543
+
544
+ if (result === 'Sign In' || result === 'Create Account') {
545
+ const session = await this.signIn();
546
+ return session !== null;
547
+ }
548
+
549
+ return false;
550
+ }
551
+
552
+ dispose(): void {
553
+ this._onDidChangeAuth.dispose();
554
+ this.disposables.forEach((d) => d.dispose());
555
+ }
556
+ }