@freetison/git-super 0.2.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,266 @@
1
+ /**
2
+ * OAuth 2.0 Flow Implementations
3
+ * Supports Device Code Flow and Authorization Code with PKCE
4
+ */
5
+
6
+ import { createHash, randomBytes } from 'node:crypto';
7
+
8
+ /**
9
+ * Device Code Flow (best for CLI applications)
10
+ * https://oauth.net/2/device-flow/
11
+ */
12
+ export class DeviceCodeFlow {
13
+ constructor(options = {}) {
14
+ this.clientId = options.clientId;
15
+ this.deviceAuthEndpoint = options.deviceAuthEndpoint;
16
+ this.tokenEndpoint = options.tokenEndpoint;
17
+ this.scopes = options.scopes || [];
18
+ this.pollInterval = options.pollInterval || 5000; // 5 seconds default
19
+ this.onUserCode = options.onUserCode; // Callback(url, userCode)
20
+ }
21
+
22
+ /**
23
+ * Initiate device authorization
24
+ * @returns {Promise<Object>} Device code response
25
+ */
26
+ async initiate() {
27
+ const response = await fetch(this.deviceAuthEndpoint, {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/x-www-form-urlencoded',
31
+ },
32
+ body: new URLSearchParams({
33
+ client_id: this.clientId,
34
+ scope: this.scopes.join(' '),
35
+ }),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`Device authorization failed: ${response.status} ${response.statusText}`);
40
+ }
41
+
42
+ const data = await response.json();
43
+
44
+ /*
45
+ * Response format:
46
+ * {
47
+ * device_code: "...",
48
+ * user_code: "ABCD-EFGH",
49
+ * verification_uri: "https://github.com/login/device",
50
+ * verification_uri_complete: "https://github.com/login/device?user_code=ABCD-EFGH",
51
+ * expires_in: 900,
52
+ * interval: 5
53
+ * }
54
+ */
55
+
56
+ return {
57
+ deviceCode: data.device_code,
58
+ userCode: data.user_code,
59
+ verificationUri: data.verification_uri,
60
+ verificationUriComplete: data.verification_uri_complete || data.verification_uri,
61
+ expiresIn: data.expires_in,
62
+ interval: data.interval || 5,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Poll for token after user authorization
68
+ * @param {string} deviceCode - Device code from initiate()
69
+ * @param {number} interval - Poll interval in seconds
70
+ * @returns {Promise<Object>} Token response
71
+ */
72
+ async pollForToken(deviceCode, interval = 5) {
73
+ const pollInterval = interval * 1000;
74
+ const maxAttempts = 180; // 15 minutes max (180 * 5s)
75
+
76
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
77
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
78
+
79
+ try {
80
+ const response = await fetch(this.tokenEndpoint, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/x-www-form-urlencoded',
84
+ },
85
+ body: new URLSearchParams({
86
+ client_id: this.clientId,
87
+ device_code: deviceCode,
88
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
89
+ }),
90
+ });
91
+
92
+ const data = await response.json();
93
+
94
+ // Check for errors
95
+ if (data.error) {
96
+ if (data.error === 'authorization_pending') {
97
+ // User hasn't authorized yet, continue polling
98
+ continue;
99
+ } else if (data.error === 'slow_down') {
100
+ // Server asking us to slow down, increase interval
101
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
102
+ continue;
103
+ } else if (data.error === 'expired_token') {
104
+ throw new Error('Device code expired. Please try again.');
105
+ } else if (data.error === 'access_denied') {
106
+ throw new Error('User denied authorization.');
107
+ } else {
108
+ throw new Error(`Authorization error: ${data.error} - ${data.error_description || ''}`);
109
+ }
110
+ }
111
+
112
+ // Success! Return tokens
113
+ return {
114
+ accessToken: data.access_token,
115
+ refreshToken: data.refresh_token,
116
+ expiresIn: data.expires_in,
117
+ tokenType: data.token_type,
118
+ scope: data.scope,
119
+ };
120
+ } catch (error) {
121
+ if (error.message.includes('Authorization error') ||
122
+ error.message.includes('expired') ||
123
+ error.message.includes('denied')) {
124
+ throw error;
125
+ }
126
+ // Network error or other issue, continue polling
127
+ console.warn(`Polling attempt ${attempt + 1} failed: ${error.message}`);
128
+ }
129
+ }
130
+
131
+ throw new Error('Authorization timeout. Please try again.');
132
+ }
133
+
134
+ /**
135
+ * Complete flow: initiate + display + poll
136
+ * @returns {Promise<Object>} Token response
137
+ */
138
+ async execute() {
139
+ // Step 1: Initiate
140
+ const deviceAuth = await this.initiate();
141
+
142
+ // Step 2: Display code to user
143
+ if (this.onUserCode) {
144
+ await this.onUserCode(deviceAuth.verificationUriComplete, deviceAuth.userCode);
145
+ }
146
+
147
+ // Step 3: Poll for token
148
+ return await this.pollForToken(deviceAuth.deviceCode, deviceAuth.interval);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Authorization Code Flow with PKCE (for browser-based flows)
154
+ * https://oauth.net/2/pkce/
155
+ */
156
+ export class PKCEFlow {
157
+ constructor(options = {}) {
158
+ this.clientId = options.clientId;
159
+ this.authEndpoint = options.authEndpoint;
160
+ this.tokenEndpoint = options.tokenEndpoint;
161
+ this.redirectUri = options.redirectUri || 'http://localhost:8080/callback';
162
+ this.scopes = options.scopes || [];
163
+ }
164
+
165
+ /**
166
+ * Generate code verifier (random string)
167
+ * @private
168
+ */
169
+ _generateCodeVerifier() {
170
+ return randomBytes(32).toString('base64url');
171
+ }
172
+
173
+ /**
174
+ * Generate code challenge from verifier
175
+ * @private
176
+ */
177
+ _generateCodeChallenge(verifier) {
178
+ return createHash('sha256')
179
+ .update(verifier)
180
+ .digest('base64url');
181
+ }
182
+
183
+ /**
184
+ * Generate state for CSRF protection
185
+ * @private
186
+ */
187
+ _generateState() {
188
+ return randomBytes(16).toString('base64url');
189
+ }
190
+
191
+ /**
192
+ * Build authorization URL
193
+ * @returns {Object} { url, codeVerifier, state }
194
+ */
195
+ buildAuthUrl() {
196
+ const codeVerifier = this._generateCodeVerifier();
197
+ const codeChallenge = this._generateCodeChallenge(codeVerifier);
198
+ const state = this._generateState();
199
+
200
+ const params = new URLSearchParams({
201
+ response_type: 'code',
202
+ client_id: this.clientId,
203
+ redirect_uri: this.redirectUri,
204
+ scope: this.scopes.join(' '),
205
+ state: state,
206
+ code_challenge: codeChallenge,
207
+ code_challenge_method: 'S256',
208
+ });
209
+
210
+ const url = `${this.authEndpoint}?${params.toString()}`;
211
+
212
+ return { url, codeVerifier, state };
213
+ }
214
+
215
+ /**
216
+ * Exchange authorization code for tokens
217
+ * @param {string} code - Authorization code
218
+ * @param {string} codeVerifier - Code verifier from buildAuthUrl
219
+ * @returns {Promise<Object>} Token response
220
+ */
221
+ async exchangeCode(code, codeVerifier) {
222
+ const response = await fetch(this.tokenEndpoint, {
223
+ method: 'POST',
224
+ headers: {
225
+ 'Content-Type': 'application/x-www-form-urlencoded',
226
+ },
227
+ body: new URLSearchParams({
228
+ grant_type: 'authorization_code',
229
+ code: code,
230
+ redirect_uri: this.redirectUri,
231
+ client_id: this.clientId,
232
+ code_verifier: codeVerifier,
233
+ }),
234
+ });
235
+
236
+ if (!response.ok) {
237
+ const error = await response.text();
238
+ throw new Error(`Token exchange failed: ${response.status} - ${error}`);
239
+ }
240
+
241
+ const data = await response.json();
242
+
243
+ return {
244
+ accessToken: data.access_token,
245
+ refreshToken: data.refresh_token,
246
+ expiresIn: data.expires_in,
247
+ tokenType: data.token_type,
248
+ scope: data.scope,
249
+ };
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Simple OAuth helper for opening browser
255
+ */
256
+ export async function openBrowser(url) {
257
+ try {
258
+ // Try to use 'open' package if available
259
+ const open = require('open');
260
+ await open(url);
261
+ return true;
262
+ } catch {
263
+ // Fallback: manual instruction
264
+ return false;
265
+ }
266
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * OAuth Token Manager
3
+ * Handles token lifecycle: get, refresh, validate, revoke
4
+ */
5
+
6
+ import { CredentialStore } from './credential-store.mjs';
7
+
8
+ /**
9
+ * Manages OAuth tokens with automatic refresh
10
+ */
11
+ export class TokenManager {
12
+ constructor(providerId, options = {}) {
13
+ this.providerId = providerId;
14
+ this.clientId = options.clientId;
15
+ this.clientSecret = options.clientSecret; // Optional (not needed for PKCE/device flow)
16
+ this.scopes = options.scopes || [];
17
+ this.tokenEndpoint = options.tokenEndpoint;
18
+ this.refreshEndpoint = options.refreshEndpoint || options.tokenEndpoint;
19
+
20
+ // Token storage
21
+ this.credentialStore = new CredentialStore();
22
+
23
+ // Token cache (in-memory for current process)
24
+ this.tokenCache = null;
25
+
26
+ // Refresh lock to prevent race conditions
27
+ this.refreshPromise = null;
28
+
29
+ // Preemptive refresh threshold (5 minutes before expiry)
30
+ this.refreshThreshold = options.refreshThreshold || 5 * 60 * 1000;
31
+ }
32
+
33
+ /**
34
+ * Get service name for credential storage
35
+ * @private
36
+ */
37
+ _getServiceName() {
38
+ return `git-super-${this.providerId}`;
39
+ }
40
+
41
+ /**
42
+ * Get access token (from cache or storage)
43
+ * @returns {Promise<string|null>}
44
+ */
45
+ async getAccessToken() {
46
+ // Try cache first
47
+ if (this.tokenCache?.accessToken) {
48
+ return this.tokenCache.accessToken;
49
+ }
50
+
51
+ // Load from secure storage
52
+ const stored = await this.credentialStore.get(this._getServiceName());
53
+ if (stored?.accessToken) {
54
+ this.tokenCache = stored;
55
+ return stored.accessToken;
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Get refresh token
63
+ * @returns {Promise<string|null>}
64
+ */
65
+ async getRefreshToken() {
66
+ const stored = await this.credentialStore.get(this._getServiceName());
67
+ return stored?.refreshToken || null;
68
+ }
69
+
70
+ /**
71
+ * Check if we have a valid token
72
+ * @returns {Promise<boolean>}
73
+ */
74
+ async hasValidToken() {
75
+ const token = await this.getAccessToken();
76
+ if (!token) {
77
+ return false;
78
+ }
79
+
80
+ // Check expiration
81
+ const stored = await this.credentialStore.get(this._getServiceName());
82
+ if (!stored?.expiresAt) {
83
+ return true; // No expiry info, assume valid
84
+ }
85
+
86
+ const now = Date.now();
87
+ const expiresAt = new Date(stored.expiresAt).getTime();
88
+
89
+ return expiresAt > now;
90
+ }
91
+
92
+ /**
93
+ * Check if token needs refresh soon
94
+ * @returns {Promise<boolean>}
95
+ */
96
+ async needsRefresh() {
97
+ const stored = await this.credentialStore.get(this._getServiceName());
98
+ if (!stored?.expiresAt) {
99
+ return false;
100
+ }
101
+
102
+ const now = Date.now();
103
+ const expiresAt = new Date(stored.expiresAt).getTime();
104
+
105
+ // Needs refresh if expires within threshold
106
+ return (expiresAt - now) < this.refreshThreshold;
107
+ }
108
+
109
+ /**
110
+ * Store tokens securely
111
+ * @param {Object} tokens - Token data
112
+ * @param {string} tokens.accessToken - Access token
113
+ * @param {string} [tokens.refreshToken] - Refresh token
114
+ * @param {number} [tokens.expiresIn] - Expiry in seconds
115
+ */
116
+ async storeTokens(tokens) {
117
+ const data = {
118
+ accessToken: tokens.accessToken,
119
+ refreshToken: tokens.refreshToken,
120
+ tokenType: tokens.tokenType || 'Bearer',
121
+ scope: tokens.scope || this.scopes.join(' '),
122
+ issuedAt: new Date().toISOString(),
123
+ };
124
+
125
+ // Calculate expiration time
126
+ if (tokens.expiresIn) {
127
+ const expiresAt = new Date(Date.now() + tokens.expiresIn * 1000);
128
+ data.expiresAt = expiresAt.toISOString();
129
+ }
130
+
131
+ // Store in secure credential store
132
+ await this.credentialStore.set(this._getServiceName(), data);
133
+
134
+ // Update cache
135
+ this.tokenCache = data;
136
+ }
137
+
138
+ /**
139
+ * Refresh the access token using refresh token
140
+ * @returns {Promise<boolean>} True if refresh succeeded
141
+ */
142
+ async refreshToken() {
143
+ // Prevent concurrent refresh attempts
144
+ if (this.refreshPromise) {
145
+ return this.refreshPromise;
146
+ }
147
+
148
+ this.refreshPromise = this._doRefresh();
149
+
150
+ try {
151
+ return await this.refreshPromise;
152
+ } finally {
153
+ this.refreshPromise = null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Internal refresh implementation
159
+ * @private
160
+ */
161
+ async _doRefresh() {
162
+ const refreshToken = await this.getRefreshToken();
163
+
164
+ if (!refreshToken) {
165
+ return false; // No refresh token available
166
+ }
167
+
168
+ try {
169
+ const response = await fetch(this.refreshEndpoint, {
170
+ method: 'POST',
171
+ headers: {
172
+ 'Content-Type': 'application/x-www-form-urlencoded',
173
+ },
174
+ body: new URLSearchParams({
175
+ grant_type: 'refresh_token',
176
+ refresh_token: refreshToken,
177
+ client_id: this.clientId,
178
+ ...(this.clientSecret && { client_secret: this.clientSecret }),
179
+ }),
180
+ });
181
+
182
+ if (!response.ok) {
183
+ console.error(`Token refresh failed: ${response.status} ${response.statusText}`);
184
+ return false;
185
+ }
186
+
187
+ const tokens = await response.json();
188
+ await this.storeTokens(tokens);
189
+
190
+ return true;
191
+ } catch (error) {
192
+ console.error(`Error refreshing token: ${error.message}`);
193
+ return false;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Revoke tokens and clear storage
199
+ * @param {string} [revokeEndpoint] - Optional revoke endpoint
200
+ */
201
+ async revokeToken(revokeEndpoint) {
202
+ const accessToken = await this.getAccessToken();
203
+
204
+ // Try to revoke on server if endpoint provided
205
+ if (revokeEndpoint && accessToken) {
206
+ try {
207
+ await fetch(revokeEndpoint, {
208
+ method: 'POST',
209
+ headers: {
210
+ 'Content-Type': 'application/x-www-form-urlencoded',
211
+ },
212
+ body: new URLSearchParams({
213
+ token: accessToken,
214
+ client_id: this.clientId,
215
+ }),
216
+ });
217
+ } catch (error) {
218
+ console.warn(`Failed to revoke token on server: ${error.message}`);
219
+ }
220
+ }
221
+
222
+ // Clear local storage
223
+ await this.credentialStore.delete(this._getServiceName());
224
+ this.tokenCache = null;
225
+ }
226
+
227
+ /**
228
+ * Get token info for status display
229
+ * @returns {Promise<Object|null>}
230
+ */
231
+ async getTokenInfo() {
232
+ const stored = await this.credentialStore.get(this._getServiceName());
233
+
234
+ if (!stored) {
235
+ return null;
236
+ }
237
+
238
+ return {
239
+ hasToken: !!stored.accessToken,
240
+ expiresAt: stored.expiresAt,
241
+ scope: stored.scope,
242
+ issuedAt: stored.issuedAt,
243
+ isValid: await this.hasValidToken(),
244
+ };
245
+ }
246
+ }