@jskit-ai/auth-web 0.1.101 → 0.1.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/auth-web",
4
- "version": "0.1.101",
4
+ "version": "0.1.102",
5
5
  "kind": "runtime",
6
6
  "description": "Auth web module: Fastify auth routes plus web login/sign-out scaffolds.",
7
7
  "dependsOn": [
@@ -158,6 +158,19 @@ export default Object.freeze({
158
158
  },
159
159
  "purpose": "Public sign-out route that clears session then returns to login."
160
160
  },
161
+ {
162
+ "id": "auth.reset-password",
163
+ "path": "/auth/reset-password",
164
+ "scope": "surface",
165
+ "surface": "auth",
166
+ "name": "auth-reset-password",
167
+ "componentKey": "auth-reset-password",
168
+ "autoRegister": false,
169
+ "guard": {
170
+ "policy": "public"
171
+ },
172
+ "purpose": "Public password reset screen for recovery links."
173
+ },
161
174
  {
162
175
  "id": "auth.default-login",
163
176
  "path": "/auth/default-login",
@@ -247,10 +260,10 @@ export default Object.freeze({
247
260
  "dependencies": {
248
261
  "runtime": {
249
262
  "@mdi/js": "^7.4.47",
250
- "@jskit-ai/auth-core": "0.1.99",
251
- "@jskit-ai/http-runtime": "0.1.99",
252
- "@jskit-ai/kernel": "0.1.101",
253
- "@jskit-ai/shell-web": "0.1.100"
263
+ "@jskit-ai/auth-core": "0.1.100",
264
+ "@jskit-ai/http-runtime": "0.1.100",
265
+ "@jskit-ai/kernel": "0.1.102",
266
+ "@jskit-ai/shell-web": "0.1.101"
254
267
  },
255
268
  "dev": {}
256
269
  },
@@ -277,6 +290,13 @@ export default Object.freeze({
277
290
  "category": "auth-web",
278
291
  "id": "auth-view-signout"
279
292
  },
293
+ {
294
+ "from": "templates/src/views/auth/ResetPasswordView.vue",
295
+ "to": "src/views/auth/ResetPasswordView.vue",
296
+ "reason": "Install minimal password reset container that renders the module-provided DefaultResetPasswordView by default.",
297
+ "category": "auth-web",
298
+ "id": "auth-view-reset-password"
299
+ },
280
300
  {
281
301
  "from": "templates/src/pages/auth/login.vue",
282
302
  "to": "src/pages/auth/login.vue",
@@ -290,6 +310,13 @@ export default Object.freeze({
290
310
  "reason": "Provide an auth-surface /auth/signout wrapper that renders the package sign-out view.",
291
311
  "category": "auth-web",
292
312
  "id": "auth-page-signout"
313
+ },
314
+ {
315
+ "from": "templates/src/pages/auth/reset-password.vue",
316
+ "to": "src/pages/auth/reset-password.vue",
317
+ "reason": "Provide an auth-surface /auth/reset-password wrapper that renders the package password reset view.",
318
+ "category": "auth-web",
319
+ "id": "auth-page-reset-password"
293
320
  }
294
321
  ],
295
322
  "text": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-web",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,6 +11,7 @@
11
11
  "./server/routes/authRoutes": "./src/server/routes/authRoutes.js",
12
12
  "./client": "./src/client/index.js",
13
13
  "./client/views/DefaultLoginView": "./src/client/views/DefaultLoginView.vue",
14
+ "./client/views/DefaultResetPasswordView": "./src/client/views/DefaultResetPasswordView.vue",
14
15
  "./client/views/DefaultSignOutView": "./src/client/views/DefaultSignOutView.vue",
15
16
  "./client/runtime/authGuardRuntime": "./src/client/runtime/authGuardRuntime.js",
16
17
  "./client/runtime/authHttpClient": "./src/client/runtime/authHttpClient.js",
@@ -18,11 +19,11 @@
18
19
  "./client/runtime/useSignOut": "./src/client/runtime/useSignOut.js"
19
20
  },
20
21
  "dependencies": {
21
- "@jskit-ai/auth-core": "0.1.99",
22
+ "@jskit-ai/auth-core": "0.1.100",
22
23
  "@mdi/js": "^7.4.47",
23
- "@jskit-ai/kernel": "0.1.101",
24
- "@jskit-ai/shell-web": "0.1.100",
25
- "@jskit-ai/http-runtime": "0.1.99"
24
+ "@jskit-ai/kernel": "0.1.102",
25
+ "@jskit-ai/shell-web": "0.1.101",
26
+ "@jskit-ai/http-runtime": "0.1.100"
26
27
  },
27
28
  "peerDependencies": {
28
29
  "@tanstack/vue-query": "^5.90.5",
@@ -89,6 +89,13 @@ export function useLoginViewActions({
89
89
  }
90
90
 
91
91
  function applySessionPayload(payload) {
92
+ if (typeof state.applyCapabilities === "function") {
93
+ state.applyCapabilities(payload || {});
94
+ }
95
+ if (payload?.authCapabilities || payload?.capabilities) {
96
+ return;
97
+ }
98
+
92
99
  state.oauthProviders.value = Array.isArray(payload?.oauthProviders)
93
100
  ? payload.oauthProviders
94
101
  .map((provider) => {
@@ -1,4 +1,5 @@
1
1
  import { computed, ref } from "vue";
2
+ import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
2
3
  import {
3
4
  resolveSurfaceIdFromPlacementPathname,
4
5
  resolveSurfaceRootPathFromPlacementContext
@@ -40,6 +41,7 @@ export function useLoginViewState({ placementContext } = {}) {
40
41
  const rememberAccountOnDevice = ref(true);
41
42
  const rememberedAccount = ref(null);
42
43
  const useRememberedAccount = ref(false);
44
+ const authCapabilities = ref(normalizeAuthCapabilities());
43
45
  const oauthProviders = ref([]);
44
46
  const oauthDefaultProvider = ref("");
45
47
  const loading = ref(false);
@@ -55,6 +57,14 @@ export function useLoginViewState({ placementContext } = {}) {
55
57
  const isForgot = computed(() => mode.value === FORGOT_MODE);
56
58
  const isOtp = computed(() => mode.value === OTP_MODE);
57
59
  const isEmailConfirmationPending = computed(() => mode.value === EMAIL_CONFIRMATION_MODE);
60
+ const canUsePasswordLogin = computed(() => authCapabilities.value.features.password.login === true);
61
+ const canRegister = computed(() => authCapabilities.value.features.password.register === true);
62
+ const canRequestPasswordRecovery = computed(
63
+ () => authCapabilities.value.features.passwordRecovery.request === true
64
+ );
65
+ const canUseOtp = computed(() => authCapabilities.value.features.otp.login === true);
66
+ const canUseOAuth = computed(() => authCapabilities.value.features.oauthLogin.enabled === true);
67
+ const canResendEmailConfirmation = computed(() => authCapabilities.value.features.emailConfirmation === true);
58
68
  const showRememberedAccount = computed(
59
69
  () => (isLogin.value || isOtp.value) && useRememberedAccount.value && Boolean(rememberedAccount.value)
60
70
  );
@@ -160,6 +170,25 @@ export function useLoginViewState({ placementContext } = {}) {
160
170
  clearRememberedAccountState();
161
171
  }
162
172
 
173
+ function applyCapabilities(payload = {}) {
174
+ authCapabilities.value = normalizeAuthCapabilities(payload.authCapabilities || payload.capabilities || {});
175
+ const oauth = authCapabilities.value.features.oauthLogin;
176
+ if (oauth.enabled) {
177
+ oauthProviders.value = oauth.providers;
178
+ oauthDefaultProvider.value = oauth.defaultProvider || "";
179
+ } else {
180
+ oauthProviders.value = [];
181
+ oauthDefaultProvider.value = "";
182
+ }
183
+ if (
184
+ (mode.value === REGISTER_MODE && !canRegister.value) ||
185
+ (mode.value === FORGOT_MODE && !canRequestPasswordRecovery.value) ||
186
+ (mode.value === OTP_MODE && !canUseOtp.value)
187
+ ) {
188
+ switchMode(LOGIN_MODE);
189
+ }
190
+ }
191
+
163
192
  function switchAccount() {
164
193
  clearRememberedAccountHint();
165
194
  clearRememberedAccountState();
@@ -172,21 +201,30 @@ export function useLoginViewState({ placementContext } = {}) {
172
201
  }
173
202
 
174
203
  function switchMode(nextMode) {
175
- if (nextMode === mode.value) {
204
+ let resolvedMode = nextMode;
205
+ if (resolvedMode === REGISTER_MODE && !canRegister.value) {
206
+ resolvedMode = LOGIN_MODE;
207
+ } else if (resolvedMode === FORGOT_MODE && !canRequestPasswordRecovery.value) {
208
+ resolvedMode = LOGIN_MODE;
209
+ } else if (resolvedMode === OTP_MODE && !canUseOtp.value) {
210
+ resolvedMode = LOGIN_MODE;
211
+ }
212
+
213
+ if (resolvedMode === mode.value) {
176
214
  return;
177
215
  }
178
216
 
179
- mode.value = nextMode;
217
+ mode.value = resolvedMode;
180
218
  resetCredentialFields();
181
219
  registerConfirmationResendPending.value = false;
182
- if (nextMode !== EMAIL_CONFIRMATION_MODE) {
220
+ if (resolvedMode !== EMAIL_CONFIRMATION_MODE) {
183
221
  pendingEmailConfirmationAddress.value = "";
184
222
  pendingEmailConfirmationMessage.value = "";
185
223
  }
186
224
  clearTransientMessages();
187
225
  resetTransientValidationState();
188
226
 
189
- if (nextMode !== LOGIN_MODE && nextMode !== OTP_MODE) {
227
+ if (resolvedMode !== LOGIN_MODE && resolvedMode !== OTP_MODE) {
190
228
  useRememberedAccount.value = false;
191
229
  return;
192
230
  }
@@ -225,6 +263,7 @@ export function useLoginViewState({ placementContext } = {}) {
225
263
  rememberAccountOnDevice,
226
264
  rememberedAccount,
227
265
  useRememberedAccount,
266
+ authCapabilities,
228
267
  oauthProviders,
229
268
  oauthDefaultProvider,
230
269
  loading,
@@ -239,6 +278,12 @@ export function useLoginViewState({ placementContext } = {}) {
239
278
  isForgot,
240
279
  isOtp,
241
280
  isEmailConfirmationPending,
281
+ canUsePasswordLogin,
282
+ canRegister,
283
+ canRequestPasswordRecovery,
284
+ canUseOtp,
285
+ canUseOAuth,
286
+ canResendEmailConfirmation,
242
287
  showRememberedAccount,
243
288
  rememberedAccountDisplayName,
244
289
  rememberedAccountMaskedEmail,
@@ -253,6 +298,7 @@ export function useLoginViewState({ placementContext } = {}) {
253
298
  resolveNormalizedEmail,
254
299
  applyRememberedAccountHint,
255
300
  applyRememberedAccountPreference,
301
+ applyCapabilities,
256
302
  clearTransientMessages,
257
303
  switchMode,
258
304
  switchAccount,
@@ -102,6 +102,9 @@ export function useLoginViewValidation({ state } = {}) {
102
102
  if (state.loading.value) {
103
103
  return false;
104
104
  }
105
+ if (state.isLogin.value && !state.canUsePasswordLogin.value) {
106
+ return false;
107
+ }
105
108
  if (emailErrorMessages.value.length > 0) {
106
109
  return false;
107
110
  }
@@ -1,10 +1,12 @@
1
1
  import { AuthWebClientProvider } from "./providers/AuthWebClientProvider.js";
2
2
  import DefaultLoginView from "./views/DefaultLoginView.vue";
3
3
  import DefaultSignOutView from "./views/DefaultSignOutView.vue";
4
+ import DefaultResetPasswordView from "./views/DefaultResetPasswordView.vue";
4
5
 
5
6
  export { AuthWebClientProvider } from "./providers/AuthWebClientProvider.js";
6
7
  export { default as DefaultLoginView } from "./views/DefaultLoginView.vue";
7
8
  export { default as DefaultSignOutView } from "./views/DefaultSignOutView.vue";
9
+ export { default as DefaultResetPasswordView } from "./views/DefaultResetPasswordView.vue";
8
10
  export { default as AuthProfileWidget } from "./views/AuthProfileWidget.vue";
9
11
  export { default as AuthProfileMenuLinkItem } from "./views/AuthProfileMenuLinkItem.vue";
10
12
  export { useAuthStore } from "./stores/useAuthStore.js";
@@ -18,7 +20,8 @@ export {
18
20
  const routeComponents = Object.freeze({
19
21
  "auth-login": DefaultLoginView,
20
22
  "auth-signout": DefaultSignOutView,
21
- "auth-default-login": DefaultLoginView
23
+ "auth-default-login": DefaultLoginView,
24
+ "auth-reset-password": DefaultResetPasswordView
22
25
  });
23
26
 
24
27
  const clientProviders = Object.freeze([AuthWebClientProvider]);
@@ -0,0 +1,56 @@
1
+ const AUTH_CALLBACK_URL_BASE = "https://jskit.invalid";
2
+
3
+ function readAuthCallbackUrlParams(url = "") {
4
+ const normalizedUrl = String(url || "").trim();
5
+ if (!normalizedUrl) {
6
+ return null;
7
+ }
8
+
9
+ try {
10
+ const parsedUrl = new URL(normalizedUrl, AUTH_CALLBACK_URL_BASE);
11
+ return {
12
+ parsedUrl,
13
+ searchParams: new URLSearchParams(parsedUrl.search || ""),
14
+ hashParams: new URLSearchParams(String(parsedUrl.hash || "").replace(/^#/, ""))
15
+ };
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function readAuthCallbackParam(callbackUrlParams, key) {
22
+ const normalizedKey = String(key || "").trim();
23
+ if (!callbackUrlParams || !normalizedKey) {
24
+ return "";
25
+ }
26
+
27
+ return String(
28
+ callbackUrlParams.searchParams.get(normalizedKey) ||
29
+ callbackUrlParams.hashParams.get(normalizedKey) ||
30
+ ""
31
+ ).trim();
32
+ }
33
+
34
+ function stripAuthCallbackParamsFromUrl(url = "", keys = []) {
35
+ const callbackUrlParams = readAuthCallbackUrlParams(url);
36
+ if (!callbackUrlParams) {
37
+ return "";
38
+ }
39
+
40
+ const normalizedKeys = Array.isArray(keys)
41
+ ? keys.map((key) => String(key || "").trim()).filter(Boolean)
42
+ : [];
43
+ normalizedKeys.forEach((key) => {
44
+ callbackUrlParams.parsedUrl.searchParams.delete(key);
45
+ callbackUrlParams.hashParams.delete(key);
46
+ });
47
+
48
+ const nextHash = callbackUrlParams.hashParams.toString();
49
+ return `${callbackUrlParams.parsedUrl.pathname}${callbackUrlParams.parsedUrl.search}${nextHash ? `#${nextHash}` : ""}`;
50
+ }
51
+
52
+ export {
53
+ readAuthCallbackParam,
54
+ readAuthCallbackUrlParams,
55
+ stripAuthCallbackParamsFromUrl
56
+ };
@@ -4,11 +4,13 @@ import { isExternalLinkTarget } from "@jskit-ai/kernel/shared/support/linkPath";
4
4
  import { normalizePathname as normalizeSurfacePathname } from "@jskit-ai/kernel/shared/surface/paths";
5
5
  import { createListenerSubscription } from "@jskit-ai/kernel/shared/support/listenerSet";
6
6
  import { normalizePermissionList } from "@jskit-ai/kernel/shared/support/permissions";
7
+ import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
7
8
 
8
9
  const GLOBAL_GUARD_EVALUATOR_KEY = "__JSKIT_WEB_SHELL_GUARD_EVALUATOR__";
9
10
  const AUTH_POLICY_AUTHENTICATED = "authenticated";
10
11
  const DEFAULT_SESSION_PATH = AUTH_PATHS.SESSION;
11
12
  const DEFAULT_LOGIN_ROUTE = "/auth/login";
13
+ const DEFAULT_RESET_PASSWORD_ROUTE = "/auth/reset-password";
12
14
  const DEFAULT_REFRESH_ON_FOREGROUND = false;
13
15
  const DEFAULT_REFRESH_ON_RECONNECT = false;
14
16
  const DEFAULT_REALTIME_REFRESH_EVENTS = Object.freeze(["users.bootstrap.changed", "auth.session.changed"]);
@@ -38,7 +40,8 @@ const DEFAULT_AUTH_STATE = Object.freeze({
38
40
  email: "",
39
41
  permissions: Object.freeze([]),
40
42
  oauthDefaultProvider: "",
41
- oauthProviders: Object.freeze([])
43
+ oauthProviders: Object.freeze([]),
44
+ authCapabilities: normalizeAuthCapabilities()
42
45
  });
43
46
 
44
47
  function asGlobalObject() {
@@ -107,6 +110,7 @@ function normalizeAuthState(payload = {}) {
107
110
  const username = authenticated ? String(payload.username || "").trim() : "";
108
111
  const email = authenticated ? String(payload.email || "").trim().toLowerCase() : "";
109
112
  const permissions = authenticated ? Object.freeze(normalizePermissionList(payload.permissions)) : Object.freeze([]);
113
+ const authCapabilities = normalizeAuthCapabilities(payload.authCapabilities || payload.capabilities || {});
110
114
 
111
115
  return Object.freeze({
112
116
  authenticated,
@@ -114,7 +118,8 @@ function normalizeAuthState(payload = {}) {
114
118
  email,
115
119
  permissions,
116
120
  oauthDefaultProvider,
117
- oauthProviders
121
+ oauthProviders,
122
+ authCapabilities
118
123
  });
119
124
  }
120
125
 
@@ -307,7 +312,8 @@ function redirectOAuthCallbackToLoginRoute(loginRoute) {
307
312
 
308
313
  const currentPathname = normalizePathname(window.location.pathname || "", "/");
309
314
  const loginPathname = normalizePathname(normalizedLoginRoute, DEFAULT_LOGIN_ROUTE);
310
- if (currentPathname === loginPathname) {
315
+ const resetPasswordPathname = normalizePathname(DEFAULT_RESET_PASSWORD_ROUTE, DEFAULT_RESET_PASSWORD_ROUTE);
316
+ if (currentPathname === loginPathname || currentPathname === resetPasswordPathname) {
311
317
  return false;
312
318
  }
313
319
 
@@ -1,4 +1,5 @@
1
1
  import { inject } from "vue";
2
+ import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
2
3
  import { isAuthGuardRuntime } from "./authGuardRuntime.js";
3
4
  import { createBrowserOAuthLaunchClient, isAuthOAuthLaunchClient } from "./oauthLaunchClient.js";
4
5
 
@@ -9,7 +10,8 @@ const EMPTY_AUTH_GUARD_STATE = Object.freeze({
9
10
  authenticated: false,
10
11
  username: "",
11
12
  oauthDefaultProvider: "",
12
- oauthProviders: Object.freeze([])
13
+ oauthProviders: Object.freeze([]),
14
+ authCapabilities: normalizeAuthCapabilities()
13
15
  });
14
16
 
15
17
  const EMPTY_AUTH_GUARD_RUNTIME = Object.freeze({
@@ -7,46 +7,29 @@ import {
7
7
  import { AUTH_PATHS } from "@jskit-ai/auth-core/shared/authPaths";
8
8
  import { normalizeAuthReturnToPath } from "../lib/returnToPath.js";
9
9
  import { authHttpRequest } from "./authHttpClient.js";
10
+ import {
11
+ readAuthCallbackParam,
12
+ readAuthCallbackUrlParams
13
+ } from "./authCallbackUrlParams.js";
10
14
  import { ensureCommandSectionValid } from "../composables/loginView/validationHelpers.js";
11
15
 
12
- function parseCallbackUrl(url = "") {
13
- const normalizedUrl = String(url || "").trim();
14
- if (!normalizedUrl) {
15
- return null;
16
- }
17
-
18
- try {
19
- return new URL(normalizedUrl, "https://jskit.invalid");
20
- } catch {
21
- return null;
22
- }
23
- }
24
-
25
16
  function readOAuthCallbackParamsFromUrl(url = "") {
26
- const parsedUrl = parseCallbackUrl(url);
27
- if (!parsedUrl) {
17
+ const callbackUrlParams = readAuthCallbackUrlParams(url);
18
+ if (!callbackUrlParams) {
28
19
  return null;
29
20
  }
30
21
 
31
- const searchParams = new URLSearchParams(parsedUrl.search || "");
32
- const hashParams = new URLSearchParams(String(parsedUrl.hash || "").replace(/^#/, ""));
33
-
34
- const code = String(searchParams.get("code") || hashParams.get("code") || "").trim();
35
- const accessToken = String(searchParams.get("access_token") || hashParams.get("access_token") || "").trim();
36
- const refreshToken = String(searchParams.get("refresh_token") || hashParams.get("refresh_token") || "").trim();
22
+ const { searchParams } = callbackUrlParams;
23
+ const code = readAuthCallbackParam(callbackUrlParams, "code");
24
+ const accessToken = readAuthCallbackParam(callbackUrlParams, "access_token");
25
+ const refreshToken = readAuthCallbackParam(callbackUrlParams, "refresh_token");
37
26
  const errorCode = String(
38
- searchParams.get("error") ||
39
- hashParams.get("error") ||
40
- searchParams.get("errorCode") ||
41
- hashParams.get("errorCode") ||
42
- ""
27
+ readAuthCallbackParam(callbackUrlParams, "error") ||
28
+ readAuthCallbackParam(callbackUrlParams, "errorCode")
43
29
  ).trim();
44
30
  const errorDescription = String(
45
- searchParams.get("error_description") ||
46
- hashParams.get("error_description") ||
47
- searchParams.get("errorDescription") ||
48
- hashParams.get("errorDescription") ||
49
- ""
31
+ readAuthCallbackParam(callbackUrlParams, "error_description") ||
32
+ readAuthCallbackParam(callbackUrlParams, "errorDescription")
50
33
  ).trim();
51
34
  const hasSessionPair = Boolean(accessToken && refreshToken);
52
35
 
@@ -0,0 +1,82 @@
1
+ import {
2
+ readAuthCallbackParam,
3
+ readAuthCallbackUrlParams,
4
+ stripAuthCallbackParamsFromUrl
5
+ } from "./authCallbackUrlParams.js";
6
+
7
+ const PASSWORD_RECOVERY_CALLBACK_PARAM_KEYS = Object.freeze([
8
+ "token",
9
+ "code",
10
+ "token_hash",
11
+ "access_token",
12
+ "refresh_token",
13
+ "expires_in",
14
+ "expires_at",
15
+ "token_type",
16
+ "type"
17
+ ]);
18
+
19
+ function readPasswordRecoveryCallbackPayloadFromUrl(url = "") {
20
+ const callbackUrlParams = readAuthCallbackUrlParams(url);
21
+ if (!callbackUrlParams) {
22
+ return null;
23
+ }
24
+
25
+ const accessToken = readAuthCallbackParam(callbackUrlParams, "access_token");
26
+ const refreshToken = readAuthCallbackParam(callbackUrlParams, "refresh_token");
27
+ const tokenHash = readAuthCallbackParam(callbackUrlParams, "token_hash");
28
+ const code =
29
+ readAuthCallbackParam(callbackUrlParams, "token") ||
30
+ readAuthCallbackParam(callbackUrlParams, "code");
31
+
32
+ if (accessToken && refreshToken) {
33
+ return Object.freeze({
34
+ accessToken,
35
+ refreshToken,
36
+ type: "recovery"
37
+ });
38
+ }
39
+ if (tokenHash) {
40
+ return Object.freeze({
41
+ tokenHash,
42
+ type: "recovery"
43
+ });
44
+ }
45
+ if (code) {
46
+ return Object.freeze({
47
+ code,
48
+ type: "recovery"
49
+ });
50
+ }
51
+ if (accessToken || refreshToken) {
52
+ return Object.freeze({
53
+ accessToken,
54
+ refreshToken,
55
+ type: "recovery"
56
+ });
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function stripPasswordRecoveryCallbackParamsFromUrl(url = "") {
62
+ return stripAuthCallbackParamsFromUrl(url, PASSWORD_RECOVERY_CALLBACK_PARAM_KEYS);
63
+ }
64
+
65
+ function stripPasswordRecoveryCallbackParamsFromLocation() {
66
+ if (typeof window !== "object" || !window.location || !window.history) {
67
+ return;
68
+ }
69
+
70
+ window.history.replaceState(
71
+ {},
72
+ "",
73
+ stripPasswordRecoveryCallbackParamsFromUrl(window.location.href)
74
+ );
75
+ }
76
+
77
+ export {
78
+ PASSWORD_RECOVERY_CALLBACK_PARAM_KEYS,
79
+ readPasswordRecoveryCallbackPayloadFromUrl,
80
+ stripPasswordRecoveryCallbackParamsFromLocation,
81
+ stripPasswordRecoveryCallbackParamsFromUrl
82
+ };
@@ -68,6 +68,7 @@ export const useAuthStore = defineStore("jskit.auth-web.auth", () => {
68
68
  const username = computed(() => String(authState.value.username || ""));
69
69
  const oauthProviders = computed(() => authState.value.oauthProviders || EMPTY_AUTH_GUARD_STATE.oauthProviders);
70
70
  const oauthDefaultProvider = computed(() => String(authState.value.oauthDefaultProvider || ""));
71
+ const authCapabilities = computed(() => authState.value.authCapabilities || EMPTY_AUTH_GUARD_STATE.authCapabilities);
71
72
 
72
73
  return {
73
74
  runtime,
@@ -76,6 +77,7 @@ export const useAuthStore = defineStore("jskit.auth-web.auth", () => {
76
77
  username,
77
78
  oauthProviders,
78
79
  oauthDefaultProvider,
80
+ authCapabilities,
79
81
  attachRuntime,
80
82
  initialize,
81
83
  refresh,
@@ -35,7 +35,7 @@
35
35
  Sign in
36
36
  </v-btn>
37
37
  <v-btn
38
- v-if="!showRememberedAccount"
38
+ v-if="!showRememberedAccount && canRegister"
39
39
  data-testid="auth-mode-register"
40
40
  class="text-none"
41
41
  :variant="isRegister ? 'flat' : 'text'"
@@ -59,6 +59,7 @@
59
59
  Go to main screen
60
60
  </v-btn>
61
61
  <v-btn
62
+ v-if="canResendEmailConfirmation"
62
63
  class="text-none"
63
64
  variant="outlined"
64
65
  color="secondary"
@@ -86,6 +87,7 @@
86
87
  </div>
87
88
 
88
89
  <v-text-field
90
+ v-if="!isLogin || canUsePasswordLogin"
89
91
  v-model="email"
90
92
  label="Email"
91
93
  variant="outlined"
@@ -98,7 +100,7 @@
98
100
  />
99
101
 
100
102
  <v-text-field
101
- v-if="!isForgot && !isOtp"
103
+ v-if="(isLogin && canUsePasswordLogin) || isRegister"
102
104
  v-model="password"
103
105
  label="Password"
104
106
  :type="showPassword ? 'text' : 'password'"
@@ -139,13 +141,22 @@
139
141
  class="mb-3"
140
142
  />
141
143
 
142
- <div v-if="isLogin" class="aux-links d-flex justify-end mb-4">
143
- <v-btn variant="text" color="secondary" @click="switchMode('forgot')">Forgot password?</v-btn>
144
- <v-btn variant="text" color="secondary" @click="switchMode('otp')">Use one-time code</v-btn>
144
+ <div v-if="isLogin && (canRequestPasswordRecovery || canUseOtp)" class="aux-links d-flex justify-end mb-4">
145
+ <v-btn
146
+ v-if="canRequestPasswordRecovery"
147
+ variant="text"
148
+ color="secondary"
149
+ @click="switchMode('forgot')"
150
+ >
151
+ Forgot password?
152
+ </v-btn>
153
+ <v-btn v-if="canUseOtp" variant="text" color="secondary" @click="switchMode('otp')">
154
+ Use one-time code
155
+ </v-btn>
145
156
  </div>
146
157
 
147
158
  <v-checkbox
148
- v-if="isLogin || isOtp"
159
+ v-if="(isLogin && canUsePasswordLogin) || isOtp"
149
160
  v-model="rememberAccountOnDevice"
150
161
  label="Remember this account on this device"
151
162
  density="compact"
@@ -165,7 +176,7 @@
165
176
  </v-btn>
166
177
  </div>
167
178
 
168
- <div v-if="isLogin || isRegister" class="oauth-actions d-grid ga-2 mb-4">
179
+ <div v-if="canUseOAuth && (isLogin || isRegister)" class="oauth-actions d-grid ga-2 mb-4">
169
180
  <v-btn
170
181
  v-for="provider in oauthProviders"
171
182
  :key="provider.id"
@@ -181,6 +192,7 @@
181
192
  </v-btn>
182
193
  </div>
183
194
  <v-btn
195
+ v-if="!isLogin || canUsePasswordLogin"
184
196
  data-testid="auth-submit"
185
197
  block
186
198
  color="primary"
@@ -251,6 +263,12 @@ const {
251
263
  isLogin,
252
264
  isRegister,
253
265
  isEmailConfirmationPending,
266
+ canUsePasswordLogin,
267
+ canRegister,
268
+ canRequestPasswordRecovery,
269
+ canUseOtp,
270
+ canUseOAuth,
271
+ canResendEmailConfirmation,
254
272
  emailConfirmationMessage,
255
273
  showRememberedAccount,
256
274
  switchMode,
@@ -0,0 +1,214 @@
1
+ <template>
2
+ <div class="reset-screen" :class="{ 'reset-screen--mobile': isMobileViewport }">
3
+ <v-container
4
+ fluid
5
+ class="reset-shell fill-height d-flex"
6
+ :class="[
7
+ isMobileViewport
8
+ ? 'reset-shell--mobile align-stretch justify-stretch pa-0'
9
+ : 'align-center justify-center py-8'
10
+ ]"
11
+ >
12
+ <v-card
13
+ class="reset-card"
14
+ :class="{ 'reset-card--mobile': isMobileViewport }"
15
+ :rounded="isMobileViewport ? false : 'lg'"
16
+ :elevation="isMobileViewport ? 0 : 1"
17
+ :border="!isMobileViewport"
18
+ >
19
+ <v-card-text class="reset-content" :class="{ 'reset-content--mobile': isMobileViewport }">
20
+ <div class="reset-header mb-5">
21
+ <h1 class="reset-title">Reset password</h1>
22
+ </div>
23
+
24
+ <v-alert v-if="errorMessage" type="error" variant="tonal" class="mb-4">
25
+ {{ errorMessage }}
26
+ </v-alert>
27
+
28
+ <v-alert v-if="status === 'done'" type="success" variant="tonal" class="mb-4">
29
+ Password updated. Sign in with your new password.
30
+ </v-alert>
31
+
32
+ <v-progress-linear v-if="status === 'checking'" indeterminate color="primary" class="mb-4" />
33
+
34
+ <v-form v-if="status === 'ready'" @submit.prevent="submitReset" novalidate>
35
+ <v-text-field
36
+ v-model="password"
37
+ label="New password"
38
+ :type="showPassword ? 'text' : 'password'"
39
+ variant="outlined"
40
+ density="comfortable"
41
+ autocomplete="new-password"
42
+ :append-inner-icon="showPassword ? mdiEyeOff : mdiEye"
43
+ :error-messages="passwordError"
44
+ class="mb-3"
45
+ @click:append-inner="showPassword = !showPassword"
46
+ />
47
+ <v-text-field
48
+ v-model="confirmPassword"
49
+ label="Confirm password"
50
+ :type="showConfirmPassword ? 'text' : 'password'"
51
+ variant="outlined"
52
+ density="comfortable"
53
+ autocomplete="new-password"
54
+ :append-inner-icon="showConfirmPassword ? mdiEyeOff : mdiEye"
55
+ :error-messages="confirmPasswordError"
56
+ class="mb-4"
57
+ @click:append-inner="showConfirmPassword = !showConfirmPassword"
58
+ />
59
+ <v-btn block color="primary" size="large" type="submit" :loading="loading" :disabled="!canSubmit">
60
+ Update password
61
+ </v-btn>
62
+ </v-form>
63
+
64
+ <div v-if="status === 'done' || status === 'error'" class="mt-4 d-flex justify-end">
65
+ <v-btn variant="text" color="secondary" href="/auth/login">Back to sign in</v-btn>
66
+ </div>
67
+ </v-card-text>
68
+ </v-card>
69
+ </v-container>
70
+ </div>
71
+ </template>
72
+
73
+ <script setup>
74
+ import { computed, onMounted, ref } from "vue";
75
+ import { mdiEye, mdiEyeOff } from "@mdi/js";
76
+ import { useDisplay } from "vuetify";
77
+ import { AUTH_PATHS } from "@jskit-ai/auth-core/shared/authPaths";
78
+ import { authPasswordResetCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetCommand";
79
+ import { authHttpRequest } from "../runtime/authHttpClient.js";
80
+ import {
81
+ readPasswordRecoveryCallbackPayloadFromUrl,
82
+ stripPasswordRecoveryCallbackParamsFromLocation
83
+ } from "../runtime/passwordRecoveryCallbackRuntime.js";
84
+ import {
85
+ validateCommandSection,
86
+ resolveFieldValidationMessage
87
+ } from "../composables/loginView/validationHelpers.js";
88
+
89
+ const { mobile: isMobileViewport } = useDisplay({ mobileBreakpoint: 960 });
90
+ const status = ref("checking");
91
+ const loading = ref(false);
92
+ const errorMessage = ref("");
93
+ const password = ref("");
94
+ const confirmPassword = ref("");
95
+ const showPassword = ref(false);
96
+ const showConfirmPassword = ref(false);
97
+
98
+ const resetPasswordPayload = computed(() => ({
99
+ password: password.value
100
+ }));
101
+ const resetPasswordValidation = computed(() =>
102
+ validateCommandSection(authPasswordResetCommand, "body", resetPasswordPayload.value)
103
+ );
104
+ const passwordError = computed(() => {
105
+ if (!password.value) {
106
+ return [];
107
+ }
108
+ const message = resolveFieldValidationMessage(resetPasswordValidation.value, "password");
109
+ return message ? [message] : [];
110
+ });
111
+ const confirmPasswordError = computed(() => {
112
+ if (!confirmPassword.value) {
113
+ return [];
114
+ }
115
+ return password.value === confirmPassword.value ? [] : ["Passwords must match."];
116
+ });
117
+ const canSubmit = computed(() =>
118
+ Boolean(password.value) &&
119
+ resetPasswordValidation.value.ok === true &&
120
+ password.value === confirmPassword.value &&
121
+ loading.value !== true
122
+ );
123
+
124
+ function readRecoveryPayload() {
125
+ if (typeof window !== "object" || !window.location) {
126
+ return null;
127
+ }
128
+ return readPasswordRecoveryCallbackPayloadFromUrl(window.location.href);
129
+ }
130
+
131
+ async function exchangeRecoveryToken() {
132
+ const recoveryPayload = readRecoveryPayload();
133
+ if (!recoveryPayload) {
134
+ status.value = "ready";
135
+ return;
136
+ }
137
+ try {
138
+ await authHttpRequest(AUTH_PATHS.PASSWORD_RECOVERY, {
139
+ method: "POST",
140
+ body: recoveryPayload
141
+ });
142
+ stripPasswordRecoveryCallbackParamsFromLocation();
143
+ status.value = "ready";
144
+ } catch (error) {
145
+ status.value = "error";
146
+ errorMessage.value = String(error?.message || "Recovery link is invalid or expired.");
147
+ }
148
+ }
149
+
150
+ async function submitReset() {
151
+ if (!canSubmit.value) {
152
+ return;
153
+ }
154
+ loading.value = true;
155
+ errorMessage.value = "";
156
+ try {
157
+ await authHttpRequest(AUTH_PATHS.PASSWORD_RESET, {
158
+ method: "POST",
159
+ body: {
160
+ password: password.value
161
+ }
162
+ });
163
+ status.value = "done";
164
+ password.value = "";
165
+ confirmPassword.value = "";
166
+ } catch (error) {
167
+ errorMessage.value = String(error?.message || "Unable to update password.");
168
+ } finally {
169
+ loading.value = false;
170
+ }
171
+ }
172
+
173
+ onMounted(exchangeRecoveryToken);
174
+ </script>
175
+
176
+ <style scoped>
177
+ .reset-screen {
178
+ position: fixed;
179
+ inset: 0;
180
+ z-index: 1;
181
+ overflow-y: auto;
182
+ min-height: 100dvh;
183
+ background-color: rgb(var(--v-theme-background));
184
+ }
185
+
186
+ .reset-shell {
187
+ min-height: 100dvh;
188
+ }
189
+
190
+ .reset-card {
191
+ width: min(480px, 100%);
192
+ }
193
+
194
+ .reset-card--mobile {
195
+ width: 100%;
196
+ min-height: 100dvh;
197
+ }
198
+
199
+ .reset-content {
200
+ padding: 28px;
201
+ }
202
+
203
+ .reset-content--mobile {
204
+ min-height: 100dvh;
205
+ padding: calc(24px + env(safe-area-inset-top, 0px)) 20px calc(32px + env(safe-area-inset-bottom, 0px));
206
+ }
207
+
208
+ .reset-title {
209
+ margin: 0;
210
+ font-size: 28px;
211
+ line-height: 1.2;
212
+ color: rgb(var(--v-theme-on-surface));
213
+ }
214
+ </style>
@@ -239,7 +239,7 @@ function buildRoutes(controller, { includeDevLoginAs = false } = {}) {
239
239
  {
240
240
  path: AUTH_PATHS.PASSWORD_RESET,
241
241
  method: "POST",
242
- auth: "required",
242
+ auth: "public",
243
243
  meta: {
244
244
  tags: ["auth"],
245
245
  summary: "Set a new password for authenticated recovery session"
@@ -1,3 +1,4 @@
1
+ import { normalizeAuthCapabilities } from "@jskit-ai/auth-core/shared/authCapabilities";
1
2
  import { AUTH_ACTION_IDS } from "../constants/authActionIds.js";
2
3
 
3
4
  class AuthWebService {
@@ -143,6 +144,15 @@ class AuthWebService {
143
144
 
144
145
  getOAuthProviderCatalogPayload() {
145
146
  const authService = this.resolveAuthService();
147
+ if (typeof authService.getCapabilities === "function") {
148
+ const capabilities = normalizeAuthCapabilities(authService.getCapabilities());
149
+ const oauth = capabilities.features.oauthLogin;
150
+ return {
151
+ authCapabilities: capabilities,
152
+ oauthProviders: oauth.enabled ? oauth.providers : [],
153
+ oauthDefaultProvider: oauth.enabled ? oauth.defaultProvider : null
154
+ };
155
+ }
146
156
  const catalog =
147
157
  typeof authService.getOAuthProviderCatalog === "function"
148
158
  ? authService.getOAuthProviderCatalog()
@@ -156,12 +166,23 @@ class AuthWebService {
156
166
  .filter((provider) => provider.id && provider.label)
157
167
  : [];
158
168
  const defaultProvider = String(catalog?.defaultProvider || "").trim().toLowerCase();
169
+ const normalizedDefaultProvider = providers.some((provider) => provider.id === defaultProvider)
170
+ ? defaultProvider
171
+ : null;
172
+ const fallbackCapabilities = normalizeAuthCapabilities({
173
+ features: {
174
+ oauthLogin: {
175
+ enabled: providers.length > 0,
176
+ providers,
177
+ defaultProvider: normalizedDefaultProvider
178
+ }
179
+ }
180
+ });
159
181
 
160
182
  return {
183
+ authCapabilities: fallbackCapabilities,
161
184
  oauthProviders: providers,
162
- oauthDefaultProvider: providers.some((provider) => provider.id === defaultProvider)
163
- ? defaultProvider
164
- : null
185
+ oauthDefaultProvider: normalizedDefaultProvider
165
186
  };
166
187
  }
167
188
  }
@@ -0,0 +1,17 @@
1
+ <route lang="json">
2
+ {
3
+ "meta": {
4
+ "guard": {
5
+ "policy": "public"
6
+ }
7
+ }
8
+ }
9
+ </route>
10
+
11
+ <script setup>
12
+ import DefaultResetPasswordView from "@jskit-ai/auth-web/client/views/DefaultResetPasswordView";
13
+ </script>
14
+
15
+ <template>
16
+ <DefaultResetPasswordView />
17
+ </template>
@@ -0,0 +1,7 @@
1
+ <script setup>
2
+ import DefaultResetPasswordView from "@jskit-ai/auth-web/client/views/DefaultResetPasswordView";
3
+ </script>
4
+
5
+ <template>
6
+ <DefaultResetPasswordView />
7
+ </template>
@@ -149,6 +149,47 @@ test("auth guard runtime redirects callback hashes on non-login routes to /auth/
149
149
  }
150
150
  });
151
151
 
152
+ test("auth guard runtime leaves recovery callback hashes on reset-password route", async () => {
153
+ const originalWindow = globalThis.window;
154
+ let redirectedTo = "";
155
+ globalThis.window = {
156
+ location: {
157
+ pathname: "/auth/reset-password",
158
+ search: "",
159
+ hash: "#access_token=access&refresh_token=refresh&type=recovery",
160
+ replace(target) {
161
+ redirectedTo = String(target || "");
162
+ }
163
+ }
164
+ };
165
+
166
+ try {
167
+ const placementRuntime = createPlacementRuntimeStub();
168
+ let fetchCalls = 0;
169
+ const runtime = createAuthGuardRuntime({
170
+ placementRuntime,
171
+ fetchImplementation: async () => {
172
+ fetchCalls += 1;
173
+ return {
174
+ ok: true,
175
+ async json() {
176
+ return {
177
+ authenticated: false
178
+ };
179
+ }
180
+ };
181
+ }
182
+ });
183
+
184
+ const state = await runtime.initialize();
185
+ assert.equal(state.authenticated, false);
186
+ assert.equal(fetchCalls, 1);
187
+ assert.equal(redirectedTo, "");
188
+ } finally {
189
+ globalThis.window = originalWindow;
190
+ }
191
+ });
192
+
152
193
  test("auth guard runtime only updates placement auth context", async () => {
153
194
  const placementRuntime = createPlacementRuntimeStub({
154
195
  user: {
@@ -12,6 +12,7 @@ import {
12
12
  test("auth-web descriptor declares auth surface ui routes", () => {
13
13
  const uiRoutes = Array.isArray(descriptor?.metadata?.ui?.routes) ? descriptor.metadata.ui.routes : [];
14
14
  const authRoutes = uiRoutes.filter((route) => String(route?.path || "").startsWith("/auth/"));
15
+ const resetRoute = authRoutes.find((route) => route.id === "auth.reset-password");
15
16
 
16
17
  assert.equal(authRoutes.length >= 2, true);
17
18
  for (const route of authRoutes) {
@@ -19,16 +20,20 @@ test("auth-web descriptor declares auth surface ui routes", () => {
19
20
  assert.equal(String(route?.surface || "").trim().toLowerCase(), "auth");
20
21
  assert.equal(String(route?.guard?.policy || "").trim().toLowerCase(), "public");
21
22
  }
23
+ assert.equal(resetRoute?.autoRegister, false);
22
24
  });
23
25
 
24
26
  test("auth-web auth page templates declare public route guard", () => {
25
27
  const loginTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/login.vue", import.meta.url));
26
28
  const signOutTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/signout.vue", import.meta.url));
29
+ const resetTemplatePath = fileURLToPath(new URL("../templates/src/pages/auth/reset-password.vue", import.meta.url));
27
30
  const loginTemplateSource = readFileSync(loginTemplatePath, "utf8");
28
31
  const signOutTemplateSource = readFileSync(signOutTemplatePath, "utf8");
32
+ const resetTemplateSource = readFileSync(resetTemplatePath, "utf8");
29
33
 
30
34
  assert.match(loginTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
31
35
  assert.match(signOutTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
36
+ assert.match(resetTemplateSource, /"guard"\s*:\s*\{\s*"policy"\s*:\s*"public"\s*\}/);
32
37
  });
33
38
 
34
39
  test("auth-web exports runtime signout helpers directly", () => {
@@ -103,6 +108,17 @@ test("default login view avoids decorative login chrome", () => {
103
108
  assert.doesNotMatch(constantsSource, /\[LOGIN_MODE\]:\s*"Sign in to continue\.?"/);
104
109
  });
105
110
 
111
+ test("default login view gates password login controls by provider capabilities", () => {
112
+ const viewPath = fileURLToPath(new URL("../src/client/views/DefaultLoginView.vue", import.meta.url));
113
+ const validationPath = fileURLToPath(new URL("../src/client/composables/loginView/useLoginViewValidation.js", import.meta.url));
114
+ const viewSource = readFileSync(viewPath, "utf8");
115
+ const validationSource = readFileSync(validationPath, "utf8");
116
+
117
+ assert.match(viewSource, /v-if="!isLogin \|\| canUsePasswordLogin"/);
118
+ assert.match(viewSource, /v-if="\(isLogin && canUsePasswordLogin\) \|\| isRegister"/);
119
+ assert.match(validationSource, /state\.isLogin\.value && !state\.canUsePasswordLogin\.value/);
120
+ });
121
+
106
122
  test("auth-web package exports only minimal client runtime/view subpaths", () => {
107
123
  const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
108
124
  const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
@@ -111,6 +127,10 @@ test("auth-web package exports only minimal client runtime/view subpaths", () =>
111
127
  exportsMap["./client/views/DefaultLoginView"],
112
128
  "./src/client/views/DefaultLoginView.vue"
113
129
  );
130
+ assert.equal(
131
+ exportsMap["./client/views/DefaultResetPasswordView"],
132
+ "./src/client/views/DefaultResetPasswordView.vue"
133
+ );
114
134
  assert.equal(
115
135
  exportsMap["./client/views/DefaultSignOutView"],
116
136
  "./src/client/views/DefaultSignOutView.vue"
@@ -134,3 +154,16 @@ test("auth-web package exports only minimal client runtime/view subpaths", () =>
134
154
  assert.equal(exportsMap["./client/views/AuthProfileWidget"], undefined);
135
155
  assert.equal(exportsMap["./client/views/AuthProfileMenuLinkItem"], undefined);
136
156
  });
157
+
158
+ test("default reset password view preserves recovery session-pair callback tokens", () => {
159
+ const viewPath = fileURLToPath(new URL("../src/client/views/DefaultResetPasswordView.vue", import.meta.url));
160
+ const viewSource = readFileSync(viewPath, "utf8");
161
+
162
+ assert.match(viewSource, /readPasswordRecoveryCallbackPayloadFromUrl\(window\.location\.href\)/);
163
+ assert.match(viewSource, /stripPasswordRecoveryCallbackParamsFromLocation\(\);/);
164
+ assert.match(viewSource, /authPasswordResetCommand/);
165
+ assert.match(viewSource, /validateCommandSection\(authPasswordResetCommand,\s*"body"/);
166
+ assert.match(viewSource, /if \(!recoveryPayload\)\s*\{\s*status\.value = "ready";/);
167
+ assert.doesNotMatch(viewSource, /if \(!recoveryPayload\)\s*\{\s*status\.value = "error";/);
168
+ assert.doesNotMatch(viewSource, /hash\.get\("access_token"\)[\s\S]*code:/);
169
+ });
@@ -13,6 +13,19 @@ test("auth fastify adapter exports controller/routes backed by shared command va
13
13
  assert.ok(authLoginPasswordCommand.operation.body.schema);
14
14
  });
15
15
 
16
+ test("auth reset route remains public and lets provider validate recovery scope", () => {
17
+ const controller = new Proxy({}, {
18
+ get() {
19
+ return () => {};
20
+ }
21
+ });
22
+ const routes = buildRoutes(controller);
23
+ const resetRoute = routes.find((route) => route.method === "POST" && route.path === "/api/password/reset");
24
+
25
+ assert.ok(resetRoute);
26
+ assert.equal(resetRoute.auth, "public");
27
+ });
28
+
16
29
  test("auth-web does not contain src/server/schema", () => {
17
30
  const testFilePath = fileURLToPath(import.meta.url);
18
31
  const packageRoot = path.resolve(path.dirname(testFilePath), "..");
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ readPasswordRecoveryCallbackPayloadFromUrl,
5
+ stripPasswordRecoveryCallbackParamsFromUrl
6
+ } from "../src/client/runtime/passwordRecoveryCallbackRuntime.js";
7
+
8
+ test("readPasswordRecoveryCallbackPayloadFromUrl preserves Supabase session-pair hashes", () => {
9
+ assert.deepEqual(
10
+ readPasswordRecoveryCallbackPayloadFromUrl(
11
+ "/auth/reset-password#access_token=access&refresh_token=refresh&type=recovery"
12
+ ),
13
+ {
14
+ accessToken: "access",
15
+ refreshToken: "refresh",
16
+ type: "recovery"
17
+ }
18
+ );
19
+ });
20
+
21
+ test("readPasswordRecoveryCallbackPayloadFromUrl reads local tokens and token hashes", () => {
22
+ assert.deepEqual(
23
+ readPasswordRecoveryCallbackPayloadFromUrl("/auth/reset-password?token=local-token&type=recovery"),
24
+ {
25
+ code: "local-token",
26
+ type: "recovery"
27
+ }
28
+ );
29
+ assert.deepEqual(
30
+ readPasswordRecoveryCallbackPayloadFromUrl("/auth/reset-password?token_hash=hashed-token&type=recovery"),
31
+ {
32
+ tokenHash: "hashed-token",
33
+ type: "recovery"
34
+ }
35
+ );
36
+ assert.equal(readPasswordRecoveryCallbackPayloadFromUrl("/auth/reset-password"), null);
37
+ });
38
+
39
+ test("readPasswordRecoveryCallbackPayloadFromUrl prefers complete recovery tokens over partial session pairs", () => {
40
+ assert.deepEqual(
41
+ readPasswordRecoveryCallbackPayloadFromUrl(
42
+ "/auth/reset-password?token_hash=hashed-token#access_token=access&type=recovery"
43
+ ),
44
+ {
45
+ tokenHash: "hashed-token",
46
+ type: "recovery"
47
+ }
48
+ );
49
+ });
50
+
51
+ test("stripPasswordRecoveryCallbackParamsFromUrl removes credentials but keeps unrelated state", () => {
52
+ assert.equal(
53
+ stripPasswordRecoveryCallbackParamsFromUrl(
54
+ "/auth/reset-password?token=local-token&returnTo=%2Fhome#access_token=access&refresh_token=refresh&tab=reset"
55
+ ),
56
+ "/auth/reset-password?returnTo=%2Fhome#tab=reset"
57
+ );
58
+ });
@@ -227,6 +227,86 @@ test("auth route provider does not resolve authService during boot", async () =>
227
227
  assert.equal(authServiceResolutions, 1);
228
228
  });
229
229
 
230
+ test("auth session route preserves provider capabilities through response validation", async () => {
231
+ const fastify = createFastifyStub();
232
+ const app = createApplication();
233
+ const httpRuntime = createHttpRuntime({ app, fastify });
234
+
235
+ app.instance("authService", {
236
+ writeSessionCookies() {},
237
+ clearSessionCookies() {},
238
+ getCapabilities() {
239
+ return {
240
+ provider: {
241
+ id: "local",
242
+ label: "Local"
243
+ },
244
+ features: {
245
+ password: {
246
+ login: true,
247
+ register: true,
248
+ change: true,
249
+ methodToggle: false
250
+ },
251
+ passwordRecovery: {
252
+ request: true,
253
+ complete: true,
254
+ delivery: "dev-log"
255
+ },
256
+ otp: {
257
+ login: false
258
+ },
259
+ oauthLogin: {
260
+ enabled: false,
261
+ providers: [],
262
+ defaultProvider: null
263
+ },
264
+ emailConfirmation: false,
265
+ profileUpdate: true,
266
+ providerLinking: {
267
+ start: false,
268
+ unlink: false
269
+ },
270
+ securityStatus: true,
271
+ signOutOtherSessions: true,
272
+ appProfileProjection: false,
273
+ devLoginAs: false
274
+ }
275
+ };
276
+ }
277
+ });
278
+ app.instance("actionExecutor", {
279
+ async execute({ actionId }) {
280
+ if (actionId === "auth.session.read") {
281
+ return {
282
+ authenticated: false
283
+ };
284
+ }
285
+ return {};
286
+ }
287
+ });
288
+
289
+ class MockAuthProvider {
290
+ static id = "auth.provider";
291
+ }
292
+
293
+ await app.start({ providers: [MockAuthProvider, AuthWebServiceProvider, AuthRouteServiceProvider] });
294
+
295
+ const registration = httpRuntime.registerRoutes();
296
+ assert.equal(registration.routeCount > 0, true);
297
+
298
+ const sessionRoute = fastify.routes.find((route) => route.method === "GET" && route.url === "/api/session");
299
+ assert.ok(sessionRoute);
300
+ const sessionReply = createReplyStub();
301
+ await sessionRoute.handler({}, sessionReply);
302
+
303
+ assert.equal(sessionReply.statusCode, 200);
304
+ assert.equal(sessionReply.payload.authenticated, false);
305
+ assert.equal(sessionReply.payload.authCapabilities.provider.id, "local");
306
+ assert.equal(sessionReply.payload.authCapabilities.features.password.login, true);
307
+ assert.equal(sessionReply.payload.authCapabilities.features.password.register, true);
308
+ });
309
+
230
310
  test("auth session route exposes denial reason when policy clears a rejected authenticated session", async () => {
231
311
  const events = [];
232
312
  const fastify = createFastifyStub();