@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-web",
3
- "version": "0.1.245",
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.242",
29
- "@jskit-ai/http-runtime": "0.1.242",
30
- "@jskit-ai/kernel": "0.1.244",
31
- "@jskit-ai/shell-web": "0.1.249",
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
- For a managed preview, copy the app-owned `tools/preview-identity` executable
34
- and declare it under `#### Preview identity` in the project's web-presented
35
- Vibe64 `Outputs` target with protocol `vibe64.preview-identity.command.v1`.
36
- Declare `AUTH_DEV_BYPASS_ENABLED` as its Enabled environment and
37
- `AUTH_DEV_BYPASS_SECRET` as its Secret environment so the preview host supplies
38
- fresh output-run-scoped values; users do not set those values by hand. JSKIT
39
- owns this executable/library pattern, Vibe64 owns the `Outputs` grammar and
40
- runtime behavior, and Genesis only composes that consumer-owned section as
41
- opaque text. The executable calls the exported managed-preview identity library
42
- directly; it does not require a JSKIT CLI.
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 optional app-owned managed-preview identity executable. Compose their
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
 
@@ -19,6 +19,7 @@ function stripOAuthParamsFromLocation() {
19
19
  "expires_at",
20
20
  "token_type",
21
21
  "state",
22
+ "iss",
22
23
  "sb",
23
24
  "type",
24
25
  "error",
@@ -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);
@@ -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 { createBrowserOAuthLaunchClient } from "./oauthLaunchClient.js";
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 || createBrowserOAuthLaunchClient();
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 { createBrowserOAuthLaunchClient, isAuthOAuthLaunchClient } from "./oauthLaunchClient.js";
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 createBrowserOAuthLaunchClient();
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
- returnTo,
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: 100dvh;
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: 100dvh;
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: 100dvh;
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: 100dvh;
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: 100dvh;
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: 100dvh;
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: 100dvh;
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: 100dvh;
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
- const result = await this.service.oauthStart(request, { provider, returnTo });
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
- assert.match(viewSource, /\.login-screen\s*\{[\s\S]*inset:\s*0;/);
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
+ });