@jskit-ai/auth-web 0.1.64 → 0.1.65

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/auth-web",
4
- "version": "0.1.64",
4
+ "version": "0.1.65",
5
5
  "kind": "runtime",
6
6
  "description": "Auth web module: Fastify auth routes plus web login/sign-out scaffolds.",
7
7
  "dependsOn": [
@@ -219,10 +219,10 @@ export default Object.freeze({
219
219
  "runtime": {
220
220
  "@tanstack/vue-query": "5.92.12",
221
221
  "@mdi/js": "^7.4.47",
222
- "@jskit-ai/auth-core": "0.1.62",
223
- "@jskit-ai/http-runtime": "0.1.62",
224
- "@jskit-ai/kernel": "0.1.63",
225
- "@jskit-ai/shell-web": "0.1.62",
222
+ "@jskit-ai/auth-core": "0.1.63",
223
+ "@jskit-ai/http-runtime": "0.1.63",
224
+ "@jskit-ai/kernel": "0.1.64",
225
+ "@jskit-ai/shell-web": "0.1.63",
226
226
  "vuetify": "^4.0.0"
227
227
  },
228
228
  "dev": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-web",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -14,17 +14,18 @@
14
14
  "./client/views/DefaultSignOutView": "./src/client/views/DefaultSignOutView.vue",
15
15
  "./client/runtime/authGuardRuntime": "./src/client/runtime/authGuardRuntime.js",
16
16
  "./client/runtime/authHttpClient": "./src/client/runtime/authHttpClient.js",
17
+ "./client/runtime/oauthCallbackRuntime": "./src/client/runtime/oauthCallbackRuntime.js",
17
18
  "./client/runtime/useSignOut": "./src/client/runtime/useSignOut.js"
18
19
  },
19
20
  "dependencies": {
20
21
  "@tanstack/vue-query": "^5.90.5",
21
- "@jskit-ai/auth-core": "0.1.62",
22
+ "@jskit-ai/auth-core": "0.1.63",
22
23
  "@mdi/js": "^7.4.47",
23
- "@jskit-ai/kernel": "0.1.63",
24
- "@jskit-ai/shell-web": "0.1.62",
24
+ "@jskit-ai/kernel": "0.1.64",
25
+ "@jskit-ai/shell-web": "0.1.63",
25
26
  "pinia": "^3.0.4",
26
27
  "vuetify": "^4.0.0",
27
- "@jskit-ai/http-runtime": "0.1.62"
28
+ "@jskit-ai/http-runtime": "0.1.63"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "vue": "^3.5.13",
@@ -2,6 +2,7 @@ import {
2
2
  OAUTH_QUERY_PARAM_PROVIDER,
3
3
  OAUTH_QUERY_PARAM_RETURN_TO
4
4
  } from "@jskit-ai/auth-core/shared/oauthCallbackParams";
5
+ import { readOAuthCallbackParamsFromUrl } from "../../runtime/oauthCallbackRuntime.js";
5
6
 
6
7
  function stripOAuthParamsFromLocation() {
7
8
  if (typeof window !== "object" || !window.location) {
@@ -46,42 +47,7 @@ function readOAuthCallbackParamsFromLocation() {
46
47
  return null;
47
48
  }
48
49
 
49
- const searchParams = new URLSearchParams(window.location.search || "");
50
- const hashParams = new URLSearchParams((window.location.hash || "").replace(/^#/, ""));
51
-
52
- const code = String(searchParams.get("code") || hashParams.get("code") || "").trim();
53
- const accessToken = String(searchParams.get("access_token") || hashParams.get("access_token") || "").trim();
54
- const refreshToken = String(searchParams.get("refresh_token") || hashParams.get("refresh_token") || "").trim();
55
- const errorCode = String(
56
- searchParams.get("error") ||
57
- hashParams.get("error") ||
58
- searchParams.get("errorCode") ||
59
- hashParams.get("errorCode") ||
60
- ""
61
- ).trim();
62
- const errorDescription = String(
63
- searchParams.get("error_description") ||
64
- hashParams.get("error_description") ||
65
- searchParams.get("errorDescription") ||
66
- hashParams.get("errorDescription") ||
67
- ""
68
- ).trim();
69
- const hasSessionPair = Boolean(accessToken && refreshToken);
70
-
71
- if (!code && !hasSessionPair && !errorCode) {
72
- return null;
73
- }
74
-
75
- return {
76
- code,
77
- accessToken,
78
- refreshToken,
79
- hasSessionPair,
80
- errorCode,
81
- errorDescription,
82
- provider: String(searchParams.get(OAUTH_QUERY_PARAM_PROVIDER) || "").trim().toLowerCase(),
83
- returnTo: String(searchParams.get(OAUTH_QUERY_PARAM_RETURN_TO) || "").trim()
84
- };
50
+ return readOAuthCallbackParamsFromUrl(window.location.href);
85
51
  }
86
52
 
87
53
  export {
@@ -5,17 +5,14 @@ import { authLoginPasswordCommand } from "@jskit-ai/auth-core/shared/commands/au
5
5
  import { authLoginOtpRequestCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOtpRequestCommand";
6
6
  import { authLoginOtpVerifyCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOtpVerifyCommand";
7
7
  import { authLoginOAuthStartCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthStartCommand";
8
- import { authLoginOAuthCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthCompleteCommand";
9
8
  import { authPasswordResetRequestCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetRequestCommand";
10
9
  import { AUTH_PATHS, buildAuthOauthStartPath } from "@jskit-ai/auth-core/shared/authPaths";
11
10
  import { authHttpRequest } from "../../runtime/authHttpClient.js";
11
+ import { completeOAuthCallbackFromCurrentLocation } from "../../runtime/oauthCallbackRuntime.js";
12
12
  import { normalizeAuthReturnToPath } from "../../lib/returnToPath.js";
13
13
  import { normalizeEmailAddress } from "./identityHelpers.js";
14
14
  import { readRememberedAccountHint } from "./rememberedAccountStorage.js";
15
- import {
16
- stripOAuthParamsFromLocation,
17
- readOAuthCallbackParamsFromLocation
18
- } from "./oauthCallbackUrl.js";
15
+ import { stripOAuthParamsFromLocation } from "./oauthCallbackUrl.js";
19
16
  import { ensureCommandSectionValid } from "./validationHelpers.js";
20
17
  import { resolveRegisterCompletionState } from "./registerCompletion.js";
21
18
 
@@ -25,7 +22,8 @@ export function useLoginViewActions({
25
22
  state,
26
23
  validation,
27
24
  queryClient,
28
- errorRuntime
25
+ errorRuntime,
26
+ oauthLaunchClient = null
29
27
  } = {}) {
30
28
  function reportAuthFeedback({
31
29
  message,
@@ -229,78 +227,46 @@ export function useLoginViewActions({
229
227
  await completeLogin();
230
228
  }
231
229
 
232
- function buildOAuthCompletePayload({ callbackParams, provider, hasSessionPair }) {
233
- const payload = {};
234
- if (provider) {
235
- payload.provider = provider;
236
- }
237
- if (callbackParams.code) {
238
- payload.code = callbackParams.code;
239
- }
240
- if (hasSessionPair) {
241
- payload.accessToken = callbackParams.accessToken;
242
- payload.refreshToken = callbackParams.refreshToken;
243
- }
244
- return payload;
245
- }
246
-
247
230
  async function handleOAuthCallbackIfPresent() {
248
- const callbackParams = readOAuthCallbackParamsFromLocation();
249
- if (!callbackParams) {
250
- return false;
251
- }
252
-
253
- state.requestedReturnTo.value = normalizeAuthReturnToPath(callbackParams.returnTo, state.requestedReturnTo.value, {
254
- allowedOrigins: state.allowedReturnToOrigins.value
231
+ const callbackResult = await completeOAuthCallbackFromCurrentLocation({
232
+ fallbackReturnTo: state.requestedReturnTo.value,
233
+ allowedReturnToOrigins: state.allowedReturnToOrigins.value,
234
+ defaultProvider: resolveDefaultOAuthProvider(),
235
+ request,
236
+ refreshSession
255
237
  });
256
238
 
257
- const provider = String(callbackParams.provider || resolveDefaultOAuthProvider() || "")
258
- .trim()
259
- .toLowerCase();
260
- const oauthError = callbackParams.errorCode;
261
- const oauthErrorDescription = callbackParams.errorDescription;
262
- const hasSessionPair = callbackParams.hasSessionPair === true;
263
-
264
- if (!provider && !hasSessionPair) {
265
- setErrorMessage("OAuth provider is missing from callback.", "auth-web.default-login-view:oauth-missing-provider");
266
- stripOAuthParamsFromLocation();
267
- return true;
239
+ if (callbackResult.handled !== true) {
240
+ return false;
268
241
  }
269
242
 
270
- if (oauthError) {
271
- setErrorMessage(oauthErrorDescription || oauthError, "auth-web.default-login-view:oauth-callback-error");
272
- stripOAuthParamsFromLocation();
273
- return true;
274
- }
243
+ state.requestedReturnTo.value = normalizeAuthReturnToPath(
244
+ callbackResult.returnTo,
245
+ state.requestedReturnTo.value,
246
+ {
247
+ allowedOrigins: state.allowedReturnToOrigins.value
248
+ }
249
+ );
275
250
 
276
251
  state.loading.value = true;
277
252
  clearTransientMessages();
278
253
 
279
254
  try {
280
- const payload = buildOAuthCompletePayload({
281
- callbackParams,
282
- provider,
283
- hasSessionPair
284
- });
285
- ensureCommandSectionValid(
286
- authLoginOAuthCompleteCommand,
287
- "body",
288
- payload,
289
- "Invalid OAuth callback payload."
290
- );
255
+ if (callbackResult.completed !== true) {
256
+ throw new Error(callbackResult.errorMessage || "Unable to complete OAuth sign-in.");
257
+ }
291
258
 
292
- const oauthResult = await request(AUTH_PATHS.OAUTH_COMPLETE, {
293
- method: "POST",
294
- body: payload
295
- });
296
259
  state.applyRememberedAccountPreference({
297
- email: oauthResult?.email || state.email.value,
298
- displayName: oauthResult?.username || oauthResult?.email || state.email.value,
260
+ email: callbackResult.oauthResult?.email || state.email.value,
261
+ displayName:
262
+ callbackResult.oauthResult?.username || callbackResult.oauthResult?.email || state.email.value,
299
263
  shouldRemember: state.rememberAccountOnDevice.value !== false
300
264
  });
301
265
 
302
266
  stripOAuthParamsFromLocation();
303
- await completeLogin();
267
+ if (typeof window === "object" && window.location) {
268
+ window.location.replace(state.requestedReturnTo.value);
269
+ }
304
270
  } catch (error) {
305
271
  setErrorMessage(String(error?.message || "Unable to complete OAuth sign-in."));
306
272
  stripOAuthParamsFromLocation();
@@ -426,9 +392,9 @@ export function useLoginViewActions({
426
392
  return undefined;
427
393
  }
428
394
 
429
- function startOAuthSignIn(providerId) {
395
+ async function startOAuthSignIn(providerId) {
430
396
  const provider = String(providerId || "").trim().toLowerCase();
431
- if (!provider || typeof window !== "object" || !window.location) {
397
+ if (!provider) {
432
398
  return;
433
399
  }
434
400
 
@@ -458,7 +424,16 @@ export function useLoginViewActions({
458
424
 
459
425
  const params = new URLSearchParams(queryPayload);
460
426
  const oauthStartPath = buildAuthOauthStartPath(provider);
461
- window.location.assign(`${oauthStartPath}?${params.toString()}`);
427
+ try {
428
+ if (!oauthLaunchClient || typeof oauthLaunchClient.open !== "function") {
429
+ throw new Error("OAuth launch client is unavailable.");
430
+ }
431
+ await oauthLaunchClient.open({
432
+ url: `${oauthStartPath}?${params.toString()}`
433
+ });
434
+ } catch (error) {
435
+ setErrorMessage(String(error?.message || "Unable to start OAuth sign-in."));
436
+ }
462
437
  }
463
438
 
464
439
  async function initializeOnMounted() {
@@ -9,6 +9,11 @@ export { default as AuthProfileWidget } from "./views/AuthProfileWidget.vue";
9
9
  export { default as AuthProfileMenuLinkItem } from "./views/AuthProfileMenuLinkItem.vue";
10
10
  export { useAuthStore } from "./stores/useAuthStore.js";
11
11
  export { useAuthGuardRuntime } from "./runtime/inject.js";
12
+ export {
13
+ completeOAuthCallbackFromCurrentLocation,
14
+ completeOAuthCallbackFromUrl,
15
+ readOAuthCallbackParamsFromUrl
16
+ } from "./runtime/oauthCallbackRuntime.js";
12
17
 
13
18
  const routeComponents = Object.freeze({
14
19
  "auth-login": DefaultLoginView,
@@ -2,9 +2,11 @@ import DefaultLoginView from "../views/DefaultLoginView.vue";
2
2
  import AuthProfileWidget from "../views/AuthProfileWidget.vue";
3
3
  import AuthProfileMenuLinkItem from "../views/AuthProfileMenuLinkItem.vue";
4
4
  import { createAuthGuardRuntime } from "../runtime/authGuardRuntime.js";
5
+ import { completeOAuthCallbackFromUrl } from "../runtime/oauthCallbackRuntime.js";
5
6
  import { useLoginView } from "../runtime/useLoginView.js";
6
7
  import { bootAuthClientProvider } from "./bootAuthClientProvider.js";
7
8
  import { resolveSurfaceNavigationTargetFromPlacementContext } from "@jskit-ai/shell-web/client/placement";
9
+ import { resolveAllowedReturnToOriginsFromPlacementContext } from "../lib/returnToPath.js";
8
10
 
9
11
  class AuthWebClientProvider {
10
12
  static id = "auth.web.client";
@@ -18,6 +20,27 @@ class AuthWebClientProvider {
18
20
  app.singleton("auth.login.useLoginView", () => useLoginView);
19
21
  app.singleton("auth.web.profile.widget", () => AuthProfileWidget);
20
22
  app.singleton("auth.web.profile.menu.link-item", () => AuthProfileMenuLinkItem);
23
+ app.singleton("auth.mobile-callback.client", () =>
24
+ Object.freeze({
25
+ async completeFromUrl({
26
+ url = "",
27
+ fallbackReturnTo = "/",
28
+ placementContext = null,
29
+ defaultProvider = "",
30
+ request = undefined,
31
+ refreshSession = async () => null
32
+ } = {}) {
33
+ return completeOAuthCallbackFromUrl({
34
+ url,
35
+ fallbackReturnTo,
36
+ allowedReturnToOrigins: resolveAllowedReturnToOriginsFromPlacementContext(placementContext),
37
+ defaultProvider,
38
+ ...(typeof request === "function" ? { request } : {}),
39
+ refreshSession
40
+ });
41
+ }
42
+ })
43
+ );
21
44
  app.singleton("runtime.auth-guard.client", () => {
22
45
  if (!app.has("runtime.web-placement.client")) {
23
46
  throw new Error("AuthWebClientProvider requires shell-web placement runtime.");
@@ -1,6 +1,12 @@
1
- import { AUTH_GUARD_RUNTIME_INJECTION_KEY } from "../runtime/inject.js";
1
+ import {
2
+ AUTH_GUARD_RUNTIME_INJECTION_KEY,
3
+ AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY
4
+ } from "../runtime/inject.js";
5
+ import { createBrowserOAuthLaunchClient } from "../runtime/oauthLaunchClient.js";
2
6
  import { useAuthStore } from "../stores/useAuthStore.js";
3
7
 
8
+ const AUTH_OAUTH_LAUNCH_CLIENT_TOKEN = "auth.oauth-launch.client";
9
+
4
10
  async function bootAuthClientProvider(app) {
5
11
  if (!app || typeof app.make !== "function" || typeof app.has !== "function") {
6
12
  throw new Error("AuthWebClientProvider requires application make()/has().");
@@ -33,7 +39,11 @@ async function bootAuthClientProvider(app) {
33
39
  return;
34
40
  }
35
41
 
42
+ const oauthLaunchClient = app.has(AUTH_OAUTH_LAUNCH_CLIENT_TOKEN)
43
+ ? app.make(AUTH_OAUTH_LAUNCH_CLIENT_TOKEN)
44
+ : createBrowserOAuthLaunchClient();
36
45
  vueApp.provide(AUTH_GUARD_RUNTIME_INJECTION_KEY, authGuardRuntime);
46
+ vueApp.provide(AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY, oauthLaunchClient);
37
47
  }
38
48
 
39
49
  export { bootAuthClientProvider };
@@ -1,7 +1,9 @@
1
1
  import { inject } from "vue";
2
2
  import { isAuthGuardRuntime } from "./authGuardRuntime.js";
3
+ import { createBrowserOAuthLaunchClient, isAuthOAuthLaunchClient } from "./oauthLaunchClient.js";
3
4
 
4
5
  const AUTH_GUARD_RUNTIME_INJECTION_KEY = "jskit.auth-web.runtime.auth-guard.client";
6
+ const AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY = "jskit.auth-web.runtime.oauth-launch.client";
5
7
 
6
8
  const EMPTY_AUTH_GUARD_STATE = Object.freeze({
7
9
  authenticated: false,
@@ -38,9 +40,24 @@ function useAuthGuardRuntime({ required = false } = {}) {
38
40
  return EMPTY_AUTH_GUARD_RUNTIME;
39
41
  }
40
42
 
43
+ function useAuthOAuthLaunchClient({ required = false } = {}) {
44
+ const client = inject(AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY, null);
45
+ if (isAuthOAuthLaunchClient(client)) {
46
+ return client;
47
+ }
48
+
49
+ if (required) {
50
+ throw new Error("OAuth launch client is not available in Vue injection context.");
51
+ }
52
+
53
+ return createBrowserOAuthLaunchClient();
54
+ }
55
+
41
56
  export {
42
57
  AUTH_GUARD_RUNTIME_INJECTION_KEY,
58
+ AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY,
43
59
  EMPTY_AUTH_GUARD_STATE,
44
60
  EMPTY_AUTH_GUARD_RUNTIME,
45
- useAuthGuardRuntime
61
+ useAuthGuardRuntime,
62
+ useAuthOAuthLaunchClient
46
63
  };
@@ -0,0 +1,194 @@
1
+ import { authLoginOAuthCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthCompleteCommand";
2
+ import {
3
+ OAUTH_QUERY_PARAM_PROVIDER,
4
+ OAUTH_QUERY_PARAM_RETURN_TO
5
+ } from "@jskit-ai/auth-core/shared/oauthCallbackParams";
6
+ import { AUTH_PATHS } from "@jskit-ai/auth-core/shared/authPaths";
7
+ import { normalizeAuthReturnToPath } from "../lib/returnToPath.js";
8
+ import { authHttpRequest } from "./authHttpClient.js";
9
+ import { ensureCommandSectionValid } from "../composables/loginView/validationHelpers.js";
10
+
11
+ function parseCallbackUrl(url = "") {
12
+ const normalizedUrl = String(url || "").trim();
13
+ if (!normalizedUrl) {
14
+ return null;
15
+ }
16
+
17
+ try {
18
+ return new URL(normalizedUrl, "https://jskit.invalid");
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function readOAuthCallbackParamsFromUrl(url = "") {
25
+ const parsedUrl = parseCallbackUrl(url);
26
+ if (!parsedUrl) {
27
+ return null;
28
+ }
29
+
30
+ const searchParams = new URLSearchParams(parsedUrl.search || "");
31
+ const hashParams = new URLSearchParams(String(parsedUrl.hash || "").replace(/^#/, ""));
32
+
33
+ const code = String(searchParams.get("code") || hashParams.get("code") || "").trim();
34
+ const accessToken = String(searchParams.get("access_token") || hashParams.get("access_token") || "").trim();
35
+ const refreshToken = String(searchParams.get("refresh_token") || hashParams.get("refresh_token") || "").trim();
36
+ const errorCode = String(
37
+ searchParams.get("error") ||
38
+ hashParams.get("error") ||
39
+ searchParams.get("errorCode") ||
40
+ hashParams.get("errorCode") ||
41
+ ""
42
+ ).trim();
43
+ const errorDescription = String(
44
+ searchParams.get("error_description") ||
45
+ hashParams.get("error_description") ||
46
+ searchParams.get("errorDescription") ||
47
+ hashParams.get("errorDescription") ||
48
+ ""
49
+ ).trim();
50
+ const hasSessionPair = Boolean(accessToken && refreshToken);
51
+
52
+ if (!code && !hasSessionPair && !errorCode) {
53
+ return null;
54
+ }
55
+
56
+ return Object.freeze({
57
+ code,
58
+ accessToken,
59
+ refreshToken,
60
+ hasSessionPair,
61
+ errorCode,
62
+ errorDescription,
63
+ provider: String(searchParams.get(OAUTH_QUERY_PARAM_PROVIDER) || "").trim().toLowerCase(),
64
+ returnTo: String(searchParams.get(OAUTH_QUERY_PARAM_RETURN_TO) || "").trim()
65
+ });
66
+ }
67
+
68
+ function buildOAuthCompletePayload({ callbackParams = null, provider = "", hasSessionPair = false } = {}) {
69
+ const payload = {};
70
+ if (provider) {
71
+ payload.provider = provider;
72
+ }
73
+ if (callbackParams?.code) {
74
+ payload.code = callbackParams.code;
75
+ }
76
+ if (hasSessionPair) {
77
+ payload.accessToken = callbackParams?.accessToken;
78
+ payload.refreshToken = callbackParams?.refreshToken;
79
+ }
80
+ return payload;
81
+ }
82
+
83
+ async function completeOAuthCallbackFromUrl({
84
+ url = "",
85
+ fallbackReturnTo = "/",
86
+ allowedReturnToOrigins = [],
87
+ defaultProvider = "",
88
+ request = authHttpRequest,
89
+ refreshSession = async () => null
90
+ } = {}) {
91
+ const callbackParams = readOAuthCallbackParamsFromUrl(url);
92
+ if (!callbackParams) {
93
+ return Object.freeze({
94
+ handled: false,
95
+ completed: false,
96
+ returnTo: normalizeAuthReturnToPath("", fallbackReturnTo, {
97
+ allowedOrigins: allowedReturnToOrigins
98
+ })
99
+ });
100
+ }
101
+
102
+ const returnTo = normalizeAuthReturnToPath(callbackParams.returnTo, fallbackReturnTo, {
103
+ allowedOrigins: allowedReturnToOrigins
104
+ });
105
+ const provider = String(callbackParams.provider || defaultProvider || "")
106
+ .trim()
107
+ .toLowerCase();
108
+ const oauthError = callbackParams.errorCode;
109
+ const oauthErrorDescription = callbackParams.errorDescription;
110
+ const hasSessionPair = callbackParams.hasSessionPair === true;
111
+
112
+ if (oauthError) {
113
+ return Object.freeze({
114
+ handled: true,
115
+ completed: false,
116
+ returnTo,
117
+ errorMessage: oauthErrorDescription || oauthError,
118
+ callbackParams
119
+ });
120
+ }
121
+
122
+ if (!provider && !hasSessionPair) {
123
+ return Object.freeze({
124
+ handled: true,
125
+ completed: false,
126
+ returnTo,
127
+ errorMessage: "OAuth provider is missing from callback.",
128
+ callbackParams
129
+ });
130
+ }
131
+
132
+ try {
133
+ const payload = buildOAuthCompletePayload({
134
+ callbackParams,
135
+ provider,
136
+ hasSessionPair
137
+ });
138
+ ensureCommandSectionValid(
139
+ authLoginOAuthCompleteCommand,
140
+ "body",
141
+ payload,
142
+ "Invalid OAuth callback payload."
143
+ );
144
+
145
+ const oauthResult = await request(AUTH_PATHS.OAUTH_COMPLETE, {
146
+ method: "POST",
147
+ body: payload
148
+ });
149
+ const session = await refreshSession();
150
+ if (!session?.authenticated) {
151
+ throw new Error("Login succeeded but the session is not active yet. Please retry.");
152
+ }
153
+
154
+ return Object.freeze({
155
+ handled: true,
156
+ completed: true,
157
+ returnTo,
158
+ callbackParams,
159
+ oauthResult,
160
+ session
161
+ });
162
+ } catch (error) {
163
+ return Object.freeze({
164
+ handled: true,
165
+ completed: false,
166
+ returnTo,
167
+ callbackParams,
168
+ errorMessage: String(error?.message || "Unable to complete OAuth sign-in.")
169
+ });
170
+ }
171
+ }
172
+
173
+ async function completeOAuthCallbackFromCurrentLocation(options = {}) {
174
+ if (typeof window !== "object" || !window?.location) {
175
+ return Object.freeze({
176
+ handled: false,
177
+ completed: false,
178
+ returnTo: normalizeAuthReturnToPath("", options.fallbackReturnTo || "/", {
179
+ allowedOrigins: options.allowedReturnToOrigins
180
+ })
181
+ });
182
+ }
183
+
184
+ return completeOAuthCallbackFromUrl({
185
+ ...options,
186
+ url: window.location.href
187
+ });
188
+ }
189
+
190
+ export {
191
+ completeOAuthCallbackFromCurrentLocation,
192
+ completeOAuthCallbackFromUrl,
193
+ readOAuthCallbackParamsFromUrl
194
+ };
@@ -0,0 +1,28 @@
1
+ function createBrowserOAuthLaunchClient({ location = null } = {}) {
2
+ const resolvedLocation =
3
+ location || (typeof window === "object" && window?.location ? window.location : null);
4
+
5
+ return Object.freeze({
6
+ async open({ url = "" } = {}) {
7
+ const normalizedUrl = String(url || "").trim();
8
+ if (!normalizedUrl) {
9
+ throw new Error("OAuth launch URL is required.");
10
+ }
11
+ if (!resolvedLocation || typeof resolvedLocation.assign !== "function") {
12
+ throw new Error("Browser location.assign() is unavailable for OAuth launch.");
13
+ }
14
+
15
+ resolvedLocation.assign(normalizedUrl);
16
+ return true;
17
+ }
18
+ });
19
+ }
20
+
21
+ function isAuthOAuthLaunchClient(value = null) {
22
+ return Boolean(value && typeof value === "object" && typeof value.open === "function");
23
+ }
24
+
25
+ export {
26
+ createBrowserOAuthLaunchClient,
27
+ isAuthOAuthLaunchClient
28
+ };
@@ -2,6 +2,7 @@ import { onMounted } from "vue";
2
2
  import { useQueryClient } from "@tanstack/vue-query";
3
3
  import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
4
4
  import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
5
+ import { useAuthOAuthLaunchClient } from "./inject.js";
5
6
  import { useLoginViewState } from "../composables/loginView/useLoginViewState.js";
6
7
  import { useLoginViewValidation } from "../composables/loginView/useLoginViewValidation.js";
7
8
  import { useLoginViewActions } from "../composables/loginView/useLoginViewActions.js";
@@ -10,6 +11,9 @@ function useLoginView() {
10
11
  const { context: placementContext } = useWebPlacementContext();
11
12
  const queryClient = useQueryClient();
12
13
  const errorRuntime = useShellWebErrorRuntime();
14
+ const oauthLaunchClient = useAuthOAuthLaunchClient({
15
+ required: true
16
+ });
13
17
 
14
18
  const state = useLoginViewState({ placementContext });
15
19
  const validation = useLoginViewValidation({ state });
@@ -17,7 +21,8 @@ function useLoginView() {
17
21
  state,
18
22
  validation,
19
23
  queryClient,
20
- errorRuntime
24
+ errorRuntime,
25
+ oauthLaunchClient
21
26
  });
22
27
 
23
28
  onMounted(actions.initializeOnMounted);
@@ -1,8 +1,22 @@
1
1
  <template>
2
- <v-main class="login-main">
3
- <v-container class="fill-height d-flex align-center justify-center py-8">
4
- <v-card class="auth-card" rounded="lg" elevation="1" border>
5
- <v-card-text class="pa-7">
2
+ <div class="login-screen" :class="{ 'login-screen--mobile': isMobileViewport }">
3
+ <v-container
4
+ fluid
5
+ class="login-shell fill-height d-flex"
6
+ :class="[
7
+ isMobileViewport
8
+ ? 'login-shell--mobile align-stretch justify-stretch pa-0'
9
+ : 'align-center justify-center py-8'
10
+ ]"
11
+ >
12
+ <v-card
13
+ class="auth-card"
14
+ :class="{ 'auth-card--mobile': isMobileViewport }"
15
+ :rounded="isMobileViewport ? false : 'lg'"
16
+ :elevation="isMobileViewport ? 0 : 1"
17
+ :border="!isMobileViewport"
18
+ >
19
+ <v-card-text class="auth-content" :class="{ 'auth-content--mobile': isMobileViewport }">
6
20
  <div class="auth-header d-flex align-start justify-space-between ga-3 mb-5">
7
21
  <div>
8
22
  <p class="auth-kicker">Jskit Workspace</p>
@@ -199,13 +213,14 @@
199
213
  </v-card-text>
200
214
  </v-card>
201
215
  </v-container>
202
- </v-main>
216
+ </div>
203
217
  </template>
204
218
 
205
219
  <script setup>
206
220
  import { onMounted } from "vue";
207
221
  import { useQueryClient } from "@tanstack/vue-query";
208
222
  import { mdiEye, mdiEyeOff } from "@mdi/js";
223
+ import { useDisplay } from "vuetify";
209
224
  import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
210
225
  import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
211
226
  import { useLoginViewState } from "../composables/loginView/useLoginViewState.js";
@@ -217,6 +232,7 @@ const {
217
232
  } = useWebPlacementContext();
218
233
  const queryClient = useQueryClient();
219
234
  const errorRuntime = useShellWebErrorRuntime();
235
+ const { mobile: isMobileViewport } = useDisplay({ mobileBreakpoint: 960 });
220
236
 
221
237
  const state = useLoginViewState({ placementContext });
222
238
  const validation = useLoginViewValidation({ state });
@@ -290,15 +306,46 @@ function toggleConfirmPasswordVisibility() {
290
306
  </script>
291
307
 
292
308
  <style scoped>
293
- .login-main {
309
+ .login-screen {
310
+ position: fixed;
311
+ inset: 0;
312
+ z-index: 1;
313
+ overflow-y: auto;
314
+ min-height: 100dvh;
294
315
  background-color: rgb(var(--v-theme-background));
295
316
  background-image: radial-gradient(circle at 15% 12%, rgba(var(--v-theme-primary), 0.14), transparent 32%);
296
317
  }
297
318
 
319
+ .login-screen--mobile {
320
+ background-image: none;
321
+ }
322
+
323
+ .login-shell {
324
+ min-height: 100dvh;
325
+ }
326
+
327
+ .login-shell--mobile {
328
+ width: 100%;
329
+ }
330
+
298
331
  .auth-card {
299
332
  width: min(520px, 100%);
300
333
  }
301
334
 
335
+ .auth-card--mobile {
336
+ width: 100%;
337
+ min-height: 100dvh;
338
+ }
339
+
340
+ .auth-content {
341
+ padding: 28px;
342
+ }
343
+
344
+ .auth-content--mobile {
345
+ min-height: 100dvh;
346
+ padding: calc(24px + env(safe-area-inset-top, 0px)) 20px calc(32px + env(safe-area-inset-bottom, 0px));
347
+ }
348
+
302
349
  .auth-kicker {
303
350
  margin: 0 0 8px;
304
351
  font-size: 12px;
@@ -361,10 +408,6 @@ function toggleConfirmPasswordVisibility() {
361
408
  }
362
409
 
363
410
  @media (max-width: 959px) {
364
- .auth-content {
365
- padding: 24px 20px;
366
- }
367
-
368
411
  .auth-title {
369
412
  font-size: 26px;
370
413
  }
@@ -8,6 +8,7 @@ test("auth-web client index defines provider-based client routes surface", () =>
8
8
 
9
9
  assert.equal(source.includes('export { useAuthStore } from "./stores/useAuthStore.js";'), true);
10
10
  assert.equal(source.includes('export { useAuthGuardRuntime } from "./runtime/inject.js";'), true);
11
+ assert.equal(source.includes('export {\n completeOAuthCallbackFromCurrentLocation,\n completeOAuthCallbackFromUrl,\n readOAuthCallbackParamsFromUrl\n} from "./runtime/oauthCallbackRuntime.js";'), true);
11
12
  assert.equal(source.includes('export { useAuth } from "./composables/useAuth.js";'), false);
12
13
  assert.equal(source.includes("const routeComponents = Object.freeze({"), true);
13
14
  assert.equal(source.includes('"auth-login": DefaultLoginView'), true);
@@ -65,6 +65,31 @@ test("auth-web runtime/useLoginView composes login view state, validation, and a
65
65
  assert.match(runtimeUseLoginViewSource, /export\s+\{\s*useLoginView\s*\};/);
66
66
  });
67
67
 
68
+ test("auth-web client provider registers a mobile auth callback completion token", () => {
69
+ const providerPath = fileURLToPath(new URL("../src/client/providers/AuthWebClientProvider.js", import.meta.url));
70
+ const providerSource = readFileSync(providerPath, "utf8");
71
+
72
+ assert.match(providerSource, /auth\.mobile-callback\.client/);
73
+ assert.match(providerSource, /completeOAuthCallbackFromUrl/);
74
+ });
75
+
76
+ test("default login view owns the viewport and becomes a full-screen mobile screen", () => {
77
+ const viewPath = fileURLToPath(new URL("../src/client/views/DefaultLoginView.vue", import.meta.url));
78
+ const viewSource = readFileSync(viewPath, "utf8");
79
+
80
+ assert.match(viewSource, /import\s+\{\s*useDisplay\s*\}\s+from\s+"vuetify";/);
81
+ assert.match(viewSource, /useDisplay\(\{\s*mobileBreakpoint:\s*960\s*\}\)/);
82
+ assert.doesNotMatch(viewSource, /<v-main\b/);
83
+ assert.match(viewSource, /login-screen--mobile/);
84
+ assert.match(viewSource, /login-shell--mobile/);
85
+ assert.match(viewSource, /auth-card--mobile/);
86
+ assert.match(viewSource, /auth-content--mobile/);
87
+ assert.match(viewSource, /:elevation="isMobileViewport \? 0 : 1"/);
88
+ assert.match(viewSource, /:border="!isMobileViewport"/);
89
+ assert.match(viewSource, /\.login-screen\s*\{[\s\S]*position:\s*fixed;/);
90
+ assert.match(viewSource, /\.login-screen\s*\{[\s\S]*inset:\s*0;/);
91
+ });
92
+
68
93
  test("auth-web package exports only minimal client runtime/view subpaths", () => {
69
94
  const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
70
95
  const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
@@ -85,6 +110,10 @@ test("auth-web package exports only minimal client runtime/view subpaths", () =>
85
110
  exportsMap["./client/runtime/authHttpClient"],
86
111
  "./src/client/runtime/authHttpClient.js"
87
112
  );
113
+ assert.equal(
114
+ exportsMap["./client/runtime/oauthCallbackRuntime"],
115
+ "./src/client/runtime/oauthCallbackRuntime.js"
116
+ );
88
117
  assert.equal(exportsMap["./client/runtime/useSignOut"], "./src/client/runtime/useSignOut.js");
89
118
  assert.equal(exportsMap["./client/composables/useDefaultLoginView"], undefined);
90
119
  assert.equal(exportsMap["./client/composables/useDefaultSignOutView"], undefined);
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildAuthOauthStartPath } from "@jskit-ai/auth-core/shared/authPaths";
4
+ import { useLoginViewActions } from "../src/client/composables/loginView/useLoginViewActions.js";
5
+
6
+ function createMinimalLoginViewState(requestedReturnTo = "/") {
7
+ return {
8
+ requestedReturnTo: { value: requestedReturnTo },
9
+ allowedReturnToOrigins: { value: [] },
10
+ errorMessage: { value: "" },
11
+ infoMessage: { value: "" },
12
+ loading: { value: false },
13
+ email: { value: "" },
14
+ password: { value: "" },
15
+ otpCode: { value: "" },
16
+ otpRequestPending: { value: false },
17
+ registerConfirmationResendPending: { value: false },
18
+ pendingEmailConfirmationAddress: { value: "" },
19
+ oauthProviders: { value: [] },
20
+ oauthDefaultProvider: { value: "google" },
21
+ isRegister: { value: false },
22
+ isOtp: { value: false },
23
+ showRememberedAccount: { value: false },
24
+ rememberedAccountDisplayName: { value: "" },
25
+ clearTransientMessages() {},
26
+ applyRememberedAccountPreference() {},
27
+ applyRememberedAccountHint() {},
28
+ enterEmailConfirmationPendingState() {},
29
+ resolveNormalizedEmail() {
30
+ return "ada@example.com";
31
+ }
32
+ };
33
+ }
34
+
35
+ test("useLoginViewActions preserves the intended destination when starting OAuth sign-in", () => {
36
+ const state = createMinimalLoginViewState("/w/acme/workouts/2026-05-07?tab=chart");
37
+ const assignedTargets = [];
38
+ const actions = useLoginViewActions({
39
+ state,
40
+ validation: {},
41
+ queryClient: {},
42
+ errorRuntime: {
43
+ report() {}
44
+ },
45
+ oauthLaunchClient: {
46
+ async open({ url }) {
47
+ assignedTargets.push(url);
48
+ }
49
+ }
50
+ });
51
+
52
+ return actions.startOAuthSignIn("google").then(() => {
53
+ const expectedPath = buildAuthOauthStartPath("google");
54
+ assert.deepEqual(assignedTargets, [
55
+ `${expectedPath}?returnTo=%2Fw%2Facme%2Fworkouts%2F2026-05-07%3Ftab%3Dchart`
56
+ ]);
57
+ assert.equal(state.errorMessage.value, "");
58
+ });
59
+ });
@@ -0,0 +1,164 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ completeOAuthCallbackFromCurrentLocation,
5
+ completeOAuthCallbackFromUrl,
6
+ readOAuthCallbackParamsFromUrl
7
+ } from "../src/client/runtime/oauthCallbackRuntime.js";
8
+
9
+ test("readOAuthCallbackParamsFromUrl returns null when the URL has no callback params", () => {
10
+ assert.equal(readOAuthCallbackParamsFromUrl("/auth/login"), null);
11
+ });
12
+
13
+ test("readOAuthCallbackParamsFromUrl reads query and hash callback params", () => {
14
+ assert.deepEqual(
15
+ readOAuthCallbackParamsFromUrl("/auth/login?oauthProvider=google&oauthReturnTo=%2Fw%2Facme&code=abc"),
16
+ {
17
+ code: "abc",
18
+ accessToken: "",
19
+ refreshToken: "",
20
+ hasSessionPair: false,
21
+ errorCode: "",
22
+ errorDescription: "",
23
+ provider: "google",
24
+ returnTo: "/w/acme"
25
+ }
26
+ );
27
+
28
+ assert.deepEqual(
29
+ readOAuthCallbackParamsFromUrl("/auth/login?oauthReturnTo=%2Fhome#access_token=access&refresh_token=refresh"),
30
+ {
31
+ code: "",
32
+ accessToken: "access",
33
+ refreshToken: "refresh",
34
+ hasSessionPair: true,
35
+ errorCode: "",
36
+ errorDescription: "",
37
+ provider: "",
38
+ returnTo: "/home"
39
+ }
40
+ );
41
+ });
42
+
43
+ test("completeOAuthCallbackFromUrl exchanges code callbacks and refreshes the session", async () => {
44
+ const calls = [];
45
+ const result = await completeOAuthCallbackFromUrl({
46
+ url: "/auth/login?oauthProvider=google&oauthReturnTo=%2Fw%2Facme&code=abc",
47
+ request: async (path, options) => {
48
+ calls.push({ path, options });
49
+ return {
50
+ username: "Ada",
51
+ email: "ada@example.com"
52
+ };
53
+ },
54
+ refreshSession: async () => ({
55
+ authenticated: true
56
+ })
57
+ });
58
+
59
+ assert.equal(result.handled, true);
60
+ assert.equal(result.completed, true);
61
+ assert.equal(result.returnTo, "/w/acme");
62
+ assert.deepEqual(calls, [
63
+ {
64
+ path: "/api/oauth/complete",
65
+ options: {
66
+ method: "POST",
67
+ body: {
68
+ provider: "google",
69
+ code: "abc"
70
+ }
71
+ }
72
+ }
73
+ ]);
74
+ });
75
+
76
+ test("completeOAuthCallbackFromUrl supports provider-less session-pair callbacks", async () => {
77
+ const result = await completeOAuthCallbackFromUrl({
78
+ url: "/auth/login?oauthReturnTo=%2Fhome#access_token=access&refresh_token=refresh",
79
+ request: async (path, options) => {
80
+ assert.equal(path, "/api/oauth/complete");
81
+ assert.deepEqual(options.body, {
82
+ accessToken: "access",
83
+ refreshToken: "refresh"
84
+ });
85
+ return {
86
+ username: "Ada"
87
+ };
88
+ },
89
+ refreshSession: async () => ({
90
+ authenticated: true
91
+ })
92
+ });
93
+
94
+ assert.equal(result.handled, true);
95
+ assert.equal(result.completed, true);
96
+ assert.equal(result.returnTo, "/home");
97
+ });
98
+
99
+ test("completeOAuthCallbackFromUrl reports callback errors without issuing a request", async () => {
100
+ const result = await completeOAuthCallbackFromUrl({
101
+ url: "/auth/login?error=access_denied&error_description=Provider%20denied%20access",
102
+ request: async () => {
103
+ throw new Error("request should not be called for callback errors");
104
+ }
105
+ });
106
+
107
+ assert.equal(result.handled, true);
108
+ assert.equal(result.completed, false);
109
+ assert.equal(result.errorMessage, "Provider denied access");
110
+ });
111
+
112
+ test("completeOAuthCallbackFromUrl reports missing providers for code callbacks", async () => {
113
+ const result = await completeOAuthCallbackFromUrl({
114
+ url: "/auth/login?code=abc",
115
+ request: async () => {
116
+ throw new Error("request should not be called when provider is missing");
117
+ }
118
+ });
119
+
120
+ assert.equal(result.handled, true);
121
+ assert.equal(result.completed, false);
122
+ assert.equal(result.errorMessage, "OAuth provider is missing from callback.");
123
+ });
124
+
125
+ test("completeOAuthCallbackFromUrl fails when the refreshed session is still unauthenticated", async () => {
126
+ const result = await completeOAuthCallbackFromUrl({
127
+ url: "/auth/login?oauthProvider=google&code=abc",
128
+ request: async () => ({
129
+ username: "Ada"
130
+ }),
131
+ refreshSession: async () => ({
132
+ authenticated: false
133
+ })
134
+ });
135
+
136
+ assert.equal(result.handled, true);
137
+ assert.equal(result.completed, false);
138
+ assert.equal(result.errorMessage, "Login succeeded but the session is not active yet. Please retry.");
139
+ });
140
+
141
+ test("completeOAuthCallbackFromCurrentLocation reads from window.location.href", async () => {
142
+ const originalWindow = globalThis.window;
143
+ globalThis.window = {
144
+ location: {
145
+ href: "https://app.example.com/auth/login?oauthProvider=google&code=abc"
146
+ }
147
+ };
148
+
149
+ try {
150
+ const result = await completeOAuthCallbackFromCurrentLocation({
151
+ request: async () => ({
152
+ username: "Ada"
153
+ }),
154
+ refreshSession: async () => ({
155
+ authenticated: true
156
+ })
157
+ });
158
+
159
+ assert.equal(result.handled, true);
160
+ assert.equal(result.completed, true);
161
+ } finally {
162
+ globalThis.window = originalWindow;
163
+ }
164
+ });
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createPinia } from "pinia";
4
- import { AUTH_GUARD_RUNTIME_INJECTION_KEY } from "../src/client/runtime/inject.js";
4
+ import {
5
+ AUTH_GUARD_RUNTIME_INJECTION_KEY,
6
+ AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY
7
+ } from "../src/client/runtime/inject.js";
5
8
  import { bootAuthClientProvider } from "../src/client/providers/bootAuthClientProvider.js";
6
9
  import { useAuthStore } from "../src/client/stores/useAuthStore.js";
7
10
 
@@ -56,7 +59,7 @@ function createAuthRuntimeStub(initialState = {}) {
56
59
  };
57
60
  }
58
61
 
59
- function createAppDouble({ authGuardRuntime, bootstrapRuntime = null } = {}) {
62
+ function createAppDouble({ authGuardRuntime, bootstrapRuntime = null, oauthLaunchClient = null } = {}) {
60
63
  const singletons = new Map();
61
64
  const singletonInstances = new Map();
62
65
  const provided = [];
@@ -91,6 +94,9 @@ function createAppDouble({ authGuardRuntime, bootstrapRuntime = null } = {}) {
91
94
  if (token === "runtime.web-bootstrap.client") {
92
95
  return Boolean(bootstrapRuntime);
93
96
  }
97
+ if (token === "auth.oauth-launch.client") {
98
+ return Boolean(oauthLaunchClient);
99
+ }
94
100
  return singletons.has(token) || singletonInstances.has(token);
95
101
  },
96
102
  make(token) {
@@ -130,6 +136,9 @@ function createAppDouble({ authGuardRuntime, bootstrapRuntime = null } = {}) {
130
136
  if (token === "runtime.web-bootstrap.client") {
131
137
  return bootstrapRuntime;
132
138
  }
139
+ if (token === "auth.oauth-launch.client") {
140
+ return oauthLaunchClient;
141
+ }
133
142
  if (singletonInstances.has(token)) {
134
143
  return singletonInstances.get(token);
135
144
  }
@@ -168,6 +177,7 @@ test("auth web client boot binds explicit Pinia store state and raw runtime inje
168
177
 
169
178
  const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
170
179
  assert.equal(providedByKey.get(AUTH_GUARD_RUNTIME_INJECTION_KEY), authGuardRuntime);
180
+ assert.equal(typeof providedByKey.get(AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY)?.open, "function");
171
181
  });
172
182
 
173
183
  test("auth web client boot refreshes shared bootstrap runtime on auth changes", async () => {
@@ -196,3 +206,35 @@ test("auth web client boot refreshes shared bootstrap runtime on auth changes",
196
206
 
197
207
  assert.deepEqual(refreshCalls, ["auth.state"]);
198
208
  });
209
+
210
+ test("auth web client boot prefers an explicitly registered OAuth launch client", async () => {
211
+ const authGuardRuntime = createAuthRuntimeStub({
212
+ authenticated: false
213
+ });
214
+ const launchCalls = [];
215
+ const oauthLaunchClient = {
216
+ async open(input = {}) {
217
+ launchCalls.push(input);
218
+ return true;
219
+ }
220
+ };
221
+ const app = createAppDouble({
222
+ authGuardRuntime,
223
+ oauthLaunchClient
224
+ });
225
+
226
+ await bootAuthClientProvider(app);
227
+
228
+ const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
229
+ const injectedLaunchClient = providedByKey.get(AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY);
230
+ assert.equal(injectedLaunchClient, oauthLaunchClient);
231
+
232
+ await injectedLaunchClient.open({
233
+ url: "/api/oauth/google/start?returnTo=%2Fw%2Facme"
234
+ });
235
+ assert.deepEqual(launchCalls, [
236
+ {
237
+ url: "/api/oauth/google/start?returnTo=%2Fw%2Facme"
238
+ }
239
+ ]);
240
+ });
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSignOutAction } from "../src/client/runtime/useSignOut.js";
4
+
5
+ function createJsonResponse(payload = {}, { status = 200 } = {}) {
6
+ return {
7
+ ok: status >= 200 && status < 300,
8
+ status,
9
+ headers: {
10
+ get(name) {
11
+ return String(name || "").toLowerCase() === "content-type" ? "application/json" : null;
12
+ }
13
+ },
14
+ async json() {
15
+ return payload;
16
+ },
17
+ async text() {
18
+ return JSON.stringify(payload);
19
+ }
20
+ };
21
+ }
22
+
23
+ test("createSignOutAction clears the session cleanly and routes back to login with returnTo", async () => {
24
+ const fetchCalls = [];
25
+ const goToEntryCalls = [];
26
+ const originalFetch = globalThis.fetch;
27
+ let sessionReads = 0;
28
+
29
+ globalThis.fetch = async (url, options = {}) => {
30
+ fetchCalls.push({
31
+ url,
32
+ method: String(options.method || "GET").toUpperCase()
33
+ });
34
+
35
+ const normalizedUrl = String(url || "");
36
+ const normalizedMethod = String(options.method || "GET").toUpperCase();
37
+
38
+ if (normalizedUrl === "/api/session" && normalizedMethod === "GET") {
39
+ sessionReads += 1;
40
+ return createJsonResponse(
41
+ sessionReads === 1
42
+ ? { authenticated: true, csrfToken: "csrf-a" }
43
+ : { authenticated: false, csrfToken: "csrf-b" }
44
+ );
45
+ }
46
+
47
+ if (normalizedUrl === "/api/logout" && normalizedMethod === "POST") {
48
+ return createJsonResponse({ ok: true });
49
+ }
50
+
51
+ throw new Error(`Unexpected fetch call: ${normalizedMethod} ${normalizedUrl}`);
52
+ };
53
+
54
+ try {
55
+ const authGuardRuntime = {
56
+ async initialize() {
57
+ return {
58
+ authenticated: false
59
+ };
60
+ },
61
+ async refresh() {
62
+ return {
63
+ authenticated: false
64
+ };
65
+ },
66
+ getState() {
67
+ return {
68
+ authenticated: false
69
+ };
70
+ }
71
+ };
72
+
73
+ const signOut = createSignOutAction({
74
+ currentSurface: {
75
+ value: "/w/acme/workouts/2026-05-07"
76
+ },
77
+ goToEntry: async ({ resolvedRoute }) => {
78
+ goToEntryCalls.push(resolvedRoute);
79
+ },
80
+ authGuardRuntime
81
+ });
82
+
83
+ await signOut();
84
+
85
+ assert.deepEqual(fetchCalls, [
86
+ { url: "/api/session", method: "GET" },
87
+ { url: "/api/logout", method: "POST" },
88
+ { url: "/api/session", method: "GET" }
89
+ ]);
90
+ assert.deepEqual(goToEntryCalls, [
91
+ "/auth/login?returnTo=%2Fw%2Facme%2Fworkouts%2F2026-05-07"
92
+ ]);
93
+ } finally {
94
+ globalThis.fetch = originalFetch;
95
+ }
96
+ });