@hexclave/js 1.0.5 → 1.0.8
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/dist/dev-tool/dev-tool-core.js +2 -2
- package/dist/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/dev-tool/dev-tool-core.js +2 -2
- package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/generated/env.d.ts +26 -0
- package/dist/esm/{lib → generated}/env.d.ts.map +1 -1
- package/dist/esm/generated/env.js +67 -0
- package/dist/esm/generated/env.js.map +1 -0
- package/dist/esm/global.d.ts +8 -0
- package/dist/esm/global.d.ts.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +53 -26
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
- package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js +28 -14
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
- package/dist/esm/lib/hexclave-app/url-targets.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/url-targets.js +25 -11
- package/dist/esm/lib/hexclave-app/url-targets.js.map +1 -1
- package/dist/esm/lib/hexclave-app/url-targets.test.js +12 -0
- package/dist/esm/lib/hexclave-app/url-targets.test.js.map +1 -1
- package/dist/generated/env.d.ts +26 -0
- package/dist/{lib → generated}/env.d.ts.map +1 -1
- package/dist/generated/env.js +69 -0
- package/dist/generated/env.js.map +1 -0
- package/dist/global.d.d.ts +0 -0
- package/dist/global.d.ts +8 -0
- package/dist/global.d.ts.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +52 -25
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
- package/dist/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.js +28 -14
- package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
- package/dist/lib/hexclave-app/url-targets.d.ts.map +1 -1
- package/dist/lib/hexclave-app/url-targets.js +25 -11
- package/dist/lib/hexclave-app/url-targets.js.map +1 -1
- package/dist/lib/hexclave-app/url-targets.test.js +12 -0
- package/dist/lib/hexclave-app/url-targets.test.js.map +1 -1
- package/package.json +8 -4
- package/src/dev-tool/dev-tool-core.ts +2 -2
- package/src/generated/.gitignore +3 -0
- package/src/global.css +13 -0
- package/src/global.d.ts +12 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +316 -3
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +69 -25
- package/src/lib/hexclave-app/apps/implementations/common.ts +33 -14
- package/src/lib/hexclave-app/url-targets.test.ts +17 -0
- package/src/lib/hexclave-app/url-targets.ts +25 -7
- package/dist/esm/lib/env.d.ts +0 -42
- package/dist/esm/lib/env.js +0 -93
- package/dist/esm/lib/env.js.map +0 -1
- package/dist/lib/env.d.ts +0 -42
- package/dist/lib/env.js +0 -95
- package/dist/lib/env.js.map +0 -1
- package/src/lib/env.ts +0 -93
|
@@ -3,8 +3,39 @@
|
|
|
3
3
|
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
4
|
//===========================================
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AccessToken } from "@hexclave/shared/dist/sessions";
|
|
7
|
+
import { Store } from "@hexclave/shared/dist/utils/stores";
|
|
6
8
|
import { StackClientApp } from "../interfaces/client-app";
|
|
7
9
|
|
|
10
|
+
function createAccessTokenString(refreshTokenId: string): string {
|
|
11
|
+
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
12
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
13
|
+
return [
|
|
14
|
+
encode({ alg: "none", typ: "JWT" }),
|
|
15
|
+
encode({
|
|
16
|
+
sub: "user-id",
|
|
17
|
+
exp: nowSeconds + 60,
|
|
18
|
+
iat: nowSeconds,
|
|
19
|
+
iss: "https://api.example.test",
|
|
20
|
+
aud: "project-id",
|
|
21
|
+
project_id: "project-id",
|
|
22
|
+
branch_id: "main",
|
|
23
|
+
refresh_token_id: refreshTokenId,
|
|
24
|
+
role: "authenticated",
|
|
25
|
+
name: null,
|
|
26
|
+
email: null,
|
|
27
|
+
email_verified: false,
|
|
28
|
+
selected_team_id: null,
|
|
29
|
+
signed_up_at: nowSeconds,
|
|
30
|
+
is_anonymous: false,
|
|
31
|
+
is_restricted: false,
|
|
32
|
+
restricted_reason: null,
|
|
33
|
+
requires_totp_mfa: false,
|
|
34
|
+
}),
|
|
35
|
+
"",
|
|
36
|
+
].join(".");
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
function createMockDocument(): Document {
|
|
9
40
|
const cookieJar = new Map<string, string>();
|
|
10
41
|
return {
|
|
@@ -23,12 +54,13 @@ function createMockDocument(): Document {
|
|
|
23
54
|
|
|
24
55
|
describe("StackClientApp cross-domain auth", () => {
|
|
25
56
|
it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => {
|
|
57
|
+
const freshAccessToken = createAccessTokenString("fresh-refresh-token-id");
|
|
26
58
|
const clientApp = new StackClientApp({
|
|
27
59
|
baseUrl: "http://localhost:12345",
|
|
28
60
|
projectId: "00000000-0000-4000-8000-000000000000",
|
|
29
61
|
publishableClientKey: "stack-pk-test",
|
|
30
62
|
tokenStore: {
|
|
31
|
-
accessToken: "stale-
|
|
63
|
+
accessToken: createAccessTokenString("stale-refresh-token-id"),
|
|
32
64
|
refreshToken: "stale-refresh-token",
|
|
33
65
|
},
|
|
34
66
|
redirectMethod: "none",
|
|
@@ -37,24 +69,43 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
37
69
|
|
|
38
70
|
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
39
71
|
const originalSendClientRequest = Reflect.get(clientInterface, "sendClientRequest");
|
|
72
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
40
73
|
const capturedRefreshTokens: string[] = [];
|
|
74
|
+
const capturedAccessTokenRefreshTokenIds: string[] = [];
|
|
75
|
+
const refreshedRawRefreshTokens: string[] = [];
|
|
41
76
|
|
|
42
77
|
Reflect.set(clientInterface, "sendClientRequest", async (_path: unknown, _requestOptions: unknown, session: unknown) => {
|
|
43
78
|
const getRefreshToken = Reflect.get(session ?? {}, "getRefreshToken");
|
|
79
|
+
const getOrFetchLikelyValidTokens = Reflect.get(session ?? {}, "getOrFetchLikelyValidTokens");
|
|
44
80
|
if (typeof getRefreshToken !== "function") {
|
|
45
81
|
throw new Error("Expected cross-domain auth to pass a session to the client interface.");
|
|
46
82
|
}
|
|
83
|
+
if (typeof getOrFetchLikelyValidTokens !== "function") {
|
|
84
|
+
throw new Error("Expected cross-domain auth to pass a session with token accessors.");
|
|
85
|
+
}
|
|
47
86
|
const refreshToken = getRefreshToken.call(session);
|
|
48
87
|
const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
|
|
49
88
|
if (typeof refreshTokenString !== "string") {
|
|
50
89
|
throw new Error("Expected cross-domain auth to pass a refresh-token-backed session.");
|
|
51
90
|
}
|
|
52
91
|
capturedRefreshTokens.push(refreshTokenString);
|
|
92
|
+
const tokens = await getOrFetchLikelyValidTokens.call(session, 0, null);
|
|
93
|
+
capturedAccessTokenRefreshTokenIds.push(tokens.accessToken.payload.refresh_token_id);
|
|
53
94
|
return {
|
|
54
95
|
ok: true,
|
|
55
96
|
json: async () => ({ redirect_url: "https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state" }),
|
|
56
97
|
};
|
|
57
98
|
});
|
|
99
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async (refreshToken: unknown) => {
|
|
100
|
+
const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
|
|
101
|
+
if (typeof refreshTokenString !== "string") {
|
|
102
|
+
throw new Error("Expected refresh token while fetching a new access token.");
|
|
103
|
+
}
|
|
104
|
+
refreshedRawRefreshTokens.push(refreshTokenString);
|
|
105
|
+
return AccessToken.createIfValid(freshAccessToken) ?? (() => {
|
|
106
|
+
throw new Error("Expected test access token to be valid");
|
|
107
|
+
})();
|
|
108
|
+
});
|
|
58
109
|
|
|
59
110
|
try {
|
|
60
111
|
const createCrossDomainAuthRedirectUrl = Reflect.get(clientApp, "_createCrossDomainAuthRedirectUrl");
|
|
@@ -68,15 +119,18 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
68
119
|
codeChallenge: "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~",
|
|
69
120
|
afterCallbackRedirectUrl: "https://example.com/account-settings",
|
|
70
121
|
overrideTokenStoreInit: {
|
|
71
|
-
accessToken: "fresh-
|
|
122
|
+
accessToken: createAccessTokenString("fresh-stale-refresh-token-id"),
|
|
72
123
|
refreshToken: "fresh-refresh-token",
|
|
73
124
|
},
|
|
74
125
|
})).resolves.toBe("https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state");
|
|
75
126
|
} finally {
|
|
76
127
|
Reflect.set(clientInterface, "sendClientRequest", originalSendClientRequest);
|
|
128
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
77
129
|
}
|
|
78
130
|
|
|
131
|
+
expect(refreshedRawRefreshTokens).toEqual(["fresh-refresh-token"]);
|
|
79
132
|
expect(capturedRefreshTokens).toEqual(["fresh-refresh-token"]);
|
|
133
|
+
expect(capturedAccessTokenRefreshTokenIds).toEqual(["fresh-refresh-token-id"]);
|
|
80
134
|
});
|
|
81
135
|
|
|
82
136
|
it("uses a fresh nested OAuth state while preserving the outer cross-domain return state", async () => {
|
|
@@ -105,7 +159,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
105
159
|
const previousWindow = globalThis.window;
|
|
106
160
|
const previousDocument = globalThis.document;
|
|
107
161
|
let redirectedUrl = "";
|
|
108
|
-
vi.spyOn(clientApp as any, "
|
|
162
|
+
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
|
109
163
|
vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
|
|
110
164
|
state: "fresh-nested-state",
|
|
111
165
|
codeChallenge: "fresh-nested-code-challenge",
|
|
@@ -138,4 +192,263 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
138
192
|
expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(outerCodeChallenge);
|
|
139
193
|
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/");
|
|
140
194
|
});
|
|
195
|
+
|
|
196
|
+
it("clears a stale target-domain session before deferring to the source-domain session", async () => {
|
|
197
|
+
const projectId = "00000000-0000-4000-8000-000000000006";
|
|
198
|
+
const hostedAccessToken = createAccessTokenString("hosted-old-refresh-token-id");
|
|
199
|
+
const clientApp = new StackClientApp({
|
|
200
|
+
baseUrl: "http://localhost:12345",
|
|
201
|
+
projectId,
|
|
202
|
+
publishableClientKey: "stack-pk-test",
|
|
203
|
+
tokenStore: "memory",
|
|
204
|
+
redirectMethod: "window",
|
|
205
|
+
urls: {
|
|
206
|
+
default: { type: "hosted" },
|
|
207
|
+
},
|
|
208
|
+
noAutomaticPrefetch: true,
|
|
209
|
+
});
|
|
210
|
+
const tokenStore = Reflect.get(clientApp, "_memoryTokenStore");
|
|
211
|
+
if (!(tokenStore instanceof Store)) {
|
|
212
|
+
throw new Error("Expected StackClientApp to use a memory token store in this test.");
|
|
213
|
+
}
|
|
214
|
+
tokenStore.set({
|
|
215
|
+
refreshToken: "hosted-old-refresh-token",
|
|
216
|
+
accessToken: hostedAccessToken,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
|
|
220
|
+
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-anonymous-refresh-token-id");
|
|
221
|
+
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/handler/oauth-callback");
|
|
222
|
+
currentUrl.searchParams.set("hexclave_cross_domain_state", "outer-state");
|
|
223
|
+
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", "outer-code-challenge");
|
|
224
|
+
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/app");
|
|
225
|
+
|
|
226
|
+
const previousWindow = globalThis.window;
|
|
227
|
+
const previousDocument = globalThis.document;
|
|
228
|
+
let redirectedUrl = "";
|
|
229
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
230
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
231
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
|
|
232
|
+
return AccessToken.createIfValid(hostedAccessToken) ?? (() => {
|
|
233
|
+
throw new Error("Expected test access token to be valid");
|
|
234
|
+
})();
|
|
235
|
+
});
|
|
236
|
+
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
|
|
237
|
+
|
|
238
|
+
globalThis.document = createMockDocument();
|
|
239
|
+
globalThis.window = {
|
|
240
|
+
location: {
|
|
241
|
+
href: currentUrl.toString(),
|
|
242
|
+
replace: (url: string) => {
|
|
243
|
+
redirectedUrl = url;
|
|
244
|
+
throw new Error("INTENTIONAL_TEST_ABORT");
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
} as any;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
|
251
|
+
} finally {
|
|
252
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
253
|
+
globalThis.window = previousWindow;
|
|
254
|
+
globalThis.document = previousDocument;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
expect(tokenStore.get()).toEqual({
|
|
258
|
+
refreshToken: null,
|
|
259
|
+
accessToken: null,
|
|
260
|
+
});
|
|
261
|
+
expect(new URL(redirectedUrl).origin).toBe("https://demo.stack-auth.com");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("uses the latest browser refresh cookie before computing nested cross-domain session IDs", async () => {
|
|
265
|
+
const projectId = "00000000-0000-4000-8000-000000000007";
|
|
266
|
+
const previousWindow = globalThis.window;
|
|
267
|
+
const previousDocument = globalThis.document;
|
|
268
|
+
|
|
269
|
+
globalThis.document = createMockDocument();
|
|
270
|
+
globalThis.window = {
|
|
271
|
+
location: {
|
|
272
|
+
href: "https://demo.stack-auth.com/",
|
|
273
|
+
protocol: "https:",
|
|
274
|
+
hostname: "demo.stack-auth.com",
|
|
275
|
+
},
|
|
276
|
+
} as any;
|
|
277
|
+
|
|
278
|
+
const clientApp = new StackClientApp({
|
|
279
|
+
baseUrl: "http://localhost:12345",
|
|
280
|
+
projectId,
|
|
281
|
+
publishableClientKey: "stack-pk-test",
|
|
282
|
+
tokenStore: "cookie",
|
|
283
|
+
redirectMethod: "none",
|
|
284
|
+
noAutomaticPrefetch: true,
|
|
285
|
+
});
|
|
286
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
287
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
288
|
+
const refreshedRawRefreshTokens: string[] = [];
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const getBrowserCookieTokenStore = Reflect.get(clientApp, "_getBrowserCookieTokenStore");
|
|
292
|
+
if (typeof getBrowserCookieTokenStore !== "function") {
|
|
293
|
+
throw new Error("Expected StackClientApp to expose _getBrowserCookieTokenStore in tests.");
|
|
294
|
+
}
|
|
295
|
+
const tokenStore = getBrowserCookieTokenStore.call(clientApp);
|
|
296
|
+
tokenStore.set({
|
|
297
|
+
refreshToken: "old-refresh-token",
|
|
298
|
+
accessToken: createAccessTokenString("old-refresh-token-id"),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
document.cookie = `__Host-hexclave-refresh-${projectId}--default=${JSON.stringify({
|
|
302
|
+
refresh_token: "new-refresh-token",
|
|
303
|
+
updated_at_millis: 1,
|
|
304
|
+
})}`;
|
|
305
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async (refreshToken: unknown) => {
|
|
306
|
+
const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
|
|
307
|
+
if (typeof refreshTokenString !== "string") {
|
|
308
|
+
throw new Error("Expected refresh token while fetching a new access token.");
|
|
309
|
+
}
|
|
310
|
+
refreshedRawRefreshTokens.push(refreshTokenString);
|
|
311
|
+
return AccessToken.createIfValid(createAccessTokenString("new-refresh-token-id")) ?? (() => {
|
|
312
|
+
throw new Error("Expected test access token to be valid");
|
|
313
|
+
})();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const fetchCurrentRefreshTokenIdIfSignedIn = Reflect.get(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn");
|
|
317
|
+
if (typeof fetchCurrentRefreshTokenIdIfSignedIn !== "function") {
|
|
318
|
+
throw new Error("Expected StackClientApp to expose _fetchCurrentRefreshTokenIdIfSignedIn in tests.");
|
|
319
|
+
}
|
|
320
|
+
await expect(fetchCurrentRefreshTokenIdIfSignedIn.call(clientApp, {
|
|
321
|
+
awaitPendingAuthResolutions: false,
|
|
322
|
+
})).resolves.toBe("new-refresh-token-id");
|
|
323
|
+
} finally {
|
|
324
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
325
|
+
globalThis.window = previousWindow;
|
|
326
|
+
globalThis.document = previousDocument;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
expect(refreshedRawRefreshTokens).toEqual(["new-refresh-token"]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("uses direct sign-out instead of hosted sign-out redirects when code execution is available", async () => {
|
|
333
|
+
const clientApp = new StackClientApp({
|
|
334
|
+
baseUrl: "http://localhost:12345",
|
|
335
|
+
projectId: "00000000-0000-4000-8000-000000000003",
|
|
336
|
+
publishableClientKey: "stack-pk-test",
|
|
337
|
+
tokenStore: "memory",
|
|
338
|
+
redirectMethod: "window",
|
|
339
|
+
urls: {
|
|
340
|
+
handler: "/handler",
|
|
341
|
+
signOut: { type: "hosted" },
|
|
342
|
+
},
|
|
343
|
+
noAutomaticPrefetch: true,
|
|
344
|
+
});
|
|
345
|
+
const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT"));
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
|
349
|
+
expect(signOutSpy).toHaveBeenCalledWith();
|
|
350
|
+
} finally {
|
|
351
|
+
signOutSpy.mockRestore();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("keeps default hosted signOut() on the source domain when afterSignOut is not configured", async () => {
|
|
356
|
+
const clientApp = new StackClientApp({
|
|
357
|
+
baseUrl: "http://localhost:12345",
|
|
358
|
+
projectId: "00000000-0000-4000-8000-000000000004",
|
|
359
|
+
publishableClientKey: "stack-pk-test",
|
|
360
|
+
tokenStore: "memory",
|
|
361
|
+
redirectMethod: "window",
|
|
362
|
+
urls: {
|
|
363
|
+
default: { type: "hosted" },
|
|
364
|
+
},
|
|
365
|
+
noAutomaticPrefetch: true,
|
|
366
|
+
});
|
|
367
|
+
const currentHref = "https://demo.stack-auth.com/settings?tab=profile";
|
|
368
|
+
|
|
369
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
370
|
+
const originalSignOut = Reflect.get(clientInterface, "signOut");
|
|
371
|
+
Reflect.set(clientInterface, "signOut", async () => {});
|
|
372
|
+
const previousWindow = globalThis.window;
|
|
373
|
+
const previousDocument = globalThis.document;
|
|
374
|
+
let redirectedUrl = "";
|
|
375
|
+
|
|
376
|
+
globalThis.document = createMockDocument();
|
|
377
|
+
globalThis.window = {
|
|
378
|
+
location: {
|
|
379
|
+
href: currentHref,
|
|
380
|
+
replace: (url: string) => {
|
|
381
|
+
redirectedUrl = url;
|
|
382
|
+
throw new Error("INTENTIONAL_TEST_ABORT");
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
} as any;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const signOut = Reflect.get(clientApp, "_signOut");
|
|
389
|
+
if (typeof signOut !== "function") {
|
|
390
|
+
throw new Error("Expected StackClientApp to expose _signOut in tests.");
|
|
391
|
+
}
|
|
392
|
+
await expect(signOut.call(clientApp, Reflect.get(clientInterface, "createSession").call(clientInterface, {
|
|
393
|
+
refreshToken: null,
|
|
394
|
+
}))).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
|
395
|
+
} finally {
|
|
396
|
+
Reflect.set(clientInterface, "signOut", originalSignOut);
|
|
397
|
+
globalThis.window = previousWindow;
|
|
398
|
+
globalThis.document = previousDocument;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
expect(redirectedUrl).toBe("/settings?tab=profile");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("ignores stale session callbacks after a newer refresh token owns the token store", async () => {
|
|
405
|
+
const clientApp = new StackClientApp({
|
|
406
|
+
baseUrl: "http://localhost:12345",
|
|
407
|
+
projectId: "00000000-0000-4000-8000-000000000005",
|
|
408
|
+
publishableClientKey: "stack-pk-test",
|
|
409
|
+
tokenStore: "memory",
|
|
410
|
+
redirectMethod: "none",
|
|
411
|
+
noAutomaticPrefetch: true,
|
|
412
|
+
});
|
|
413
|
+
const oldAccessToken = createAccessTokenString("old-refresh-token-id");
|
|
414
|
+
const refreshedOldAccessToken = createAccessTokenString("refreshed-old-refresh-token-id");
|
|
415
|
+
const newAccessToken = createAccessTokenString("new-refresh-token-id");
|
|
416
|
+
const tokenStore = new Store({
|
|
417
|
+
refreshToken: "old-refresh-token",
|
|
418
|
+
accessToken: oldAccessToken,
|
|
419
|
+
});
|
|
420
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
421
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
422
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
|
|
423
|
+
return AccessToken.createIfValid(refreshedOldAccessToken) ?? (() => {
|
|
424
|
+
throw new Error("Expected test access token to be valid");
|
|
425
|
+
})();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const getSessionFromTokenStore = Reflect.get(clientApp, "_getSessionFromTokenStore");
|
|
430
|
+
if (typeof getSessionFromTokenStore !== "function") {
|
|
431
|
+
throw new Error("Expected StackClientApp to expose _getSessionFromTokenStore in tests.");
|
|
432
|
+
}
|
|
433
|
+
const oldSession = getSessionFromTokenStore.call(clientApp, tokenStore);
|
|
434
|
+
tokenStore.set({
|
|
435
|
+
refreshToken: "new-refresh-token",
|
|
436
|
+
accessToken: newAccessToken,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await oldSession.fetchNewTokens();
|
|
440
|
+
expect(tokenStore.get()).toEqual({
|
|
441
|
+
refreshToken: "new-refresh-token",
|
|
442
|
+
accessToken: newAccessToken,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
oldSession.markInvalid();
|
|
446
|
+
expect(tokenStore.get()).toEqual({
|
|
447
|
+
refreshToken: "new-refresh-token",
|
|
448
|
+
accessToken: newAccessToken,
|
|
449
|
+
});
|
|
450
|
+
} finally {
|
|
451
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
141
454
|
});
|
|
@@ -39,14 +39,14 @@ import { Store, storeLock } from "@hexclave/shared/dist/utils/stores";
|
|
|
39
39
|
import { deindent, mergeScopeStrings } from "@hexclave/shared/dist/utils/strings";
|
|
40
40
|
import type { TurnstileAction } from "@hexclave/shared/dist/utils/turnstile";
|
|
41
41
|
import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@hexclave/shared/dist/utils/turnstile-flow";
|
|
42
|
-
import { createUrlIfValid, isRelative } from "@hexclave/shared/dist/utils/urls";
|
|
42
|
+
import { createUrlIfValid, getRelativePart, isRelative } from "@hexclave/shared/dist/utils/urls";
|
|
43
43
|
import { generateUuid } from "@hexclave/shared/dist/utils/uuids";
|
|
44
44
|
import * as cookie from "cookie";
|
|
45
45
|
import type * as yup from "yup";
|
|
46
46
|
import { constructRedirectUrl } from "../../../../utils/url";
|
|
47
47
|
import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth";
|
|
48
48
|
import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, getCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie";
|
|
49
|
-
import { envVars } from "
|
|
49
|
+
import { envVars } from "../../../../generated/env";
|
|
50
50
|
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys";
|
|
51
51
|
import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrlOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, ResolvedHandlerUrls, TokenStoreInit, hexclaveAppInternalsSymbol } from "../../common";
|
|
52
52
|
import { DeprecatedOAuthConnection, OAuthConnection } from "../../connected-accounts";
|
|
@@ -776,12 +776,16 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
776
776
|
return currentUrl.toString();
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
-
protected async
|
|
779
|
+
protected async _fetchCurrentRefreshTokenIdIfSignedIn(options?: {
|
|
780
780
|
awaitPendingAuthResolutions?: boolean,
|
|
781
781
|
overrideTokenStoreInit?: TokenStoreInit,
|
|
782
782
|
}): Promise<string | null> {
|
|
783
783
|
const session = await this._getSession(options?.overrideTokenStoreInit, options);
|
|
784
|
-
|
|
784
|
+
// Nested cross-domain auth passes this ID to another origin, which later
|
|
785
|
+
// asks us to prove the same raw refresh token. A cached access token can be
|
|
786
|
+
// valid but stale relative to the refresh token, so mint from the refresh
|
|
787
|
+
// token that owns this session before exposing the ID.
|
|
788
|
+
const tokens = await session.fetchNewTokens();
|
|
785
789
|
if (tokens?.refreshToken == null) {
|
|
786
790
|
return null;
|
|
787
791
|
}
|
|
@@ -799,7 +803,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
799
803
|
return options.url;
|
|
800
804
|
}
|
|
801
805
|
|
|
802
|
-
const refreshTokenId = await this.
|
|
806
|
+
const refreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({
|
|
803
807
|
awaitPendingAuthResolutions: options.awaitPendingAuthResolutions,
|
|
804
808
|
overrideTokenStoreInit: options.overrideTokenStoreInit,
|
|
805
809
|
});
|
|
@@ -854,7 +858,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
854
858
|
if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
|
|
855
859
|
throw new Error(`Nested cross-domain auth after-callback redirect URL ${afterCallbackRedirectUrlString} is not trusted.`);
|
|
856
860
|
}
|
|
857
|
-
const currentRefreshTokenId = await this.
|
|
861
|
+
const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
|
|
858
862
|
if (currentRefreshTokenId !== refreshTokenId) {
|
|
859
863
|
throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
|
|
860
864
|
}
|
|
@@ -873,8 +877,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
873
877
|
|
|
874
878
|
// We are on b.com. Bounce to the trusted callback on a.com with a normal OAuth request
|
|
875
879
|
// shape; a.com will verify the source session and issue the one-time code.
|
|
876
|
-
const currentRefreshTokenId = await this.
|
|
880
|
+
const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
|
|
877
881
|
if (currentRefreshTokenId === refreshTokenId) return false;
|
|
882
|
+
if (currentRefreshTokenId != null) {
|
|
883
|
+
const session = await this._getSession(undefined, { awaitPendingAuthResolutions: false });
|
|
884
|
+
session.markInvalid();
|
|
885
|
+
}
|
|
878
886
|
const callbackUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.callbackUrl);
|
|
879
887
|
if (callbackUrlString == null) {
|
|
880
888
|
throw new HexclaveAssertionError("Nested cross-domain auth URL is missing callback URL");
|
|
@@ -1067,6 +1075,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1067
1075
|
accessToken,
|
|
1068
1076
|
};
|
|
1069
1077
|
}
|
|
1078
|
+
private _getCurrentBrowserCookieTokenStoreValue(old: TokenObject | null): TokenObject {
|
|
1079
|
+
const tokens = this._getTokensFromCookies(this._getAllBrowserCookies());
|
|
1080
|
+
return {
|
|
1081
|
+
refreshToken: tokens.refreshToken,
|
|
1082
|
+
accessToken: tokens.accessToken ?? (old?.refreshToken === tokens.refreshToken ? old.accessToken : null),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1070
1085
|
protected get _accessTokenCookieName() {
|
|
1071
1086
|
// The access token, unlike the refresh token, should not depend on the project ID. We never want to store the
|
|
1072
1087
|
// access token in cookies more than once because of how big it is (there's a limit of 4096 bytes for all cookies
|
|
@@ -1209,20 +1224,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1209
1224
|
}
|
|
1210
1225
|
|
|
1211
1226
|
if (this._storedBrowserCookieTokenStore === null) {
|
|
1212
|
-
|
|
1213
|
-
const tokens = this._getTokensFromCookies(this._getAllBrowserCookies());
|
|
1214
|
-
return {
|
|
1215
|
-
refreshToken: tokens.refreshToken,
|
|
1216
|
-
accessToken: tokens.accessToken ?? (old?.refreshToken === tokens.refreshToken ? old.accessToken : null),
|
|
1217
|
-
};
|
|
1218
|
-
};
|
|
1219
|
-
this._storedBrowserCookieTokenStore = new Store<TokenObject>(getCurrentValue(null));
|
|
1227
|
+
this._storedBrowserCookieTokenStore = new Store<TokenObject>(this._getCurrentBrowserCookieTokenStoreValue(null));
|
|
1220
1228
|
let hasSucceededInWriting = true;
|
|
1221
1229
|
|
|
1222
1230
|
setInterval(() => {
|
|
1223
1231
|
if (hasSucceededInWriting) {
|
|
1224
1232
|
const oldValue = this._storedBrowserCookieTokenStore!.get();
|
|
1225
|
-
const currentValue =
|
|
1233
|
+
const currentValue = this._getCurrentBrowserCookieTokenStoreValue(oldValue);
|
|
1226
1234
|
if (!deepPlainEquals(currentValue, oldValue)) {
|
|
1227
1235
|
this._storedBrowserCookieTokenStore!.set(currentValue);
|
|
1228
1236
|
}
|
|
@@ -1256,6 +1264,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1256
1264
|
}
|
|
1257
1265
|
}
|
|
1258
1266
|
});
|
|
1267
|
+
} else {
|
|
1268
|
+
const oldValue = this._storedBrowserCookieTokenStore.get();
|
|
1269
|
+
const currentValue = this._getCurrentBrowserCookieTokenStoreValue(oldValue);
|
|
1270
|
+
if (!deepPlainEquals(currentValue, oldValue)) {
|
|
1271
|
+
this._storedBrowserCookieTokenStore.set(currentValue);
|
|
1272
|
+
}
|
|
1259
1273
|
}
|
|
1260
1274
|
|
|
1261
1275
|
return this._storedBrowserCookieTokenStore;
|
|
@@ -1392,17 +1406,17 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1392
1406
|
accessToken: tokenObj.accessToken,
|
|
1393
1407
|
});
|
|
1394
1408
|
session.onAccessTokenChange((newAccessToken) => {
|
|
1395
|
-
tokenStore.update((old) => ({
|
|
1409
|
+
tokenStore.update((old) => InternalSession.calculateSessionKey(old) === sessionKey ? {
|
|
1396
1410
|
...old,
|
|
1397
1411
|
accessToken: newAccessToken?.token ?? null
|
|
1398
|
-
})
|
|
1412
|
+
} : old);
|
|
1399
1413
|
});
|
|
1400
1414
|
session.onInvalidate(() => {
|
|
1401
|
-
tokenStore.update((old) => ({
|
|
1415
|
+
tokenStore.update((old) => InternalSession.calculateSessionKey(old) === sessionKey ? {
|
|
1402
1416
|
...old,
|
|
1403
1417
|
accessToken: null,
|
|
1404
1418
|
refreshToken: null,
|
|
1405
|
-
})
|
|
1419
|
+
} : old);
|
|
1406
1420
|
});
|
|
1407
1421
|
|
|
1408
1422
|
let sessionsBySessionKey = this._sessionsByTokenStoreAndSessionKey.get(tokenStore) ?? new Map();
|
|
@@ -2294,16 +2308,16 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2294
2308
|
private _getBotChallengeSiteKeys(): { visibleSiteKey: string, invisibleSiteKey: string } | null {
|
|
2295
2309
|
if (!isBrowserLike()) return null;
|
|
2296
2310
|
|
|
2297
|
-
const visibleSiteKey = envVars.
|
|
2311
|
+
const visibleSiteKey = envVars.HEXCLAVE_BOT_CHALLENGE_SITE_KEY;
|
|
2298
2312
|
if (!visibleSiteKey) {
|
|
2299
2313
|
if (!this._botChallengeSiteKeysWarned) {
|
|
2300
2314
|
this._botChallengeSiteKeysWarned = true;
|
|
2301
|
-
console.warn("[stack-auth]
|
|
2315
|
+
console.warn("[stack-auth] HEXCLAVE_BOT_CHALLENGE_SITE_KEY is not set — bot challenge fraud protection is disabled. Set the env variable to enable it.");
|
|
2302
2316
|
}
|
|
2303
2317
|
return null;
|
|
2304
2318
|
}
|
|
2305
2319
|
|
|
2306
|
-
const invisibleSiteKey = envVars.
|
|
2320
|
+
const invisibleSiteKey = envVars.HEXCLAVE_BOT_CHALLENGE_INVISIBLE_SITE_KEY ?? visibleSiteKey;
|
|
2307
2321
|
|
|
2308
2322
|
return { visibleSiteKey, invisibleSiteKey };
|
|
2309
2323
|
}
|
|
@@ -2485,6 +2499,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2485
2499
|
overrideTokenStoreInit?: TokenStoreInit,
|
|
2486
2500
|
}): Promise<string> {
|
|
2487
2501
|
const session = await this._getSession(options.overrideTokenStoreInit, { awaitPendingAuthResolutions: options.awaitPendingAuthResolutions });
|
|
2502
|
+
// The authorize endpoint intentionally verifies that the access token and
|
|
2503
|
+
// raw refresh token describe the same DB session. Force the access token to
|
|
2504
|
+
// be minted from the refresh token we are about to send, instead of reusing
|
|
2505
|
+
// a still-valid cached token from a pre-handoff session snapshot.
|
|
2506
|
+
await session.fetchNewTokens();
|
|
2488
2507
|
const response = await this._interface.sendClientRequest(
|
|
2489
2508
|
"/auth/oauth/cross-domain/authorize",
|
|
2490
2509
|
{
|
|
@@ -2611,7 +2630,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2611
2630
|
|
|
2612
2631
|
async redirectToSignIn(options?: RedirectToOptions) { return await this._redirectToHandler("signIn", options); }
|
|
2613
2632
|
async redirectToSignUp(options?: RedirectToOptions) { return await this._redirectToHandler("signUp", options); }
|
|
2614
|
-
async redirectToSignOut(options?: RedirectToOptions) {
|
|
2633
|
+
async redirectToSignOut(options?: RedirectToOptions) {
|
|
2634
|
+
const configuredSignOutTarget = this._urlOptions.signOut ?? this._urlOptions.default;
|
|
2635
|
+
if (typeof configuredSignOutTarget !== "string" && configuredSignOutTarget?.type === "hosted") {
|
|
2636
|
+
return await this.signOut();
|
|
2637
|
+
}
|
|
2638
|
+
return await this._redirectToHandler("signOut", options);
|
|
2639
|
+
}
|
|
2615
2640
|
async redirectToEmailVerification(options?: RedirectToOptions) { return await this._redirectToHandler("emailVerification", options); }
|
|
2616
2641
|
async redirectToPasswordReset(options?: RedirectToOptions) { return await this._redirectToHandler("passwordReset", options); }
|
|
2617
2642
|
async redirectToForgotPassword(options?: RedirectToOptions) { return await this._redirectToHandler("forgotPassword", options); }
|
|
@@ -3329,11 +3354,30 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3329
3354
|
if (options?.redirectUrl) {
|
|
3330
3355
|
await this._redirectTo({ url: options.redirectUrl, replace: true });
|
|
3331
3356
|
} else {
|
|
3332
|
-
await this.
|
|
3357
|
+
await this._redirectToDefaultAfterSignOut();
|
|
3333
3358
|
}
|
|
3334
3359
|
});
|
|
3335
3360
|
}
|
|
3336
3361
|
|
|
3362
|
+
protected async _redirectToDefaultAfterSignOut(): Promise<void> {
|
|
3363
|
+
if (this._urlOptions.afterSignOut != null) {
|
|
3364
|
+
await this.redirectToAfterSignOut({ replace: true });
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
if (this._urlOptions.home != null) {
|
|
3369
|
+
await this.redirectToHome({ replace: true });
|
|
3370
|
+
return;
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
if (this._urlOptions.default?.type === "hosted" && typeof window !== "undefined") {
|
|
3374
|
+
await this._redirectTo({ url: getRelativePart(new URL(window.location.href)), replace: true });
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
await this.redirectToAfterSignOut({ replace: true });
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3337
3381
|
async signOut(options?: { redirectUrl?: URL | string, tokenStore?: TokenStoreInit }): Promise<void> {
|
|
3338
3382
|
const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any });
|
|
3339
3383
|
if (user) {
|