@jskit-ai/auth-core 0.1.114 → 0.1.115

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-core",
4
- "version": "0.1.114",
4
+ "version": "0.1.115",
5
5
  "kind": "runtime",
6
6
  "dependsOn": [
7
7
  "@jskit-ai/value-app-config-shared"
@@ -74,7 +74,7 @@ export default Object.freeze({
74
74
  "mutations": {
75
75
  "dependencies": {
76
76
  "runtime": {
77
- "@jskit-ai/kernel": "0.1.116",
77
+ "@jskit-ai/kernel": "0.1.117",
78
78
  "@fastify/cookie": "^11.0.2",
79
79
  "@fastify/csrf-protection": "^7.1.0",
80
80
  "@fastify/rate-limit": "^10.3.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-core",
3
- "version": "0.1.114",
3
+ "version": "0.1.115",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -14,6 +14,8 @@
14
14
  "./server/authPolicyContextResolverRegistry": "./src/server/authPolicyContextResolverRegistry.js",
15
15
  "./server/authServiceDecoratorRegistry": "./src/server/authServiceDecoratorRegistry.js",
16
16
  "./server/authActor": "./src/server/authActor.js",
17
+ "./server/booleanFlag": "./src/server/booleanFlag.js",
18
+ "./server/devAuth": "./src/server/devAuth.js",
17
19
  "./server/unsupportedOperation": "./src/server/unsupportedOperation.js",
18
20
  "./server/utils": "./src/server/utils.js",
19
21
  "./server/validators": "./src/server/validators.js",
@@ -54,7 +56,7 @@
54
56
  "./shared/commands/authSessionReadCommand": "./src/shared/commands/authSessionReadCommand.js"
55
57
  },
56
58
  "dependencies": {
57
- "@jskit-ai/kernel": "0.1.116",
59
+ "@jskit-ai/kernel": "0.1.117",
58
60
  "@fastify/cookie": "^11.0.2",
59
61
  "@fastify/csrf-protection": "^7.1.0",
60
62
  "@fastify/rate-limit": "^10.3.0",
@@ -14,6 +14,7 @@ import {
14
14
  authLoginOtpVerifyCommand,
15
15
  authLoginOAuthStartCommand,
16
16
  authLoginOAuthCompleteCommand,
17
+ authDevLoginAsCommand,
17
18
  authPasswordResetRequestCommand,
18
19
  authPasswordRecoveryCompleteCommand,
19
20
  authPasswordResetCommand
@@ -157,6 +158,25 @@ const baseAuthActions = Object.freeze([
157
158
  return deps.authService.oauthComplete(input);
158
159
  }
159
160
  },
161
+ {
162
+ id: "auth.dev.loginAs",
163
+ version: 1,
164
+ kind: "command",
165
+ channels: ["api", "internal"],
166
+ surfacesFrom: "enabled",
167
+ input: authDevLoginAsCommand.operation.body,
168
+ idempotency: "none",
169
+ audit: {
170
+ actionName: "auth.dev.loginAs"
171
+ },
172
+ observability: {},
173
+ async execute(input, context, deps) {
174
+ return deps.authService.devLoginAs(
175
+ requireRequestContext(context, "auth.dev.loginAs"),
176
+ input
177
+ );
178
+ }
179
+ },
160
180
  {
161
181
  id: "auth.password.reset.request",
162
182
  version: 1,
@@ -0,0 +1,15 @@
1
+ function parseBooleanFlag(value, fallback = false) {
2
+ const normalized = String(value || "").trim().toLowerCase();
3
+ if (!normalized) {
4
+ return fallback;
5
+ }
6
+ if (["1", "true", "yes", "on"].includes(normalized)) {
7
+ return true;
8
+ }
9
+ if (["0", "false", "no", "off"].includes(normalized)) {
10
+ return false;
11
+ }
12
+ return fallback;
13
+ }
14
+
15
+ export { parseBooleanFlag };
@@ -0,0 +1,127 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { parseBooleanFlag } from "./booleanFlag.js";
4
+
5
+ const DEV_AUTH_SECRET_HEADER = "x-jskit-dev-auth-secret";
6
+
7
+ function normalizeRequestHostname(request) {
8
+ const hostHeader = String(request?.headers?.host || "").trim();
9
+ const firstHost = hostHeader.split(",")[0]?.trim();
10
+ if (!firstHost) {
11
+ return "";
12
+ }
13
+ try {
14
+ return new URL(`http://${firstHost}`).hostname.trim().toLowerCase();
15
+ } catch {
16
+ return firstHost
17
+ .replace(/^\[/, "")
18
+ .replace(/\]$/, "")
19
+ .split(":")[0]
20
+ .trim()
21
+ .toLowerCase();
22
+ }
23
+ }
24
+
25
+ function normalizeLoopbackIp(value) {
26
+ return String(value || "")
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/^\[|\]$/gu, "")
30
+ .replace(/^::ffff:/u, "");
31
+ }
32
+
33
+ function isLoopbackIp(value) {
34
+ const normalized = normalizeLoopbackIp(value);
35
+ return normalized === "::1" || normalized === "127.0.0.1" || normalized.startsWith("127.");
36
+ }
37
+
38
+ function isLoopbackHostname(value) {
39
+ const normalized = String(value || "")
40
+ .trim()
41
+ .toLowerCase()
42
+ .replace(/^\[|\]$/gu, "");
43
+ return normalized === "localhost" ||
44
+ normalized.endsWith(".localhost") ||
45
+ normalized === "::1" ||
46
+ normalized === "127.0.0.1";
47
+ }
48
+
49
+ function isLocalDevAuthRequest(request) {
50
+ const remoteAddress = String(
51
+ request?.socket?.remoteAddress || request?.raw?.socket?.remoteAddress || ""
52
+ ).trim();
53
+ return isLoopbackIp(remoteAddress) && isLoopbackHostname(normalizeRequestHostname(request));
54
+ }
55
+
56
+ function resolveDevAuthPolicy({
57
+ enabled = false,
58
+ nodeEnv = "development",
59
+ secret = ""
60
+ } = {}) {
61
+ return Object.freeze({
62
+ enabled: parseBooleanFlag(enabled, false),
63
+ isProduction: String(nodeEnv || "development").trim().toLowerCase() === "production",
64
+ secret: String(secret || "").trim()
65
+ });
66
+ }
67
+
68
+ function resolveDevAuthPolicyFromEnv(env = {}) {
69
+ return resolveDevAuthPolicy({
70
+ enabled: env?.AUTH_DEV_BYPASS_ENABLED,
71
+ nodeEnv: env?.NODE_ENV,
72
+ secret: env?.AUTH_DEV_BYPASS_SECRET
73
+ });
74
+ }
75
+
76
+ function assertDevAuthPolicy(policy = {}) {
77
+ if (!policy.enabled) {
78
+ return;
79
+ }
80
+ if (policy.isProduction) {
81
+ throw new Error("AUTH_DEV_BYPASS_ENABLED must not be enabled in production.");
82
+ }
83
+ if (!policy.secret) {
84
+ throw new Error("AUTH_DEV_BYPASS_SECRET is required when AUTH_DEV_BYPASS_ENABLED=true.");
85
+ }
86
+ }
87
+
88
+ function ensureDevAuthRuntimeAvailable(policy = {}, request = null) {
89
+ if (!policy.enabled || policy.isProduction) {
90
+ throw new AppError(404, "Not found.");
91
+ }
92
+ if (!policy.secret) {
93
+ throw new AppError(500, "AUTH_DEV_BYPASS_SECRET is required when AUTH_DEV_BYPASS_ENABLED=true.");
94
+ }
95
+ if (!isLocalDevAuthRequest(request)) {
96
+ throw new AppError(403, "Dev auth bootstrap is only available from localhost.");
97
+ }
98
+ }
99
+
100
+ function requestHeader(request, name = "") {
101
+ const headers = request?.headers || request?.raw?.headers || {};
102
+ const value = headers[String(name || "").toLowerCase()] ?? headers[name];
103
+ return Array.isArray(value) ? value[0] : String(value || "");
104
+ }
105
+
106
+ function secretMatches(value = "", expected = "") {
107
+ const actualBuffer = Buffer.from(String(value || ""));
108
+ const expectedBuffer = Buffer.from(String(expected || ""));
109
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
110
+ }
111
+
112
+ function ensureDevAuthExchangeAvailable(policy = {}, request = null) {
113
+ ensureDevAuthRuntimeAvailable(policy, request);
114
+ if (!secretMatches(requestHeader(request, DEV_AUTH_SECRET_HEADER), policy.secret)) {
115
+ throw new AppError(403, "Dev auth exchange is not authorized.");
116
+ }
117
+ }
118
+
119
+ export {
120
+ assertDevAuthPolicy,
121
+ DEV_AUTH_SECRET_HEADER,
122
+ ensureDevAuthExchangeAvailable,
123
+ ensureDevAuthRuntimeAvailable,
124
+ isLocalDevAuthRequest,
125
+ resolveDevAuthPolicy,
126
+ resolveDevAuthPolicyFromEnv
127
+ };
@@ -3,24 +3,11 @@ import { registerRouteVisibilityResolver } from "@jskit-ai/kernel/server/http";
3
3
  import {
4
4
  resolveComposedAuthPolicyContextResolver
5
5
  } from "../authPolicyContextResolverRegistry.js";
6
+ import { parseBooleanFlag } from "../booleanFlag.js";
6
7
  import { authPolicyPlugin } from "../lib/plugin.js";
7
8
  import { createAuthActionContextContributor } from "../lib/actionContextContributor.js";
8
9
  import { createAuthRouteVisibilityResolver } from "../lib/routeVisibilityResolver.js";
9
10
 
10
- function parseBoolean(value, fallback = false) {
11
- const raw = String(value || "").trim().toLowerCase();
12
- if (!raw) {
13
- return fallback;
14
- }
15
- if (["1", "true", "yes", "on"].includes(raw)) {
16
- return true;
17
- }
18
- if (["0", "false", "no", "off"].includes(raw)) {
19
- return false;
20
- }
21
- return fallback;
22
- }
23
-
24
11
  function parseList(value) {
25
12
  return String(value || "")
26
13
  .split(",")
@@ -109,7 +96,7 @@ class FastifyAuthPolicyServiceProvider {
109
96
  apiPrefix: String(env.AUTH_API_PREFIX || "/api/").trim() || "/api/",
110
97
  unsafeMethods: parseList(env.AUTH_CSRF_UNSAFE_METHODS),
111
98
  csrfCookieOpts: {
112
- secure: parseBoolean(env.AUTH_CSRF_COOKIE_SECURE, false)
99
+ secure: parseBooleanFlag(env.AUTH_CSRF_COOKIE_SECURE, false)
113
100
  }
114
101
  }
115
102
  );
@@ -80,6 +80,40 @@ test("auth logout action delegates to selected auth provider and notifies sessio
80
80
  ]);
81
81
  });
82
82
 
83
+ test("shared dev login-as action passes the trusted request to the selected auth provider", async () => {
84
+ const action = buildAuthActions().find((definition) => definition.id === "auth.dev.loginAs");
85
+ const request = {
86
+ headers: {
87
+ "x-jskit-dev-auth-secret": "secret"
88
+ }
89
+ };
90
+ const input = {
91
+ email: "ada@example.com"
92
+ };
93
+ let received = null;
94
+
95
+ const result = await action.execute(input, {
96
+ requestMeta: {
97
+ request
98
+ }
99
+ }, {
100
+ authService: {
101
+ async devLoginAs(receivedRequest, receivedInput) {
102
+ received = {
103
+ input: receivedInput,
104
+ request: receivedRequest
105
+ };
106
+ return {
107
+ ok: true
108
+ };
109
+ }
110
+ }
111
+ });
112
+
113
+ assert.deepEqual(result, { ok: true });
114
+ assert.deepEqual(received, { input, request });
115
+ });
116
+
83
117
  test("AuthActionsServiceProvider registers shared auth actions against auth.provider", async () => {
84
118
  const app = createApplication();
85
119
  const logoutCalls = [];
@@ -122,7 +156,7 @@ test("AuthActionsServiceProvider registers shared auth actions against auth.prov
122
156
  const definitions = actionExecutor.listDefinitions();
123
157
  assert.equal(definitions.some((definition) => definition.id === "auth.login.password"), true);
124
158
  assert.equal(definitions.some((definition) => definition.id === "auth.register"), true);
125
- assert.equal(definitions.some((definition) => definition.id === "auth.dev.loginAs"), false);
159
+ assert.equal(definitions.some((definition) => definition.id === "auth.dev.loginAs"), true);
126
160
  assert.deepEqual(definitions.find((definition) => definition.id === "auth.session.read")?.surfaces, [
127
161
  "home",
128
162
  "console"
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { parseBooleanFlag } from "../src/server/booleanFlag.js";
5
+ import {
6
+ DEV_AUTH_SECRET_HEADER,
7
+ assertDevAuthPolicy,
8
+ ensureDevAuthExchangeAvailable,
9
+ ensureDevAuthRuntimeAvailable,
10
+ isLocalDevAuthRequest,
11
+ resolveDevAuthPolicy
12
+ } from "../src/server/devAuth.js";
13
+
14
+ const DEV_AUTH_SECRET = "preview-exchange-secret";
15
+
16
+ function createRequest({
17
+ headers = {},
18
+ host = "localhost:4100",
19
+ remoteAddress = "127.0.0.1"
20
+ } = {}) {
21
+ return {
22
+ headers: {
23
+ host,
24
+ ...headers
25
+ },
26
+ socket: {
27
+ remoteAddress
28
+ }
29
+ };
30
+ }
31
+
32
+ function enabledPolicy(overrides = {}) {
33
+ return resolveDevAuthPolicy({
34
+ enabled: true,
35
+ nodeEnv: "development",
36
+ secret: DEV_AUTH_SECRET,
37
+ ...overrides
38
+ });
39
+ }
40
+
41
+ test("parseBooleanFlag normalizes conventional environment flag values", () => {
42
+ assert.equal(parseBooleanFlag("YES"), true);
43
+ assert.equal(parseBooleanFlag("off", true), false);
44
+ assert.equal(parseBooleanFlag("unknown", true), true);
45
+ assert.equal(parseBooleanFlag("", false), false);
46
+ });
47
+
48
+ test("dev auth policy rejects production and missing-secret enablement", () => {
49
+ assert.throws(
50
+ () => assertDevAuthPolicy(enabledPolicy({ nodeEnv: "production" })),
51
+ /must not be enabled in production/
52
+ );
53
+ assert.throws(
54
+ () => assertDevAuthPolicy(enabledPolicy({ secret: "" })),
55
+ /AUTH_DEV_BYPASS_SECRET is required/
56
+ );
57
+ assert.doesNotThrow(() => assertDevAuthPolicy(resolveDevAuthPolicy()));
58
+ });
59
+
60
+ test("dev auth trusts only a direct loopback connection with a loopback Host", () => {
61
+ assert.equal(isLocalDevAuthRequest(createRequest()), true);
62
+ assert.equal(isLocalDevAuthRequest(createRequest({ host: "app.localhost:4100" })), true);
63
+ assert.equal(isLocalDevAuthRequest(createRequest({ host: "example.com" })), false);
64
+ assert.equal(isLocalDevAuthRequest(createRequest({ remoteAddress: "203.0.113.7" })), false);
65
+ assert.equal(isLocalDevAuthRequest(createRequest({
66
+ headers: {
67
+ "x-forwarded-for": "127.0.0.1",
68
+ "x-forwarded-host": "localhost"
69
+ },
70
+ host: "localhost:4100",
71
+ remoteAddress: "203.0.113.7"
72
+ })), false);
73
+ });
74
+
75
+ test("dev auth runtime accepts native session checks without the exchange header", () => {
76
+ assert.doesNotThrow(() => ensureDevAuthRuntimeAvailable(enabledPolicy(), createRequest()));
77
+ });
78
+
79
+ test("dev auth exchange requires the exact fixed-header secret", () => {
80
+ assert.doesNotThrow(() => ensureDevAuthExchangeAvailable(
81
+ enabledPolicy(),
82
+ createRequest({
83
+ headers: {
84
+ [DEV_AUTH_SECRET_HEADER]: DEV_AUTH_SECRET
85
+ }
86
+ })
87
+ ));
88
+ assert.throws(
89
+ () => ensureDevAuthExchangeAvailable(enabledPolicy(), createRequest()),
90
+ /not authorized/
91
+ );
92
+ assert.throws(
93
+ () => ensureDevAuthExchangeAvailable(
94
+ enabledPolicy(),
95
+ createRequest({
96
+ headers: {
97
+ [DEV_AUTH_SECRET_HEADER]: `${DEV_AUTH_SECRET}-wrong`
98
+ }
99
+ })
100
+ ),
101
+ /not authorized/
102
+ );
103
+ });