@unifiedmemory/cli 1.3.13 → 1.3.14
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/.env.example +10 -0
- package/README.md +28 -0
- package/commands/debug.js +67 -0
- package/commands/init.js +17 -8
- package/commands/login.js +46 -94
- package/commands/org.js +25 -177
- package/commands/record.js +14 -11
- package/commands/usage.js +163 -0
- package/index.js +37 -6
- package/lib/clerk-api.js +9 -12
- package/lib/config.js +11 -1
- package/lib/mcp-proxy.js +9 -10
- package/lib/mcp-server.js +139 -34
- package/lib/token-refresh.js +10 -24
- package/lib/token-storage.js +33 -27
- package/lib/token-validation.js +2 -85
- package/package.json +1 -1
- package/tests/unit/mcp-proxy.test.js +9 -6
- package/tests/unit/token-refresh.test.js +18 -44
- package/tests/unit/token-storage.test.js +1 -2
- package/tests/unit/token-validation.test.js +24 -241
package/lib/token-storage.js
CHANGED
|
@@ -14,12 +14,6 @@ export function saveToken(tokenData) {
|
|
|
14
14
|
// Ensure directory permissions are correct (in case it was created with wrong permissions)
|
|
15
15
|
fs.chmodSync(TOKEN_DIR, 0o700);
|
|
16
16
|
|
|
17
|
-
// Preserve existing organization context if not explicitly overwritten
|
|
18
|
-
const existingData = getToken();
|
|
19
|
-
if (existingData && existingData.selectedOrg && !tokenData.selectedOrg) {
|
|
20
|
-
tokenData.selectedOrg = existingData.selectedOrg;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
// Write token file with owner-only read/write permissions (0600)
|
|
24
18
|
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
|
|
25
19
|
// Explicitly set file permissions (writeFileSync mode option doesn't always work with extended attributes)
|
|
@@ -46,31 +40,43 @@ export function clearToken() {
|
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
43
|
+
* Get organization context from JWT claims
|
|
44
|
+
* JWT is the single source of truth for org context
|
|
45
|
+
* @returns {Object|null} Organization context or null if personal context
|
|
51
46
|
*/
|
|
52
|
-
export function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
export function getOrgContext() {
|
|
48
|
+
const token = getToken();
|
|
49
|
+
|
|
50
|
+
// Try nested 'o' object first (original OAuth token format)
|
|
51
|
+
if (token?.decoded?.o?.o_id) {
|
|
52
|
+
return {
|
|
53
|
+
id: token.decoded.o.o_id,
|
|
54
|
+
name: token.decoded.o.o_name,
|
|
55
|
+
slug: token.decoded.o.o_slug,
|
|
56
|
+
role: token.decoded.o.o_role
|
|
57
|
+
};
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
//
|
|
59
|
-
if (
|
|
60
|
-
|
|
60
|
+
// Fall back to flat org_* claims (org-scoped token from Clerk)
|
|
61
|
+
if (token?.decoded?.org_id) {
|
|
62
|
+
return {
|
|
63
|
+
id: token.decoded.org_id,
|
|
64
|
+
name: token.decoded.org_name,
|
|
65
|
+
slug: token.decoded.org_slug,
|
|
66
|
+
role: token.decoded.org_role
|
|
67
|
+
};
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
// Fall back to selectedOrgId from login state parameter
|
|
71
|
+
// This is used when JWT doesn't have org claims (Clerk setActive limitation)
|
|
72
|
+
if (token?.selectedOrgId) {
|
|
73
|
+
return {
|
|
74
|
+
id: token.selectedOrgId,
|
|
75
|
+
name: token.selectedOrgName || null,
|
|
76
|
+
slug: null,
|
|
77
|
+
role: null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
* Get the currently selected organization context
|
|
71
|
-
* @returns {Object|null} Organization data or null if personal context
|
|
72
|
-
*/
|
|
73
|
-
export function getSelectedOrg() {
|
|
74
|
-
const tokenData = getToken();
|
|
75
|
-
return tokenData?.selectedOrg || null;
|
|
81
|
+
return null;
|
|
76
82
|
}
|
package/lib/token-validation.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { getToken
|
|
1
|
+
import { getToken } from './token-storage.js';
|
|
2
2
|
import { isTokenExpired, refreshAccessToken } from './token-refresh.js';
|
|
3
|
-
import { getOrgScopedToken } from './clerk-api.js';
|
|
4
|
-
import { parseJWT } from './jwt-utils.js';
|
|
5
|
-
import { config } from './config.js';
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* Load token and refresh if expired
|
|
6
|
+
* JWT is the single source of truth - no recovery logic needed
|
|
9
7
|
* @param {boolean} requireAuth - Whether to throw if not authenticated (default true)
|
|
10
8
|
* @returns {Promise<Object|null>} - Token data or null
|
|
11
9
|
* @throws {Error} - If not authenticated and requireAuth is true, or if refresh fails
|
|
@@ -46,86 +44,5 @@ export async function loadAndRefreshToken(requireAuth = true) {
|
|
|
46
44
|
}
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
// Check if token has org context but lacks org claims in JWT
|
|
50
|
-
// This can happen if getOrgScopedToken() failed during login
|
|
51
|
-
if (tokenData.selectedOrg?.id && !tokenData.decoded?.o) {
|
|
52
|
-
console.error('⚠️ Token missing org claims, attempting to fix...');
|
|
53
|
-
|
|
54
|
-
// Check for sid in decoded token or preserved originalSid from login
|
|
55
|
-
let sessionId = tokenData.decoded?.sid || tokenData.originalSid;
|
|
56
|
-
let currentToken = tokenData.idToken || tokenData.accessToken;
|
|
57
|
-
|
|
58
|
-
// If no sid, try to refresh first to get a new base token with sid
|
|
59
|
-
if (!sessionId && tokenData.refresh_token) {
|
|
60
|
-
console.error(' Refreshing to obtain session ID...');
|
|
61
|
-
try {
|
|
62
|
-
const tokenParams = {
|
|
63
|
-
client_id: config.clerkClientId,
|
|
64
|
-
refresh_token: tokenData.refresh_token,
|
|
65
|
-
grant_type: 'refresh_token',
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
if (config.clerkClientSecret) {
|
|
69
|
-
tokenParams.client_secret = config.clerkClientSecret;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const response = await fetch(`https://${config.clerkDomain}/oauth/token`, {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
75
|
-
body: new URLSearchParams(tokenParams),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (response.ok) {
|
|
79
|
-
const newTokenData = await response.json();
|
|
80
|
-
const refreshedToken = newTokenData.id_token || newTokenData.access_token;
|
|
81
|
-
const refreshedDecoded = parseJWT(refreshedToken);
|
|
82
|
-
|
|
83
|
-
sessionId = refreshedDecoded?.sid;
|
|
84
|
-
currentToken = refreshedToken;
|
|
85
|
-
|
|
86
|
-
// Update tokenData with refreshed values
|
|
87
|
-
tokenData.accessToken = newTokenData.access_token;
|
|
88
|
-
tokenData.idToken = newTokenData.id_token;
|
|
89
|
-
tokenData.refresh_token = newTokenData.refresh_token || tokenData.refresh_token;
|
|
90
|
-
tokenData.decoded = refreshedDecoded;
|
|
91
|
-
|
|
92
|
-
if (sessionId) {
|
|
93
|
-
console.error(' ✓ Obtained session ID');
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
} catch (error) {
|
|
97
|
-
console.error(` Could not refresh token: ${error.message}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Now try to get org-scoped token if we have sessionId
|
|
102
|
-
if (sessionId) {
|
|
103
|
-
try {
|
|
104
|
-
const orgToken = await getOrgScopedToken(
|
|
105
|
-
sessionId,
|
|
106
|
-
tokenData.selectedOrg.id,
|
|
107
|
-
currentToken
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const decoded = parseJWT(orgToken.jwt);
|
|
111
|
-
const updatedToken = {
|
|
112
|
-
...tokenData,
|
|
113
|
-
idToken: orgToken.jwt,
|
|
114
|
-
decoded: decoded,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
saveToken(updatedToken);
|
|
118
|
-
console.error('✓ Token updated with org claims');
|
|
119
|
-
return updatedToken;
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.error(`⚠️ Could not get org-scoped token: ${error.message}`);
|
|
122
|
-
// Continue with existing token - API calls may fail
|
|
123
|
-
}
|
|
124
|
-
} else {
|
|
125
|
-
console.error('⚠️ Could not obtain session ID for org-scoped token');
|
|
126
|
-
console.error(' Please run: um login');
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
47
|
return tokenData;
|
|
131
48
|
}
|
package/package.json
CHANGED
|
@@ -221,7 +221,7 @@ describe('mcp-proxy', () => {
|
|
|
221
221
|
|
|
222
222
|
const authContext = {
|
|
223
223
|
decoded: { sub: 'user_123' },
|
|
224
|
-
|
|
224
|
+
orgContext: { id: 'org_456' },
|
|
225
225
|
};
|
|
226
226
|
|
|
227
227
|
const projectContext = {
|
|
@@ -245,7 +245,7 @@ describe('mcp-proxy', () => {
|
|
|
245
245
|
expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('org_456');
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
it('should
|
|
248
|
+
it('should not inject org when no orgContext (personal context)', async () => {
|
|
249
249
|
let capturedBody = null;
|
|
250
250
|
|
|
251
251
|
server.use(
|
|
@@ -263,7 +263,7 @@ describe('mcp-proxy', () => {
|
|
|
263
263
|
|
|
264
264
|
const authContext = {
|
|
265
265
|
decoded: { sub: 'user_123' },
|
|
266
|
-
// No
|
|
266
|
+
// No orgContext - personal context
|
|
267
267
|
};
|
|
268
268
|
|
|
269
269
|
await callRemoteMCPTool(
|
|
@@ -274,9 +274,12 @@ describe('mcp-proxy', () => {
|
|
|
274
274
|
null
|
|
275
275
|
);
|
|
276
276
|
|
|
277
|
-
//
|
|
278
|
-
expect(capturedBody.params.arguments.pathParams.org).
|
|
279
|
-
expect(capturedBody.params.arguments.headers['X-Org-Id']).
|
|
277
|
+
// No org injection for personal context - gateway handles this
|
|
278
|
+
expect(capturedBody.params.arguments.pathParams.org).toBeUndefined();
|
|
279
|
+
expect(capturedBody.params.arguments.headers['X-Org-Id']).toBeUndefined();
|
|
280
|
+
// User should still be injected
|
|
281
|
+
expect(capturedBody.params.arguments.pathParams.user).toBe('user_123');
|
|
282
|
+
expect(capturedBody.params.arguments.headers['X-User-Id']).toBe('user_123');
|
|
280
283
|
});
|
|
281
284
|
|
|
282
285
|
it('should return tool execution result', async () => {
|
|
@@ -44,17 +44,9 @@ vi.mock('../../lib/jwt-utils.js', () => ({
|
|
|
44
44
|
}),
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
|
-
vi.mock('../../lib/clerk-api.js', () => ({
|
|
48
|
-
getOrgScopedToken: vi.fn().mockResolvedValue({
|
|
49
|
-
jwt: 'org_scoped_jwt',
|
|
50
|
-
object: 'token',
|
|
51
|
-
}),
|
|
52
|
-
}));
|
|
53
|
-
|
|
54
47
|
// Import after mocking
|
|
55
48
|
import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
|
|
56
49
|
import { saveToken } from '../../lib/token-storage.js';
|
|
57
|
-
import { getOrgScopedToken } from '../../lib/clerk-api.js';
|
|
58
50
|
|
|
59
51
|
describe('isTokenExpired', () => {
|
|
60
52
|
it('returns true for null tokenData', () => {
|
|
@@ -181,7 +173,7 @@ describe('refreshAccessToken', () => {
|
|
|
181
173
|
);
|
|
182
174
|
});
|
|
183
175
|
|
|
184
|
-
it('preserves
|
|
176
|
+
it('preserves selectedOrgId and selectedOrgName in refreshed token', async () => {
|
|
185
177
|
global.fetch = vi.fn().mockResolvedValue({
|
|
186
178
|
ok: true,
|
|
187
179
|
json: () => Promise.resolve({
|
|
@@ -193,60 +185,42 @@ describe('refreshAccessToken', () => {
|
|
|
193
185
|
}),
|
|
194
186
|
});
|
|
195
187
|
|
|
196
|
-
|
|
197
|
-
|
|
188
|
+
await refreshAccessToken({
|
|
189
|
+
refresh_token: 'rt_123',
|
|
190
|
+
selectedOrgId: 'org_456',
|
|
191
|
+
selectedOrgName: 'Test Org',
|
|
192
|
+
});
|
|
198
193
|
|
|
199
194
|
expect(saveToken).toHaveBeenCalledWith(
|
|
200
195
|
expect.objectContaining({
|
|
201
|
-
|
|
196
|
+
selectedOrgId: 'org_456',
|
|
197
|
+
selectedOrgName: 'Test Org',
|
|
202
198
|
})
|
|
203
199
|
);
|
|
204
200
|
});
|
|
205
201
|
|
|
206
|
-
it('
|
|
202
|
+
it('preserves sessionId through refresh', async () => {
|
|
207
203
|
global.fetch = vi.fn().mockResolvedValue({
|
|
208
204
|
ok: true,
|
|
209
205
|
json: () => Promise.resolve({
|
|
210
206
|
access_token: 'new_access_token',
|
|
211
|
-
id_token: '
|
|
207
|
+
id_token: 'new_id_token',
|
|
212
208
|
refresh_token: 'new_refresh_token',
|
|
213
209
|
token_type: 'Bearer',
|
|
214
210
|
expires_in: 3600,
|
|
215
211
|
}),
|
|
216
212
|
});
|
|
217
213
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
expect(getOrgScopedToken).toHaveBeenCalledWith(
|
|
222
|
-
'sess_new_123',
|
|
223
|
-
'org_456',
|
|
224
|
-
'new_id_token_with_sid'
|
|
225
|
-
);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('continues with base token when getOrgScopedToken fails', async () => {
|
|
229
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
230
|
-
ok: true,
|
|
231
|
-
json: () => Promise.resolve({
|
|
232
|
-
access_token: 'new_access_token',
|
|
233
|
-
id_token: 'new_id_token_with_sid',
|
|
234
|
-
refresh_token: 'new_refresh_token',
|
|
235
|
-
token_type: 'Bearer',
|
|
236
|
-
expires_in: 3600,
|
|
237
|
-
}),
|
|
214
|
+
await refreshAccessToken({
|
|
215
|
+
refresh_token: 'rt_123',
|
|
216
|
+
sessionId: 'sess_123',
|
|
238
217
|
});
|
|
239
218
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Should not throw - continues with base token
|
|
246
|
-
const result = await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
|
|
247
|
-
|
|
248
|
-
expect(result).toBeDefined();
|
|
249
|
-
expect(result.idToken).toBe('new_id_token_with_sid'); // Falls back to base token
|
|
219
|
+
expect(saveToken).toHaveBeenCalledWith(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
sessionId: 'sess_123',
|
|
222
|
+
})
|
|
223
|
+
);
|
|
250
224
|
});
|
|
251
225
|
|
|
252
226
|
it('throws error when OAuth refresh fails with 400', async () => {
|
|
@@ -132,7 +132,6 @@ describe('token-storage module behavior', () => {
|
|
|
132
132
|
expect(typeof ts.saveToken).toBe('function');
|
|
133
133
|
expect(typeof ts.getToken).toBe('function');
|
|
134
134
|
expect(typeof ts.clearToken).toBe('function');
|
|
135
|
-
expect(typeof ts.
|
|
136
|
-
expect(typeof ts.getSelectedOrg).toBe('function');
|
|
135
|
+
expect(typeof ts.getOrgContext).toBe('function');
|
|
137
136
|
});
|
|
138
137
|
});
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for lib/token-validation.js
|
|
3
3
|
*
|
|
4
|
-
* Tests token loading
|
|
4
|
+
* Tests token loading and expiration checking.
|
|
5
|
+
* The implementation is simple: load token, refresh if expired.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
8
9
|
|
|
9
|
-
// Mock dependencies
|
|
10
|
+
// Mock dependencies
|
|
10
11
|
vi.mock('../../lib/token-storage.js', () => ({
|
|
11
12
|
getToken: vi.fn(),
|
|
12
|
-
saveToken: vi.fn(),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
vi.mock('../../lib/token-refresh.js', () => ({
|
|
@@ -17,28 +17,10 @@ vi.mock('../../lib/token-refresh.js', () => ({
|
|
|
17
17
|
refreshAccessToken: vi.fn(),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
-
vi.mock('../../lib/clerk-api.js', () => ({
|
|
21
|
-
getOrgScopedToken: vi.fn(),
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
vi.mock('../../lib/jwt-utils.js', () => ({
|
|
25
|
-
parseJWT: vi.fn(),
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
vi.mock('../../lib/config.js', () => ({
|
|
29
|
-
config: {
|
|
30
|
-
clerkDomain: 'clerk.test.com',
|
|
31
|
-
clerkClientId: 'test_client_id',
|
|
32
|
-
clerkClientSecret: 'test_client_secret',
|
|
33
|
-
},
|
|
34
|
-
}));
|
|
35
|
-
|
|
36
20
|
// Import after mocking
|
|
37
21
|
import { loadAndRefreshToken } from '../../lib/token-validation.js';
|
|
38
|
-
import { getToken
|
|
22
|
+
import { getToken } from '../../lib/token-storage.js';
|
|
39
23
|
import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
|
|
40
|
-
import { getOrgScopedToken } from '../../lib/clerk-api.js';
|
|
41
|
-
import { parseJWT } from '../../lib/jwt-utils.js';
|
|
42
24
|
|
|
43
25
|
describe('loadAndRefreshToken', () => {
|
|
44
26
|
const futureExp = Math.floor(Date.now() / 1000) + 3600;
|
|
@@ -130,238 +112,39 @@ describe('loadAndRefreshToken', () => {
|
|
|
130
112
|
});
|
|
131
113
|
});
|
|
132
114
|
|
|
133
|
-
describe('
|
|
134
|
-
it('
|
|
135
|
-
const
|
|
136
|
-
idToken: '
|
|
137
|
-
decoded: { exp: futureExp,
|
|
138
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
vi.mocked(getToken).mockReturnValue(tokenMissingOrgClaims);
|
|
142
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
143
|
-
jwt: 'org_scoped_jwt',
|
|
144
|
-
});
|
|
145
|
-
vi.mocked(parseJWT).mockReturnValue({
|
|
146
|
-
exp: futureExp,
|
|
147
|
-
o: { o_id: 'org_456' },
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
await loadAndRefreshToken();
|
|
151
|
-
|
|
152
|
-
expect(getOrgScopedToken).toHaveBeenCalledWith(
|
|
153
|
-
'sess_123',
|
|
154
|
-
'org_456',
|
|
155
|
-
'token_no_org'
|
|
156
|
-
);
|
|
157
|
-
expect(saveToken).toHaveBeenCalled();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('recovers using originalSid when decoded.sid is missing', async () => {
|
|
161
|
-
const tokenWithOriginalSid = {
|
|
162
|
-
idToken: 'token_org_scoped',
|
|
163
|
-
decoded: { exp: futureExp }, // No sid - org-scoped token
|
|
164
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
165
|
-
originalSid: 'sess_original',
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
vi.mocked(getToken).mockReturnValue(tokenWithOriginalSid);
|
|
169
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
170
|
-
jwt: 'new_org_scoped_jwt',
|
|
171
|
-
});
|
|
172
|
-
vi.mocked(parseJWT).mockReturnValue({
|
|
173
|
-
exp: futureExp,
|
|
174
|
-
o: { o_id: 'org_456' },
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
await loadAndRefreshToken();
|
|
178
|
-
|
|
179
|
-
expect(getOrgScopedToken).toHaveBeenCalledWith(
|
|
180
|
-
'sess_original',
|
|
181
|
-
'org_456',
|
|
182
|
-
'token_org_scoped'
|
|
183
|
-
);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('skips recovery when token already has org claims', async () => {
|
|
187
|
-
const tokenWithOrgClaims = {
|
|
188
|
-
idToken: 'complete_token',
|
|
189
|
-
decoded: { exp: futureExp, o: { o_id: 'org_456' } },
|
|
190
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
vi.mocked(getToken).mockReturnValue(tokenWithOrgClaims);
|
|
194
|
-
|
|
195
|
-
const result = await loadAndRefreshToken();
|
|
196
|
-
|
|
197
|
-
expect(getOrgScopedToken).not.toHaveBeenCalled();
|
|
198
|
-
expect(result.decoded.o).toBeDefined();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('skips recovery when no selectedOrg (personal account)', async () => {
|
|
202
|
-
const personalToken = {
|
|
203
|
-
idToken: 'personal_token',
|
|
204
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
205
|
-
// No selectedOrg - personal account
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
vi.mocked(getToken).mockReturnValue(personalToken);
|
|
209
|
-
|
|
210
|
-
await loadAndRefreshToken();
|
|
211
|
-
|
|
212
|
-
expect(getOrgScopedToken).not.toHaveBeenCalled();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('skips recovery when selectedOrg has no id', async () => {
|
|
216
|
-
const tokenWithEmptyOrg = {
|
|
217
|
-
idToken: 'token_empty_org',
|
|
218
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
219
|
-
selectedOrg: {}, // Empty org object
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
vi.mocked(getToken).mockReturnValue(tokenWithEmptyOrg);
|
|
223
|
-
|
|
224
|
-
await loadAndRefreshToken();
|
|
225
|
-
|
|
226
|
-
expect(getOrgScopedToken).not.toHaveBeenCalled();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('continues gracefully when recovery fails', async () => {
|
|
230
|
-
const brokenToken = {
|
|
231
|
-
idToken: 'broken_token',
|
|
232
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
233
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
vi.mocked(getToken).mockReturnValue(brokenToken);
|
|
237
|
-
vi.mocked(getOrgScopedToken).mockRejectedValue(new Error('API error'));
|
|
238
|
-
|
|
239
|
-
// Should not throw, just log warning and return token
|
|
240
|
-
const result = await loadAndRefreshToken();
|
|
241
|
-
|
|
242
|
-
expect(result).toBeDefined();
|
|
243
|
-
expect(result.idToken).toBe('broken_token');
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('saves updated token after successful recovery', async () => {
|
|
247
|
-
const tokenMissingOrgClaims = {
|
|
248
|
-
idToken: 'token_no_org',
|
|
249
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
250
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
251
|
-
refresh_token: 'rt_123',
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
vi.mocked(getToken).mockReturnValue(tokenMissingOrgClaims);
|
|
255
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
256
|
-
jwt: 'org_scoped_jwt',
|
|
257
|
-
});
|
|
258
|
-
vi.mocked(parseJWT).mockReturnValue({
|
|
259
|
-
exp: futureExp,
|
|
260
|
-
o: { o_id: 'org_456' },
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
await loadAndRefreshToken();
|
|
264
|
-
|
|
265
|
-
expect(saveToken).toHaveBeenCalledWith(
|
|
266
|
-
expect.objectContaining({
|
|
267
|
-
idToken: 'org_scoped_jwt',
|
|
268
|
-
})
|
|
269
|
-
);
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
describe('OAuth refresh fallback for missing sid', () => {
|
|
274
|
-
const originalFetch = global.fetch;
|
|
275
|
-
|
|
276
|
-
afterEach(() => {
|
|
277
|
-
global.fetch = originalFetch;
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('attempts OAuth refresh when no sid/originalSid but has refresh_token', async () => {
|
|
281
|
-
const tokenNoSid = {
|
|
282
|
-
idToken: 'token_no_sid',
|
|
283
|
-
decoded: { exp: futureExp }, // No sid
|
|
284
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
115
|
+
describe('valid token handling', () => {
|
|
116
|
+
it('returns token data when not expired', async () => {
|
|
117
|
+
const validToken = {
|
|
118
|
+
idToken: 'valid_token',
|
|
119
|
+
decoded: { exp: futureExp, sub: 'user_123' },
|
|
285
120
|
refresh_token: 'rt_123',
|
|
286
|
-
// No originalSid either
|
|
287
121
|
};
|
|
288
122
|
|
|
289
|
-
vi.mocked(getToken).mockReturnValue(
|
|
290
|
-
|
|
291
|
-
// Mock fetch for OAuth refresh
|
|
292
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
293
|
-
ok: true,
|
|
294
|
-
json: () => Promise.resolve({
|
|
295
|
-
access_token: 'new_at',
|
|
296
|
-
id_token: 'new_idt_with_sid',
|
|
297
|
-
refresh_token: 'new_rt',
|
|
298
|
-
}),
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// parseJWT returns token with sid after refresh
|
|
302
|
-
vi.mocked(parseJWT)
|
|
303
|
-
.mockReturnValueOnce({ exp: futureExp, sid: 'sess_new' }) // First call: parse refreshed token
|
|
304
|
-
.mockReturnValueOnce({ exp: futureExp, o: { o_id: 'org_456' } }); // Second call: parse org-scoped token
|
|
305
|
-
|
|
306
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
307
|
-
jwt: 'org_scoped_jwt_after_refresh',
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
await loadAndRefreshToken();
|
|
311
|
-
|
|
312
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
313
|
-
expect.stringContaining('/oauth/token'),
|
|
314
|
-
expect.any(Object)
|
|
315
|
-
);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('continues without org token when OAuth refresh fails', async () => {
|
|
319
|
-
const tokenNoSid = {
|
|
320
|
-
idToken: 'token_no_sid',
|
|
321
|
-
decoded: { exp: futureExp },
|
|
322
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
323
|
-
refresh_token: 'rt_123',
|
|
324
|
-
};
|
|
123
|
+
vi.mocked(getToken).mockReturnValue(validToken);
|
|
124
|
+
vi.mocked(isTokenExpired).mockReturnValue(false);
|
|
325
125
|
|
|
326
|
-
vi.mocked(getToken).mockReturnValue(tokenNoSid);
|
|
327
|
-
|
|
328
|
-
// Mock fetch to fail
|
|
329
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
330
|
-
ok: false,
|
|
331
|
-
status: 400,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// Should not throw, just return token as-is
|
|
335
126
|
const result = await loadAndRefreshToken();
|
|
336
127
|
|
|
337
|
-
expect(
|
|
338
|
-
expect(result
|
|
128
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
129
|
+
expect(result).toEqual(validToken);
|
|
339
130
|
});
|
|
340
131
|
|
|
341
|
-
it('
|
|
342
|
-
const
|
|
343
|
-
idToken: '
|
|
344
|
-
decoded: { exp: futureExp },
|
|
345
|
-
|
|
346
|
-
|
|
132
|
+
it('returns token with org context when present', async () => {
|
|
133
|
+
const orgToken = {
|
|
134
|
+
idToken: 'org_token',
|
|
135
|
+
decoded: { exp: futureExp, sub: 'user_123', org_id: 'org_456' },
|
|
136
|
+
selectedOrgId: 'org_456',
|
|
137
|
+
selectedOrgName: 'Test Org',
|
|
138
|
+
refresh_token: 'rt_123',
|
|
347
139
|
};
|
|
348
140
|
|
|
349
|
-
vi.mocked(getToken).mockReturnValue(
|
|
350
|
-
|
|
351
|
-
// Spy on console.error
|
|
352
|
-
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
141
|
+
vi.mocked(getToken).mockReturnValue(orgToken);
|
|
142
|
+
vi.mocked(isTokenExpired).mockReturnValue(false);
|
|
353
143
|
|
|
354
144
|
const result = await loadAndRefreshToken();
|
|
355
145
|
|
|
356
|
-
|
|
357
|
-
expect(result).
|
|
358
|
-
|
|
359
|
-
// Should have logged warning about missing session ID
|
|
360
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
361
|
-
expect.stringContaining('Could not obtain session ID')
|
|
362
|
-
);
|
|
363
|
-
|
|
364
|
-
consoleSpy.mockRestore();
|
|
146
|
+
expect(result.selectedOrgId).toBe('org_456');
|
|
147
|
+
expect(result.selectedOrgName).toBe('Test Org');
|
|
365
148
|
});
|
|
366
149
|
});
|
|
367
150
|
});
|