@unifiedmemory/cli 1.3.11 → 1.3.13

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 CHANGED
@@ -37,6 +37,14 @@
37
37
  # REDIRECT_URI=http://localhost:3333/callback
38
38
  # PORT=3333
39
39
 
40
+ # ============================================
41
+ # OAuth Success Page Link
42
+ # ============================================
43
+ # Optional website link shown on the OAuth success page
44
+ # Users can visit this URL to manage their account or subscription
45
+ # Default: https://unifiedmemory.ai/oauth/callback
46
+ # OAUTH_SUCCESS_URL=https://unifiedmemory.ai/oauth/callback
47
+
40
48
  # ============================================
41
49
  # Clerk Client Secret (Optional)
42
50
  # ============================================
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.12] - 2026-01-21
11
+
12
+ ### Fixed
13
+
14
+ - **Organization mismatch error in MCP server** - JWT tokens now properly include org claims
15
+ - **Root cause**: When `getOrgScopedToken()` failed during login, the fallback path lost the session ID needed for recovery
16
+ - **commands/login.js**: Fallback now preserves `originalSid` in auth.json, enabling automatic recovery on next API call
17
+ - **commands/org.js**: `um org switch` now gets org-scoped token instead of just updating selected org
18
+ - This fixes 401 MISMATCH errors when the gateway reads org from JWT but finds no `o.o_id` claim
19
+
20
+ ### Added
21
+
22
+ - **`um org fix` command** - Manual recovery for broken org tokens
23
+ - Diagnoses token state and attempts to get org-scoped JWT
24
+ - Tries OAuth refresh to obtain session ID if missing
25
+ - Provides clear error messages with recovery steps
26
+ - Usage: Run `um org fix` if MCP server fails with org mismatch errors
27
+
28
+ ## [1.3.11] - 2026-01-20
29
+
10
30
  ### Changed
11
31
 
12
32
  - **lib/config.js** - Embedded production defaults for zero-configuration installation
package/README.md CHANGED
@@ -113,6 +113,18 @@ Switch between your organizations or personal account.
113
113
  um org switch
114
114
  ```
115
115
 
116
+ ### `um org fix`
117
+ Fix organization token if API calls fail with org mismatch errors.
118
+
119
+ ```bash
120
+ um org fix
121
+ ```
122
+
123
+ This command diagnoses and repairs tokens that are missing organization claims. Use it when:
124
+ - MCP server returns 401 MISMATCH errors
125
+ - `um status` shows an org selected but API calls fail
126
+ - After switching organizations if token update failed
127
+
116
128
  ### `um note create`
117
129
  Manually save a note to your vault (useful for capturing important context).
118
130
 
package/commands/login.js CHANGED
@@ -143,15 +143,100 @@ export async function login() {
143
143
  // Debug: Log the token response structure
144
144
  console.log(chalk.gray('\nToken response keys:'), Object.keys(tokenData));
145
145
 
146
- // Send success response to browser first
146
+ // Send success response to browser
147
147
  res.writeHead(200, { 'Content-Type': 'text/html' });
148
148
  res.end(`
149
- <html>
150
- <body style="font-family: system-ui; padding: 2rem; text-align: center;">
151
- <h1 style="color: #10B981;">✅ Authentication Successful!</h1>
152
- <p>You have successfully authenticated with Clerk.</p>
153
- <p>You can close this window and return to the CLI.</p>
154
- <script>setTimeout(() => window.close(), 3000);</script>
149
+ <!DOCTYPE html>
150
+ <html lang="en">
151
+ <head>
152
+ <meta charset="UTF-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
+ <title>Login Successful</title>
155
+ <link rel="preconnect" href="https://fonts.googleapis.com">
156
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
157
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
158
+ <style>
159
+ * {
160
+ margin: 0;
161
+ padding: 0;
162
+ box-sizing: border-box;
163
+ }
164
+ body {
165
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
166
+ background: hsl(220, 30%, 8%);
167
+ color: hsl(0, 0%, 100%);
168
+ min-height: 100vh;
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ padding: 2rem;
173
+ }
174
+ .card {
175
+ background: hsl(220, 25%, 12%);
176
+ border: 1px solid hsl(220, 20%, 20%);
177
+ border-radius: 0.5rem;
178
+ padding: 2rem;
179
+ text-align: center;
180
+ max-width: 32rem;
181
+ width: 100%;
182
+ }
183
+ .logo {
184
+ width: 64px;
185
+ height: 64px;
186
+ margin: 0 auto 1.5rem;
187
+ display: block;
188
+ }
189
+ h1 {
190
+ font-size: 1.875rem;
191
+ font-weight: 700;
192
+ margin-bottom: 0.5rem;
193
+ color: hsl(0, 0%, 100%);
194
+ }
195
+ .description {
196
+ font-size: 1.125rem;
197
+ color: hsl(0, 0%, 65%);
198
+ margin-bottom: 1.5rem;
199
+ }
200
+ .message {
201
+ color: hsl(0, 0%, 65%);
202
+ line-height: 1.6;
203
+ }
204
+ .optional-link {
205
+ margin-top: 2rem;
206
+ padding-top: 1.5rem;
207
+ border-top: 1px solid hsl(220, 20%, 20%);
208
+ font-size: 0.8125rem;
209
+ color: hsl(0, 0%, 50%);
210
+ line-height: 1.5;
211
+ }
212
+ .optional-link a {
213
+ color: hsl(0, 0%, 55%);
214
+ text-decoration: none;
215
+ border-bottom: 1px solid transparent;
216
+ transition: border-color 0.2s;
217
+ }
218
+ .optional-link a:hover {
219
+ border-bottom-color: hsl(0, 0%, 55%);
220
+ }
221
+ </style>
222
+ </head>
223
+ <body>
224
+ <div class="card">
225
+ <img src="https://unifiedmemory.ai/images/theme/axolotl-logo.png" alt="UnifiedMemory.ai Logo" class="logo" />
226
+ <h1>Login Successful</h1>
227
+ <p class="description">Your authentication is complete.</p>
228
+ <p class="message">You may now close this tab and return to the terminal to continue using the CLI.</p>
229
+ <div class="optional-link">
230
+ You can manage your account or subscription on the web at<br>
231
+ <a href="https://unifiedmemory.ai/account">unifiedmemory.ai</a>
232
+ </div>
233
+ </div>
234
+ <script>
235
+ // Auto-close window after 10 seconds
236
+ setTimeout(() => {
237
+ window.close();
238
+ }, 10000);
239
+ </script>
155
240
  </body>
156
241
  </html>
157
242
  `);
@@ -243,12 +328,24 @@ export async function login() {
243
328
  console.error(chalk.yellow('\n⚠️ Failed to get org-scoped token:'), error.message);
244
329
  console.log(chalk.gray(' Continuing with original token (may have limited org access)'));
245
330
 
246
- // Fall back to updating selected org without new token
247
- updateSelectedOrg(selectedOrg);
331
+ // Save token with selectedOrg AND originalSid for recovery
332
+ // This allows token-validation.js to retry getting org-scoped token later
333
+ saveToken({
334
+ accessToken: tokenData.access_token,
335
+ idToken: tokenData.id_token,
336
+ refresh_token: tokenData.refresh_token,
337
+ tokenType: 'Bearer',
338
+ expiresIn: tokenData.expires_in,
339
+ receivedAt: Date.now(),
340
+ decoded: decoded,
341
+ selectedOrg: selectedOrg,
342
+ originalSid: decoded.sid // Preserve session ID for recovery
343
+ });
248
344
 
249
345
  console.log(chalk.green(`\n✅ Using organization context: ${chalk.bold(selectedOrg.name)}`));
250
346
  console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
251
347
  console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
348
+ console.log(chalk.yellow(' ⚠️ Token lacks org claims - recovery will be attempted on next use'));
252
349
  }
253
350
  } else {
254
351
  console.log(chalk.green('\n✅ Using personal account context'));
package/commands/org.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import chalk from 'chalk';
2
- import { updateSelectedOrg, getSelectedOrg } from '../lib/token-storage.js';
2
+ import { updateSelectedOrg, getSelectedOrg, saveToken, getToken } from '../lib/token-storage.js';
3
3
  import { loadAndRefreshToken } from '../lib/token-validation.js';
4
- import { getUserOrganizations, getOrganizationsFromToken } from '../lib/clerk-api.js';
4
+ import { refreshAccessToken } from '../lib/token-refresh.js';
5
+ import { getUserOrganizations, getOrganizationsFromToken, getOrgScopedToken } from '../lib/clerk-api.js';
6
+ import { parseJWT } from '../lib/jwt-utils.js';
5
7
  import { promptOrganizationSelection, displayOrganizationSelection } from '../lib/org-selection-ui.js';
6
8
 
7
9
  /**
@@ -46,15 +48,50 @@ export async function switchOrg() {
46
48
  // Prompt user to select
47
49
  const selectedOrg = await promptOrganizationSelection(memberships, currentOrg);
48
50
 
49
- // Update selected organization
50
- updateSelectedOrg(selectedOrg);
51
-
52
- // Display result (with "Switched to" instead of "Selected")
51
+ // Update selected organization with org-scoped token
53
52
  if (selectedOrg) {
54
- console.log(chalk.green(`\n✅ Switched to organization: ${chalk.bold(selectedOrg.name)}`));
55
- console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
56
- console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
53
+ try {
54
+ // Get session ID from current token
55
+ const sessionId = tokenData.decoded?.sid || tokenData.originalSid;
56
+ const currentToken = tokenData.idToken || tokenData.accessToken;
57
+
58
+ if (sessionId) {
59
+ console.log(chalk.cyan('\n🔄 Getting organization-scoped token...'));
60
+ const orgToken = await getOrgScopedToken(sessionId, selectedOrg.id, currentToken);
61
+
62
+ // Update with org-scoped token
63
+ saveToken({
64
+ ...tokenData,
65
+ idToken: orgToken.jwt,
66
+ decoded: parseJWT(orgToken.jwt),
67
+ selectedOrg: selectedOrg,
68
+ originalSid: sessionId
69
+ });
70
+
71
+ console.log(chalk.green(`\n✅ Switched to organization: ${chalk.bold(selectedOrg.name)}`));
72
+ console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
73
+ console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
74
+ console.log(chalk.gray(' ✓ Token updated with organization context'));
75
+ } else {
76
+ // Fall back to just updating selectedOrg (recovery will try later)
77
+ updateSelectedOrg(selectedOrg);
78
+ console.log(chalk.green(`\n✅ Switched to organization: ${chalk.bold(selectedOrg.name)}`));
79
+ console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
80
+ console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
81
+ console.log(chalk.yellow(' ⚠️ No session ID available - token lacks org claims'));
82
+ console.log(chalk.gray(' Try: um login'));
83
+ }
84
+ } catch (error) {
85
+ console.error(chalk.yellow(`\n⚠️ Failed to get org-scoped token: ${error.message}`));
86
+ updateSelectedOrg(selectedOrg);
87
+ console.log(chalk.green(`\n✅ Switched to organization: ${chalk.bold(selectedOrg.name)}`));
88
+ console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
89
+ console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
90
+ console.log(chalk.gray(' Saved org selection, but token may need refresh'));
91
+ console.log(chalk.gray(' Try: um org fix'));
92
+ }
57
93
  } else {
94
+ updateSelectedOrg(null);
58
95
  console.log(chalk.green('\n✅ Switched to personal account context'));
59
96
  }
60
97
 
@@ -80,3 +117,92 @@ export async function showOrg() {
80
117
 
81
118
  console.log(chalk.gray('\nRun `um org switch` to change organization\n'));
82
119
  }
120
+
121
+ /**
122
+ * Fix organization token if API calls fail with org mismatch
123
+ * Attempts to get org-scoped token for the currently selected organization
124
+ */
125
+ export async function fixOrg() {
126
+ const tokenData = getToken();
127
+
128
+ if (!tokenData) {
129
+ console.error(chalk.red('❌ Not authenticated. Run: um login'));
130
+ return;
131
+ }
132
+
133
+ if (!tokenData.selectedOrg) {
134
+ console.error(chalk.yellow('⚠️ No organization selected. Run: um org switch'));
135
+ return;
136
+ }
137
+
138
+ console.log(chalk.blue('\n🔧 Organization Token Fix\n'));
139
+ console.log(chalk.gray(` Selected org: ${tokenData.selectedOrg.name}`));
140
+ console.log(chalk.gray(` Org ID: ${tokenData.selectedOrg.id}`));
141
+
142
+ // Check if already has org claims
143
+ if (tokenData.decoded?.o?.o_id) {
144
+ console.log(chalk.green('\n✓ Token already has organization claims'));
145
+ console.log(chalk.gray(` Org in token: ${tokenData.decoded.o.o_id}`));
146
+ if (tokenData.decoded.o.o_id === tokenData.selectedOrg.id) {
147
+ console.log(chalk.green(' ✓ Org matches selected organization'));
148
+ } else {
149
+ console.log(chalk.yellow(' ⚠️ Org mismatch - token has different org than selected'));
150
+ console.log(chalk.gray(' Run: um org switch'));
151
+ }
152
+ return;
153
+ }
154
+
155
+ console.log(chalk.yellow('\n⚠️ Token missing org claims. Attempting fix...'));
156
+
157
+ // Try to get session ID
158
+ let sessionId = tokenData.decoded?.sid || tokenData.originalSid;
159
+ let currentToken = tokenData.idToken || tokenData.accessToken;
160
+
161
+ // If no sessionId, try OAuth refresh first
162
+ if (!sessionId && tokenData.refresh_token) {
163
+ console.log(chalk.gray(' Refreshing token to obtain session ID...'));
164
+ try {
165
+ const refreshed = await refreshAccessToken(tokenData);
166
+ sessionId = refreshed.decoded?.sid;
167
+ currentToken = refreshed.idToken || refreshed.accessToken;
168
+
169
+ if (sessionId) {
170
+ console.log(chalk.gray(' ✓ Got session ID from refresh'));
171
+ }
172
+ } catch (error) {
173
+ console.error(chalk.red(` Refresh failed: ${error.message}`));
174
+ }
175
+ }
176
+
177
+ if (!sessionId) {
178
+ console.error(chalk.red('\n❌ Cannot obtain session ID'));
179
+ console.log(chalk.gray(' The token does not contain a session ID needed for org-scoped tokens.'));
180
+ console.log(chalk.gray(' Run: um login'));
181
+ return;
182
+ }
183
+
184
+ // Get org-scoped token
185
+ try {
186
+ console.log(chalk.cyan('\n🔄 Getting organization-scoped token...'));
187
+ const orgToken = await getOrgScopedToken(
188
+ sessionId,
189
+ tokenData.selectedOrg.id,
190
+ currentToken
191
+ );
192
+
193
+ const decoded = parseJWT(orgToken.jwt);
194
+ saveToken({
195
+ ...tokenData,
196
+ idToken: orgToken.jwt,
197
+ decoded: decoded,
198
+ originalSid: sessionId
199
+ });
200
+
201
+ console.log(chalk.green('\n✅ Token fixed with organization claims'));
202
+ console.log(chalk.gray(` Org: ${tokenData.selectedOrg.name}`));
203
+ console.log(chalk.gray(` Org ID in token: ${decoded?.o?.o_id || 'N/A'}`));
204
+ } catch (error) {
205
+ console.error(chalk.red(`\n❌ Failed to get org token: ${error.message}`));
206
+ console.log(chalk.gray('\nYou may need to run: um login'));
207
+ }
208
+ }
package/index.js CHANGED
@@ -9,7 +9,7 @@ import { dirname, join } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { login } from './commands/login.js';
11
11
  import { init } from './commands/init.js';
12
- import { switchOrg, showOrg } from './commands/org.js';
12
+ import { switchOrg, showOrg, fixOrg } from './commands/org.js';
13
13
  import { record } from './commands/record.js';
14
14
  import { config } from './lib/config.js';
15
15
  import { getSelectedOrg } from './lib/token-storage.js';
@@ -160,6 +160,19 @@ orgCommand
160
160
  }
161
161
  });
162
162
 
163
+ orgCommand
164
+ .command('fix')
165
+ .description('Fix organization token if API calls fail with org mismatch')
166
+ .action(async () => {
167
+ try {
168
+ await fixOrg();
169
+ process.exit(0);
170
+ } catch (error) {
171
+ console.error(chalk.red('Failed to fix organization token:'), error.message);
172
+ process.exit(1);
173
+ }
174
+ });
175
+
163
176
  // MCP server commands
164
177
  const mcpCommand = program
165
178
  .command('mcp')
package/lib/config.js CHANGED
@@ -19,7 +19,10 @@ export const config = {
19
19
 
20
20
  // OAuth flow configuration (localhost defaults for callback server)
21
21
  redirectUri: process.env.REDIRECT_URI || 'http://localhost:3333/callback',
22
- port: parseInt(process.env.PORT || '3333', 10)
22
+ port: parseInt(process.env.PORT || '3333', 10),
23
+
24
+ // OAuth success page configuration (optional website link for account management)
25
+ oauthSuccessUrl: process.env.OAUTH_SUCCESS_URL || 'https://unifiedmemory.ai/oauth/callback'
23
26
  };
24
27
 
25
28
  // Validation function - validates configuration values
@@ -41,5 +44,12 @@ export function validateConfig() {
41
44
  throw new Error('CLERK_CLIENT_ID cannot be empty');
42
45
  }
43
46
 
47
+ // Validate oauthSuccessUrl format if provided
48
+ try {
49
+ new URL(config.oauthSuccessUrl);
50
+ } catch (e) {
51
+ throw new Error(`OAUTH_SUCCESS_URL must be a valid URL (got: ${config.oauthSuccessUrl})`);
52
+ }
53
+
44
54
  return true;
45
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unifiedmemory/cli",
3
- "version": "1.3.11",
3
+ "version": "1.3.13",
4
4
  "description": "UnifiedMemory CLI - AI code assistant integration",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,20 @@
1
+ {
2
+ "accessToken": "mock_access_token",
3
+ "idToken": "mock_id_token_no_org",
4
+ "refresh_token": "mock_refresh_token",
5
+ "tokenType": "Bearer",
6
+ "expiresIn": 3600,
7
+ "receivedAt": 1700000000000,
8
+ "decoded": {
9
+ "sub": "user_123456789",
10
+ "email": "test@test.com",
11
+ "exp": 9999999999,
12
+ "iat": 1600000000,
13
+ "sid": "sess_123456789"
14
+ },
15
+ "selectedOrg": {
16
+ "id": "org_456789012",
17
+ "name": "Test Organization",
18
+ "role": "admin"
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "accessToken": "mock_access_token",
3
+ "idToken": "mock_id_token_org_scoped",
4
+ "refresh_token": "mock_refresh_token",
5
+ "tokenType": "Bearer",
6
+ "expiresIn": 3600,
7
+ "receivedAt": 1700000000000,
8
+ "decoded": {
9
+ "sub": "user_123456789",
10
+ "email": "test@test.com",
11
+ "exp": 9999999999,
12
+ "iat": 1600000000
13
+ },
14
+ "selectedOrg": {
15
+ "id": "org_456789012",
16
+ "name": "Test Organization",
17
+ "role": "admin"
18
+ },
19
+ "originalSid": "sess_original_123"
20
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "accessToken": "mock_access_token",
3
+ "idToken": "mock_id_token_with_org",
4
+ "refresh_token": "mock_refresh_token",
5
+ "tokenType": "Bearer",
6
+ "expiresIn": 3600,
7
+ "receivedAt": 1700000000000,
8
+ "decoded": {
9
+ "sub": "user_123456789",
10
+ "email": "test@test.com",
11
+ "exp": 9999999999,
12
+ "iat": 1600000000,
13
+ "sid": "sess_123456789",
14
+ "o": {
15
+ "o_id": "org_456789012",
16
+ "o_role": "admin"
17
+ }
18
+ },
19
+ "selectedOrg": {
20
+ "id": "org_456789012",
21
+ "name": "Test Organization",
22
+ "role": "admin"
23
+ },
24
+ "originalSid": "sess_123456789"
25
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Mock for lib/clerk-api.js
3
+ *
4
+ * Provides mock implementations of Clerk API functions
5
+ * for testing token refresh and recovery functionality.
6
+ */
7
+
8
+ import { vi } from 'vitest';
9
+
10
+ /**
11
+ * Creates a configurable mock for lib/clerk-api.js
12
+ * @param {Object} options - Configuration options
13
+ * @param {Object} options.orgToken - Response for getOrgScopedToken
14
+ * @param {Array} options.orgs - List of organizations for getUserOrganizations
15
+ * @returns {Object} Mock clerk-api functions
16
+ */
17
+ export function createClerkApiMock(options = {}) {
18
+ const defaultOrgToken = {
19
+ jwt: 'mock_org_scoped_jwt',
20
+ object: 'token'
21
+ };
22
+
23
+ return {
24
+ getOrgScopedToken: vi.fn().mockResolvedValue(options.orgToken || defaultOrgToken),
25
+ getUserOrganizations: vi.fn().mockResolvedValue(options.orgs || []),
26
+ getOrganizationsFromToken: vi.fn().mockReturnValue(options.orgs || []),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Pre-configured mock for successful org token retrieval
32
+ */
33
+ export const successfulOrgTokenMock = createClerkApiMock({
34
+ orgToken: {
35
+ jwt: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm8iOnsib19pZCI6Im9yZ180NTYifX0.sig',
36
+ object: 'token'
37
+ }
38
+ });
39
+
40
+ /**
41
+ * Creates a mock that fails when getting org-scoped token
42
+ * @param {string} errorMessage - Error message to throw
43
+ * @returns {Object} Mock clerk-api functions that fail
44
+ */
45
+ export function createFailingOrgTokenMock(errorMessage = 'Failed to get org token') {
46
+ return {
47
+ getOrgScopedToken: vi.fn().mockRejectedValue(new Error(errorMessage)),
48
+ getUserOrganizations: vi.fn().mockResolvedValue([]),
49
+ getOrganizationsFromToken: vi.fn().mockReturnValue([]),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Creates a mock with custom org token response containing decoded claims
55
+ * @param {Object} decodedClaims - Claims to include in the mock JWT
56
+ * @returns {Object} Mock clerk-api functions
57
+ */
58
+ export function createOrgTokenMockWithClaims(decodedClaims = {}) {
59
+ // Create a simple mock JWT structure (not cryptographically valid, but parseable)
60
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
61
+ const payload = Buffer.from(JSON.stringify({
62
+ sub: 'user_123',
63
+ exp: Math.floor(Date.now() / 1000) + 3600,
64
+ o: { o_id: 'org_456', o_role: 'admin' },
65
+ ...decodedClaims
66
+ })).toString('base64url');
67
+ const signature = 'mock_signature';
68
+
69
+ const mockJwt = `${header}.${payload}.${signature}`;
70
+
71
+ return createClerkApiMock({
72
+ orgToken: {
73
+ jwt: mockJwt,
74
+ object: 'token'
75
+ }
76
+ });
77
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Unit tests for lib/token-refresh.js
3
+ *
4
+ * Tests token expiration checking and OAuth refresh functionality.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+
9
+ // Mock the dependencies before importing the module
10
+ vi.mock('../../lib/config.js', () => ({
11
+ config: {
12
+ clerkDomain: 'clerk.test.com',
13
+ clerkClientId: 'test_client_id',
14
+ clerkClientSecret: 'test_client_secret',
15
+ }
16
+ }));
17
+
18
+ vi.mock('../../lib/token-storage.js', () => ({
19
+ getToken: vi.fn(),
20
+ saveToken: vi.fn(),
21
+ }));
22
+
23
+ vi.mock('../../lib/jwt-utils.js', () => ({
24
+ parseJWT: vi.fn((jwt) => {
25
+ // Return mock decoded payload
26
+ if (jwt === 'new_id_token_with_sid') {
27
+ return {
28
+ sub: 'user_123',
29
+ exp: Math.floor(Date.now() / 1000) + 3600,
30
+ sid: 'sess_new_123',
31
+ };
32
+ }
33
+ if (jwt === 'org_scoped_jwt') {
34
+ return {
35
+ sub: 'user_123',
36
+ exp: Math.floor(Date.now() / 1000) + 3600,
37
+ o: { o_id: 'org_456' },
38
+ };
39
+ }
40
+ return {
41
+ sub: 'user_123',
42
+ exp: Math.floor(Date.now() / 1000) + 3600,
43
+ };
44
+ }),
45
+ }));
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
+ // Import after mocking
55
+ import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
56
+ import { saveToken } from '../../lib/token-storage.js';
57
+ import { getOrgScopedToken } from '../../lib/clerk-api.js';
58
+
59
+ describe('isTokenExpired', () => {
60
+ it('returns true for null tokenData', () => {
61
+ expect(isTokenExpired(null)).toBe(true);
62
+ });
63
+
64
+ it('returns true for undefined tokenData', () => {
65
+ expect(isTokenExpired(undefined)).toBe(true);
66
+ });
67
+
68
+ it('returns true for empty object', () => {
69
+ expect(isTokenExpired({})).toBe(true);
70
+ });
71
+
72
+ it('returns true for missing decoded field', () => {
73
+ expect(isTokenExpired({ accessToken: 'test' })).toBe(true);
74
+ });
75
+
76
+ it('returns true for missing exp in decoded', () => {
77
+ expect(isTokenExpired({ decoded: { sub: 'user_123' } })).toBe(true);
78
+ });
79
+
80
+ it('returns true when token is already expired', () => {
81
+ const exp = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
82
+ expect(isTokenExpired({ decoded: { exp } })).toBe(true);
83
+ });
84
+
85
+ it('returns true when within 5-minute expiry buffer', () => {
86
+ const exp = Math.floor(Date.now() / 1000) + 120; // 2 minutes from now
87
+ expect(isTokenExpired({ decoded: { exp } })).toBe(true);
88
+ });
89
+
90
+ it('returns true when exactly at 5-minute buffer', () => {
91
+ const exp = Math.floor(Date.now() / 1000) + 300; // exactly 5 minutes from now
92
+ expect(isTokenExpired({ decoded: { exp } })).toBe(true);
93
+ });
94
+
95
+ it('returns false when more than 5 minutes remaining', () => {
96
+ const exp = Math.floor(Date.now() / 1000) + 600; // 10 minutes from now
97
+ expect(isTokenExpired({ decoded: { exp } })).toBe(false);
98
+ });
99
+
100
+ it('returns false when 6 minutes remaining', () => {
101
+ const exp = Math.floor(Date.now() / 1000) + 360; // 6 minutes from now
102
+ expect(isTokenExpired({ decoded: { exp } })).toBe(false);
103
+ });
104
+
105
+ it('returns false for far future expiration', () => {
106
+ const exp = 9999999999; // Far future
107
+ expect(isTokenExpired({ decoded: { exp } })).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('refreshAccessToken', () => {
112
+ const originalFetch = global.fetch;
113
+
114
+ beforeEach(() => {
115
+ vi.clearAllMocks();
116
+ });
117
+
118
+ afterEach(() => {
119
+ global.fetch = originalFetch;
120
+ });
121
+
122
+ it('throws error when no refresh_token', async () => {
123
+ await expect(refreshAccessToken({})).rejects.toThrow('No refresh token available');
124
+ });
125
+
126
+ it('throws error when refresh_token is undefined', async () => {
127
+ await expect(refreshAccessToken({ refresh_token: undefined })).rejects.toThrow('No refresh token available');
128
+ });
129
+
130
+ it('calls OAuth endpoint with correct parameters', async () => {
131
+ global.fetch = vi.fn().mockResolvedValue({
132
+ ok: true,
133
+ json: () => Promise.resolve({
134
+ access_token: 'new_access_token',
135
+ id_token: 'new_id_token',
136
+ refresh_token: 'new_refresh_token',
137
+ token_type: 'Bearer',
138
+ expires_in: 3600,
139
+ }),
140
+ });
141
+
142
+ await refreshAccessToken({ refresh_token: 'rt_123' });
143
+
144
+ expect(global.fetch).toHaveBeenCalledWith(
145
+ 'https://clerk.test.com/oauth/token',
146
+ expect.objectContaining({
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
149
+ })
150
+ );
151
+
152
+ // Verify the body contains correct params
153
+ const callArgs = global.fetch.mock.calls[0];
154
+ const body = callArgs[1].body;
155
+ expect(body.get('grant_type')).toBe('refresh_token');
156
+ expect(body.get('refresh_token')).toBe('rt_123');
157
+ expect(body.get('client_id')).toBe('test_client_id');
158
+ });
159
+
160
+ it('saves refreshed token to storage', async () => {
161
+ global.fetch = vi.fn().mockResolvedValue({
162
+ ok: true,
163
+ json: () => Promise.resolve({
164
+ access_token: 'new_access_token',
165
+ id_token: 'new_id_token',
166
+ refresh_token: 'new_refresh_token',
167
+ token_type: 'Bearer',
168
+ expires_in: 3600,
169
+ }),
170
+ });
171
+
172
+ await refreshAccessToken({ refresh_token: 'rt_123' });
173
+
174
+ expect(saveToken).toHaveBeenCalledWith(
175
+ expect.objectContaining({
176
+ accessToken: 'new_access_token',
177
+ idToken: expect.any(String),
178
+ tokenType: 'Bearer',
179
+ refresh_token: 'new_refresh_token',
180
+ })
181
+ );
182
+ });
183
+
184
+ it('preserves selectedOrg in refreshed token', async () => {
185
+ global.fetch = vi.fn().mockResolvedValue({
186
+ ok: true,
187
+ json: () => Promise.resolve({
188
+ access_token: 'new_access_token',
189
+ id_token: 'new_id_token',
190
+ refresh_token: 'new_refresh_token',
191
+ token_type: 'Bearer',
192
+ expires_in: 3600,
193
+ }),
194
+ });
195
+
196
+ const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
197
+ await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
198
+
199
+ expect(saveToken).toHaveBeenCalledWith(
200
+ expect.objectContaining({
201
+ selectedOrg: selectedOrg,
202
+ })
203
+ );
204
+ });
205
+
206
+ it('gets org-scoped token when selectedOrg exists and refreshed token has sid', async () => {
207
+ global.fetch = vi.fn().mockResolvedValue({
208
+ ok: true,
209
+ json: () => Promise.resolve({
210
+ access_token: 'new_access_token',
211
+ id_token: 'new_id_token_with_sid',
212
+ refresh_token: 'new_refresh_token',
213
+ token_type: 'Bearer',
214
+ expires_in: 3600,
215
+ }),
216
+ });
217
+
218
+ const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
219
+ await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
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
+ }),
238
+ });
239
+
240
+ // Make getOrgScopedToken fail
241
+ vi.mocked(getOrgScopedToken).mockRejectedValueOnce(new Error('API error'));
242
+
243
+ const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
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
250
+ });
251
+
252
+ it('throws error when OAuth refresh fails with 400', async () => {
253
+ global.fetch = vi.fn().mockResolvedValue({
254
+ ok: false,
255
+ status: 400,
256
+ text: () => Promise.resolve(JSON.stringify({
257
+ error: 'invalid_grant',
258
+ error_description: 'Refresh token expired',
259
+ })),
260
+ });
261
+
262
+ await expect(refreshAccessToken({ refresh_token: 'bad_rt' }))
263
+ .rejects.toThrow('Token refresh failed');
264
+ });
265
+
266
+ it('throws error when OAuth refresh fails with 401', async () => {
267
+ global.fetch = vi.fn().mockResolvedValue({
268
+ ok: false,
269
+ status: 401,
270
+ text: () => Promise.resolve('Unauthorized'),
271
+ });
272
+
273
+ await expect(refreshAccessToken({ refresh_token: 'bad_rt' }))
274
+ .rejects.toThrow('Token refresh failed');
275
+ });
276
+
277
+ it('throws error when network fails', async () => {
278
+ global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
279
+
280
+ await expect(refreshAccessToken({ refresh_token: 'rt_123' }))
281
+ .rejects.toThrow('Token refresh failed');
282
+ });
283
+
284
+ it('uses existing refresh_token if new one not provided', async () => {
285
+ global.fetch = vi.fn().mockResolvedValue({
286
+ ok: true,
287
+ json: () => Promise.resolve({
288
+ access_token: 'new_access_token',
289
+ id_token: 'new_id_token',
290
+ // No refresh_token in response
291
+ token_type: 'Bearer',
292
+ expires_in: 3600,
293
+ }),
294
+ });
295
+
296
+ await refreshAccessToken({ refresh_token: 'original_rt' });
297
+
298
+ expect(saveToken).toHaveBeenCalledWith(
299
+ expect.objectContaining({
300
+ refresh_token: 'original_rt', // Should preserve original
301
+ })
302
+ );
303
+ });
304
+ });
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Unit tests for lib/token-validation.js
3
+ *
4
+ * Tests token loading, expiration checking, and org claims recovery.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+
9
+ // Mock dependencies - using factory functions to avoid hoisting issues
10
+ vi.mock('../../lib/token-storage.js', () => ({
11
+ getToken: vi.fn(),
12
+ saveToken: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('../../lib/token-refresh.js', () => ({
16
+ isTokenExpired: vi.fn(),
17
+ refreshAccessToken: vi.fn(),
18
+ }));
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
+ // Import after mocking
37
+ import { loadAndRefreshToken } from '../../lib/token-validation.js';
38
+ import { getToken, saveToken } from '../../lib/token-storage.js';
39
+ 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
+
43
+ describe('loadAndRefreshToken', () => {
44
+ const futureExp = Math.floor(Date.now() / 1000) + 3600;
45
+
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ // Default: token not expired
49
+ vi.mocked(isTokenExpired).mockReturnValue(false);
50
+ });
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks();
54
+ });
55
+
56
+ describe('when not authenticated', () => {
57
+ it('throws error when requireAuth=true and no token', async () => {
58
+ vi.mocked(getToken).mockReturnValue(null);
59
+
60
+ await expect(loadAndRefreshToken(true))
61
+ .rejects.toThrow('Not authenticated');
62
+ });
63
+
64
+ it('throws error when requireAuth not specified (defaults to true) and no token', async () => {
65
+ vi.mocked(getToken).mockReturnValue(null);
66
+
67
+ await expect(loadAndRefreshToken())
68
+ .rejects.toThrow('Not authenticated');
69
+ });
70
+
71
+ it('returns null when requireAuth=false and no token', async () => {
72
+ vi.mocked(getToken).mockReturnValue(null);
73
+
74
+ const result = await loadAndRefreshToken(false);
75
+ expect(result).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('token expiration handling', () => {
80
+ it('refreshes token when expired', async () => {
81
+ const expiredToken = {
82
+ idToken: 'expired_token',
83
+ decoded: { exp: Math.floor(Date.now() / 1000) - 3600 },
84
+ refresh_token: 'rt_123',
85
+ };
86
+
87
+ vi.mocked(getToken).mockReturnValue(expiredToken);
88
+ vi.mocked(isTokenExpired).mockReturnValue(true);
89
+
90
+ const refreshedToken = {
91
+ idToken: 'new_token',
92
+ decoded: { exp: futureExp },
93
+ refresh_token: 'rt_new',
94
+ };
95
+ vi.mocked(refreshAccessToken).mockResolvedValue(refreshedToken);
96
+
97
+ const result = await loadAndRefreshToken();
98
+
99
+ expect(refreshAccessToken).toHaveBeenCalledWith(expiredToken);
100
+ expect(result).toEqual(refreshedToken);
101
+ });
102
+
103
+ it('throws when expired with no refresh_token', async () => {
104
+ const expiredToken = {
105
+ idToken: 'expired_token',
106
+ decoded: { exp: Math.floor(Date.now() / 1000) - 3600 },
107
+ // No refresh_token
108
+ };
109
+
110
+ vi.mocked(getToken).mockReturnValue(expiredToken);
111
+ vi.mocked(isTokenExpired).mockReturnValue(true);
112
+
113
+ await expect(loadAndRefreshToken())
114
+ .rejects.toThrow('no refresh token');
115
+ });
116
+
117
+ it('throws when refresh fails', async () => {
118
+ const expiredToken = {
119
+ idToken: 'expired_token',
120
+ decoded: { exp: Math.floor(Date.now() / 1000) - 3600 },
121
+ refresh_token: 'rt_123',
122
+ };
123
+
124
+ vi.mocked(getToken).mockReturnValue(expiredToken);
125
+ vi.mocked(isTokenExpired).mockReturnValue(true);
126
+ vi.mocked(refreshAccessToken).mockRejectedValue(new Error('Refresh failed'));
127
+
128
+ await expect(loadAndRefreshToken())
129
+ .rejects.toThrow('refresh failed');
130
+ });
131
+ });
132
+
133
+ describe('org claims recovery', () => {
134
+ it('recovers using decoded.sid when org claims missing', async () => {
135
+ const tokenMissingOrgClaims = {
136
+ idToken: 'token_no_org',
137
+ decoded: { exp: futureExp, sid: 'sess_123' },
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' },
285
+ refresh_token: 'rt_123',
286
+ // No originalSid either
287
+ };
288
+
289
+ vi.mocked(getToken).mockReturnValue(tokenNoSid);
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
+ };
325
+
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
+ const result = await loadAndRefreshToken();
336
+
337
+ expect(result).toBeDefined();
338
+ expect(result.idToken).toBe('token_no_sid');
339
+ });
340
+
341
+ it('logs warning when no recovery path available', async () => {
342
+ const fullyBrokenToken = {
343
+ idToken: 'broken_token',
344
+ decoded: { exp: futureExp },
345
+ selectedOrg: { id: 'org_456', name: 'Test Org' },
346
+ // No sid, no originalSid, no refresh_token
347
+ };
348
+
349
+ vi.mocked(getToken).mockReturnValue(fullyBrokenToken);
350
+
351
+ // Spy on console.error
352
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
353
+
354
+ const result = await loadAndRefreshToken();
355
+
356
+ // Should still return token (API calls may fail later)
357
+ expect(result).toBeDefined();
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();
365
+ });
366
+ });
367
+ });