@jskit-ai/auth-core 0.1.242 → 0.1.244
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.json +2 -2
- package/src/server/actions/auth.contributor.js +5 -57
- package/src/server/lib/plugin.js +16 -7
- package/src/server/providers/AuthPolicyProvider.js +6 -3
- package/src/server/utils.js +10 -0
- package/src/shared/commands/authCommandValidators.js +2 -1
- package/src/shared/commands/authDevLoginAsCommand.js +2 -2
- package/src/shared/commands/authLoginOAuthCompleteCommand.js +8 -0
- package/test/commandValidators.test.js +6 -6
- package/test/plugin.test.js +32 -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.244",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -135,6 +135,6 @@
|
|
|
135
135
|
}
|
|
136
136
|
},
|
|
137
137
|
"peerDependencies": {
|
|
138
|
-
"@jskit-ai/kernel": "0.1.
|
|
138
|
+
"@jskit-ai/kernel": "0.1.246"
|
|
139
139
|
}
|
|
140
140
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
2
|
import {
|
|
3
3
|
emptyInputValidator
|
|
4
|
-
} from "@jskit-ai/kernel/
|
|
4
|
+
} from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
|
|
5
5
|
import {
|
|
6
6
|
composeSchemaDefinitions
|
|
7
7
|
} from "@jskit-ai/kernel/shared/validators";
|
|
@@ -54,10 +54,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
54
54
|
surfacesFrom: "enabled",
|
|
55
55
|
input: authRegisterCommand.operation.body,
|
|
56
56
|
idempotency: "none",
|
|
57
|
-
audit: {
|
|
58
|
-
actionName: "auth.register"
|
|
59
|
-
},
|
|
60
|
-
observability: {},
|
|
61
57
|
async run(authService, input) {
|
|
62
58
|
return authService.register(input);
|
|
63
59
|
}
|
|
@@ -70,10 +66,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
70
66
|
surfacesFrom: "enabled",
|
|
71
67
|
input: authRegisterConfirmationResendCommand.operation.body,
|
|
72
68
|
idempotency: "none",
|
|
73
|
-
audit: {
|
|
74
|
-
actionName: "auth.register.confirmation.resend"
|
|
75
|
-
},
|
|
76
|
-
observability: {},
|
|
77
69
|
async run(authService, input) {
|
|
78
70
|
return authService.resendRegisterConfirmation(input);
|
|
79
71
|
}
|
|
@@ -86,10 +78,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
86
78
|
surfacesFrom: "enabled",
|
|
87
79
|
input: authLoginPasswordCommand.operation.body,
|
|
88
80
|
idempotency: "none",
|
|
89
|
-
audit: {
|
|
90
|
-
actionName: "auth.login.password"
|
|
91
|
-
},
|
|
92
|
-
observability: {},
|
|
93
81
|
async run(authService, input) {
|
|
94
82
|
return authService.login(input);
|
|
95
83
|
}
|
|
@@ -102,10 +90,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
102
90
|
surfacesFrom: "enabled",
|
|
103
91
|
input: authLoginOtpRequestCommand.operation.body,
|
|
104
92
|
idempotency: "none",
|
|
105
|
-
audit: {
|
|
106
|
-
actionName: "auth.login.otp.request"
|
|
107
|
-
},
|
|
108
|
-
observability: {},
|
|
109
93
|
async run(authService, input) {
|
|
110
94
|
return authService.requestOtpLogin(input);
|
|
111
95
|
}
|
|
@@ -118,10 +102,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
118
102
|
surfacesFrom: "enabled",
|
|
119
103
|
input: authLoginOtpVerifyCommand.operation.body,
|
|
120
104
|
idempotency: "none",
|
|
121
|
-
audit: {
|
|
122
|
-
actionName: "auth.login.otp.verify"
|
|
123
|
-
},
|
|
124
|
-
observability: {},
|
|
125
105
|
async run(authService, input) {
|
|
126
106
|
return authService.verifyOtpLogin(input);
|
|
127
107
|
}
|
|
@@ -134,12 +114,8 @@ const authActionSpecifications = Object.freeze([
|
|
|
134
114
|
surfacesFrom: "enabled",
|
|
135
115
|
input: authLoginOAuthStartInput,
|
|
136
116
|
idempotency: "none",
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
},
|
|
140
|
-
observability: {},
|
|
141
|
-
async run(authService, input) {
|
|
142
|
-
return authService.oauthStart(input);
|
|
117
|
+
async run(authService, input, context) {
|
|
118
|
+
return authService.oauthStart(input, context?.requestMeta?.request || null);
|
|
143
119
|
}
|
|
144
120
|
},
|
|
145
121
|
{
|
|
@@ -150,12 +126,8 @@ const authActionSpecifications = Object.freeze([
|
|
|
150
126
|
surfacesFrom: "enabled",
|
|
151
127
|
input: authLoginOAuthCompleteCommand.operation.body,
|
|
152
128
|
idempotency: "none",
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
},
|
|
156
|
-
observability: {},
|
|
157
|
-
async run(authService, input) {
|
|
158
|
-
return authService.oauthComplete(input);
|
|
129
|
+
async run(authService, input, context) {
|
|
130
|
+
return authService.oauthComplete(input, context?.requestMeta?.request || null);
|
|
159
131
|
}
|
|
160
132
|
},
|
|
161
133
|
{
|
|
@@ -166,10 +138,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
166
138
|
surfacesFrom: "enabled",
|
|
167
139
|
input: authDevLoginAsCommand.operation.body,
|
|
168
140
|
idempotency: "none",
|
|
169
|
-
audit: {
|
|
170
|
-
actionName: "auth.dev.loginAs"
|
|
171
|
-
},
|
|
172
|
-
observability: {},
|
|
173
141
|
async run(authService, input, context) {
|
|
174
142
|
return authService.devLoginAs(
|
|
175
143
|
requireRequestContext(context, "auth.dev.loginAs"),
|
|
@@ -185,10 +153,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
185
153
|
surfacesFrom: "enabled",
|
|
186
154
|
input: authPasswordResetRequestCommand.operation.body,
|
|
187
155
|
idempotency: "none",
|
|
188
|
-
audit: {
|
|
189
|
-
actionName: "auth.password.reset.request"
|
|
190
|
-
},
|
|
191
|
-
observability: {},
|
|
192
156
|
async run(authService, input) {
|
|
193
157
|
return authService.requestPasswordReset(input);
|
|
194
158
|
}
|
|
@@ -201,10 +165,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
201
165
|
surfacesFrom: "enabled",
|
|
202
166
|
input: authPasswordRecoveryCompleteCommand.operation.body,
|
|
203
167
|
idempotency: "none",
|
|
204
|
-
audit: {
|
|
205
|
-
actionName: "auth.password.recovery.complete"
|
|
206
|
-
},
|
|
207
|
-
observability: {},
|
|
208
168
|
async run(authService, input) {
|
|
209
169
|
return authService.completePasswordRecovery(input);
|
|
210
170
|
}
|
|
@@ -217,10 +177,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
217
177
|
surfacesFrom: "enabled",
|
|
218
178
|
input: authPasswordResetCommand.operation.body,
|
|
219
179
|
idempotency: "none",
|
|
220
|
-
audit: {
|
|
221
|
-
actionName: "auth.password.reset"
|
|
222
|
-
},
|
|
223
|
-
observability: {},
|
|
224
180
|
async run(authService, input, context) {
|
|
225
181
|
return authService.resetPassword(requireRequestContext(context, "auth.password.reset"), input);
|
|
226
182
|
}
|
|
@@ -234,10 +190,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
234
190
|
input: emptyInputValidator,
|
|
235
191
|
output: authLogoutOutput,
|
|
236
192
|
idempotency: "none",
|
|
237
|
-
audit: {
|
|
238
|
-
actionName: "auth.logout"
|
|
239
|
-
},
|
|
240
|
-
observability: {},
|
|
241
193
|
async run(authService, _input, context) {
|
|
242
194
|
let logoutResult = {
|
|
243
195
|
ok: true,
|
|
@@ -260,10 +212,6 @@ const authActionSpecifications = Object.freeze([
|
|
|
260
212
|
surfacesFrom: "enabled",
|
|
261
213
|
input: emptyInputValidator,
|
|
262
214
|
idempotency: "none",
|
|
263
|
-
audit: {
|
|
264
|
-
actionName: "auth.session.read"
|
|
265
|
-
},
|
|
266
|
-
observability: {},
|
|
267
215
|
async run(authService, _input, context) {
|
|
268
216
|
return authService.authenticateRequest(requireRequestContext(context, "auth.session.read"));
|
|
269
217
|
}
|
package/src/server/lib/plugin.js
CHANGED
|
@@ -2,6 +2,7 @@ import fastifyCookie from "@fastify/cookie";
|
|
|
2
2
|
import fastifyCsrfProtection from "@fastify/csrf-protection";
|
|
3
3
|
import fastifyRateLimit from "@fastify/rate-limit";
|
|
4
4
|
import { safeRequestUrl } from "@jskit-ai/kernel/server/runtime/requestUrl";
|
|
5
|
+
import { hostOnlyCookieName } from "../utils.js";
|
|
5
6
|
|
|
6
7
|
import { assertAuthPolicyDeps, normalizeActorResolution } from "./authPolicySupport.js";
|
|
7
8
|
import { AUTH_POLICY_DENY_REASONS, createAuthPolicyError } from "./errors.js";
|
|
@@ -101,18 +102,26 @@ function authPolicyPlugin(deps = {}, options = {}) {
|
|
|
101
102
|
? options.createError
|
|
102
103
|
: (status, message) => createAuthPolicyError(status, message);
|
|
103
104
|
|
|
105
|
+
// CSRF uses tokens checked against a secret cookie, never SameSite alone. In
|
|
106
|
+
// production that cookie is host-only (__Host-), so a sibling host cannot plant
|
|
107
|
+
// a secret it knows and forge tokens.
|
|
108
|
+
const csrfCookieSecure = typeof csrfCookieOpts.secure === "boolean" ? csrfCookieOpts.secure : nodeEnv === "production";
|
|
109
|
+
const csrfCookieOptions = {
|
|
110
|
+
path: "/",
|
|
111
|
+
sameSite: "lax",
|
|
112
|
+
httpOnly: true,
|
|
113
|
+
...csrfCookieOpts,
|
|
114
|
+
secure: csrfCookieSecure
|
|
115
|
+
};
|
|
116
|
+
const csrfCookieHostOnly = csrfCookieSecure && csrfCookieOptions.path === "/" && !csrfCookieOptions.domain;
|
|
117
|
+
|
|
104
118
|
return async function registerAuthPolicyPlugin(fastify) {
|
|
105
119
|
await fastify.register(fastifyCookie);
|
|
106
120
|
await fastify.register(fastifyRateLimit, rateLimitPluginOptions);
|
|
107
121
|
await fastify.register(fastifyCsrfProtection, {
|
|
108
122
|
getToken: resolveCsrfTokenFn,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
sameSite: "lax",
|
|
112
|
-
secure: nodeEnv === "production",
|
|
113
|
-
httpOnly: true,
|
|
114
|
-
...csrfCookieOpts
|
|
115
|
-
}
|
|
123
|
+
cookieKey: hostOnlyCookieName("_csrf", csrfCookieHostOnly),
|
|
124
|
+
cookieOpts: csrfCookieOptions
|
|
116
125
|
});
|
|
117
126
|
|
|
118
127
|
fastify.decorateRequest("user", null);
|
|
@@ -28,9 +28,12 @@ function createPolicy({ authService, extensions, env }) {
|
|
|
28
28
|
nodeEnv: String(env.NODE_ENV || "development").trim() || "development",
|
|
29
29
|
apiPrefix: String(env.AUTH_API_PREFIX || "/api/").trim() || "/api/",
|
|
30
30
|
unsafeMethods: Object.freeze(parseList(env.AUTH_CSRF_UNSAFE_METHODS)),
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// Unset AUTH_CSRF_COOKIE_SECURE follows NODE_ENV (Secure in production).
|
|
32
|
+
csrfCookieOpts: Object.freeze(
|
|
33
|
+
String(env.AUTH_CSRF_COOKIE_SECURE ?? "").trim()
|
|
34
|
+
? { secure: parseBooleanFlag(env.AUTH_CSRF_COOKIE_SECURE, false) }
|
|
35
|
+
: {}
|
|
36
|
+
)
|
|
34
37
|
})
|
|
35
38
|
});
|
|
36
39
|
}
|
package/src/server/utils.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { normalizeReturnToPath as normalizeSharedReturnToPath } from "@jskit-ai/kernel/shared/support";
|
|
2
2
|
|
|
3
|
+
const HOST_ONLY_COOKIE_PREFIX = "__Host-";
|
|
4
|
+
|
|
5
|
+
// Secure (HTTPS) cookies carry the __Host- prefix: the browser then requires
|
|
6
|
+
// Secure, Path=/ and no Domain, so a sibling host under the same parent domain
|
|
7
|
+
// can neither set nor overwrite them (no cookie tossing or session fixation).
|
|
8
|
+
export function hostOnlyCookieName(name, secure) {
|
|
9
|
+
const cookieName = String(name || "").trim();
|
|
10
|
+
return secure && !cookieName.startsWith(HOST_ONLY_COOKIE_PREFIX) ? `${HOST_ONLY_COOKIE_PREFIX}${cookieName}` : cookieName;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export function normalizeEmail(value) {
|
|
4
14
|
return String(value || "")
|
|
5
15
|
.trim()
|
|
@@ -183,7 +183,8 @@ const oauthCompleteOutputValidator = deepFreeze({
|
|
|
183
183
|
ok: { type: "boolean", required: true },
|
|
184
184
|
provider: { ...oauthProviderFieldDefinition, required: false },
|
|
185
185
|
username: { type: "string", required: true, minLength: 1, maxLength: 120 },
|
|
186
|
-
email: { ...authEmailFieldDefinition, required: true }
|
|
186
|
+
email: { ...authEmailFieldDefinition, required: true },
|
|
187
|
+
returnTo: { ...oauthReturnToFieldDefinition, required: false }
|
|
187
188
|
}),
|
|
188
189
|
mode: "replace"
|
|
189
190
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
|
-
import {
|
|
2
|
+
import { recordIdSchema } from "@jskit-ai/kernel/shared/validators";
|
|
3
3
|
import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
|
|
4
4
|
import {
|
|
5
5
|
authEmailValidator,
|
|
@@ -24,7 +24,7 @@ const AUTH_DEV_LOGIN_AS_MESSAGES = createCommandMessages({
|
|
|
24
24
|
const authDevLoginAsBodyValidator = deepFreeze({
|
|
25
25
|
schema: createSchema({
|
|
26
26
|
userId: {
|
|
27
|
-
...
|
|
27
|
+
...recordIdSchema,
|
|
28
28
|
required: false
|
|
29
29
|
},
|
|
30
30
|
email: {
|
|
@@ -23,6 +23,12 @@ const AUTH_LOGIN_OAUTH_COMPLETE_MESSAGES = createCommandMessages({
|
|
|
23
23
|
},
|
|
24
24
|
refreshToken: {
|
|
25
25
|
default: "Refresh token is invalid."
|
|
26
|
+
},
|
|
27
|
+
state: {
|
|
28
|
+
default: "OAuth state is invalid."
|
|
29
|
+
},
|
|
30
|
+
iss: {
|
|
31
|
+
default: "OAuth issuer is invalid."
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
});
|
|
@@ -33,6 +39,8 @@ const authLoginOAuthCompleteBodyValidator = deepFreeze({
|
|
|
33
39
|
code: { ...authRecoveryTokenFieldDefinition, required: false },
|
|
34
40
|
accessToken: { ...authAccessTokenFieldDefinition, required: false },
|
|
35
41
|
refreshToken: { ...authRefreshTokenFieldDefinition, required: false },
|
|
42
|
+
state: { type: "string", required: false, minLength: 1, maxLength: 512 },
|
|
43
|
+
iss: { type: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
36
44
|
error: { type: "string", required: false, minLength: 1, maxLength: 128 },
|
|
37
45
|
errorDescription: { type: "string", required: false, minLength: 1, maxLength: 1024 },
|
|
38
46
|
error_code: { type: "string", required: false, minLength: 1, maxLength: 128 },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
|
|
4
4
|
import { authRegisterCommand } from "../src/shared/commands/authRegisterCommand.js";
|
|
5
5
|
import { authRegisterConfirmationResendCommand } from "../src/shared/commands/authRegisterConfirmationResendCommand.js";
|
|
6
6
|
import { authLoginPasswordCommand } from "../src/shared/commands/authLoginPasswordCommand.js";
|
|
@@ -36,7 +36,7 @@ test("auth commands expose canonical operation validator messages", () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("oauth complete command allows provider-less session-pair callbacks", () => {
|
|
39
|
-
const bodySchema =
|
|
39
|
+
const bodySchema = resolveSchemaTransportSchemaDefinition(
|
|
40
40
|
authLoginOAuthCompleteCommand.operation.body,
|
|
41
41
|
{
|
|
42
42
|
context: "auth login oauth complete body",
|
|
@@ -46,7 +46,7 @@ test("oauth complete command allows provider-less session-pair callbacks", () =>
|
|
|
46
46
|
const bodyRequired = Array.isArray(bodySchema?.required) ? bodySchema.required : [];
|
|
47
47
|
assert.equal(bodyRequired.includes("provider"), false);
|
|
48
48
|
|
|
49
|
-
const responseSchema =
|
|
49
|
+
const responseSchema = resolveSchemaTransportSchemaDefinition(
|
|
50
50
|
authLoginOAuthCompleteCommand.operation.response,
|
|
51
51
|
{
|
|
52
52
|
context: "auth login oauth complete response",
|
|
@@ -78,21 +78,21 @@ test("auth command body validators lowercase email inputs", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test("auth session and oauth start commands expose explicit nested response schemas", () => {
|
|
81
|
-
const sessionResponseSchema =
|
|
81
|
+
const sessionResponseSchema = resolveSchemaTransportSchemaDefinition(
|
|
82
82
|
authSessionReadCommand.operation.response,
|
|
83
83
|
{
|
|
84
84
|
context: "auth session read response",
|
|
85
85
|
defaultMode: "replace"
|
|
86
86
|
}
|
|
87
87
|
);
|
|
88
|
-
const sessionUnavailableSchema =
|
|
88
|
+
const sessionUnavailableSchema = resolveSchemaTransportSchemaDefinition(
|
|
89
89
|
authSessionReadCommand.operation.unavailableResponse,
|
|
90
90
|
{
|
|
91
91
|
context: "auth session read unavailable response",
|
|
92
92
|
defaultMode: "replace"
|
|
93
93
|
}
|
|
94
94
|
);
|
|
95
|
-
const oauthStartResponseSchema =
|
|
95
|
+
const oauthStartResponseSchema = resolveSchemaTransportSchemaDefinition(
|
|
96
96
|
authLoginOAuthStartCommand.operation.response,
|
|
97
97
|
{
|
|
98
98
|
context: "auth login oauth start response",
|
package/test/plugin.test.js
CHANGED
|
@@ -48,6 +48,38 @@ test("auth policy protects real Fastify requests with CSRF and route rate limits
|
|
|
48
48
|
assert.equal((await app.inject({ method: "GET", url: "/api/limited" })).statusCode, 429);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
test("production CSRF secrets live in a host-only __Host- cookie a sibling host cannot plant", async (t) => {
|
|
52
|
+
const app = Fastify();
|
|
53
|
+
t.after(() => app.close());
|
|
54
|
+
await authPolicyPlugin({
|
|
55
|
+
async resolveActor() {
|
|
56
|
+
return { authenticated: false, actor: null, transientFailure: false };
|
|
57
|
+
},
|
|
58
|
+
hasPermission() {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}, { nodeEnv: "production" })(app);
|
|
62
|
+
app.get("/api/csrf", { config: { authPolicy: "public" } }, (_request, reply) => ({ token: reply.generateCsrf() }));
|
|
63
|
+
app.post("/api/write", { config: { authPolicy: "public" } }, () => ({ accepted: true }));
|
|
64
|
+
|
|
65
|
+
const csrf = await app.inject({ method: "GET", url: "/api/csrf" });
|
|
66
|
+
const [secretCookie] = csrf.cookies;
|
|
67
|
+
assert.equal(secretCookie.name, "__Host-_csrf");
|
|
68
|
+
assert.equal(secretCookie.path, "/");
|
|
69
|
+
assert.equal(secretCookie.secure, true);
|
|
70
|
+
assert.equal(secretCookie.domain, undefined);
|
|
71
|
+
const token = csrf.json().token;
|
|
72
|
+
const accepted = await app.inject({
|
|
73
|
+
method: "POST", url: "/api/write", cookies: { "__Host-_csrf": secretCookie.value }, headers: { "x-csrf-token": token }
|
|
74
|
+
});
|
|
75
|
+
assert.equal(accepted.statusCode, 200);
|
|
76
|
+
// A secret tossed under the old, unprefixed name is never read.
|
|
77
|
+
const tossed = await app.inject({
|
|
78
|
+
method: "POST", url: "/api/write", cookies: { _csrf: secretCookie.value }, headers: { "x-csrf-token": token }
|
|
79
|
+
});
|
|
80
|
+
assert.equal(tossed.statusCode, 403);
|
|
81
|
+
});
|
|
82
|
+
|
|
51
83
|
test("requires resolveActor and hasPermission dependencies", () => {
|
|
52
84
|
assert.throws(() => authPolicyPlugin(), /resolveActor is required/);
|
|
53
85
|
assert.throws(() => authPolicyPlugin({ resolveActor() {} }), /hasPermission is required/);
|