@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 +8 -0
- package/CHANGELOG.md +20 -0
- package/README.md +12 -0
- package/commands/login.js +106 -9
- package/commands/org.js +135 -9
- package/index.js +14 -1
- package/lib/config.js +11 -1
- package/package.json +1 -1
- package/tests/fixtures/token-missing-org-claims.json +20 -0
- package/tests/fixtures/token-no-sid-has-original.json +20 -0
- package/tests/fixtures/token-with-org-claims.json +25 -0
- package/tests/mocks/clerk-api.mock.js +77 -0
- package/tests/unit/token-refresh.test.js +304 -0
- package/tests/unit/token-validation.test.js +367 -0
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
|
|
146
|
+
// Send success response to browser
|
|
147
147
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
148
148
|
res.end(`
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
<
|
|
154
|
-
<
|
|
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
|
-
//
|
|
247
|
-
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
@@ -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
|
+
});
|