@jskit-ai/auth-web 0.1.86 → 0.1.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.descriptor.mjs +5 -5
- package/package.json +5 -5
- package/src/client/composables/loginView/useLoginViewActions.js +5 -0
- package/src/client/runtime/oauthCallbackRuntime.js +5 -0
- package/src/server/controllers/AuthController.js +3 -0
- package/test/loginViewActions.test.js +122 -1
- package/test/oauthCallbackRuntime.test.js +46 -0
- package/test/providerRuntime.test.js +62 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
"packageVersion": 1,
|
|
3
3
|
"packageId": "@jskit-ai/auth-web",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.88",
|
|
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.
|
|
251
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
252
|
-
"@jskit-ai/kernel": "0.1.
|
|
253
|
-
"@jskit-ai/shell-web": "0.1.
|
|
250
|
+
"@jskit-ai/auth-core": "0.1.86",
|
|
251
|
+
"@jskit-ai/http-runtime": "0.1.86",
|
|
252
|
+
"@jskit-ai/kernel": "0.1.87",
|
|
253
|
+
"@jskit-ai/shell-web": "0.1.86"
|
|
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.
|
|
3
|
+
"version": "0.1.88",
|
|
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.
|
|
21
|
+
"@jskit-ai/auth-core": "0.1.86",
|
|
22
22
|
"@mdi/js": "^7.4.47",
|
|
23
|
-
"@jskit-ai/kernel": "0.1.
|
|
24
|
-
"@jskit-ai/shell-web": "0.1.
|
|
25
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
23
|
+
"@jskit-ai/kernel": "0.1.87",
|
|
24
|
+
"@jskit-ai/shell-web": "0.1.86",
|
|
25
|
+
"@jskit-ai/http-runtime": "0.1.86"
|
|
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
|
+
});
|