@jskit-ai/auth-web 0.1.245 → 0.1.246
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.json +5 -5
- package/patterns/auth-surface/PATTERN.md +33 -11
- package/src/client/composables/loginView/oauthCallbackUrl.js +1 -0
- package/src/client/composables/loginView/useLoginViewActions.js +4 -0
- package/src/client/index.js +4 -0
- package/src/client/runtime/authClient.js +2 -2
- package/src/client/runtime/inject.js +3 -2
- package/src/client/runtime/oauthCallbackRuntime.js +14 -1
- package/src/client/runtime/popupOAuthLaunchClient.js +144 -0
- package/src/client/views/DefaultLoginView.vue +10 -6
- package/src/client/views/DefaultResetPasswordView.vue +7 -5
- package/src/server/controllers/AuthController.js +17 -2
- package/src/server/services/AuthWebService.js +16 -0
- package/test/clientSurface.test.js +18 -1
- package/test/managedPreviewIdentity.test.js +20 -0
- package/test/oauthCallbackRuntime.test.js +20 -0
- package/test/popupOAuthLaunchClient.test.js +103 -0
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.246",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"@mdi/js": "^7.4.47"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"@jskit-ai/auth-core": "0.1.
|
|
29
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
30
|
-
"@jskit-ai/kernel": "0.1.
|
|
31
|
-
"@jskit-ai/shell-web": "0.1.
|
|
28
|
+
"@jskit-ai/auth-core": "0.1.243",
|
|
29
|
+
"@jskit-ai/http-runtime": "0.1.243",
|
|
30
|
+
"@jskit-ai/kernel": "0.1.245",
|
|
31
|
+
"@jskit-ai/shell-web": "0.1.250",
|
|
32
32
|
"@tanstack/vue-query": "^5.90.5",
|
|
33
33
|
"pinia": "^4.0.3",
|
|
34
34
|
"vue": "^3.5.44",
|
|
@@ -30,16 +30,33 @@ Use the views, auth guard, HTTP client integration, sign-out runtime, providers,
|
|
|
30
30
|
and Playwright auth helper exported by `@jskit-ai/auth-web`. Use auth policies
|
|
31
31
|
from `@jskit-ai/auth-core` for server authorization.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
Every application with accounts includes the managed-preview login. It is
|
|
34
|
+
standard, not optional: adding this pattern adds it, so a managed host's
|
|
35
|
+
preview can sign its browser in as an existing account without anyone asking
|
|
36
|
+
for it. Copy the app-owned `tools/preview-identity` executable (keep it
|
|
37
|
+
executable) and declare it under `#### Preview identity` in the project's
|
|
38
|
+
web-presented Vibe64 `Outputs` target, exactly:
|
|
39
|
+
|
|
40
|
+
```markdown
|
|
41
|
+
#### Preview identity
|
|
42
|
+
|
|
43
|
+
- Command: `tools/preview-identity`
|
|
44
|
+
- Protocol: `vibe64.preview-identity.command.v1`
|
|
45
|
+
- Identity types: `email` `user-id`
|
|
46
|
+
- Enabled environment: `AUTH_DEV_BYPASS_ENABLED`
|
|
47
|
+
- Secret environment: `AUTH_DEV_BYPASS_SECRET`
|
|
48
|
+
- Runtimes: `nodejs`
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The preview host supplies fresh output-run-scoped values for both environment
|
|
52
|
+
names; users do not set them by hand, and the exchange stays disabled without
|
|
53
|
+
them. The person using the host chooses which existing accounts the preview may
|
|
54
|
+
sign in as; the application never creates users for it. JSKIT owns this
|
|
55
|
+
executable/library pattern, Vibe64 owns the `Outputs` grammar and runtime
|
|
56
|
+
behavior, and Genesis only composes that consumer-owned section as opaque text.
|
|
57
|
+
The executable calls the exported managed-preview identity library directly; it
|
|
58
|
+
does not require a JSKIT CLI. A project without Genesis or a managed host keeps
|
|
59
|
+
the executable; it is inert unless both environment values are present.
|
|
43
60
|
|
|
44
61
|
Choose local-auth storage through the installed capability provider. A
|
|
45
62
|
database-backed application installs `@jskit-ai/auth-provider-local-db-core`,
|
|
@@ -52,6 +69,8 @@ a user as a side effect.
|
|
|
52
69
|
## Invariants
|
|
53
70
|
|
|
54
71
|
- Credentials and provider secrets come from environment values.
|
|
72
|
+
- An application with accounts ships the executable `tools/preview-identity`
|
|
73
|
+
and declares it under `#### Preview identity` in its web `Outputs` target.
|
|
55
74
|
- Auth backend selection comes from the installed provider graph, not an
|
|
56
75
|
`AUTH_LOCAL_BACKEND` environment value.
|
|
57
76
|
- The selected auth backend and the storage containing the application's users
|
|
@@ -72,7 +91,8 @@ a user as a side effect.
|
|
|
72
91
|
|
|
73
92
|
`example/` contains concrete route wrappers, editable view wrappers, and the
|
|
74
93
|
client runtime helpers needed by an authentication surface. It also contains
|
|
75
|
-
the
|
|
94
|
+
the standard app-owned managed-preview identity executable,
|
|
95
|
+
`tools/preview-identity`. Compose their
|
|
76
96
|
surface and placement declarations into the application's ordinary config and
|
|
77
97
|
placement files.
|
|
78
98
|
|
|
@@ -87,6 +107,8 @@ Exercise failed and successful login, sign-out, reset, protected navigation,
|
|
|
87
107
|
keyboard interaction, warm-cache return navigation, and browser refresh.
|
|
88
108
|
Open the login route with an existing authenticated session and confirm it
|
|
89
109
|
returns to the requested screen; repeat signed out and confirm the form remains.
|
|
110
|
+
Confirm `tools/preview-identity` is committed as executable and the web
|
|
111
|
+
`Outputs` target declares the `#### Preview identity` block above.
|
|
90
112
|
|
|
91
113
|
## Avoid
|
|
92
114
|
|
|
@@ -14,6 +14,7 @@ import { normalizeAuthReturnToPath } from "../../lib/returnToPath.js";
|
|
|
14
14
|
import { normalizeEmailAddress } from "./identityHelpers.js";
|
|
15
15
|
import { readRememberedAccountHint } from "./rememberedAccountStorage.js";
|
|
16
16
|
import { stripOAuthParamsFromLocation } from "./oauthCallbackUrl.js";
|
|
17
|
+
import { completeOAuthPopupSignIn } from "../../runtime/popupOAuthLaunchClient.js";
|
|
17
18
|
import { ensureCommandSectionValid } from "./validationHelpers.js";
|
|
18
19
|
import { resolveRegisterCompletionState } from "./registerCompletion.js";
|
|
19
20
|
|
|
@@ -282,6 +283,9 @@ export function useLoginViewActions({
|
|
|
282
283
|
shouldRemember: state.rememberAccountOnDevice.value !== false
|
|
283
284
|
});
|
|
284
285
|
|
|
286
|
+
// A sign-in window opened by a framed app hands back and closes; if the
|
|
287
|
+
// browser keeps it open, it continues like an ordinary sign-in.
|
|
288
|
+
completeOAuthPopupSignIn();
|
|
285
289
|
stripOAuthParamsFromLocation();
|
|
286
290
|
if (typeof window === "object" && window.location) {
|
|
287
291
|
window.location.replace(state.requestedReturnTo.value);
|
package/src/client/index.js
CHANGED
|
@@ -10,6 +10,10 @@ export { default as AuthProfileWidget } from "./views/AuthProfileWidget.vue";
|
|
|
10
10
|
export { default as AuthProfileMenuLinkItem } from "./views/AuthProfileMenuLinkItem.vue";
|
|
11
11
|
export { useAuthStore } from "./stores/useAuthStore.js";
|
|
12
12
|
export { useAuthGuardRuntime } from "./runtime/inject.js";
|
|
13
|
+
export {
|
|
14
|
+
completeOAuthPopupSignIn,
|
|
15
|
+
createPopupOAuthLaunchClient
|
|
16
|
+
} from "./runtime/popupOAuthLaunchClient.js";
|
|
13
17
|
export {
|
|
14
18
|
completeOAuthCallbackFromCurrentLocation,
|
|
15
19
|
completeOAuthCallbackFromUrl,
|
|
@@ -3,7 +3,7 @@ import { resolveSurfaceNavigationTargetFromPlacementContext } from "@jskit-ai/sh
|
|
|
3
3
|
import { resolveAllowedReturnToOriginsFromPlacementContext } from "../lib/returnToPath.js";
|
|
4
4
|
import { createAuthGuardRuntime } from "./authGuardRuntime.js";
|
|
5
5
|
import { completeOAuthCallbackFromUrl } from "./oauthCallbackRuntime.js";
|
|
6
|
-
import {
|
|
6
|
+
import { createPopupOAuthLaunchClient } from "./popupOAuthLaunchClient.js";
|
|
7
7
|
import {
|
|
8
8
|
AUTH_GUARD_RUNTIME_INJECTION_KEY,
|
|
9
9
|
AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY
|
|
@@ -44,7 +44,7 @@ function createAuthClient({ mobile = null, pinia, realtime = null, shell, vueApp
|
|
|
44
44
|
realtimeSocket: realtime?.socket || null
|
|
45
45
|
});
|
|
46
46
|
const mobileCallback = createMobileCallbackCompleter();
|
|
47
|
-
const oauthLaunch = mobile?.oauthLaunch ||
|
|
47
|
+
const oauthLaunch = mobile?.oauthLaunch || createPopupOAuthLaunchClient();
|
|
48
48
|
let stopAuthRefresh = null;
|
|
49
49
|
|
|
50
50
|
async function initialize() {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { inject } from "vue";
|
|
2
2
|
import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
|
|
3
3
|
import { isAuthGuardRuntime } from "./authGuardRuntime.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isAuthOAuthLaunchClient } from "./oauthLaunchClient.js";
|
|
5
|
+
import { createPopupOAuthLaunchClient } from "./popupOAuthLaunchClient.js";
|
|
5
6
|
|
|
6
7
|
const AUTH_GUARD_RUNTIME_INJECTION_KEY = "jskit.auth-web.runtime.auth-guard.client";
|
|
7
8
|
const AUTH_OAUTH_LAUNCH_CLIENT_INJECTION_KEY = "jskit.auth-web.runtime.oauth-launch.client";
|
|
@@ -52,7 +53,7 @@ function useAuthOAuthLaunchClient({ required = false } = {}) {
|
|
|
52
53
|
throw new Error("OAuth launch client is not available in Vue injection context.");
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
return
|
|
56
|
+
return createPopupOAuthLaunchClient();
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export {
|
|
@@ -21,6 +21,8 @@ function readOAuthCallbackParamsFromUrl(url = "") {
|
|
|
21
21
|
|
|
22
22
|
const { searchParams } = callbackUrlParams;
|
|
23
23
|
const code = readAuthCallbackParam(callbackUrlParams, "code");
|
|
24
|
+
const state = readAuthCallbackParam(callbackUrlParams, "state");
|
|
25
|
+
const iss = readAuthCallbackParam(callbackUrlParams, "iss");
|
|
24
26
|
const accessToken = readAuthCallbackParam(callbackUrlParams, "access_token");
|
|
25
27
|
const refreshToken = readAuthCallbackParam(callbackUrlParams, "refresh_token");
|
|
26
28
|
const errorCode = String(
|
|
@@ -39,6 +41,8 @@ function readOAuthCallbackParamsFromUrl(url = "") {
|
|
|
39
41
|
|
|
40
42
|
return Object.freeze({
|
|
41
43
|
code,
|
|
44
|
+
state,
|
|
45
|
+
iss,
|
|
42
46
|
accessToken,
|
|
43
47
|
refreshToken,
|
|
44
48
|
hasSessionPair,
|
|
@@ -57,6 +61,12 @@ function buildOAuthCompletePayload({ callbackParams = null, provider = "", hasSe
|
|
|
57
61
|
if (callbackParams?.code) {
|
|
58
62
|
payload.code = callbackParams.code;
|
|
59
63
|
}
|
|
64
|
+
if (callbackParams?.state) {
|
|
65
|
+
payload.state = callbackParams.state;
|
|
66
|
+
}
|
|
67
|
+
if (callbackParams?.iss) {
|
|
68
|
+
payload.iss = callbackParams.iss;
|
|
69
|
+
}
|
|
60
70
|
if (hasSessionPair) {
|
|
61
71
|
payload.accessToken = callbackParams?.accessToken;
|
|
62
72
|
payload.refreshToken = callbackParams?.refreshToken;
|
|
@@ -142,7 +152,10 @@ async function completeOAuthCallbackFromUrl({
|
|
|
142
152
|
return Object.freeze({
|
|
143
153
|
handled: true,
|
|
144
154
|
completed: true,
|
|
145
|
-
|
|
155
|
+
// A provider with one fixed redirect address returns the requested page itself.
|
|
156
|
+
returnTo: oauthResult?.returnTo && !callbackParams.returnTo
|
|
157
|
+
? normalizeAuthReturnToPath(oauthResult.returnTo, fallbackReturnTo, { allowedOrigins: allowedReturnToOrigins })
|
|
158
|
+
: returnTo,
|
|
146
159
|
callbackParams,
|
|
147
160
|
oauthResult,
|
|
148
161
|
session
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createBrowserOAuthLaunchClient } from "./oauthLaunchClient.js";
|
|
2
|
+
|
|
3
|
+
// A sign-in page refuses to be framed, so a framed app (an editor Preview, an
|
|
4
|
+
// embedded dashboard) signs in through a window of its own. The window
|
|
5
|
+
// completes the callback on the app's origin, tells the framed page, and closes.
|
|
6
|
+
const OAUTH_POPUP_WINDOW_NAME = "jskit-oauth-sign-in";
|
|
7
|
+
const OAUTH_POPUP_MESSAGE_TYPE = "jskit.auth-web.oauth-popup.complete";
|
|
8
|
+
const OAUTH_POPUP_MARKER_KEY = "jskit.auth-web.oauth-popup.started-at";
|
|
9
|
+
const OAUTH_POPUP_LIFETIME_MS = 10 * 60 * 1000;
|
|
10
|
+
const OAUTH_POPUP_BLOCKED_MESSAGE =
|
|
11
|
+
"Your browser blocked the sign-in window. Allow pop-ups for this page, then try again.";
|
|
12
|
+
|
|
13
|
+
function browserWindow() {
|
|
14
|
+
return typeof window === "object" && window ? window : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isFramedWindow(win) {
|
|
18
|
+
try {
|
|
19
|
+
return win.self !== win.top;
|
|
20
|
+
} catch {
|
|
21
|
+
// A cross-origin parent hides window.top: that is a frame too.
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function storageOf(win) {
|
|
27
|
+
try {
|
|
28
|
+
return win.localStorage || null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function popupFeatures(win, width = 520, height = 680) {
|
|
35
|
+
const left = Math.max(0, Math.round((win.screenX || 0) + ((win.outerWidth || width) - width) / 2));
|
|
36
|
+
const top = Math.max(0, Math.round((win.screenY || 0) + ((win.outerHeight || height) - height) / 2));
|
|
37
|
+
return `popup=yes,width=${width},height=${height},left=${left},top=${top}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openChannel(win) {
|
|
41
|
+
return typeof win.BroadcastChannel === "function" ? new win.BroadcastChannel(OAUTH_POPUP_MESSAGE_TYPE) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createPopupOAuthLaunchClient({
|
|
45
|
+
window: win = browserWindow(),
|
|
46
|
+
fallback = createBrowserOAuthLaunchClient(),
|
|
47
|
+
onComplete = null
|
|
48
|
+
} = {}) {
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
async open({ url = "" } = {}) {
|
|
51
|
+
if (!win || !isFramedWindow(win)) {
|
|
52
|
+
return fallback.open({ url });
|
|
53
|
+
}
|
|
54
|
+
const normalizedUrl = String(url || "").trim();
|
|
55
|
+
if (!normalizedUrl) {
|
|
56
|
+
throw new Error("OAuth launch URL is required.");
|
|
57
|
+
}
|
|
58
|
+
const popup = win.open(normalizedUrl, OAUTH_POPUP_WINDOW_NAME, popupFeatures(win));
|
|
59
|
+
if (!popup) {
|
|
60
|
+
throw new Error(OAUTH_POPUP_BLOCKED_MESSAGE);
|
|
61
|
+
}
|
|
62
|
+
storageOf(win)?.setItem(OAUTH_POPUP_MARKER_KEY, String(Date.now()));
|
|
63
|
+
popup.focus?.();
|
|
64
|
+
|
|
65
|
+
// Some sign-in pages cut the opener link, so the window also answers on
|
|
66
|
+
// a same-origin channel. Closing it without finishing leaves this page as it was.
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const channel = openChannel(win);
|
|
69
|
+
let timer = null;
|
|
70
|
+
const finish = (completed) => {
|
|
71
|
+
win.removeEventListener("message", onMessage);
|
|
72
|
+
channel?.close();
|
|
73
|
+
win.clearTimeout(timer);
|
|
74
|
+
resolve(completed);
|
|
75
|
+
if (!completed) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (typeof onComplete === "function") {
|
|
79
|
+
onComplete();
|
|
80
|
+
} else {
|
|
81
|
+
win.location.reload();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const onMessage = (event) => {
|
|
85
|
+
if (event?.origin === win.location.origin && event?.data?.type === OAUTH_POPUP_MESSAGE_TYPE) {
|
|
86
|
+
finish(true);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
win.addEventListener("message", onMessage);
|
|
90
|
+
if (channel) {
|
|
91
|
+
channel.onmessage = (event) => {
|
|
92
|
+
if (event?.data?.type === OAUTH_POPUP_MESSAGE_TYPE) {
|
|
93
|
+
finish(true);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
timer = win.setTimeout(() => finish(false), OAUTH_POPUP_LIFETIME_MS);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sameOriginOpener(win) {
|
|
104
|
+
try {
|
|
105
|
+
const opener = win.opener;
|
|
106
|
+
return opener && opener !== win && !opener.closed && opener.location.origin === win.location.origin ? opener : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Called by the callback page after a completed sign-in. When this page is the
|
|
114
|
+
* sign-in window opened by a framed app, it tells that app and closes, and the
|
|
115
|
+
* function returns true. Otherwise it does nothing and returns false.
|
|
116
|
+
*/
|
|
117
|
+
function completeOAuthPopupSignIn(win = browserWindow()) {
|
|
118
|
+
if (!win) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const storage = storageOf(win);
|
|
122
|
+
const startedAt = Number(storage?.getItem(OAUTH_POPUP_MARKER_KEY) || 0);
|
|
123
|
+
const opener = sameOriginOpener(win);
|
|
124
|
+
const recentlyStarted = startedAt > 0 && Date.now() - startedAt < OAUTH_POPUP_LIFETIME_MS;
|
|
125
|
+
if (!opener && !recentlyStarted) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
storage?.removeItem(OAUTH_POPUP_MARKER_KEY);
|
|
129
|
+
const message = { type: OAUTH_POPUP_MESSAGE_TYPE };
|
|
130
|
+
opener?.postMessage(message, win.location.origin);
|
|
131
|
+
const channel = openChannel(win);
|
|
132
|
+
channel?.postMessage(message);
|
|
133
|
+
channel?.close();
|
|
134
|
+
win.close();
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export {
|
|
139
|
+
OAUTH_POPUP_BLOCKED_MESSAGE,
|
|
140
|
+
OAUTH_POPUP_MESSAGE_TYPE,
|
|
141
|
+
completeOAuthPopupSignIn,
|
|
142
|
+
createPopupOAuthLaunchClient,
|
|
143
|
+
isFramedWindow
|
|
144
|
+
};
|
|
@@ -233,6 +233,7 @@ import { mdiEye, mdiEyeOff } from "@mdi/js";
|
|
|
233
233
|
import { useDisplay } from "vuetify";
|
|
234
234
|
import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
|
|
235
235
|
import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
|
|
236
|
+
import { useAuthOAuthLaunchClient } from "../runtime/inject.js";
|
|
236
237
|
import { useLoginViewState } from "../composables/loginView/useLoginViewState.js";
|
|
237
238
|
import { useLoginViewValidation } from "../composables/loginView/useLoginViewValidation.js";
|
|
238
239
|
import { useLoginViewActions } from "../composables/loginView/useLoginViewActions.js";
|
|
@@ -250,7 +251,8 @@ const actions = useLoginViewActions({
|
|
|
250
251
|
state,
|
|
251
252
|
validation,
|
|
252
253
|
queryClient,
|
|
253
|
-
errorRuntime
|
|
254
|
+
errorRuntime,
|
|
255
|
+
oauthLaunchClient: useAuthOAuthLaunchClient()
|
|
254
256
|
});
|
|
255
257
|
|
|
256
258
|
onMounted(actions.initializeOnMounted);
|
|
@@ -323,11 +325,13 @@ function toggleConfirmPasswordVisibility() {
|
|
|
323
325
|
|
|
324
326
|
<style scoped>
|
|
325
327
|
.login-screen {
|
|
328
|
+
/* Start below shell banners shown on a surface without an app bar. */
|
|
329
|
+
--auth-screen-height: calc(100dvh - var(--shell-error-banners-inline-height, 0px));
|
|
326
330
|
position: fixed;
|
|
327
|
-
inset: 0;
|
|
331
|
+
inset: var(--shell-error-banners-inline-height, 0px) 0 0;
|
|
328
332
|
z-index: 1;
|
|
329
333
|
overflow-y: auto;
|
|
330
|
-
min-height:
|
|
334
|
+
min-height: var(--auth-screen-height);
|
|
331
335
|
background-color: rgb(var(--v-theme-background));
|
|
332
336
|
background-image: radial-gradient(circle at 15% 12%, rgba(var(--v-theme-primary), 0.14), transparent 32%);
|
|
333
337
|
}
|
|
@@ -337,7 +341,7 @@ function toggleConfirmPasswordVisibility() {
|
|
|
337
341
|
}
|
|
338
342
|
|
|
339
343
|
.login-shell {
|
|
340
|
-
min-height:
|
|
344
|
+
min-height: var(--auth-screen-height);
|
|
341
345
|
}
|
|
342
346
|
|
|
343
347
|
.login-shell--mobile {
|
|
@@ -350,7 +354,7 @@ function toggleConfirmPasswordVisibility() {
|
|
|
350
354
|
|
|
351
355
|
.auth-card--mobile {
|
|
352
356
|
width: 100%;
|
|
353
|
-
min-height:
|
|
357
|
+
min-height: var(--auth-screen-height);
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
.auth-content {
|
|
@@ -358,7 +362,7 @@ function toggleConfirmPasswordVisibility() {
|
|
|
358
362
|
}
|
|
359
363
|
|
|
360
364
|
.auth-content--mobile {
|
|
361
|
-
min-height:
|
|
365
|
+
min-height: var(--auth-screen-height);
|
|
362
366
|
padding: calc(24px + env(safe-area-inset-top, 0px)) 20px calc(32px + env(safe-area-inset-bottom, 0px));
|
|
363
367
|
}
|
|
364
368
|
|
|
@@ -175,16 +175,18 @@ onMounted(exchangeRecoveryToken);
|
|
|
175
175
|
|
|
176
176
|
<style scoped>
|
|
177
177
|
.reset-screen {
|
|
178
|
+
/* Start below shell banners shown on a surface without an app bar. */
|
|
179
|
+
--auth-screen-height: calc(100dvh - var(--shell-error-banners-inline-height, 0px));
|
|
178
180
|
position: fixed;
|
|
179
|
-
inset: 0;
|
|
181
|
+
inset: var(--shell-error-banners-inline-height, 0px) 0 0;
|
|
180
182
|
z-index: 1;
|
|
181
183
|
overflow-y: auto;
|
|
182
|
-
min-height:
|
|
184
|
+
min-height: var(--auth-screen-height);
|
|
183
185
|
background-color: rgb(var(--v-theme-background));
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
.reset-shell {
|
|
187
|
-
min-height:
|
|
189
|
+
min-height: var(--auth-screen-height);
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
.reset-card {
|
|
@@ -193,7 +195,7 @@ onMounted(exchangeRecoveryToken);
|
|
|
193
195
|
|
|
194
196
|
.reset-card--mobile {
|
|
195
197
|
width: 100%;
|
|
196
|
-
min-height:
|
|
198
|
+
min-height: var(--auth-screen-height);
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
.reset-content {
|
|
@@ -201,7 +203,7 @@ onMounted(exchangeRecoveryToken);
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
.reset-content--mobile {
|
|
204
|
-
min-height:
|
|
206
|
+
min-height: var(--auth-screen-height);
|
|
205
207
|
padding: calc(24px + env(safe-area-inset-top, 0px)) 20px calc(32px + env(safe-area-inset-bottom, 0px));
|
|
206
208
|
}
|
|
207
209
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
1
2
|
import { normalizeAuthDenied } from "@jskit-ai/auth-core/shared/authDenied";
|
|
2
3
|
import { AUTH_ACTION_IDS } from "../constants/authActionIds.js";
|
|
3
4
|
import { AuthWebService } from "../services/AuthWebService.js";
|
|
@@ -75,20 +76,34 @@ class AuthController {
|
|
|
75
76
|
async oauthStart(request, reply) {
|
|
76
77
|
const provider = request.params?.provider;
|
|
77
78
|
const returnTo = request.query?.returnTo;
|
|
78
|
-
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await this.service.oauthStart(request, { provider, returnTo });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!(error instanceof AppError) || error.statusCode === 500) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
// The browser navigated here, so the login page explains instead of a JSON body.
|
|
87
|
+
const explained = new URLSearchParams({ error: "sign_in_unavailable", error_description: error.message });
|
|
88
|
+
reply.redirect(`/auth/login?${explained}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.service.writeOAuthStartCookies(reply, result);
|
|
79
92
|
reply.redirect(result.url);
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
async oauthComplete(request, reply) {
|
|
83
96
|
const payload = request.body || {};
|
|
84
97
|
const result = await this.service.oauthComplete(request, payload);
|
|
98
|
+
this.service.clearOAuthStartCookies(reply);
|
|
85
99
|
this.service.writeSessionCookies(reply, result.session);
|
|
86
100
|
|
|
87
101
|
reply.code(200).send({
|
|
88
102
|
ok: true,
|
|
89
103
|
provider: result.provider,
|
|
90
104
|
username: result.actor.displayName,
|
|
91
|
-
email: result.actor.email
|
|
105
|
+
email: result.actor.email,
|
|
106
|
+
...(result.returnTo ? { returnTo: result.returnTo } : {})
|
|
92
107
|
});
|
|
93
108
|
}
|
|
94
109
|
|
|
@@ -124,6 +124,22 @@ class AuthWebService {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Providers that bind an OAuth attempt to the browser (state, nonce, PKCE)
|
|
128
|
+
// keep it in their own short-lived cookie; others need no hook.
|
|
129
|
+
writeOAuthStartCookies(reply, result) {
|
|
130
|
+
const authService = this.resolveAuthService();
|
|
131
|
+
if (reply && typeof authService.writeOAuthStartCookies === "function") {
|
|
132
|
+
authService.writeOAuthStartCookies(reply, result);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
clearOAuthStartCookies(reply) {
|
|
137
|
+
const authService = this.resolveAuthService();
|
|
138
|
+
if (reply && typeof authService.clearOAuthStartCookies === "function") {
|
|
139
|
+
authService.clearOAuthStartCookies(reply);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
clearSessionCookies(reply) {
|
|
128
144
|
if (reply) {
|
|
129
145
|
this.resolveAuthService().clearSessionCookies(reply);
|
|
@@ -92,7 +92,17 @@ test("default login view owns the viewport and becomes a full-screen mobile scre
|
|
|
92
92
|
assert.match(viewSource, /:elevation="isMobileViewport \? 0 : 1"/);
|
|
93
93
|
assert.match(viewSource, /:border="!isMobileViewport"/);
|
|
94
94
|
assert.match(viewSource, /\.login-screen\s*\{[\s\S]*position:\s*fixed;/);
|
|
95
|
-
|
|
95
|
+
// The full-viewport screen starts below shell banners shown on this bar-less surface.
|
|
96
|
+
assert.match(viewSource, /\.login-screen\s*\{[\s\S]*inset:\s*var\(--shell-error-banners-inline-height, 0px\) 0 0;/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("default reset view starts below shell banners on its bar-less surface", () => {
|
|
100
|
+
const viewPath = fileURLToPath(new URL("../src/client/views/DefaultResetPasswordView.vue", import.meta.url));
|
|
101
|
+
const viewSource = readFileSync(viewPath, "utf8");
|
|
102
|
+
|
|
103
|
+
assert.match(viewSource, /\.reset-screen\s*\{[\s\S]*position:\s*fixed;/);
|
|
104
|
+
assert.match(viewSource, /\.reset-screen\s*\{[\s\S]*inset:\s*var\(--shell-error-banners-inline-height, 0px\) 0 0;/);
|
|
105
|
+
assert.doesNotMatch(viewSource, /min-height:\s*100dvh/);
|
|
96
106
|
});
|
|
97
107
|
|
|
98
108
|
test("default login view avoids decorative login chrome", () => {
|
|
@@ -167,3 +177,10 @@ test("default reset password view preserves recovery session-pair callback token
|
|
|
167
177
|
assert.doesNotMatch(viewSource, /if \(!recoveryPayload\)\s*\{\s*status\.value = "error";/);
|
|
168
178
|
assert.doesNotMatch(viewSource, /hash\.get\("access_token"\)[\s\S]*code:/);
|
|
169
179
|
});
|
|
180
|
+
|
|
181
|
+
test("default login view hands its OAuth buttons the launch client", () => {
|
|
182
|
+
const viewPath = fileURLToPath(new URL("../src/client/views/DefaultLoginView.vue", import.meta.url));
|
|
183
|
+
const viewSource = readFileSync(viewPath, "utf8");
|
|
184
|
+
|
|
185
|
+
assert.match(viewSource, /oauthLaunchClient: useAuthOAuthLaunchClient\(\)/);
|
|
186
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
3
|
import test from "node:test";
|
|
3
4
|
|
|
4
5
|
import {
|
|
@@ -105,3 +106,22 @@ test("managed preview identity rejects a disabled or non-local exchange", async
|
|
|
105
106
|
assert.equal(remote.ok, false);
|
|
106
107
|
assert.equal(remote.code, "jskit_managed_preview_identity_target_invalid");
|
|
107
108
|
});
|
|
109
|
+
|
|
110
|
+
test("the auth surface pattern ships the managed-preview login as standard", () => {
|
|
111
|
+
const pattern = readFileSync(new URL("../patterns/auth-surface/PATTERN.md", import.meta.url), "utf8");
|
|
112
|
+
const executable = new URL("../patterns/auth-surface/example/tools/preview-identity", import.meta.url);
|
|
113
|
+
|
|
114
|
+
assert.equal(statSync(executable).mode & 0o111, 0o111);
|
|
115
|
+
assert.match(readFileSync(executable, "utf8"), /runManagedPreviewIdentityCommand/u);
|
|
116
|
+
assert.match(pattern, /standard, not optional/u);
|
|
117
|
+
assert.ok(pattern.includes([
|
|
118
|
+
"#### Preview identity",
|
|
119
|
+
"",
|
|
120
|
+
"- Command: `tools/preview-identity`",
|
|
121
|
+
`- Protocol: \`${MANAGED_PREVIEW_IDENTITY_PROTOCOL}\``,
|
|
122
|
+
"- Identity types: `email` `user-id`",
|
|
123
|
+
"- Enabled environment: `AUTH_DEV_BYPASS_ENABLED`",
|
|
124
|
+
"- Secret environment: `AUTH_DEV_BYPASS_SECRET`",
|
|
125
|
+
"- Runtimes: `nodejs`"
|
|
126
|
+
].join("\n")));
|
|
127
|
+
});
|
|
@@ -15,6 +15,8 @@ test("readOAuthCallbackParamsFromUrl reads query and hash callback params", () =
|
|
|
15
15
|
readOAuthCallbackParamsFromUrl("/auth/login?oauthProvider=google&oauthReturnTo=%2Fw%2Facme&code=abc"),
|
|
16
16
|
{
|
|
17
17
|
code: "abc",
|
|
18
|
+
state: "",
|
|
19
|
+
iss: "",
|
|
18
20
|
accessToken: "",
|
|
19
21
|
refreshToken: "",
|
|
20
22
|
hasSessionPair: false,
|
|
@@ -29,6 +31,8 @@ test("readOAuthCallbackParamsFromUrl reads query and hash callback params", () =
|
|
|
29
31
|
readOAuthCallbackParamsFromUrl("/auth/login?oauthReturnTo=%2Fhome#access_token=access&refresh_token=refresh"),
|
|
30
32
|
{
|
|
31
33
|
code: "",
|
|
34
|
+
state: "",
|
|
35
|
+
iss: "",
|
|
32
36
|
accessToken: "access",
|
|
33
37
|
refreshToken: "refresh",
|
|
34
38
|
hasSessionPair: true,
|
|
@@ -208,3 +212,19 @@ test("completeOAuthCallbackFromCurrentLocation reads from window.location.href",
|
|
|
208
212
|
globalThis.window = originalWindow;
|
|
209
213
|
}
|
|
210
214
|
});
|
|
215
|
+
|
|
216
|
+
test("completeOAuthCallbackFromUrl forwards state and iss, and takes the page from a fixed-address provider", async () => {
|
|
217
|
+
const calls = [];
|
|
218
|
+
const result = await completeOAuthCallbackFromUrl({
|
|
219
|
+
url: "/auth/login?code=abc&state=st-1&iss=https%3A%2F%2Faccounts.example.test",
|
|
220
|
+
defaultProvider: "oidc",
|
|
221
|
+
request: async (path, options) => {
|
|
222
|
+
calls.push(options.body);
|
|
223
|
+
return { username: "Ada", email: "ada@example.com", returnTo: "/reports" };
|
|
224
|
+
},
|
|
225
|
+
refreshSession: async () => ({ authenticated: true })
|
|
226
|
+
});
|
|
227
|
+
assert.deepEqual(calls, [{ provider: "oidc", code: "abc", state: "st-1", iss: "https://accounts.example.test" }]);
|
|
228
|
+
assert.equal(result.completed, true);
|
|
229
|
+
assert.equal(result.returnTo, "/reports");
|
|
230
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
OAUTH_POPUP_BLOCKED_MESSAGE,
|
|
5
|
+
OAUTH_POPUP_MESSAGE_TYPE,
|
|
6
|
+
completeOAuthPopupSignIn,
|
|
7
|
+
createPopupOAuthLaunchClient
|
|
8
|
+
} from "../src/client/runtime/popupOAuthLaunchClient.js";
|
|
9
|
+
|
|
10
|
+
function memoryStorage() {
|
|
11
|
+
const values = new Map();
|
|
12
|
+
return {
|
|
13
|
+
getItem: (key) => (values.has(key) ? values.get(key) : null),
|
|
14
|
+
setItem: (key, value) => values.set(key, String(value)),
|
|
15
|
+
removeItem: (key) => values.delete(key)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fakeWindow({ framed = false, popup = {}, storage = memoryStorage() } = {}) {
|
|
20
|
+
const listeners = new Set();
|
|
21
|
+
const win = {
|
|
22
|
+
location: { origin: "https://app.example.test", reloaded: 0, reload() { this.reloaded += 1; } },
|
|
23
|
+
localStorage: storage,
|
|
24
|
+
opened: [],
|
|
25
|
+
closed: false,
|
|
26
|
+
open(url, name, features) {
|
|
27
|
+
this.opened.push({ url, name, features });
|
|
28
|
+
return popup;
|
|
29
|
+
},
|
|
30
|
+
close() {
|
|
31
|
+
this.closed = true;
|
|
32
|
+
},
|
|
33
|
+
addEventListener: (_type, listener) => listeners.add(listener),
|
|
34
|
+
removeEventListener: (_type, listener) => listeners.delete(listener),
|
|
35
|
+
dispatch: (event) => [...listeners].forEach((listener) => listener(event)),
|
|
36
|
+
setTimeout: () => 1,
|
|
37
|
+
clearTimeout() {},
|
|
38
|
+
listenerCount: () => listeners.size
|
|
39
|
+
};
|
|
40
|
+
win.self = win;
|
|
41
|
+
win.top = framed ? {} : win;
|
|
42
|
+
return win;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("an unframed page signs in by navigating, as before", async () => {
|
|
46
|
+
const navigations = [];
|
|
47
|
+
const win = fakeWindow();
|
|
48
|
+
const client = createPopupOAuthLaunchClient({ window: win, fallback: { open: async ({ url }) => navigations.push(url) } });
|
|
49
|
+
await client.open({ url: "/api/oauth/oidc/start" });
|
|
50
|
+
assert.deepEqual(navigations, ["/api/oauth/oidc/start"]);
|
|
51
|
+
assert.equal(win.opened.length, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a framed page opens a sign-in window and reloads when it reports success", async () => {
|
|
55
|
+
const win = fakeWindow({ framed: true });
|
|
56
|
+
const client = createPopupOAuthLaunchClient({ window: win });
|
|
57
|
+
const opening = client.open({ url: "/api/oauth/oidc/start?returnTo=%2F" });
|
|
58
|
+
assert.equal(win.opened[0].url, "/api/oauth/oidc/start?returnTo=%2F");
|
|
59
|
+
assert.equal(win.opened[0].name, "jskit-oauth-sign-in");
|
|
60
|
+
assert.match(win.opened[0].features, /^popup=yes,width=520,height=680/u);
|
|
61
|
+
|
|
62
|
+
win.dispatch({ origin: "https://attacker.example", data: { type: OAUTH_POPUP_MESSAGE_TYPE } });
|
|
63
|
+
win.dispatch({ origin: "https://app.example.test", data: { type: "something-else" } });
|
|
64
|
+
assert.equal(win.location.reloaded, 0, "only this origin's completion message counts");
|
|
65
|
+
|
|
66
|
+
win.dispatch({ origin: "https://app.example.test", data: { type: OAUTH_POPUP_MESSAGE_TYPE } });
|
|
67
|
+
assert.equal(await opening, true);
|
|
68
|
+
assert.equal(win.location.reloaded, 1);
|
|
69
|
+
assert.equal(win.listenerCount(), 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a blocked window says how to allow it", async () => {
|
|
73
|
+
const win = fakeWindow({ framed: true, popup: null });
|
|
74
|
+
await assert.rejects(createPopupOAuthLaunchClient({ window: win }).open({ url: "/start" }), { message: OAUTH_POPUP_BLOCKED_MESSAGE });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the callback window tells its opener and closes", () => {
|
|
78
|
+
const messages = [];
|
|
79
|
+
const opener = { closed: false, location: { origin: "https://app.example.test" }, postMessage: (message, origin) => messages.push({ message, origin }) };
|
|
80
|
+
const popupWindow = { ...fakeWindow(), opener };
|
|
81
|
+
assert.equal(completeOAuthPopupSignIn(popupWindow), true);
|
|
82
|
+
assert.deepEqual(messages, [{ message: { type: OAUTH_POPUP_MESSAGE_TYPE }, origin: "https://app.example.test" }]);
|
|
83
|
+
assert.equal(popupWindow.closed, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a callback in an ordinary tab is left alone", () => {
|
|
87
|
+
const tab = fakeWindow();
|
|
88
|
+
assert.equal(completeOAuthPopupSignIn(tab), false);
|
|
89
|
+
assert.equal(tab.closed, false);
|
|
90
|
+
const foreignOpener = { ...fakeWindow(), opener: { closed: false, location: { get origin() { throw new Error("cross-origin"); } } } };
|
|
91
|
+
assert.equal(completeOAuthPopupSignIn(foreignOpener), false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a window whose opener link was cut still finishes through the shared channel", () => {
|
|
95
|
+
const storage = memoryStorage();
|
|
96
|
+
const framed = fakeWindow({ framed: true, storage, popup: {} });
|
|
97
|
+
createPopupOAuthLaunchClient({ window: framed }).open({ url: "/start" });
|
|
98
|
+
const callback = fakeWindow({ storage });
|
|
99
|
+
callback.opener = null;
|
|
100
|
+
assert.equal(completeOAuthPopupSignIn(callback), true);
|
|
101
|
+
assert.equal(callback.closed, true);
|
|
102
|
+
assert.equal(storage.getItem("jskit.auth-web.oauth-popup.started-at"), null);
|
|
103
|
+
});
|