@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.
- package/package.descriptor.mjs +2 -2
- package/package.json +4 -2
- package/src/server/actions/auth.contributor.js +20 -0
- package/src/server/booleanFlag.js +15 -0
- package/src/server/devAuth.js +127 -0
- package/src/server/providers/FastifyAuthPolicyServiceProvider.js +2 -15
- package/test/authActions.test.js +35 -1
- package/test/devAuth.test.js +103 -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-core",
|
|
4
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
99
|
+
secure: parseBooleanFlag(env.AUTH_CSRF_COOKIE_SECURE, false)
|
|
113
100
|
}
|
|
114
101
|
}
|
|
115
102
|
);
|
package/test/authActions.test.js
CHANGED
|
@@ -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"),
|
|
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
|
+
});
|