@akinon/next 2.0.41-beta.0 → 2.0.41

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/CHANGELOG.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # @akinon/next
2
2
 
3
- ## 2.0.41-beta.0
4
-
5
- ### Patch Changes
6
-
7
- - cbbbfd75: ZERO-4376: Bootstrap beta cycle (next-main pre-release motor)
3
+ ## 2.0.41
8
4
 
9
5
  ## 2.0.40
10
6
 
package/api/auth.ts CHANGED
@@ -10,6 +10,7 @@ import getRootHostname, {
10
10
  getRequestRootHostname
11
11
  } from '../utils/get-root-hostname';
12
12
  import { getCsrfCookieFlags } from '../utils/csrf';
13
+ import { isEditorEmbedEnabled } from '../utils/editor-embed';
13
14
  import { LocaleUrlStrategy } from '../localization';
14
15
  import { cookies, headers } from 'next/headers';
15
16
 
@@ -88,8 +89,37 @@ function buildFieldErrors(response: any) {
88
89
  // v5 (App Router / next-auth v5) — named export for new brands
89
90
  // ============================================================
90
91
 
92
+ /**
93
+ * Cookie attributes that let the shopper sign in while the storefront runs
94
+ * inside the theme editor's preview iframe. Auth.js defaults to
95
+ * SameSite=Lax, and in a cross-site iframe (editor origin vs storefront
96
+ * origin — or localhost vs 127.0.0.1 in local dev) the browser then drops
97
+ * the CSRF cookie, so every credentials POST fails with MissingCSRF before
98
+ * it reaches the provider. SameSite=None requires Secure; browsers accept
99
+ * Secure cookies from plain-http localhost, so local development is covered
100
+ * too. Scoped to editor-enabled deployments (and dev) — a storefront without
101
+ * the editor keeps the stricter Lax default.
102
+ */
103
+ const getEmbedAwareCookies = () => {
104
+ if (!isEditorEmbedEnabled()) return undefined;
105
+
106
+ const options = {
107
+ sameSite: 'none' as const,
108
+ secure: true,
109
+ path: '/',
110
+ httpOnly: true
111
+ };
112
+
113
+ return {
114
+ sessionToken: { options },
115
+ callbackUrl: { options },
116
+ csrfToken: { options }
117
+ };
118
+ };
119
+
91
120
  const getDefaultAuthConfig = () => {
92
121
  return {
122
+ cookies: getEmbedAwareCookies(),
93
123
  providers: [
94
124
  Credentials({
95
125
  id: 'oauth',
@@ -266,6 +296,11 @@ const getDefaultAuthConfig = () => {
266
296
  httpOnly: true,
267
297
  secure: true,
268
298
  maxAge,
299
+ // Without SameSite the browser defaults to Lax and drops the
300
+ // commerce session on every request from the theme editor's
301
+ // cross-site preview iframe — the shopper signs in and the
302
+ // next authenticated call 401s straight back to the login.
303
+ ...(isEditorEmbedEnabled() ? { sameSite: 'none' as const } : {}),
269
304
  ...(rootHostname ? { domain: rootHostname } : {})
270
305
  };
271
306
 
package/api/client.ts CHANGED
@@ -10,6 +10,7 @@ import { LocaleUrlStrategy } from '../localization';
10
10
  import { fixtureManager, MockMode } from '../lib/fixture-manager';
11
11
  import { user } from '../data/urls';
12
12
  import { getCsrfCookieFlags, isCsrfHttpOnly } from '../utils/csrf';
13
+ import { isEditorEmbedEnabled } from '../utils/editor-embed';
13
14
 
14
15
  const CSRF_TOKEN_SLUG = user.csrfToken.replace(/^\//, '');
15
16
 
@@ -168,7 +169,16 @@ async function proxyRequest(...args) {
168
169
  // browser can no longer mirror it into the `x-csrftoken` header, so the
169
170
  // proxy validates the request origin and injects the header server-side
170
171
  // from the cookie that the browser sent with this request.
171
- if (isCsrfHttpOnly() && STATE_CHANGING_METHODS.includes(req.method)) {
172
+ //
173
+ // The origin check also runs whenever editor embedding is enabled: embed
174
+ // mode relaxes session cookies to SameSite=None, so the browser attaches
175
+ // them to cross-site requests and the Lax barrier this check replaces is
176
+ // gone. `isOriginAllowed` keeps accepting origin-less requests, so
177
+ // non-browser clients are unaffected.
178
+ if (
179
+ (isCsrfHttpOnly() || isEditorEmbedEnabled()) &&
180
+ STATE_CHANGING_METHODS.includes(req.method)
181
+ ) {
172
182
  if (!isOriginAllowed(req)) {
173
183
  logger.warn('Client Proxy Request - Blocked cross-origin request', {
174
184
  url: req.url,
@@ -180,9 +190,11 @@ async function proxyRequest(...args) {
180
190
  );
181
191
  }
182
192
 
183
- const csrfToken = nextCookies.get('csrftoken')?.value;
184
- if (csrfToken) {
185
- fetchOptions.headers['x-csrftoken'] = csrfToken;
193
+ if (isCsrfHttpOnly()) {
194
+ const csrfToken = nextCookies.get('csrftoken')?.value;
195
+ if (csrfToken) {
196
+ fetchOptions.headers['x-csrftoken'] = csrfToken;
197
+ }
186
198
  }
187
199
  }
188
200
 
@@ -335,6 +347,16 @@ async function proxyRequest(...args) {
335
347
  cookie.sameSite = flags.sameSite;
336
348
  }
337
349
  }
350
+ // Commerce sends its session cookies without SameSite; forwarded
351
+ // as-is the browser defaults them to Lax and drops them inside
352
+ // the theme editor's cross-site preview iframe, killing the
353
+ // session right after login. Editor-enabled deployments relax
354
+ // every forwarded cookie the same way the auth cookies already
355
+ // are.
356
+ if (isEditorEmbedEnabled()) {
357
+ cookie.sameSite = 'none';
358
+ cookie.secure = true;
359
+ }
338
360
  return formatCookieString(cookie);
339
361
  })
340
362
  .join(', ');
@@ -7,6 +7,7 @@ import {
7
7
  import Settings from 'settings';
8
8
  import { getUrlPathWithLocale } from '../utils/localization';
9
9
  import logger from '../utils/log';
10
+ import { makeSetCookieEmbedAware } from '../utils/editor-embed';
10
11
 
11
12
  const LOGIN_URL_REGEX = /^\/(\w+)\/login\/?$/;
12
13
  const CALLBACK_URL_REGEX = /^\/(\w+)\/login\/callback\/?$/;
@@ -58,7 +59,9 @@ function fetchCommerce(
58
59
 
59
60
  function forwardCookies(from: Response, to: NextResponse): void {
60
61
  from.headers.getSetCookie().forEach((cookie) => {
61
- to.headers.append('set-cookie', cookie);
62
+ // Relayed verbatim these would default to Lax and be rejected inside
63
+ // the editor's cross-site preview iframe.
64
+ to.headers.append('set-cookie', makeSetCookieEmbedAware(cookie));
62
65
  });
63
66
  }
64
67
 
@@ -5,6 +5,7 @@ import logger from '../utils/log';
5
5
  import { urlLocaleMatcherRegex } from '../utils';
6
6
  import { getUrlPathWithLocale } from '../utils/localization';
7
7
  import { shouldIgnoreRedirect } from '../utils/redirect-ignore';
8
+ import { makeSetCookieEmbedAware } from '../utils/editor-embed';
8
9
  import { ROUTES } from 'routes';
9
10
 
10
11
  // This middleware is used to handle url redirections set in Omnitron
@@ -76,7 +77,9 @@ const withUrlRedirection =
76
77
 
77
78
  if (setCookies && setCookies.length > 0) {
78
79
  for (const cookie of setCookies) {
79
- response.headers.append('Set-Cookie', cookie);
80
+ // Relayed verbatim these would default to Lax and be rejected
81
+ // inside the editor's cross-site preview iframe.
82
+ response.headers.append('Set-Cookie', makeSetCookieEmbedAware(cookie));
80
83
  }
81
84
  }
82
85
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@akinon/next",
3
3
  "description": "Core package for Project Zero Next",
4
- "version": "2.0.41-beta.0",
4
+ "version": "2.0.41",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "set-cookie-parser": "2.6.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@akinon/eslint-plugin-projectzero": "2.0.41-beta.0",
39
+ "@akinon/eslint-plugin-projectzero": "2.0.41",
40
40
  "@babel/core": "7.26.10",
41
41
  "@babel/preset-env": "7.26.9",
42
42
  "@babel/preset-typescript": "7.27.0",
package/utils/csrf.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import settings from 'settings';
2
2
 
3
+ import { isEditorEmbedEnabled } from './editor-embed';
4
+
3
5
  /**
4
6
  * Whether the Django `csrftoken` cookie should be hardened with the
5
7
  * `HttpOnly` flag.
@@ -16,7 +18,7 @@ export function isCsrfHttpOnly(): boolean {
16
18
  export interface CsrfCookieFlags {
17
19
  httpOnly: boolean;
18
20
  secure: boolean;
19
- sameSite: 'lax';
21
+ sameSite: 'lax' | 'none';
20
22
  }
21
23
 
22
24
  /**
@@ -26,9 +28,22 @@ export interface CsrfCookieFlags {
26
28
  * `SameSite=Lax` is the Next.js-layer CSRF defense: the browser will not
27
29
  * attach the cookie on cross-site state-changing requests, so the proxy's
28
30
  * server-side header injection cannot be abused from a third-party origin.
31
+ *
32
+ * Inside the theme editor's preview iframe the storefront itself is the
33
+ * cross-site party, and a Lax cookie is never sent — the shopper signs in
34
+ * and the next state-changing call fails. Editor-enabled deployments
35
+ * therefore switch to `None` + `Secure`. What replaces the Lax barrier
36
+ * there: the BFF's origin check runs for every state-changing request in
37
+ * embed mode (see api/client.ts), and the commerce backend's `x-csrftoken`
38
+ * double-submit stays on either way.
29
39
  */
30
40
  export function getCsrfCookieFlags(): CsrfCookieFlags {
31
41
  const httpOnly = isCsrfHttpOnly();
42
+
43
+ if (isEditorEmbedEnabled()) {
44
+ return { httpOnly, secure: true, sameSite: 'none' };
45
+ }
46
+
32
47
  return {
33
48
  httpOnly,
34
49
  secure: httpOnly && process.env.NODE_ENV === 'production',
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Whether this storefront can run inside the theme editor's preview iframe.
3
+ * Editor-enabled deployments declare the editor origins; local development
4
+ * is always embeddable.
5
+ *
6
+ * Cookie attributes hang off this: the preview iframe is cross-site from
7
+ * the editor's (or Omnitron's) top-level page, and a browser only sends
8
+ * `SameSite=None; Secure` cookies there — every session-bearing cookie has
9
+ * to switch when embedding is on, or the shopper signs in and the very next
10
+ * commerce call comes back 401.
11
+ */
12
+ const hasValidEditorOrigin = (raw: string | undefined): boolean => {
13
+ if (!raw) return false;
14
+
15
+ // Embed mode relaxes the whole storefront's cookie posture, so a stray
16
+ // character or leftover whitespace in the env var must not flip it — only
17
+ // entries that parse as real origins count.
18
+ return raw
19
+ .split(',')
20
+ .map((entry) => entry.trim())
21
+ .filter(Boolean)
22
+ .some((entry) => {
23
+ try {
24
+ return Boolean(new URL(entry).origin);
25
+ } catch {
26
+ return false;
27
+ }
28
+ });
29
+ };
30
+
31
+ export const isEditorEmbedEnabled = (): boolean =>
32
+ hasValidEditorOrigin(process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS) ||
33
+ process.env.NODE_ENV !== 'production';
34
+
35
+ /**
36
+ * Rewrites a raw Set-Cookie header so it survives the editor's cross-site
37
+ * preview iframe: `SameSite=None` requires `Secure`, and any prior SameSite
38
+ * value must be replaced rather than doubled. Outside embed mode the header
39
+ * passes through untouched. Use wherever commerce Set-Cookies are relayed
40
+ * verbatim (middlewares) — the BFF proxy applies the same rewrite itself.
41
+ */
42
+ export const makeSetCookieEmbedAware = (setCookie: string): string => {
43
+ if (!isEditorEmbedEnabled()) return setCookie;
44
+
45
+ const withoutAttrs = setCookie
46
+ .replace(/;\s*samesite=[^;]*/gi, '')
47
+ .replace(/;\s*secure(?=;|\s*$)/gi, '');
48
+
49
+ return `${withoutAttrs}; Secure; SameSite=None`;
50
+ };