@jskit-ai/auth-web 0.1.86 → 0.1.87

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.86",
4
+ "version": "0.1.87",
5
5
  "kind": "runtime",
6
6
  "description": "Auth web module: Fastify auth routes plus web login/sign-out scaffolds.",
7
7
  "dependsOn": [
@@ -247,10 +247,10 @@ export default Object.freeze({
247
247
  "dependencies": {
248
248
  "runtime": {
249
249
  "@mdi/js": "^7.4.47",
250
- "@jskit-ai/auth-core": "0.1.84",
251
- "@jskit-ai/http-runtime": "0.1.84",
252
- "@jskit-ai/kernel": "0.1.85",
253
- "@jskit-ai/shell-web": "0.1.84"
250
+ "@jskit-ai/auth-core": "0.1.85",
251
+ "@jskit-ai/http-runtime": "0.1.85",
252
+ "@jskit-ai/kernel": "0.1.86",
253
+ "@jskit-ai/shell-web": "0.1.85"
254
254
  },
255
255
  "dev": {}
256
256
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-web",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -18,11 +18,11 @@
18
18
  "./client/runtime/useSignOut": "./src/client/runtime/useSignOut.js"
19
19
  },
20
20
  "dependencies": {
21
- "@jskit-ai/auth-core": "0.1.84",
21
+ "@jskit-ai/auth-core": "0.1.85",
22
22
  "@mdi/js": "^7.4.47",
23
- "@jskit-ai/kernel": "0.1.85",
24
- "@jskit-ai/shell-web": "0.1.84",
25
- "@jskit-ai/http-runtime": "0.1.84"
23
+ "@jskit-ai/kernel": "0.1.86",
24
+ "@jskit-ai/shell-web": "0.1.85",
25
+ "@jskit-ai/http-runtime": "0.1.85"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@tanstack/vue-query": "^5.90.5",
@@ -7,6 +7,7 @@ import { authLoginOtpVerifyCommand } from "@jskit-ai/auth-core/shared/commands/a
7
7
  import { authLoginOAuthStartCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthStartCommand";
8
8
  import { authPasswordResetRequestCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetRequestCommand";
9
9
  import { AUTH_PATHS, buildAuthOauthStartPath } from "@jskit-ai/auth-core/shared/authPaths";
10
+ import { resolveAuthDeniedLoginMessage } from "@jskit-ai/auth-core/shared/authDenied";
10
11
  import { authHttpRequest } from "../../runtime/authHttpClient.js";
11
12
  import { completeOAuthCallbackFromCurrentLocation } from "../../runtime/oauthCallbackRuntime.js";
12
13
  import { normalizeAuthReturnToPath } from "../../lib/returnToPath.js";
@@ -136,6 +137,10 @@ export function useLoginViewActions({
136
137
  async function completeLogin() {
137
138
  const session = await refreshSession();
138
139
  if (!session?.authenticated) {
140
+ const authDeniedMessage = resolveAuthDeniedLoginMessage(session?.authDenied);
141
+ if (authDeniedMessage) {
142
+ throw new Error(authDeniedMessage);
143
+ }
139
144
  throw new Error("Login succeeded but the session is not active yet. Please retry.");
140
145
  }
141
146
  if (typeof window === "object" && window.location) {
@@ -1,4 +1,5 @@
1
1
  import { authLoginOAuthCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthCompleteCommand";
2
+ import { resolveAuthDeniedLoginMessage } from "@jskit-ai/auth-core/shared/authDenied";
2
3
  import {
3
4
  OAUTH_QUERY_PARAM_PROVIDER,
4
5
  OAUTH_QUERY_PARAM_RETURN_TO
@@ -148,6 +149,10 @@ async function completeOAuthCallbackFromUrl({
148
149
  });
149
150
  const session = await refreshSession();
150
151
  if (!session?.authenticated) {
152
+ const authDeniedMessage = resolveAuthDeniedLoginMessage(session?.authDenied);
153
+ if (authDeniedMessage) {
154
+ throw new Error(authDeniedMessage);
155
+ }
151
156
  throw new Error("Login succeeded but the session is not active yet. Please retry.");
152
157
  }
153
158
 
@@ -1,3 +1,4 @@
1
+ import { normalizeAuthDenied } from "@jskit-ai/auth-core/shared/authDenied";
1
2
  import { AUTH_ACTION_IDS } from "../constants/authActionIds.js";
2
3
  import { AuthWebService } from "../services/AuthWebService.js";
3
4
 
@@ -146,9 +147,11 @@ class AuthController {
146
147
  }
147
148
 
148
149
  if (!authResult.authenticated) {
150
+ const authDenied = normalizeAuthDenied(authResult.authDenied);
149
151
  reply.code(200).send({
150
152
  authenticated: false,
151
153
  csrfToken,
154
+ ...(authDenied ? { authDenied } : {}),
152
155
  ...oauthCatalogPayload
153
156
  });
154
157
  return;
@@ -2,6 +2,25 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { buildAuthOauthStartPath } from "@jskit-ai/auth-core/shared/authPaths";
4
4
  import { useLoginViewActions } from "../src/client/composables/loginView/useLoginViewActions.js";
5
+ import { clearAuthCsrfTokenCache } from "../src/client/runtime/authHttpClient.js";
6
+
7
+ function createJsonResponse(payload = {}, { status = 200 } = {}) {
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ headers: {
12
+ get(name) {
13
+ return String(name || "").toLowerCase() === "content-type" ? "application/json" : null;
14
+ }
15
+ },
16
+ async json() {
17
+ return payload;
18
+ },
19
+ async text() {
20
+ return JSON.stringify(payload);
21
+ }
22
+ };
23
+ }
5
24
 
6
25
  function createMinimalLoginViewState(requestedReturnTo = "/") {
7
26
  return {
@@ -10,15 +29,18 @@ function createMinimalLoginViewState(requestedReturnTo = "/") {
10
29
  errorMessage: { value: "" },
11
30
  infoMessage: { value: "" },
12
31
  loading: { value: false },
32
+ submitAttempted: { value: false },
13
33
  email: { value: "" },
14
- password: { value: "" },
34
+ password: { value: "password-123" },
15
35
  otpCode: { value: "" },
16
36
  otpRequestPending: { value: false },
17
37
  registerConfirmationResendPending: { value: false },
18
38
  pendingEmailConfirmationAddress: { value: "" },
39
+ rememberAccountOnDevice: { value: true },
19
40
  oauthProviders: { value: [] },
20
41
  oauthDefaultProvider: { value: "google" },
21
42
  isRegister: { value: false },
43
+ isForgot: { value: false },
22
44
  isOtp: { value: false },
23
45
  showRememberedAccount: { value: false },
24
46
  rememberedAccountDisplayName: { value: "" },
@@ -57,3 +79,102 @@ test("useLoginViewActions preserves the intended destination when starting OAuth
57
79
  assert.equal(state.errorMessage.value, "");
58
80
  });
59
81
  });
82
+
83
+ async function runPasswordLoginWithPostLoginSession(sessionPayload) {
84
+ clearAuthCsrfTokenCache();
85
+ const state = createMinimalLoginViewState("/");
86
+ const fetchCalls = [];
87
+ const reports = [];
88
+ const originalFetch = globalThis.fetch;
89
+ let sessionReads = 0;
90
+
91
+ globalThis.fetch = async (url, options = {}) => {
92
+ const normalizedUrl = String(url || "");
93
+ const method = String(options.method || "GET").toUpperCase();
94
+ fetchCalls.push({ url: normalizedUrl, method });
95
+
96
+ if (normalizedUrl === "/api/session" && method === "GET") {
97
+ sessionReads += 1;
98
+ return createJsonResponse(
99
+ sessionReads === 1
100
+ ? { authenticated: false, csrfToken: "csrf-a" }
101
+ : { csrfToken: "csrf-b", ...sessionPayload }
102
+ );
103
+ }
104
+
105
+ if (normalizedUrl === "/api/login" && method === "POST") {
106
+ return createJsonResponse({ ok: true, username: "Ada" });
107
+ }
108
+
109
+ throw new Error(`Unexpected fetch call: ${method} ${normalizedUrl}`);
110
+ };
111
+
112
+ try {
113
+ const actions = useLoginViewActions({
114
+ state,
115
+ validation: {
116
+ canSubmit: { value: true }
117
+ },
118
+ queryClient: {
119
+ async fetchQuery({ queryFn }) {
120
+ return queryFn();
121
+ }
122
+ },
123
+ errorRuntime: {
124
+ report(entry) {
125
+ reports.push(entry);
126
+ }
127
+ }
128
+ });
129
+
130
+ await actions.submitAuth();
131
+ return { state, fetchCalls, reports };
132
+ } finally {
133
+ globalThis.fetch = originalFetch;
134
+ clearAuthCsrfTokenCache();
135
+ }
136
+ }
137
+
138
+ test("useLoginViewActions shows allowlist denial after successful password login", async () => {
139
+ const { state, fetchCalls, reports } = await runPasswordLoginWithPostLoginSession({
140
+ authenticated: false,
141
+ authDenied: {
142
+ code: "not_allowlisted",
143
+ message: "This account is not allowed to access this application."
144
+ }
145
+ });
146
+
147
+ assert.equal(
148
+ state.errorMessage.value,
149
+ "Sign-in succeeded, but this account is not allowed to access this application."
150
+ );
151
+ assert.deepEqual(fetchCalls.map((entry) => `${entry.method} ${entry.url}`), [
152
+ "GET /api/session",
153
+ "POST /api/login",
154
+ "GET /api/session"
155
+ ]);
156
+ assert.equal(reports[0]?.message, state.errorMessage.value);
157
+ });
158
+
159
+ test("useLoginViewActions shows blocked denial after successful password login", async () => {
160
+ const { state } = await runPasswordLoginWithPostLoginSession({
161
+ authenticated: false,
162
+ authDenied: {
163
+ code: "blocked",
164
+ message: "This account has been blocked from accessing this application."
165
+ }
166
+ });
167
+
168
+ assert.equal(
169
+ state.errorMessage.value,
170
+ "Sign-in succeeded, but this account has been blocked from accessing this application."
171
+ );
172
+ });
173
+
174
+ test("useLoginViewActions keeps retry message for generic post-login unauthenticated sessions", async () => {
175
+ const { state } = await runPasswordLoginWithPostLoginSession({
176
+ authenticated: false
177
+ });
178
+
179
+ assert.equal(state.errorMessage.value, "Login succeeded but the session is not active yet. Please retry.");
180
+ });
@@ -138,6 +138,52 @@ test("completeOAuthCallbackFromUrl fails when the refreshed session is still una
138
138
  assert.equal(result.errorMessage, "Login succeeded but the session is not active yet. Please retry.");
139
139
  });
140
140
 
141
+ test("completeOAuthCallbackFromUrl maps allowlist auth denial to a clear message", async () => {
142
+ const result = await completeOAuthCallbackFromUrl({
143
+ url: "/auth/login?oauthProvider=google&code=abc",
144
+ request: async () => ({
145
+ username: "Ada"
146
+ }),
147
+ refreshSession: async () => ({
148
+ authenticated: false,
149
+ authDenied: {
150
+ code: "not_allowlisted",
151
+ message: "This account is not allowed to access this application."
152
+ }
153
+ })
154
+ });
155
+
156
+ assert.equal(result.handled, true);
157
+ assert.equal(result.completed, false);
158
+ assert.equal(
159
+ result.errorMessage,
160
+ "Sign-in succeeded, but this account is not allowed to access this application."
161
+ );
162
+ });
163
+
164
+ test("completeOAuthCallbackFromUrl maps blocked auth denial to a clear message", async () => {
165
+ const result = await completeOAuthCallbackFromUrl({
166
+ url: "/auth/login?oauthProvider=google&code=abc",
167
+ request: async () => ({
168
+ username: "Ada"
169
+ }),
170
+ refreshSession: async () => ({
171
+ authenticated: false,
172
+ authDenied: {
173
+ code: "blocked",
174
+ message: "This account has been blocked from accessing this application."
175
+ }
176
+ })
177
+ });
178
+
179
+ assert.equal(result.handled, true);
180
+ assert.equal(result.completed, false);
181
+ assert.equal(
182
+ result.errorMessage,
183
+ "Sign-in succeeded, but this account has been blocked from accessing this application."
184
+ );
185
+ });
186
+
141
187
  test("completeOAuthCallbackFromCurrentLocation reads from window.location.href", async () => {
142
188
  const originalWindow = globalThis.window;
143
189
  globalThis.window = {
@@ -28,6 +28,9 @@ function createReplyStub() {
28
28
  sent: false,
29
29
  statusCode: null,
30
30
  payload: null,
31
+ async generateCsrf() {
32
+ return "csrf-test";
33
+ },
31
34
  code(value) {
32
35
  this.statusCode = value;
33
36
  return this;
@@ -223,3 +226,62 @@ test("auth route provider does not resolve authService during boot", async () =>
223
226
  assert.equal(loginReply.statusCode, 200);
224
227
  assert.equal(authServiceResolutions, 1);
225
228
  });
229
+
230
+ test("auth session route exposes denial reason when policy clears a rejected authenticated session", async () => {
231
+ const events = [];
232
+ const fastify = createFastifyStub();
233
+ const app = createApplication();
234
+ const httpRuntime = createHttpRuntime({ app, fastify });
235
+
236
+ const authService = {
237
+ writeSessionCookies() {
238
+ events.push({ type: "writeSession" });
239
+ },
240
+ clearSessionCookies() {
241
+ events.push({ type: "clearSession" });
242
+ },
243
+ getOAuthProviderCatalog() {
244
+ return { providers: [], defaultProvider: "" };
245
+ }
246
+ };
247
+
248
+ app.instance("authService", authService);
249
+ app.instance("actionExecutor", {
250
+ async execute({ actionId }) {
251
+ if (actionId === "auth.session.read") {
252
+ return {
253
+ authenticated: false,
254
+ clearSession: true,
255
+ transientFailure: false,
256
+ authDenied: {
257
+ code: "not_allowlisted",
258
+ message: "This account is not allowed to access this application."
259
+ }
260
+ };
261
+ }
262
+ return {};
263
+ }
264
+ });
265
+
266
+ class MockAuthProvider {
267
+ static id = "auth.provider";
268
+ }
269
+
270
+ await app.start({ providers: [MockAuthProvider, AuthWebServiceProvider, AuthRouteServiceProvider] });
271
+
272
+ const registration = httpRuntime.registerRoutes();
273
+ assert.equal(registration.routeCount > 0, true);
274
+
275
+ const sessionRoute = fastify.routes.find((route) => route.method === "GET" && route.url === "/api/session");
276
+ assert.ok(sessionRoute);
277
+ const sessionReply = createReplyStub();
278
+ await sessionRoute.handler({}, sessionReply);
279
+
280
+ assert.equal(sessionReply.statusCode, 200);
281
+ assert.equal(sessionReply.payload.authenticated, false);
282
+ assert.deepEqual(sessionReply.payload.authDenied, {
283
+ code: "not_allowlisted",
284
+ message: "This account is not allowed to access this application."
285
+ });
286
+ assert.deepEqual(events, [{ type: "clearSession" }]);
287
+ });