@jskit-ai/auth-core 0.1.4

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.
Files changed (55) hide show
  1. package/package.descriptor.mjs +95 -0
  2. package/package.json +51 -0
  3. package/src/client/authApi.js +1 -0
  4. package/src/client/index.js +2 -0
  5. package/src/client/providers/AccessCoreClientProvider.js +23 -0
  6. package/src/client/providers/FastifyAuthPolicyClientProvider.js +13 -0
  7. package/src/client/signOutFlow.js +1 -0
  8. package/src/server/inviteTokens.js +41 -0
  9. package/src/server/lib/actionContextContributor.js +36 -0
  10. package/src/server/lib/authPolicySupport.js +38 -0
  11. package/src/server/lib/errors.js +20 -0
  12. package/src/server/lib/index.js +3 -0
  13. package/src/server/lib/objectUtils.js +5 -0
  14. package/src/server/lib/plugin.js +247 -0
  15. package/src/server/lib/routeMeta.js +64 -0
  16. package/src/server/lib/routeVisibilityResolver.js +25 -0
  17. package/src/server/lib/tokens.js +3 -0
  18. package/src/server/membershipAccess.js +67 -0
  19. package/src/server/providers/AccessCoreServiceProvider.js +35 -0
  20. package/src/server/providers/FastifyAuthPolicyServiceProvider.js +124 -0
  21. package/src/server/utils.js +26 -0
  22. package/src/server/validators.js +183 -0
  23. package/src/shared/authApi.js +50 -0
  24. package/src/shared/authConstraints.js +13 -0
  25. package/src/shared/authMethods.js +170 -0
  26. package/src/shared/authPaths.js +24 -0
  27. package/src/shared/commands/authCommandValidators.js +255 -0
  28. package/src/shared/commands/authLoginOAuthCompleteCommand.js +68 -0
  29. package/src/shared/commands/authLoginOAuthStartCommand.js +72 -0
  30. package/src/shared/commands/authLoginOtpRequestCommand.js +56 -0
  31. package/src/shared/commands/authLoginOtpVerifyCommand.js +64 -0
  32. package/src/shared/commands/authLoginPasswordCommand.js +57 -0
  33. package/src/shared/commands/authLogoutCommand.js +23 -0
  34. package/src/shared/commands/authPasswordRecoveryCompleteCommand.js +67 -0
  35. package/src/shared/commands/authPasswordResetCommand.js +49 -0
  36. package/src/shared/commands/authPasswordResetRequestCommand.js +50 -0
  37. package/src/shared/commands/authRegisterCommand.js +57 -0
  38. package/src/shared/commands/authSessionReadCommand.js +26 -0
  39. package/src/shared/index.js +3 -0
  40. package/src/shared/inputNormalization.js +1 -0
  41. package/src/shared/inviteTokens.js +38 -0
  42. package/src/shared/oauthCallbackParams.js +5 -0
  43. package/src/shared/oauthProviders.js +66 -0
  44. package/src/shared/signOutFlow.js +28 -0
  45. package/test/actionContextContributor.test.js +44 -0
  46. package/test/authApi.test.js +47 -0
  47. package/test/authMethods.test.js +95 -0
  48. package/test/authPaths.test.js +17 -0
  49. package/test/commandValidators.test.js +33 -0
  50. package/test/plugin.test.js +250 -0
  51. package/test/providerRuntime.test.js +114 -0
  52. package/test/routeMeta.test.js +95 -0
  53. package/test/routeVisibilityResolver.test.js +34 -0
  54. package/test/serverUtils.test.js +28 -0
  55. package/test/signOutFlow.test.js +67 -0
@@ -0,0 +1,250 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createFakeFastifyPolicyRuntime } from "../../../tooling/testUtils/fakeFastify.mjs";
4
+
5
+ import { authPolicyPlugin } from "../src/server/lib/index.js";
6
+
7
+ test("requires resolveActor and hasPermission dependencies", () => {
8
+ assert.throws(() => authPolicyPlugin(), /resolveActor is required/);
9
+ assert.throws(() => authPolicyPlugin({ resolveActor() {} }), /hasPermission is required/);
10
+ });
11
+
12
+ test("resolves csrf token headers and skips auth for non-api/public routes", async () => {
13
+ let actorCalls = 0;
14
+ const { fastify, state } = createFakeFastifyPolicyRuntime();
15
+ const registerPlugin = authPolicyPlugin(
16
+ {
17
+ async resolveActor() {
18
+ actorCalls += 1;
19
+ return {
20
+ authenticated: false,
21
+ actor: null,
22
+ transientFailure: false
23
+ };
24
+ },
25
+ hasPermission() {
26
+ return true;
27
+ }
28
+ },
29
+ {
30
+ nodeEnv: "test"
31
+ }
32
+ );
33
+
34
+ await registerPlugin(fastify);
35
+ assert.ok(state.preHandler);
36
+ assert.ok(state.csrfOptions);
37
+ const getToken = state.csrfOptions.getToken;
38
+ assert.equal(getToken({ headers: { "csrf-token": "a" } }), "a");
39
+ assert.equal(getToken({ headers: { "x-csrf-token": "b" } }), "b");
40
+ assert.equal(getToken({ headers: { "x-xsrf-token": "c" } }), "c");
41
+ assert.equal(getToken({ headers: {} }), null);
42
+
43
+ await state.preHandler({ method: "GET", raw: { url: "/health" }, routeOptions: {} }, {});
44
+ await state.preHandler(
45
+ {
46
+ method: "GET",
47
+ raw: { url: "/api/public" },
48
+ routeOptions: {
49
+ config: {
50
+ authPolicy: "public"
51
+ }
52
+ }
53
+ },
54
+ {}
55
+ );
56
+ assert.equal(actorCalls, 0);
57
+ });
58
+
59
+ test("propagates csrf callback error", async () => {
60
+ const { fastify, state } = createFakeFastifyPolicyRuntime({
61
+ csrfHandler(_request, _reply, done) {
62
+ done(new Error("csrf callback failed"));
63
+ }
64
+ });
65
+
66
+ const registerPlugin = authPolicyPlugin({
67
+ async resolveActor() {
68
+ return {
69
+ authenticated: false,
70
+ actor: null,
71
+ transientFailure: false
72
+ };
73
+ },
74
+ hasPermission() {
75
+ return true;
76
+ }
77
+ });
78
+
79
+ await registerPlugin(fastify);
80
+ await assert.rejects(
81
+ () =>
82
+ state.preHandler(
83
+ {
84
+ method: "POST",
85
+ raw: { url: "/api/public-action" },
86
+ routeOptions: {
87
+ config: {
88
+ authPolicy: "public"
89
+ }
90
+ },
91
+ headers: {}
92
+ },
93
+ {}
94
+ ),
95
+ /csrf callback failed/
96
+ );
97
+ });
98
+
99
+ test("enforces own policy owner checks and invalid policy guard", async () => {
100
+ const denyEvents = [];
101
+ const { fastify, state } = createFakeFastifyPolicyRuntime();
102
+ const registerPlugin = authPolicyPlugin({
103
+ async resolveActor(request) {
104
+ if (request.headers?.["x-profile"] === "null") {
105
+ return {
106
+ authenticated: true,
107
+ actor: null,
108
+ transientFailure: false
109
+ };
110
+ }
111
+
112
+ return {
113
+ authenticated: true,
114
+ actor: {
115
+ id: 7
116
+ },
117
+ transientFailure: false
118
+ };
119
+ },
120
+ hasPermission() {
121
+ return true;
122
+ },
123
+ onPolicyDenied(event) {
124
+ denyEvents.push(event);
125
+ }
126
+ });
127
+
128
+ await registerPlugin(fastify);
129
+
130
+ await assert.rejects(
131
+ () =>
132
+ state.preHandler(
133
+ {
134
+ method: "GET",
135
+ raw: { url: "/api/own-a" },
136
+ headers: { "x-profile": "null" },
137
+ routeOptions: {
138
+ config: {
139
+ authPolicy: "own",
140
+ ownerResolver({ user }) {
141
+ assert.equal(user, null);
142
+ return 1;
143
+ }
144
+ }
145
+ }
146
+ },
147
+ {}
148
+ ),
149
+ /Route owner could not be resolved/
150
+ );
151
+
152
+ await assert.rejects(
153
+ () =>
154
+ state.preHandler(
155
+ {
156
+ method: "GET",
157
+ raw: { url: "/api/own-b" },
158
+ headers: {},
159
+ params: { id: 99 },
160
+ routeOptions: {
161
+ config: {
162
+ authPolicy: "own",
163
+ ownerParam: "id",
164
+ userField: "id"
165
+ }
166
+ }
167
+ },
168
+ {}
169
+ ),
170
+ /Forbidden/
171
+ );
172
+
173
+ await assert.rejects(
174
+ () =>
175
+ state.preHandler(
176
+ {
177
+ method: "GET",
178
+ raw: { url: "/api/bad-policy" },
179
+ headers: {},
180
+ routeOptions: {
181
+ config: {
182
+ authPolicy: "unknown-policy"
183
+ }
184
+ }
185
+ },
186
+ {}
187
+ ),
188
+ /Invalid route auth policy configuration/
189
+ );
190
+
191
+ assert.deepEqual(
192
+ denyEvents.map((event) => event.reason),
193
+ ["owner_unresolved", "forbidden_owner_mismatch", "invalid_auth_policy"]
194
+ );
195
+ });
196
+
197
+ test("enforces permission checks and resolves workspace context when requested", async () => {
198
+ const denyEvents = [];
199
+ const { fastify, state } = createFakeFastifyPolicyRuntime();
200
+ const registerPlugin = authPolicyPlugin({
201
+ async resolveActor() {
202
+ return {
203
+ authenticated: true,
204
+ actor: {
205
+ id: 7
206
+ },
207
+ transientFailure: false
208
+ };
209
+ },
210
+ async resolveContext() {
211
+ return {
212
+ workspace: {
213
+ id: 11
214
+ },
215
+ membership: {
216
+ roleId: "member"
217
+ },
218
+ permissions: []
219
+ };
220
+ },
221
+ hasPermission({ permission, permissions }) {
222
+ return Array.isArray(permissions) && permissions.includes(permission);
223
+ },
224
+ onPolicyDenied(event) {
225
+ denyEvents.push(event);
226
+ }
227
+ });
228
+
229
+ await registerPlugin(fastify);
230
+ await assert.rejects(
231
+ () =>
232
+ state.preHandler(
233
+ {
234
+ method: "GET",
235
+ raw: { url: "/api/workspace/projects" },
236
+ routeOptions: {
237
+ config: {
238
+ authPolicy: "required",
239
+ contextPolicy: "required",
240
+ permission: "projects.read"
241
+ }
242
+ }
243
+ },
244
+ {}
245
+ ),
246
+ /Forbidden/
247
+ );
248
+
249
+ assert.deepEqual(denyEvents.map((event) => event.reason), ["forbidden_permission"]);
250
+ });
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
4
+ import { AUTH_POLICY_CONTEXT_RESOLVER_TOKEN } from "../src/server/lib/tokens.js";
5
+ import { FastifyAuthPolicyServiceProvider } from "../src/server/providers/FastifyAuthPolicyServiceProvider.js";
6
+ import { createFakeFastifyPolicyRuntime } from "../../../tooling/testUtils/fakeFastify.mjs";
7
+
8
+ test("FastifyAuthPolicyServiceProvider registers auth policy plugin through provider boot", async () => {
9
+ const { fastify, state } = createFakeFastifyPolicyRuntime();
10
+ const bag = new Map([
11
+ [KERNEL_TOKENS.Fastify, fastify],
12
+ [KERNEL_TOKENS.Env, { NODE_ENV: "test" }],
13
+ [KERNEL_TOKENS.Logger, console],
14
+ [
15
+ "authService",
16
+ {
17
+ async authenticateRequest() {
18
+ return {
19
+ authenticated: false,
20
+ actor: null,
21
+ transientFailure: false
22
+ };
23
+ }
24
+ }
25
+ ]
26
+ ]);
27
+
28
+ const app = {
29
+ has(token) {
30
+ return bag.has(token);
31
+ },
32
+ make(token) {
33
+ if (!bag.has(token)) {
34
+ throw new Error(`Missing token ${String(token)}`);
35
+ }
36
+ return bag.get(token);
37
+ }
38
+ };
39
+
40
+ const provider = new FastifyAuthPolicyServiceProvider();
41
+ provider.register(app);
42
+ await provider.boot(app);
43
+
44
+ assert.ok(state.requestDecorators.has("user"));
45
+ assert.ok(state.requestDecorators.has("workspace"));
46
+ assert.ok(state.requestDecorators.has("membership"));
47
+ assert.ok(state.requestDecorators.has("permissions"));
48
+ assert.equal(typeof state.preHandler, "function");
49
+ assert.ok(state.registeredPlugins.length >= 3);
50
+ });
51
+
52
+ test("FastifyAuthPolicyServiceProvider wires optional auth policy context resolver", async () => {
53
+ const { fastify, state } = createFakeFastifyPolicyRuntime();
54
+ const bag = new Map([
55
+ [KERNEL_TOKENS.Fastify, fastify],
56
+ [KERNEL_TOKENS.Env, { NODE_ENV: "test" }],
57
+ [KERNEL_TOKENS.Logger, console],
58
+ [
59
+ "authService",
60
+ {
61
+ async authenticateRequest() {
62
+ return {
63
+ authenticated: true,
64
+ actor: { id: 7 },
65
+ transientFailure: false
66
+ };
67
+ }
68
+ }
69
+ ],
70
+ [
71
+ AUTH_POLICY_CONTEXT_RESOLVER_TOKEN,
72
+ async ({ actor, request }) => ({
73
+ workspace: { id: 11, slug: String(request?.params?.workspaceSlug || "").toLowerCase() },
74
+ membership: { roleId: "member" },
75
+ permissions: actor?.id === 7 ? ["projects.read"] : []
76
+ })
77
+ ]
78
+ ]);
79
+
80
+ const app = {
81
+ has(token) {
82
+ return bag.has(token);
83
+ },
84
+ make(token) {
85
+ if (!bag.has(token)) {
86
+ throw new Error(`Missing token ${String(token)}`);
87
+ }
88
+ return bag.get(token);
89
+ }
90
+ };
91
+
92
+ const provider = new FastifyAuthPolicyServiceProvider();
93
+ provider.register(app);
94
+ await provider.boot(app);
95
+
96
+ const request = {
97
+ method: "GET",
98
+ raw: { url: "/api/w/acme/projects" },
99
+ params: { workspaceSlug: "ACME" },
100
+ routeOptions: {
101
+ config: {
102
+ authPolicy: "required",
103
+ contextPolicy: "required",
104
+ permission: "projects.read"
105
+ }
106
+ }
107
+ };
108
+
109
+ await state.preHandler(request, {});
110
+ assert.equal(request.workspace?.id, 11);
111
+ assert.equal(request.workspace?.slug, "acme");
112
+ assert.equal(request.membership?.roleId, "member");
113
+ assert.deepEqual(request.permissions, ["projects.read"]);
114
+ });
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { mergeAuthPolicy, withAuthPolicy } from "../src/server/lib/index.js";
5
+
6
+ test("withAuthPolicy applies stable defaults", () => {
7
+ const wrapped = withAuthPolicy();
8
+ assert.deepEqual(wrapped, {
9
+ config: {
10
+ authPolicy: "public",
11
+ contextPolicy: "none",
12
+ surface: "",
13
+ permission: "",
14
+ ownerParam: null,
15
+ userField: "id",
16
+ ownerResolver: null,
17
+ csrfProtection: true
18
+ }
19
+ });
20
+ });
21
+
22
+ test("mergeAuthPolicy preserves existing config fields and adds auth policy defaults", () => {
23
+ const merged = mergeAuthPolicy(
24
+ {
25
+ method: "GET",
26
+ url: "/api/example",
27
+ config: {
28
+ rateLimit: {
29
+ max: 5,
30
+ timeWindow: "1 minute"
31
+ }
32
+ }
33
+ },
34
+ {}
35
+ );
36
+
37
+ assert.deepEqual(merged.config, {
38
+ rateLimit: {
39
+ max: 5,
40
+ timeWindow: "1 minute"
41
+ },
42
+ authPolicy: "public",
43
+ contextPolicy: "none",
44
+ surface: "",
45
+ permission: "",
46
+ ownerParam: null,
47
+ userField: "id",
48
+ ownerResolver: null,
49
+ csrfProtection: true
50
+ });
51
+ });
52
+
53
+ test("mergeAuthPolicy normalizes policy metadata and keeps explicit settings", () => {
54
+ const resolver = () => 7;
55
+ const merged = mergeAuthPolicy(
56
+ {
57
+ config: {
58
+ authPolicy: "public",
59
+ contextPolicy: "none"
60
+ }
61
+ },
62
+ {
63
+ authPolicy: "own",
64
+ contextPolicy: "required",
65
+ surface: "admin",
66
+ permission: "workspace.members.manage",
67
+ ownerParam: "userId",
68
+ userField: "id",
69
+ ownerResolver: resolver,
70
+ csrfProtection: false
71
+ }
72
+ );
73
+
74
+ assert.equal(merged.config.authPolicy, "own");
75
+ assert.equal(merged.config.contextPolicy, "required");
76
+ assert.equal(merged.config.surface, "admin");
77
+ assert.equal(merged.config.permission, "workspace.members.manage");
78
+ assert.equal(merged.config.ownerParam, "userId");
79
+ assert.equal(merged.config.userField, "id");
80
+ assert.equal(merged.config.ownerResolver, resolver);
81
+ assert.equal(merged.config.csrfProtection, false);
82
+ });
83
+
84
+ test("mergeAuthPolicy coerces unsupported owner resolver values to null", () => {
85
+ const merged = mergeAuthPolicy(
86
+ {
87
+ config: {
88
+ ownerResolver: "not-a-function"
89
+ }
90
+ },
91
+ {}
92
+ );
93
+
94
+ assert.equal(merged.config.ownerResolver, null);
95
+ });
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createAuthRouteVisibilityResolver } from "../src/server/lib/routeVisibilityResolver.js";
4
+
5
+ test("auth route visibility resolver contributes actor scope for core user visibility only", () => {
6
+ const resolver = createAuthRouteVisibilityResolver();
7
+
8
+ assert.deepEqual(
9
+ resolver.resolve({
10
+ visibility: "user",
11
+ context: {
12
+ actor: {
13
+ id: "user_7"
14
+ }
15
+ }
16
+ }),
17
+ {
18
+ userOwnerId: "user_7",
19
+ requiresActorScope: true
20
+ }
21
+ );
22
+
23
+ assert.deepEqual(
24
+ resolver.resolve({
25
+ visibility: "workspace_user",
26
+ context: {
27
+ actor: {
28
+ id: "user_7"
29
+ }
30
+ }
31
+ }),
32
+ {}
33
+ );
34
+ });
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { normalizeReturnToPath } from "../src/server/utils.js";
5
+
6
+ test("normalizeReturnToPath keeps internal paths", () => {
7
+ assert.equal(normalizeReturnToPath("/w/acme"), "/w/acme");
8
+ });
9
+
10
+ test("normalizeReturnToPath allows absolute urls for configured origins", () => {
11
+ assert.equal(
12
+ normalizeReturnToPath("https://app.example.com/w/acme", {
13
+ fallback: "/",
14
+ allowedOrigins: ["https://app.example.com", "https://admin.example.com"]
15
+ }),
16
+ "https://app.example.com/w/acme"
17
+ );
18
+ });
19
+
20
+ test("normalizeReturnToPath rejects absolute urls for unconfigured origins", () => {
21
+ assert.equal(
22
+ normalizeReturnToPath("https://evil.example.com/phishing", {
23
+ fallback: "/",
24
+ allowedOrigins: ["https://app.example.com"]
25
+ }),
26
+ "/"
27
+ );
28
+ });
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { runAuthSignOutFlow } from "../src/client/signOutFlow.js";
5
+
6
+ test("runAuthSignOutFlow executes logout then cleanup hooks", async () => {
7
+ const calls = [];
8
+ await runAuthSignOutFlow({
9
+ authApi: {
10
+ async logout() {
11
+ calls.push("logout");
12
+ }
13
+ },
14
+ clearCsrfTokenCache() {
15
+ calls.push("clearCsrf");
16
+ },
17
+ async afterSignOut() {
18
+ calls.push("afterSignOut");
19
+ }
20
+ });
21
+
22
+ assert.deepEqual(calls, ["logout", "clearCsrf", "afterSignOut"]);
23
+ });
24
+
25
+ test("runAuthSignOutFlow runs cleanup hooks when logout fails and rethrows error", async () => {
26
+ const calls = [];
27
+ const expectedError = new Error("logout failed");
28
+
29
+ await assert.rejects(
30
+ () =>
31
+ runAuthSignOutFlow({
32
+ authApi: {
33
+ async logout() {
34
+ calls.push("logout");
35
+ throw expectedError;
36
+ }
37
+ },
38
+ clearCsrfTokenCache() {
39
+ calls.push("clearCsrf");
40
+ },
41
+ async afterSignOut() {
42
+ calls.push("afterSignOut");
43
+ }
44
+ }),
45
+ expectedError
46
+ );
47
+
48
+ assert.deepEqual(calls, ["logout", "clearCsrf", "afterSignOut"]);
49
+ });
50
+
51
+ test("runAuthSignOutFlow validates authApi logout shape", async () => {
52
+ await assert.rejects(
53
+ () =>
54
+ runAuthSignOutFlow({
55
+ authApi: null
56
+ }),
57
+ /requires authApi/
58
+ );
59
+
60
+ await assert.rejects(
61
+ () =>
62
+ runAuthSignOutFlow({
63
+ authApi: {}
64
+ }),
65
+ /requires authApi\.logout/
66
+ );
67
+ });