@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.
- package/LICENSE +201 -0
- package/README.md +384 -0
- package/bin/git-super.mjs +576 -0
- package/lib/ARCHITECTURE.md +254 -0
- package/lib/auth/auth-strategy.mjs +132 -0
- package/lib/auth/credential-store.mjs +222 -0
- package/lib/auth/oauth-flows.mjs +266 -0
- package/lib/auth/token-manager.mjs +246 -0
- package/lib/cli/auth-commands.mjs +327 -0
- package/lib/config/config-loader.mjs +167 -0
- package/lib/fallback/add-files-strategy.mjs +15 -0
- package/lib/fallback/base-fallback-strategy.mjs +34 -0
- package/lib/fallback/delete-files-strategy.mjs +15 -0
- package/lib/fallback/fallback-resolver.mjs +54 -0
- package/lib/fallback/modify-files-strategy.mjs +15 -0
- package/lib/providers/anthropic-provider.mjs +44 -0
- package/lib/providers/azure-openai-provider.mjs +185 -0
- package/lib/providers/base-oauth-provider.mjs +62 -0
- package/lib/providers/base-provider.mjs +29 -0
- package/lib/providers/generic-oidc-provider.mjs +144 -0
- package/lib/providers/github-copilot-provider.mjs +113 -0
- package/lib/providers/ollama-provider.mjs +109 -0
- package/lib/providers/openai-provider.mjs +44 -0
- package/lib/providers/provider-registry.mjs +99 -0
- package/package.json +59 -0
|
@@ -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
|
+
}
|