Skip to content

Commit d4400ee

Browse files
committed
test(password-protection): fix and add more tests
1 parent d1c12a3 commit d4400ee

2 files changed

Lines changed: 189 additions & 4 deletions

File tree

packages/core/src/server/authentication-service.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ export class AuthenticationService {
141141
return payload.storeId === this.storeId
142142
}
143143

144+
private isJwtExpiredError(error: unknown): boolean {
145+
return (
146+
error instanceof errors.JWTExpired ||
147+
(error instanceof Error && error.name === 'JWTExpired')
148+
)
149+
}
150+
144151
private async verifyToken(token: string): Promise<{
145152
valid: boolean
146153
expired: boolean
@@ -161,7 +168,7 @@ export class AuthenticationService {
161168
payload: p,
162169
}
163170
} catch (error) {
164-
if (error instanceof errors.JWTExpired) {
171+
if (this.isJwtExpiredError(error)) {
165172
const payload = decodeJwt(token) as unknown as TokenPayload
166173

167174
if (!this.payloadMatchesStore(payload)) {

packages/core/test/server/authentication-service.test.ts

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,11 @@ describe('AuthenticationService', () => {
109109
'neg-cache-token'
110110
)
111111
expect(global.fetch).toHaveBeenCalledWith(
112-
expect.stringContaining('/api/v1/password-protection/status'),
112+
expect.objectContaining({
113+
href: expect.stringMatching(
114+
/\/api\/v1\/password-protection\/status.*storeId=test-store/
115+
),
116+
}),
113117
expect.any(Object)
114118
)
115119
})
@@ -150,6 +154,58 @@ describe('AuthenticationService', () => {
150154
expect(response.status).toBe(200)
151155
})
152156

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+
153209
it('verifies JWT locally when cookie present (not protected payload)', async () => {
154210
;(global.fetch as jest.Mock).mockResolvedValueOnce({
155211
ok: true,
@@ -210,6 +266,33 @@ describe('AuthenticationService', () => {
210266
expect(response.status).toBe(307)
211267
})
212268

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+
213296
it('renews session when JWT is expired and WebOps renew succeeds', async () => {
214297
;(global.fetch as jest.Mock).mockResolvedValueOnce({
215298
ok: true,
@@ -244,6 +327,87 @@ describe('AuthenticationService', () => {
244327
expect(response.cookies.get('__fs_auth_token')?.value).toBe('renewed-token')
245328
})
246329

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+
247411
it('fail-closes to login on default domain when status request fails', async () => {
248412
;(global.fetch as jest.Mock).mockRejectedValue(new Error('network'))
249413

@@ -254,7 +418,20 @@ describe('AuthenticationService', () => {
254418
expect(response.headers.get('location')).toContain('/fs-auth-login')
255419
})
256420

257-
it('fail-opens on custom domain when status request fails', async () => {
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 () => {
258435
process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED = 'true'
259436
;(global.fetch as jest.Mock).mockRejectedValue(new Error('network'))
260437

@@ -265,7 +442,8 @@ describe('AuthenticationService', () => {
265442

266443
const { response } = await service.authenticateRequest(request)
267444

268-
expect(response.status).toBe(200)
445+
expect(response.status).toBe(307)
446+
expect(response.headers.get('location')).toContain('/fs-auth-login')
269447
})
270448

271449
it('does not enforce protection on custom domain when env gate is off', async () => {

0 commit comments

Comments
 (0)