@faststore/core 3.99.4 → 3.100.0
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/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +49 -42
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/config.json +3 -3
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/prerender-manifest.js +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/routes-manifest.json +1 -1
- package/.next/server/chunks/3779.js +1 -0
- package/.next/server/chunks/3890.js +1 -1
- package/.next/server/chunks/7098.js +2 -2
- package/.next/server/chunks/83.js +1 -1
- package/.next/server/chunks/948.js +3 -3
- package/.next/server/chunks/9563.js +1 -1
- package/.next/server/chunks/{9984.js → UINavbarSlider.js} +1 -1
- package/.next/server/edge-runtime-webpack.js +24 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-manifest.json +27 -2
- package/.next/server/pages/404.js +1 -1
- package/.next/server/pages/404.js.nft.json +1 -1
- package/.next/server/pages/500.js +1 -1
- package/.next/server/pages/500.js.nft.json +1 -1
- package/.next/server/pages/[...slug].js +1 -1
- package/.next/server/pages/[...slug].js.nft.json +1 -1
- package/.next/server/pages/[slug]/p.js +1 -1
- package/.next/server/pages/[slug]/p.js.nft.json +1 -1
- package/.next/server/pages/_app.js.nft.json +1 -1
- package/.next/server/pages/_document.js.nft.json +1 -1
- package/.next/server/pages/_error.js.nft.json +1 -1
- package/.next/server/pages/api/fs/auth/login.js +1 -0
- package/.next/server/pages/api/fs/auth/login.js.nft.json +1 -0
- package/.next/server/pages/api/fs/logout.js.nft.json +1 -1
- package/.next/server/pages/api/graphql.js +1 -1
- package/.next/server/pages/api/graphql.js.nft.json +1 -1
- package/.next/server/pages/api/health/live.js.nft.json +1 -1
- package/.next/server/pages/api/health/ready.js.nft.json +1 -1
- package/.next/server/pages/api/preview.js.nft.json +1 -1
- package/.next/server/pages/checkout.js +1 -1
- package/.next/server/pages/checkout.js.nft.json +1 -1
- package/.next/server/pages/en-US/404.html +2 -2
- package/.next/server/pages/en-US/500.html +2 -2
- package/.next/server/pages/en-US/checkout.html +2 -2
- package/.next/server/pages/en-US/fs-auth-login.html +42 -0
- package/.next/server/pages/en-US/login.html +2 -2
- package/.next/server/pages/en-US/s.html +2 -2
- package/.next/server/pages/en-US.html +2 -2
- package/.next/server/pages/fs-auth-login.js.nft.json +1 -0
- package/.next/server/pages/index.js +1 -1
- package/.next/server/pages/index.js.nft.json +1 -1
- package/.next/server/pages/login.js +1 -1
- package/.next/server/pages/login.js.nft.json +1 -1
- package/.next/server/pages/pvt/account/403.js +1 -1
- package/.next/server/pages/pvt/account/403.js.nft.json +1 -1
- package/.next/server/pages/pvt/account/404.js +1 -1
- package/.next/server/pages/pvt/account/404.js.nft.json +1 -1
- package/.next/server/pages/pvt/account/[...unknown].js.nft.json +1 -1
- package/.next/server/pages/pvt/account/orders/[id].js +1 -1
- package/.next/server/pages/pvt/account/orders/[id].js.nft.json +1 -1
- package/.next/server/pages/pvt/account/orders.js +1 -1
- package/.next/server/pages/pvt/account/orders.js.nft.json +1 -1
- package/.next/server/pages/pvt/account/profile.js +1 -1
- package/.next/server/pages/pvt/account/profile.js.nft.json +1 -1
- package/.next/server/pages/pvt/account/security.js +1 -1
- package/.next/server/pages/pvt/account/security.js.nft.json +1 -1
- package/.next/server/pages/pvt/account/user-details.js +1 -1
- package/.next/server/pages/pvt/account/user-details.js.nft.json +1 -1
- package/.next/server/pages/pvt/account.js.nft.json +1 -1
- package/.next/server/pages/s.js +1 -1
- package/.next/server/pages/s.js.nft.json +1 -1
- package/.next/server/pages-manifest.json +1 -1
- package/.next/server/src/middleware.js +5417 -0
- package/.next/server/src/middleware.js.map +1 -0
- package/.next/server/webpack-runtime.js +1 -1
- package/.next/static/9kMY_8ze-x7sawSXOfQGe/_buildManifest.js +1 -0
- package/.next/static/chunks/main-083a618181bb5fbb.js +1 -0
- package/.next/static/chunks/pages/{_app-84aaff8a9d4de60e.js → _app-35e8dda638ee2672.js} +1 -1
- package/.next/static/chunks/pages/fs-auth-login-ba2d1b3ef0ef0415.js +1 -0
- package/.next/static/chunks/pages/pvt/account/403-ccddb4364ef27179.js +1 -0
- package/.next/static/chunks/webpack-ff85816fb58111fa.js +1 -0
- package/.next/static/css/3bd4edd425711792.css +1 -0
- package/.next/trace +156 -143
- package/.turbo/turbo-build.log +46 -21
- package/.turbo/turbo-test.log +11 -8
- package/CHANGELOG.md +12 -0
- package/package.json +3 -2
- package/src/middleware.ts +87 -0
- package/src/pages/api/fs/auth/login.ts +123 -0
- package/src/pages/api/graphql.ts +2 -2
- package/src/pages/fs-auth-login/fs-auth-login.module.scss +41 -0
- package/src/pages/fs-auth-login/index.tsx +90 -0
- package/src/pages/pvt/account/403.tsx +9 -0
- package/src/sdk/account/useRefreshToken.ts +12 -0
- package/src/sdk/session/index.ts +3 -7
- package/src/server/authentication-service.ts +270 -0
- package/src/server/password-protection/webops-api.ts +38 -0
- package/src/utils/isLocalHost.ts +33 -0
- package/src/utils/loginResponse.ts +20 -0
- package/test/server/authentication-service.test.ts +460 -0
- package/test/utils/isLocalHost.browser.test.ts +40 -0
- package/test/utils/isLocalHost.test.ts +38 -0
- package/.next/static/Xk7WgpVOdfEoXARPhQHxf/_buildManifest.js +0 -1
- package/.next/static/chunks/main-ec03882c4375091d.js +0 -1
- package/.next/static/chunks/pages/pvt/account/403-491e6b062487b30f.js +0 -1
- package/.next/static/chunks/webpack-61b283a96fbb5ee2.js +0 -1
- package/src/middleware__DISABLED.ts +0 -65
- /package/.next/static/{Xk7WgpVOdfEoXARPhQHxf → 9kMY_8ze-x7sawSXOfQGe}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { decodeJwt, errors, importSPKI, jwtVerify } from 'jose'
|
|
2
|
+
import { NextRequest } from 'next/server'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AuthenticationService,
|
|
6
|
+
resetPasswordProtectionPublicKeyCacheForTests,
|
|
7
|
+
} from '../../src/server/authentication-service'
|
|
8
|
+
|
|
9
|
+
function setNodeEnv(value: string | undefined): void {
|
|
10
|
+
const env = process.env as Record<string, string | undefined>
|
|
11
|
+
env.NODE_ENV = value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
jest.mock('../../discovery.config', () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
default: {
|
|
17
|
+
api: { storeId: 'test-store' },
|
|
18
|
+
},
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.mock('jose', () => {
|
|
22
|
+
class MockJWTExpired extends Error {
|
|
23
|
+
payload: Record<string, unknown>
|
|
24
|
+
claim: string
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
message: string,
|
|
28
|
+
payload: Record<string, unknown>,
|
|
29
|
+
claim = 'exp'
|
|
30
|
+
) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = 'JWTExpired'
|
|
33
|
+
this.payload = payload
|
|
34
|
+
this.claim = claim
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
jwtVerify: jest.fn(),
|
|
40
|
+
importSPKI: jest.fn(),
|
|
41
|
+
decodeJwt: jest.fn(),
|
|
42
|
+
errors: {
|
|
43
|
+
JWTExpired: MockJWTExpired,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const jwtVerifyMock = jwtVerify as jest.MockedFunction<typeof jwtVerify>
|
|
49
|
+
const importSPKIMock = importSPKI as jest.MockedFunction<typeof importSPKI>
|
|
50
|
+
const decodeJwtMock = decodeJwt as jest.MockedFunction<typeof decodeJwt>
|
|
51
|
+
|
|
52
|
+
function previewRequest(
|
|
53
|
+
path: string,
|
|
54
|
+
init?: ConstructorParameters<typeof NextRequest>[1]
|
|
55
|
+
): NextRequest {
|
|
56
|
+
return new NextRequest(`https://preview.vtex.app${path}`, {
|
|
57
|
+
...init,
|
|
58
|
+
headers: {
|
|
59
|
+
host: 'preview.vtex.app',
|
|
60
|
+
...init?.headers,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('AuthenticationService', () => {
|
|
66
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
67
|
+
const originalCustomDomains = process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
setNodeEnv('production')
|
|
71
|
+
delete process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED
|
|
72
|
+
resetPasswordProtectionPublicKeyCacheForTests()
|
|
73
|
+
jest.clearAllMocks()
|
|
74
|
+
importSPKIMock.mockResolvedValue({} as CryptoKey)
|
|
75
|
+
global.fetch = jest.fn()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
setNodeEnv(originalNodeEnv)
|
|
80
|
+
process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED = originalCustomDomains
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('skips protection in development', async () => {
|
|
84
|
+
setNodeEnv('development')
|
|
85
|
+
|
|
86
|
+
const service = new AuthenticationService()
|
|
87
|
+
const { response } = await service.authenticateRequest(previewRequest('/p'))
|
|
88
|
+
|
|
89
|
+
expect(response.status).toBe(200)
|
|
90
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('allows traffic when WebOps reports not protected and sets negative-cache cookie', async () => {
|
|
94
|
+
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
95
|
+
ok: true,
|
|
96
|
+
json: async () => ({
|
|
97
|
+
protected: false,
|
|
98
|
+
token: 'neg-cache-token',
|
|
99
|
+
}),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const service = new AuthenticationService()
|
|
103
|
+
const { response } = await service.authenticateRequest(
|
|
104
|
+
previewRequest('/products')
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(200)
|
|
108
|
+
expect(response.cookies.get('__fs_auth_token')?.value).toBe(
|
|
109
|
+
'neg-cache-token'
|
|
110
|
+
)
|
|
111
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
href: expect.stringMatching(
|
|
114
|
+
/\/api\/v1\/password-protection\/status.*storeId=test-store/
|
|
115
|
+
),
|
|
116
|
+
}),
|
|
117
|
+
expect.any(Object)
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('redirects to login when protected and host matches DEFAULT_DOMAINS scope', async () => {
|
|
122
|
+
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
123
|
+
ok: true,
|
|
124
|
+
json: async () => ({
|
|
125
|
+
protected: true,
|
|
126
|
+
passwordVersion: 'v1',
|
|
127
|
+
scope: 'DEFAULT_DOMAINS',
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const service = new AuthenticationService()
|
|
132
|
+
const { response } = await service.authenticateRequest(
|
|
133
|
+
previewRequest('/secret')
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(response.status).toBe(307)
|
|
137
|
+
const location = response.headers.get('location') ?? ''
|
|
138
|
+
expect(location).toContain('/fs-auth-login')
|
|
139
|
+
expect(location).toContain('returnTo=%2Fsecret')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('allows traffic when protected but scope does not apply to this host', async () => {
|
|
143
|
+
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
144
|
+
ok: true,
|
|
145
|
+
json: async () => ({
|
|
146
|
+
protected: true,
|
|
147
|
+
scope: 'CUSTOM_DOMAINS',
|
|
148
|
+
}),
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const service = new AuthenticationService()
|
|
152
|
+
const { response } = await service.authenticateRequest(previewRequest('/p'))
|
|
153
|
+
|
|
154
|
+
expect(response.status).toBe(200)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('redirects to login when protected for ALL_DOMAINS on custom host', async () => {
|
|
158
|
+
process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED = 'true'
|
|
159
|
+
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
160
|
+
ok: true,
|
|
161
|
+
json: async () => ({
|
|
162
|
+
protected: true,
|
|
163
|
+
scope: 'ALL_DOMAINS',
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const service = new AuthenticationService()
|
|
168
|
+
const { response } = await service.authenticateRequest(
|
|
169
|
+
new NextRequest('https://shop.example.com/checkout', {
|
|
170
|
+
headers: { host: 'shop.example.com' },
|
|
171
|
+
})
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(response.status).toBe(307)
|
|
175
|
+
expect(response.headers.get('location')).toContain('returnTo=%2Fcheckout')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('allows traffic when JWT is valid and store is password-protected', async () => {
|
|
179
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
180
|
+
ok: true,
|
|
181
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
jwtVerifyMock.mockResolvedValueOnce({
|
|
185
|
+
payload: {
|
|
186
|
+
storeId: 'test-store',
|
|
187
|
+
protected: true,
|
|
188
|
+
scope: 'DEFAULT_DOMAINS',
|
|
189
|
+
},
|
|
190
|
+
protectedHeader: { alg: 'RS256' },
|
|
191
|
+
} as unknown as Awaited<ReturnType<typeof jwtVerify>>)
|
|
192
|
+
|
|
193
|
+
const service = new AuthenticationService()
|
|
194
|
+
const { response } = await service.authenticateRequest(
|
|
195
|
+
previewRequest('/account', {
|
|
196
|
+
headers: { cookie: '__fs_auth_token=valid' },
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
expect(response.status).toBe(200)
|
|
201
|
+
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
202
|
+
expect((global.fetch as jest.Mock).mock.calls[0][0]).toEqual(
|
|
203
|
+
expect.objectContaining({
|
|
204
|
+
pathname: '/api/v1/password-protection/public-key',
|
|
205
|
+
})
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('verifies JWT locally when cookie present (not protected payload)', async () => {
|
|
210
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
211
|
+
ok: true,
|
|
212
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
jwtVerifyMock.mockResolvedValueOnce({
|
|
216
|
+
payload: {
|
|
217
|
+
storeId: 'test-store',
|
|
218
|
+
protected: false,
|
|
219
|
+
},
|
|
220
|
+
protectedHeader: { alg: 'RS256' },
|
|
221
|
+
} as unknown as Awaited<ReturnType<typeof jwtVerify>>)
|
|
222
|
+
|
|
223
|
+
const service = new AuthenticationService()
|
|
224
|
+
const { response } = await service.authenticateRequest(
|
|
225
|
+
previewRequest('/p', {
|
|
226
|
+
headers: {
|
|
227
|
+
host: 'preview.vtex.app',
|
|
228
|
+
cookie: '__fs_auth_token=abc',
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
expect(response.status).toBe(200)
|
|
234
|
+
expect(jwtVerifyMock).toHaveBeenCalled()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('treats wrong storeId in JWT as unauthenticated and calls status', async () => {
|
|
238
|
+
jwtVerifyMock.mockResolvedValueOnce({
|
|
239
|
+
payload: {
|
|
240
|
+
storeId: 'other-store',
|
|
241
|
+
protected: false,
|
|
242
|
+
},
|
|
243
|
+
protectedHeader: { alg: 'RS256' },
|
|
244
|
+
} as unknown as Awaited<ReturnType<typeof jwtVerify>>)
|
|
245
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
246
|
+
ok: true,
|
|
247
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
248
|
+
})
|
|
249
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
250
|
+
ok: true,
|
|
251
|
+
json: async () => ({
|
|
252
|
+
protected: true,
|
|
253
|
+
scope: 'DEFAULT_DOMAINS',
|
|
254
|
+
}),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const service = new AuthenticationService()
|
|
258
|
+
const { response } = await service.authenticateRequest(
|
|
259
|
+
previewRequest('/x', {
|
|
260
|
+
headers: {
|
|
261
|
+
cookie: '__fs_auth_token=bad',
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
expect(response.status).toBe(307)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('falls back to status when cookie JWT fails verification for non-expiry reasons', async () => {
|
|
270
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
271
|
+
ok: true,
|
|
272
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
273
|
+
})
|
|
274
|
+
jwtVerifyMock.mockRejectedValueOnce(new Error('invalid signature'))
|
|
275
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
276
|
+
ok: true,
|
|
277
|
+
json: async () => ({
|
|
278
|
+
protected: false,
|
|
279
|
+
token: 'after-bad-jwt',
|
|
280
|
+
}),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const service = new AuthenticationService()
|
|
284
|
+
const { response } = await service.authenticateRequest(
|
|
285
|
+
previewRequest('/p', {
|
|
286
|
+
headers: { cookie: '__fs_auth_token=garbage' },
|
|
287
|
+
})
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
expect(response.status).toBe(200)
|
|
291
|
+
expect(response.cookies.get('__fs_auth_token')?.value).toBe('after-bad-jwt')
|
|
292
|
+
expect(jwtVerifyMock).toHaveBeenCalled()
|
|
293
|
+
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('renews session when JWT is expired and WebOps renew succeeds', async () => {
|
|
297
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
298
|
+
ok: true,
|
|
299
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
jwtVerifyMock.mockRejectedValueOnce(
|
|
303
|
+
new errors.JWTExpired('jwt expired', { storeId: 'test-store' })
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
decodeJwtMock.mockReturnValueOnce({
|
|
307
|
+
storeId: 'test-store',
|
|
308
|
+
protected: true,
|
|
309
|
+
scope: 'DEFAULT_DOMAINS',
|
|
310
|
+
} as ReturnType<typeof decodeJwt>)
|
|
311
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
312
|
+
ok: true,
|
|
313
|
+
json: async () => ({
|
|
314
|
+
valid: true,
|
|
315
|
+
token: 'renewed-token',
|
|
316
|
+
}),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const service = new AuthenticationService()
|
|
320
|
+
const { response } = await service.authenticateRequest(
|
|
321
|
+
previewRequest('/p', {
|
|
322
|
+
headers: { cookie: '__fs_auth_token=old' },
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
expect(response.status).toBe(200)
|
|
327
|
+
expect(response.cookies.get('__fs_auth_token')?.value).toBe('renewed-token')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('redirects to login when JWT is expired and renew response is not ok', async () => {
|
|
331
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
332
|
+
ok: true,
|
|
333
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
334
|
+
})
|
|
335
|
+
jwtVerifyMock.mockRejectedValueOnce(
|
|
336
|
+
new errors.JWTExpired('jwt expired', { storeId: 'test-store' })
|
|
337
|
+
)
|
|
338
|
+
decodeJwtMock.mockReturnValueOnce({
|
|
339
|
+
storeId: 'test-store',
|
|
340
|
+
protected: true,
|
|
341
|
+
scope: 'DEFAULT_DOMAINS',
|
|
342
|
+
} as ReturnType<typeof decodeJwt>)
|
|
343
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
344
|
+
ok: false,
|
|
345
|
+
status: 502,
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const service = new AuthenticationService()
|
|
349
|
+
const { response } = await service.authenticateRequest(
|
|
350
|
+
previewRequest('/p', {
|
|
351
|
+
headers: { cookie: '__fs_auth_token=expired' },
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
expect(response.status).toBe(307)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('redirects to login when JWT is expired and renew body is not valid', async () => {
|
|
359
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
360
|
+
ok: true,
|
|
361
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
362
|
+
})
|
|
363
|
+
jwtVerifyMock.mockRejectedValueOnce(
|
|
364
|
+
new errors.JWTExpired('jwt expired', { storeId: 'test-store' })
|
|
365
|
+
)
|
|
366
|
+
decodeJwtMock.mockReturnValueOnce({
|
|
367
|
+
storeId: 'test-store',
|
|
368
|
+
protected: true,
|
|
369
|
+
scope: 'DEFAULT_DOMAINS',
|
|
370
|
+
} as ReturnType<typeof decodeJwt>)
|
|
371
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
372
|
+
ok: true,
|
|
373
|
+
json: async () => ({ valid: false }),
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const service = new AuthenticationService()
|
|
377
|
+
const { response } = await service.authenticateRequest(
|
|
378
|
+
previewRequest('/p', {
|
|
379
|
+
headers: { cookie: '__fs_auth_token=expired' },
|
|
380
|
+
})
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
expect(response.status).toBe(307)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('allows traffic when renew fails but expired JWT scope does not apply to this host', async () => {
|
|
387
|
+
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
388
|
+
ok: true,
|
|
389
|
+
json: async () => ({ publicKey: 'test-pem' }),
|
|
390
|
+
})
|
|
391
|
+
jwtVerifyMock.mockRejectedValueOnce(
|
|
392
|
+
new errors.JWTExpired('jwt expired', { storeId: 'test-store' })
|
|
393
|
+
)
|
|
394
|
+
decodeJwtMock.mockReturnValueOnce({
|
|
395
|
+
storeId: 'test-store',
|
|
396
|
+
protected: true,
|
|
397
|
+
scope: 'CUSTOM_DOMAINS',
|
|
398
|
+
} as ReturnType<typeof decodeJwt>)
|
|
399
|
+
;(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('renew down'))
|
|
400
|
+
|
|
401
|
+
const service = new AuthenticationService()
|
|
402
|
+
const { response } = await service.authenticateRequest(
|
|
403
|
+
previewRequest('/p', {
|
|
404
|
+
headers: { cookie: '__fs_auth_token=old' },
|
|
405
|
+
})
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
expect(response.status).toBe(200)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('fail-closes to login on default domain when status request fails', async () => {
|
|
412
|
+
;(global.fetch as jest.Mock).mockRejectedValue(new Error('network'))
|
|
413
|
+
|
|
414
|
+
const service = new AuthenticationService()
|
|
415
|
+
const { response } = await service.authenticateRequest(previewRequest('/p'))
|
|
416
|
+
|
|
417
|
+
expect(response.status).toBe(307)
|
|
418
|
+
expect(response.headers.get('location')).toContain('/fs-auth-login')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('fail-closes to login when status endpoint returns a non-ok HTTP status', async () => {
|
|
422
|
+
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
423
|
+
ok: false,
|
|
424
|
+
status: 503,
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const service = new AuthenticationService()
|
|
428
|
+
const { response } = await service.authenticateRequest(previewRequest('/p'))
|
|
429
|
+
|
|
430
|
+
expect(response.status).toBe(307)
|
|
431
|
+
expect(response.headers.get('location')).toContain('/fs-auth-login')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('fail-closes to login on custom domain when status request fails', async () => {
|
|
435
|
+
process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED = 'true'
|
|
436
|
+
;(global.fetch as jest.Mock).mockRejectedValue(new Error('network'))
|
|
437
|
+
|
|
438
|
+
const service = new AuthenticationService()
|
|
439
|
+
const request = new NextRequest('https://shop.example.com/page', {
|
|
440
|
+
headers: { host: 'shop.example.com' },
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const { response } = await service.authenticateRequest(request)
|
|
444
|
+
|
|
445
|
+
expect(response.status).toBe(307)
|
|
446
|
+
expect(response.headers.get('location')).toContain('/fs-auth-login')
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('does not enforce protection on custom domain when env gate is off', async () => {
|
|
450
|
+
const service = new AuthenticationService()
|
|
451
|
+
const request = new NextRequest('https://shop.example.com/page', {
|
|
452
|
+
headers: { host: 'shop.example.com' },
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
const { response } = await service.authenticateRequest(request)
|
|
456
|
+
|
|
457
|
+
expect(response.status).toBe(200)
|
|
458
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
459
|
+
})
|
|
460
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { isLocalHostBrowser } from '../../src/utils/isLocalHost'
|
|
6
|
+
|
|
7
|
+
const originalLocation = window.location
|
|
8
|
+
|
|
9
|
+
function stubHostname(hostname: string) {
|
|
10
|
+
// jsdom forbids assigning to `window.location` directly, so we redefine the
|
|
11
|
+
// descriptor with a minimal stub that exposes the hostname we want to test.
|
|
12
|
+
Object.defineProperty(window, 'location', {
|
|
13
|
+
configurable: true,
|
|
14
|
+
value: { ...originalLocation, hostname },
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('isLocalHostBrowser', () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
Object.defineProperty(window, 'location', {
|
|
21
|
+
configurable: true,
|
|
22
|
+
value: originalLocation,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns true when window.location.hostname is "localhost"', () => {
|
|
27
|
+
stubHostname('localhost')
|
|
28
|
+
expect(isLocalHostBrowser()).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns true when window.location.hostname is "127.0.0.1"', () => {
|
|
32
|
+
stubHostname('127.0.0.1')
|
|
33
|
+
expect(isLocalHostBrowser()).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns false for a production hostname', () => {
|
|
37
|
+
stubHostname('brandless.fast.store')
|
|
38
|
+
expect(isLocalHostBrowser()).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isLocalHost } from '../../src/utils/isLocalHost'
|
|
2
|
+
|
|
3
|
+
describe('isLocalHost', () => {
|
|
4
|
+
it('returns true for "localhost"', () => {
|
|
5
|
+
expect(isLocalHost('localhost')).toBe(true)
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('returns true for "127.0.0.1"', () => {
|
|
9
|
+
expect(isLocalHost('127.0.0.1')).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('is case-insensitive', () => {
|
|
13
|
+
expect(isLocalHost('LOCALHOST')).toBe(true)
|
|
14
|
+
expect(isLocalHost('Localhost')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns false for a production hostname', () => {
|
|
18
|
+
expect(isLocalHost('brandless.fast.store')).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns false for IPv6 loopback (not in the bypass list)', () => {
|
|
22
|
+
// We intentionally bypass only the two canonical local hosts. IPv6
|
|
23
|
+
// loopback (`::1`) and other loopback variants are out of scope.
|
|
24
|
+
expect(isLocalHost('::1')).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns false for inputs that still carry a port', () => {
|
|
28
|
+
// Callers MUST normalize the hostname (strip the port) before invoking
|
|
29
|
+
// this helper.
|
|
30
|
+
expect(isLocalHost('localhost:3000')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns false for null/undefined/empty input', () => {
|
|
34
|
+
expect(isLocalHost(null)).toBe(false)
|
|
35
|
+
expect(isLocalHost(undefined)).toBe(false)
|
|
36
|
+
expect(isLocalHost('')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
self.__BUILD_MANIFEST=function(c,s,t,a,e,u,n){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/":[c,s,t,e,"static/css/02259c549b2179f2.css","static/chunks/pages/index-8774d5b4a8744eb2.js"],"/404":[c,s,t,a,"static/chunks/pages/404-e3dfbbac003b02a2.js"],"/500":[c,s,t,a,"static/chunks/pages/500-c7814e085081d7b8.js"],"/_error":["static/chunks/pages/_error-2b0148be56a716e9.js"],"/checkout":[c,s,t,"static/chunks/pages/checkout-2071467ad42dfb99.js"],"/login":[c,s,t,a,"static/chunks/pages/login-e74747c9f9cb5b49.js"],"/pvt/account":["static/chunks/pages/pvt/account-65fefcc699344bdb.js"],"/pvt/account/403":[c,s,t,"static/css/6819dddf2b1b5ea7.css","static/chunks/pages/pvt/account/403-491e6b062487b30f.js"],"/pvt/account/404":[c,s,t,"static/css/ea11d74a2848b093.css","static/chunks/pages/pvt/account/404-5ad472d9f2ee1980.js"],"/pvt/account/orders":[c,s,t,"static/css/14176a4704ff97b2.css","static/chunks/pages/pvt/account/orders-faab39f2aff05fae.js"],"/pvt/account/orders/[id]":[c,s,t,"static/css/82f3cd529b672812.css","static/chunks/pages/pvt/account/orders/[id]-64e42157c0974c48.js"],"/pvt/account/profile":[c,s,t,"static/css/a852afa9be31e946.css","static/chunks/pages/pvt/account/profile-9672177ee2665bc0.js"],"/pvt/account/security":[c,s,t,"static/css/24d91007fc96be10.css","static/chunks/pages/pvt/account/security-03eb1ccab9a30563.js"],"/pvt/account/user-details":[c,s,t,"static/css/16d65422bfa80f51.css","static/chunks/pages/pvt/account/user-details-7838e3f79781596c.js"],"/pvt/account/[...unknown]":["static/chunks/pages/pvt/account/[...unknown]-f80f645594d2740c.js"],"/s":[c,s,t,u,n,a,"static/chunks/pages/s-162ba789e6611175.js"],"/[slug]/p":[c,"static/chunks/227-f109b24190a008ab.js",s,t,e,"static/css/1c4983f11b861ea0.css","static/chunks/3166-0b30a3887c0c346d.js","static/css/54cedf4b65e5863f.css","static/chunks/pages/[slug]/p-644f1132f5a0f736.js"],"/[...slug]":[c,s,t,u,n,"static/chunks/pages/[...slug]-dc8a395a43b081f1.js"],sortedPages:["/","/404","/500","/_app","/_error","/checkout","/login","/pvt/account","/pvt/account/403","/pvt/account/404","/pvt/account/orders","/pvt/account/orders/[id]","/pvt/account/profile","/pvt/account/security","/pvt/account/user-details","/pvt/account/[...unknown]","/s","/[slug]/p","/[...slug]"]}}("static/chunks/9298-1d1ed0a54133cb59.js","static/css/2c19433e38fd7533.css","static/chunks/9173-515913f3f10caf5e.js","static/css/2980acad3f8e1028.css","static/css/837662922091162f.css","static/css/fdc1868dcfd42aa4.css","static/chunks/7191-d221f01388571ca9.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();
|