@jskit-ai/auth-core 0.1.242 → 0.1.243

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.243",
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.245"
139
139
  }
140
140
  }
@@ -138,8 +138,8 @@ const authActionSpecifications = Object.freeze([
138
138
  actionName: "auth.login.oauth.start"
139
139
  },
140
140
  observability: {},
141
- async run(authService, input) {
142
- return authService.oauthStart(input);
141
+ async run(authService, input, context) {
142
+ return authService.oauthStart(input, context?.requestMeta?.request || null);
143
143
  }
144
144
  },
145
145
  {
@@ -154,8 +154,8 @@ const authActionSpecifications = Object.freeze([
154
154
  actionName: "auth.login.oauth.complete"
155
155
  },
156
156
  observability: {},
157
- async run(authService, input) {
158
- return authService.oauthComplete(input);
157
+ async run(authService, input, context) {
158
+ return authService.oauthComplete(input, context?.requestMeta?.request || null);
159
159
  }
160
160
  },
161
161
  {
@@ -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
  });
@@ -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 },
@@ -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/);