@shipfox/client-auth 6.0.1 → 6.0.3
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +20 -0
- package/dist/components/redirect-context.d.ts.map +1 -1
- package/dist/components/redirect-context.js +6 -9
- package/dist/components/redirect-context.js.map +1 -1
- package/dist/components/redirect-target.d.ts +7 -0
- package/dist/components/redirect-target.d.ts.map +1 -1
- package/dist/components/redirect-target.js +46 -12
- package/dist/components/redirect-target.js.map +1 -1
- package/dist/components/workspace-switcher.d.ts.map +1 -1
- package/dist/components/workspace-switcher.js +3 -2
- package/dist/components/workspace-switcher.js.map +1 -1
- package/dist/hooks/api/login-auth.js +1 -1
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.js +1 -1
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +1 -1
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +1 -1
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
- package/dist/pages/workspace-onboarding-page.js +4 -1
- package/dist/pages/workspace-onboarding-page.js.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -3
- package/dist/routes/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/components/auth-provider.test.tsx +132 -19
- package/src/components/redirect-context.test.ts +13 -0
- package/src/components/redirect-context.ts +5 -8
- package/src/components/redirect-target.test.ts +47 -2
- package/src/components/redirect-target.ts +54 -12
- package/src/components/workspace-switcher.tsx +3 -2
- package/src/hooks/api/login-auth.ts +1 -1
- package/src/hooks/api/logout-auth.ts +1 -1
- package/src/hooks/api/password-reset-auth.ts +1 -1
- package/src/hooks/api/verify-email-auth.ts +1 -1
- package/src/pages/workspace-onboarding-page.tsx +4 -1
- package/src/routes/index.tsx +5 -3
- package/src/state/last-workspace.test.ts +21 -28
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "6.0.
|
|
4
|
+
"version": "6.0.3",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@swc/helpers": "^0.5.17",
|
|
33
33
|
"@tanstack/react-form": "^1.32.0",
|
|
34
|
-
"@shipfox/api-auth-dto": "9.0.
|
|
35
|
-
"@shipfox/api-workspaces-dto": "9.0.
|
|
34
|
+
"@shipfox/api-auth-dto": "9.0.2",
|
|
35
|
+
"@shipfox/api-workspaces-dto": "9.0.2",
|
|
36
36
|
"@shipfox/client-api": "6.0.1",
|
|
37
|
-
"@shipfox/client-invitations": "6.0.
|
|
38
|
-
"@shipfox/client-shell": "6.0.
|
|
39
|
-
"@shipfox/client-ui": "6.0.
|
|
40
|
-
"@shipfox/react-ui": "0.3.
|
|
37
|
+
"@shipfox/client-invitations": "6.0.2",
|
|
38
|
+
"@shipfox/client-shell": "6.0.2",
|
|
39
|
+
"@shipfox/client-ui": "6.0.2",
|
|
40
|
+
"@shipfox/react-ui": "0.3.7"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
-
import {QueryClient, useQuery} from '@tanstack/react-query';
|
|
1
|
+
import {ApiError, configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query';
|
|
3
3
|
import {render, screen, waitFor} from '@testing-library/react';
|
|
4
4
|
import {useEffect, useRef} from 'react';
|
|
5
5
|
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
@@ -45,6 +45,8 @@ function StatusProbe() {
|
|
|
45
45
|
|
|
46
46
|
function PrivateDataProbe() {
|
|
47
47
|
const auth = useAuthState();
|
|
48
|
+
const queryClient = useQueryClient();
|
|
49
|
+
const seeded = useRef(false);
|
|
48
50
|
const privateData = useQuery({
|
|
49
51
|
queryKey: ['private-data'],
|
|
50
52
|
queryFn: () => new Promise<string>(() => undefined),
|
|
@@ -52,6 +54,12 @@ function PrivateDataProbe() {
|
|
|
52
54
|
});
|
|
53
55
|
const login = useLoginAuth();
|
|
54
56
|
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!auth.isAuthenticated || seeded.current) return;
|
|
59
|
+
seeded.current = true;
|
|
60
|
+
queryClient.setQueryData(['private-data'], 'User A private data');
|
|
61
|
+
}, [auth.isAuthenticated, queryClient]);
|
|
62
|
+
|
|
55
63
|
return (
|
|
56
64
|
<div>
|
|
57
65
|
<span data-testid="private-data">{privateData.data ?? 'loading'}</span>
|
|
@@ -114,6 +122,24 @@ describe('AuthProvider', () => {
|
|
|
114
122
|
expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
|
|
115
123
|
});
|
|
116
124
|
|
|
125
|
+
test('clears a hydrated private cache before accepting the first principal', async () => {
|
|
126
|
+
const queryClient = new QueryClient();
|
|
127
|
+
const fetchImpl = vi
|
|
128
|
+
.fn()
|
|
129
|
+
.mockResolvedValue(jsonResponse({token: 'other-token', user: otherUser}));
|
|
130
|
+
configureApiClient({fetchImpl});
|
|
131
|
+
queryClient.setQueryData(['private-data'], 'previous principal private data');
|
|
132
|
+
|
|
133
|
+
render(
|
|
134
|
+
<AuthProvider queryClient={queryClient}>
|
|
135
|
+
<StatusProbe />
|
|
136
|
+
</AuthProvider>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
|
|
140
|
+
expect(queryClient.getQueryData(['private-data'])).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
117
143
|
test('keeps the current session when refresh fails with a server error', async () => {
|
|
118
144
|
const fetchImpl = vi
|
|
119
145
|
.fn()
|
|
@@ -156,21 +182,30 @@ describe('AuthProvider', () => {
|
|
|
156
182
|
.fn()
|
|
157
183
|
.mockImplementation(() => Promise.resolve(jsonResponse({token: 'access-token', user})));
|
|
158
184
|
configureApiClient({fetchImpl});
|
|
159
|
-
queryClient.setQueryData(['private-data'], 'current user private data');
|
|
160
185
|
const onDone = vi.fn();
|
|
186
|
+
const onReady = vi.fn();
|
|
161
187
|
|
|
162
188
|
function RefreshAfterBootProbe() {
|
|
163
189
|
const auth = useAuthState();
|
|
164
190
|
const refresh = useRefreshAuth();
|
|
165
|
-
const
|
|
191
|
+
const queryClient = useQueryClient();
|
|
192
|
+
const didSeed = useRef(false);
|
|
166
193
|
|
|
167
194
|
useEffect(() => {
|
|
168
|
-
if (auth.status !== 'authenticated' ||
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
if (auth.status !== 'authenticated' || didSeed.current) return;
|
|
196
|
+
didSeed.current = true;
|
|
197
|
+
queryClient.setQueryData(['private-data'], 'current user private data');
|
|
198
|
+
onReady();
|
|
199
|
+
}, [auth.status, queryClient]);
|
|
172
200
|
|
|
173
|
-
return
|
|
201
|
+
return (
|
|
202
|
+
<>
|
|
203
|
+
<StatusProbe />
|
|
204
|
+
<button type="button" onClick={() => void refresh().then(onDone)}>
|
|
205
|
+
Refresh
|
|
206
|
+
</button>
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
174
209
|
}
|
|
175
210
|
|
|
176
211
|
render(
|
|
@@ -179,6 +214,9 @@ describe('AuthProvider', () => {
|
|
|
179
214
|
</AuthProvider>,
|
|
180
215
|
);
|
|
181
216
|
|
|
217
|
+
await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1));
|
|
218
|
+
cancelQueries.mockClear();
|
|
219
|
+
screen.getByRole('button', {name: 'Refresh'}).click();
|
|
182
220
|
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
|
183
221
|
expect(cancelQueries).not.toHaveBeenCalled();
|
|
184
222
|
expect(queryClient.getQueryData(['private-data'])).toBe('current user private data');
|
|
@@ -211,6 +249,80 @@ describe('AuthProvider', () => {
|
|
|
211
249
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
212
250
|
});
|
|
213
251
|
|
|
252
|
+
test('does not restore a stale refresh result after a principal transition', async () => {
|
|
253
|
+
let refreshRequestCount = 0;
|
|
254
|
+
let workspaceRequestCount = 0;
|
|
255
|
+
const initialWorkspaceRefresh = new Promise<Response>(() => undefined);
|
|
256
|
+
const onFreshRefresh = vi.fn();
|
|
257
|
+
const onRefreshError = vi.fn();
|
|
258
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
259
|
+
const url = (input as Request).url;
|
|
260
|
+
if (url.endsWith('/auth/refresh')) {
|
|
261
|
+
refreshRequestCount += 1;
|
|
262
|
+
return refreshRequestCount === 1
|
|
263
|
+
? Promise.resolve(jsonResponse({token: 'access-token', user}))
|
|
264
|
+
: Promise.resolve(jsonResponse({token: 'other-token-2', user: otherUser}));
|
|
265
|
+
}
|
|
266
|
+
if (url.endsWith('/auth/login'))
|
|
267
|
+
return Promise.resolve(jsonResponse({token: 'other-token', user: otherUser}));
|
|
268
|
+
if (url.endsWith('/workspaces')) {
|
|
269
|
+
workspaceRequestCount += 1;
|
|
270
|
+
return workspaceRequestCount === 1
|
|
271
|
+
? initialWorkspaceRefresh
|
|
272
|
+
: Promise.resolve(jsonResponse({memberships: []}));
|
|
273
|
+
}
|
|
274
|
+
return Promise.resolve(jsonResponse({token: 'access-token', user}));
|
|
275
|
+
});
|
|
276
|
+
configureApiClient({fetchImpl});
|
|
277
|
+
|
|
278
|
+
function RefreshAndLoginProbe() {
|
|
279
|
+
const auth = useAuthState();
|
|
280
|
+
const login = useLoginAuth();
|
|
281
|
+
const refresh = useRefreshAuth();
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
void refresh().catch(onRefreshError);
|
|
285
|
+
}, [refresh]);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div>
|
|
289
|
+
<span data-testid="email">{auth.user?.email ?? 'none'}</span>
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={() =>
|
|
293
|
+
void login.mutateAsync({email: 'other@example.com', password: 'password'})
|
|
294
|
+
}
|
|
295
|
+
>
|
|
296
|
+
Login as other user
|
|
297
|
+
</button>
|
|
298
|
+
<button type="button" onClick={() => void refresh().then(onFreshRefresh)}>
|
|
299
|
+
Refresh current principal
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
render(
|
|
306
|
+
<AuthProvider>
|
|
307
|
+
<RefreshAndLoginProbe />
|
|
308
|
+
</AuthProvider>,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await waitFor(() => expect(workspaceRequestCount).toBe(1));
|
|
312
|
+
screen.getByRole('button', {name: 'Login as other user'}).click();
|
|
313
|
+
await waitFor(() => expect(screen.getByTestId('email')).toHaveTextContent('other@example.com'));
|
|
314
|
+
await waitFor(() => expect(onRefreshError).toHaveBeenCalledTimes(1));
|
|
315
|
+
const refreshError = onRefreshError.mock.calls[0]?.[0];
|
|
316
|
+
expect(refreshError).toBeInstanceOf(ApiError);
|
|
317
|
+
expect(refreshError).toMatchObject({code: 'unauthorized', status: 401});
|
|
318
|
+
|
|
319
|
+
screen.getByRole('button', {name: 'Refresh current principal'}).click();
|
|
320
|
+
await waitFor(() => expect(onFreshRefresh).toHaveBeenCalledTimes(1));
|
|
321
|
+
expect(refreshRequestCount).toBe(2);
|
|
322
|
+
|
|
323
|
+
expect(screen.getByTestId('email')).toHaveTextContent('other@example.com');
|
|
324
|
+
});
|
|
325
|
+
|
|
214
326
|
test('logout clears local state even when the API call fails', async () => {
|
|
215
327
|
const queryClient = new QueryClient();
|
|
216
328
|
const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
|
|
@@ -219,8 +331,16 @@ describe('AuthProvider', () => {
|
|
|
219
331
|
.mockResolvedValueOnce(jsonResponse({token: 'access-token', user}))
|
|
220
332
|
.mockRejectedValueOnce(new Error('offline'));
|
|
221
333
|
configureApiClient({fetchImpl});
|
|
222
|
-
queryClient.setQueryData(['private-data'], 'private data');
|
|
223
334
|
const onQueryAbort = vi.fn();
|
|
335
|
+
|
|
336
|
+
render(
|
|
337
|
+
<AuthProvider queryClient={queryClient}>
|
|
338
|
+
<StatusProbe />
|
|
339
|
+
</AuthProvider>,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
|
|
343
|
+
queryClient.setQueryData(['private-data'], 'private data');
|
|
224
344
|
void queryClient
|
|
225
345
|
.fetchQuery({
|
|
226
346
|
queryKey: ['in-flight-private-data'],
|
|
@@ -233,14 +353,7 @@ describe('AuthProvider', () => {
|
|
|
233
353
|
}),
|
|
234
354
|
})
|
|
235
355
|
.catch(() => undefined);
|
|
236
|
-
|
|
237
|
-
render(
|
|
238
|
-
<AuthProvider queryClient={queryClient}>
|
|
239
|
-
<StatusProbe />
|
|
240
|
-
</AuthProvider>,
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
|
|
356
|
+
cancelQueries.mockClear();
|
|
244
357
|
screen.getByRole('button', {name: 'Logout'}).click();
|
|
245
358
|
|
|
246
359
|
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
|
|
@@ -312,7 +425,6 @@ describe('AuthProvider', () => {
|
|
|
312
425
|
return Promise.resolve(jsonResponse({token: 'access-token', user}));
|
|
313
426
|
});
|
|
314
427
|
configureApiClient({fetchImpl});
|
|
315
|
-
queryClient.setQueryData(['private-data'], 'User A private data');
|
|
316
428
|
|
|
317
429
|
render(
|
|
318
430
|
<AuthProvider queryClient={queryClient}>
|
|
@@ -323,6 +435,7 @@ describe('AuthProvider', () => {
|
|
|
323
435
|
await waitFor(() =>
|
|
324
436
|
expect(screen.getByTestId('private-data')).toHaveTextContent('User A private data'),
|
|
325
437
|
);
|
|
438
|
+
cancelQueries.mockClear();
|
|
326
439
|
screen.getByRole('button', {name: 'Switch user'}).click();
|
|
327
440
|
|
|
328
441
|
await waitFor(() =>
|
|
@@ -22,12 +22,25 @@ describe('parseRedirectContext', () => {
|
|
|
22
22
|
expect(context).toEqual({invitationToken: 'raw-invitation-token'});
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
test.each([
|
|
26
|
+
['/%2569nvitations/accept?token=double-encoded-token', 'double-encoded-token'],
|
|
27
|
+
['/%252569nvitations/accept?token=triple-encoded-token', 'triple-encoded-token'],
|
|
28
|
+
])('separates an invitation token from a deeply encoded path: %s', (redirect, token) => {
|
|
29
|
+
const context = parseRedirectContext(redirect);
|
|
30
|
+
|
|
31
|
+
expect(context).toEqual({invitationToken: token});
|
|
32
|
+
expect(context.returnTo).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
25
35
|
test.each([
|
|
26
36
|
'https://attacker.example',
|
|
27
37
|
'//attacker.example',
|
|
28
38
|
'/auth/login',
|
|
29
39
|
'/%61uth/login',
|
|
40
|
+
'/%2561uth/login',
|
|
41
|
+
'/%252561uth/login',
|
|
30
42
|
'/%E0%80%80',
|
|
43
|
+
'/safe/%25252e%25252e/auth/login',
|
|
31
44
|
])('rejects malformed or unsafe redirect %s', (redirect) => {
|
|
32
45
|
const context = parseRedirectContext(redirect);
|
|
33
46
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {isAuthPath, resolveRedirectPath} from './redirect-target.js';
|
|
2
2
|
|
|
3
3
|
const INVITATION_ACCEPT_PATH = '/invitations/accept';
|
|
4
4
|
|
|
@@ -13,14 +13,11 @@ export interface RedirectContext {
|
|
|
13
13
|
* short-lived invitation flow instead of forwarding it through generic redirects.
|
|
14
14
|
*/
|
|
15
15
|
export function parseRedirectContext(value: unknown): RedirectContext {
|
|
16
|
-
const
|
|
17
|
-
if (!
|
|
16
|
+
const resolved = resolveRedirectPath(value);
|
|
17
|
+
if (!resolved || isAuthPath(resolved.pathname)) return {};
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
const [path, queryString = ''] = decoded.split('?', 2);
|
|
21
|
-
if (path !== INVITATION_ACCEPT_PATH) return {returnTo: redirect};
|
|
19
|
+
if (resolved.pathname !== INVITATION_ACCEPT_PATH) return {returnTo: resolved.redirect};
|
|
22
20
|
|
|
23
|
-
const
|
|
24
|
-
const invitationToken = params.get('token');
|
|
21
|
+
const invitationToken = resolved.target.searchParams.get('token');
|
|
25
22
|
return invitationToken ? {invitationToken} : {};
|
|
26
23
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import {sanitizeLogoutRedirectPath, sanitizeRedirectPath} from './redirect-target.js';
|
|
2
2
|
|
|
3
|
+
function encodeRepeatedly(value: string, times: number): string {
|
|
4
|
+
let encoded = value;
|
|
5
|
+
for (let iteration = 0; iteration < times; iteration += 1) {
|
|
6
|
+
encoded = encodeURIComponent(encoded);
|
|
7
|
+
}
|
|
8
|
+
return encoded;
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
describe('sanitizeRedirectPath', () => {
|
|
4
12
|
describe.each([
|
|
5
13
|
['simple absolute path', '/foo'],
|
|
@@ -41,8 +49,12 @@ describe('sanitizeRedirectPath', () => {
|
|
|
41
49
|
});
|
|
42
50
|
|
|
43
51
|
describe('decode-then-check defenses', () => {
|
|
44
|
-
test(
|
|
45
|
-
|
|
52
|
+
test.each([
|
|
53
|
+
['single-encoded', '/%61uth/login'],
|
|
54
|
+
['double-encoded', '/%2561uth/login'],
|
|
55
|
+
['triple-encoded', '/%252561uth/login'],
|
|
56
|
+
])('rejects %s /auth/* path', (_label, input) => {
|
|
57
|
+
const result = sanitizeRedirectPath(input);
|
|
46
58
|
|
|
47
59
|
expect(result).toBeUndefined();
|
|
48
60
|
});
|
|
@@ -64,6 +76,30 @@ describe('sanitizeRedirectPath', () => {
|
|
|
64
76
|
|
|
65
77
|
expect(result).toBeUndefined();
|
|
66
78
|
});
|
|
79
|
+
|
|
80
|
+
test('fails closed when the path exceeds the decode iteration cap', () => {
|
|
81
|
+
const deeplyEncodedAuthPath = `/${encodeRepeatedly('%61', 10)}uth/login`;
|
|
82
|
+
|
|
83
|
+
expect(sanitizeRedirectPath(deeplyEncodedAuthPath)).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('rejects malformed percent-encoding that only surfaces after the first decode', () => {
|
|
87
|
+
const result = sanitizeRedirectPath('/%25E0%2580%2580');
|
|
88
|
+
|
|
89
|
+
expect(result).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('rejects an /auth/* path disguised behind encoded dot segments', () => {
|
|
93
|
+
const result = sanitizeRedirectPath('/safe/%25252e%25252e/auth/login');
|
|
94
|
+
|
|
95
|
+
expect(result).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rejects a protocol-relative URL revealed only after multiple decodes', () => {
|
|
99
|
+
const result = sanitizeRedirectPath('/%25252fevil.com');
|
|
100
|
+
|
|
101
|
+
expect(result).toBeUndefined();
|
|
102
|
+
});
|
|
67
103
|
});
|
|
68
104
|
});
|
|
69
105
|
|
|
@@ -90,7 +126,16 @@ describe('sanitizeLogoutRedirectPath', () => {
|
|
|
90
126
|
['login route with a query', '/auth/login?redirect=/workspaces/abc'],
|
|
91
127
|
['raw invitation token', '/invitations/accept?token=sf_i_raw-token'],
|
|
92
128
|
['raw invitation token with trailing slash', '/invitations/accept/?token=sf_i_raw-token'],
|
|
129
|
+
['double-encoded auth route', '/%2561uth/login'],
|
|
130
|
+
['triple-encoded auth route', '/%252561uth/login'],
|
|
131
|
+
['double-encoded invitation token', '/%2569nvitations/accept?token=sf_i_raw-token'],
|
|
132
|
+
['triple-encoded invitation token', '/%252569nvitations/accept?token=sf_i_raw-token'],
|
|
93
133
|
['malformed percent encoding', '/%E0%80%80'],
|
|
134
|
+
[
|
|
135
|
+
'invitation token disguised behind encoded dot segments',
|
|
136
|
+
'/safe/%25252e%25252e/invitations/accept?token=sf_i_raw-token',
|
|
137
|
+
],
|
|
138
|
+
['auth route disguised behind encoded dot segments', '/safe/%25252e%25252e/auth/login'],
|
|
94
139
|
])('falls back for %s', (_label, input) => {
|
|
95
140
|
test('returns login without forwarding unsafe state', () => {
|
|
96
141
|
expect(sanitizeLogoutRedirectPath(input)).toBe('/auth/login');
|
|
@@ -3,8 +3,38 @@ const LOGIN_PATH = '/auth/login';
|
|
|
3
3
|
const INVITATION_ACCEPT_PATH = '/invitations/accept';
|
|
4
4
|
const DEFAULT_LOGOUT_REDIRECT = LOGIN_PATH;
|
|
5
5
|
const TRAILING_SLASHES = /\/+$/;
|
|
6
|
+
const MAX_PATH_DECODE_ITERATIONS = 10;
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
export interface ResolvedRedirectPath {
|
|
9
|
+
pathname: string;
|
|
10
|
+
redirect: string;
|
|
11
|
+
target: URL;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Repeated decoding can reveal `.`/`..` segments or a `//host` prefix that were
|
|
15
|
+
// hidden behind extra encoding layers (e.g. `%25252e%25252e` -> `..`). Those only
|
|
16
|
+
// get collapsed and re-checked against the origin by feeding the fully-decoded,
|
|
17
|
+
// percent-free string back through the URL parser; without this, a multiply
|
|
18
|
+
// encoded `/safe/../auth/login` or `/safe/../invitations/accept?token=...`
|
|
19
|
+
// evades the pathname checks below even though this loop "sees" the real path.
|
|
20
|
+
function decodePathToFixedPoint(pathname: string): string | undefined {
|
|
21
|
+
let current = pathname;
|
|
22
|
+
|
|
23
|
+
for (let iteration = 0; iteration < MAX_PATH_DECODE_ITERATIONS; iteration += 1) {
|
|
24
|
+
let decoded: string;
|
|
25
|
+
try {
|
|
26
|
+
decoded = decodeURIComponent(current);
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (decoded === current) return current;
|
|
31
|
+
current = decoded;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveRedirectPath(value: unknown): ResolvedRedirectPath | undefined {
|
|
8
38
|
if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
|
|
9
39
|
let decoded: string;
|
|
10
40
|
try {
|
|
@@ -19,27 +49,36 @@ function resolveRedirectPath(value: unknown): URL | undefined {
|
|
|
19
49
|
return undefined;
|
|
20
50
|
}
|
|
21
51
|
if (target.origin !== REDIRECT_ORIGIN) return undefined;
|
|
22
|
-
|
|
52
|
+
const decodedPathname = decodePathToFixedPoint(target.pathname);
|
|
53
|
+
if (decodedPathname === undefined) return undefined;
|
|
54
|
+
let canonical: URL;
|
|
55
|
+
try {
|
|
56
|
+
canonical = new URL(decodedPathname, REDIRECT_ORIGIN);
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (canonical.origin !== REDIRECT_ORIGIN) return undefined;
|
|
61
|
+
return {pathname: canonical.pathname, redirect: formatRedirectPath(target), target};
|
|
23
62
|
}
|
|
24
63
|
|
|
25
64
|
function formatRedirectPath(target: URL): string {
|
|
26
65
|
return `${target.pathname}${target.search}${target.hash}`;
|
|
27
66
|
}
|
|
28
67
|
|
|
29
|
-
function isAuthPath(pathname: string): boolean {
|
|
68
|
+
export function isAuthPath(pathname: string): boolean {
|
|
30
69
|
return pathname === '/auth' || pathname.startsWith('/auth/');
|
|
31
70
|
}
|
|
32
71
|
|
|
33
72
|
// Resolves and canonicalizes an internal path before returning it, so browser URL
|
|
34
73
|
// parsing cannot turn a seemingly safe path into an external or auth route.
|
|
35
74
|
export function sanitizeRedirectPath(value: unknown): string | undefined {
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
38
|
-
return
|
|
75
|
+
const resolved = resolveRedirectPath(value);
|
|
76
|
+
if (!resolved || isAuthPath(resolved.pathname)) return undefined;
|
|
77
|
+
return resolved.redirect;
|
|
39
78
|
}
|
|
40
79
|
|
|
41
|
-
function containsInvitationToken(target: URL): boolean {
|
|
42
|
-
const normalizedPathname =
|
|
80
|
+
function containsInvitationToken(target: URL, pathname: string): boolean {
|
|
81
|
+
const normalizedPathname = pathname.replace(TRAILING_SLASHES, '') || '/';
|
|
43
82
|
return normalizedPathname === INVITATION_ACCEPT_PATH && target.searchParams.has('token');
|
|
44
83
|
}
|
|
45
84
|
|
|
@@ -50,10 +89,13 @@ function containsInvitationToken(target: URL): boolean {
|
|
|
50
89
|
* survive this boundary. Invalid values fail closed to login.
|
|
51
90
|
*/
|
|
52
91
|
export function sanitizeLogoutRedirectPath(value: unknown): string {
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
55
|
-
if (
|
|
92
|
+
const resolved = resolveRedirectPath(value);
|
|
93
|
+
if (!resolved) return DEFAULT_LOGOUT_REDIRECT;
|
|
94
|
+
if (
|
|
95
|
+
isAuthPath(resolved.pathname) ||
|
|
96
|
+
containsInvitationToken(resolved.target, resolved.pathname)
|
|
97
|
+
) {
|
|
56
98
|
return DEFAULT_LOGOUT_REDIRECT;
|
|
57
99
|
}
|
|
58
|
-
return
|
|
100
|
+
return resolved.redirect;
|
|
59
101
|
}
|
|
@@ -11,7 +11,7 @@ import {Icon} from '@shipfox/react-ui/icon';
|
|
|
11
11
|
import {useNavigate} from '@tanstack/react-router';
|
|
12
12
|
import {useSetAtom} from 'jotai';
|
|
13
13
|
import {useAuthState} from '#hooks/use-auth-state.js';
|
|
14
|
-
import {lastWorkspaceIdAtom} from '#state/last-workspace.js';
|
|
14
|
+
import {lastWorkspaceIdAtom, rememberLastWorkspaceId} from '#state/last-workspace.js';
|
|
15
15
|
|
|
16
16
|
export interface WorkspaceSwitcherProps {
|
|
17
17
|
activeWorkspaceId: string | undefined;
|
|
@@ -19,13 +19,14 @@ export interface WorkspaceSwitcherProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function WorkspaceSwitcher({activeWorkspaceId, onSelect}: WorkspaceSwitcherProps) {
|
|
22
|
-
const {workspaces} = useAuthState();
|
|
22
|
+
const {user, workspaces} = useAuthState();
|
|
23
23
|
const navigate = useNavigate();
|
|
24
24
|
const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
|
|
25
25
|
|
|
26
26
|
const handleSelect = (workspaceId: string) => {
|
|
27
27
|
try {
|
|
28
28
|
setLastWorkspaceId(workspaceId);
|
|
29
|
+
if (user?.id) rememberLastWorkspaceId(user.id, workspaceId);
|
|
29
30
|
} catch {
|
|
30
31
|
// localStorage may throw in private browsing or quota-exceeded; persistence is best-effort.
|
|
31
32
|
}
|
|
@@ -12,7 +12,8 @@ import {useNavigate} from '@tanstack/react-router';
|
|
|
12
12
|
import {useSetAtom} from 'jotai';
|
|
13
13
|
import {useState} from 'react';
|
|
14
14
|
import {useCreateWorkspaceAuth} from '#hooks/api/workspace-auth.js';
|
|
15
|
-
import {
|
|
15
|
+
import {useAuthState} from '#hooks/use-auth-state.js';
|
|
16
|
+
import {lastWorkspaceIdAtom, rememberLastWorkspaceId} from '#state/last-workspace.js';
|
|
16
17
|
import {workspaceOnboardingErrorToFormError} from './form-errors.js';
|
|
17
18
|
|
|
18
19
|
const previewMetrics = [
|
|
@@ -34,6 +35,7 @@ const previewBars = [
|
|
|
34
35
|
|
|
35
36
|
export function WorkspaceOnboardingPage() {
|
|
36
37
|
const createWorkspace = useCreateWorkspaceAuth();
|
|
38
|
+
const {user} = useAuthState();
|
|
37
39
|
const navigate = useNavigate();
|
|
38
40
|
const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
|
|
39
41
|
const [formError, setFormError] = useState<string | undefined>();
|
|
@@ -50,6 +52,7 @@ export function WorkspaceOnboardingPage() {
|
|
|
50
52
|
// future visits to `/` land on it.
|
|
51
53
|
try {
|
|
52
54
|
setLastWorkspaceId(created.id);
|
|
55
|
+
if (user?.id) rememberLastWorkspaceId(user.id, created.id);
|
|
53
56
|
} catch {
|
|
54
57
|
// localStorage may throw in private browsing or quota-exceeded.
|
|
55
58
|
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -10,9 +10,11 @@ export default defineRoute({
|
|
|
10
10
|
if (!auth.isAuthenticated) throw redirect({to: '/auth/login'});
|
|
11
11
|
const [first, ...rest] = auth.workspaces;
|
|
12
12
|
if (!first) throw redirect({to: '/setup/workspaces/new'});
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const principalId = auth.user?.id;
|
|
14
|
+
const target = principalId
|
|
15
|
+
? [first, ...rest].find((workspace) => workspace.id === getLastWorkspaceId(principalId))
|
|
16
|
+
: undefined;
|
|
17
|
+
throw redirect({to: '/workspaces/$wid', params: {wid: (target ?? first).id}});
|
|
16
18
|
},
|
|
17
19
|
component: FullPageLoader,
|
|
18
20
|
});
|