@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.
- package/package.descriptor.mjs +5 -5
- package/package.json +6 -5
- package/src/client/composables/loginView/oauthCallbackUrl.js +2 -36
- package/src/client/composables/loginView/useLoginViewActions.js +40 -65
- package/src/client/index.js +5 -0
- package/src/client/providers/AuthWebClientProvider.js +23 -0
- package/src/client/providers/bootAuthClientProvider.js +11 -1
- package/src/client/runtime/inject.js +18 -1
- package/src/client/runtime/oauthCallbackRuntime.js +194 -0
- package/src/client/runtime/oauthLaunchClient.js +28 -0
- package/src/client/runtime/useLoginView.js +6 -1
- package/src/client/views/DefaultLoginView.vue +53 -10
- package/test/clientBoot.test.js +1 -0
- package/test/clientSurface.test.js +29 -0
- package/test/loginViewActions.test.js +59 -0
- package/test/oauthCallbackRuntime.test.js +164 -0
- package/test/provider.test.js +44 -2
- package/test/useSignOut.test.js +96 -0
package/package.descriptor.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
223
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
224
|
-
"@jskit-ai/kernel": "0.1.
|
|
225
|
-
"@jskit-ai/shell-web": "0.1.
|
|
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.
|
|
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.
|
|
22
|
+
"@jskit-ai/auth-core": "0.1.63",
|
|
22
23
|
"@mdi/js": "^7.4.47",
|
|
23
|
-
"@jskit-ai/kernel": "0.1.
|
|
24
|
-
"@jskit-ai/shell-web": "0.1.
|
|
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.
|
|
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
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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() {
|
package/src/client/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
3
|
-
<v-container
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
</
|
|
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-
|
|
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
|
}
|
package/test/clientBoot.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/provider.test.js
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|