@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 +1 -5
- package/api/auth.ts +35 -0
- package/api/client.ts +26 -4
- package/middlewares/oauth-login.ts +4 -1
- package/middlewares/url-redirection.ts +4 -1
- package/package.json +2 -2
- package/utils/csrf.ts +16 -1
- package/utils/editor-embed.ts +50 -0
package/CHANGELOG.md
CHANGED
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|