@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-core",
3
- "version": "0.1.242",
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.244"
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/shared/actions/actionContributorHelpers";
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
- audit: {
138
- actionName: "auth.login.oauth.start"
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
- audit: {
154
- actionName: "auth.login.oauth.complete"
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
  }
@@ -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
- cookieOpts: {
110
- path: "/",
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
- csrfCookieOpts: Object.freeze({
32
- secure: parseBooleanFlag(env.AUTH_CSRF_COOKIE_SECURE, false)
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
  }
@@ -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 { recordIdInputSchema } from "@jskit-ai/kernel/shared/validators";
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
- ...recordIdInputSchema,
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 { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
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 = resolveStructuredSchemaTransportSchema(
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 = resolveStructuredSchemaTransportSchema(
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 = resolveStructuredSchemaTransportSchema(
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 = resolveStructuredSchemaTransportSchema(
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 = resolveStructuredSchemaTransportSchema(
95
+ const oauthStartResponseSchema = resolveSchemaTransportSchemaDefinition(
96
96
  authLoginOAuthStartCommand.operation.response,
97
97
  {
98
98
  context: "auth login oauth start response",
@@ -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/);