@faststore/core 4.3.0-dev.7 → 4.3.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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +267 -32
- package/CHANGELOG.md +12 -0
- package/package.json +9 -6
- package/src/pages/api/fs/password-protection/unlock.ts +123 -0
- package/src/pages/password-protection/index.tsx +93 -0
- package/src/pages/password-protection/password-protection.module.scss +43 -0
- package/src/proxy.ts +47 -2
- package/src/server/password-protection/webops-api.ts +38 -0
- package/src/server/password-protection-service.ts +283 -0
- package/src/utils/unlockResponse.ts +25 -0
- package/test/pages/api/unlock.test.ts +277 -0
- package/test/pages/password-protection.browser.test.tsx +201 -0
- package/test/proxy.test.ts +99 -0
- package/test/server/password-protection-service.test.ts +624 -0
- package/test/server/webops-api.test.ts +92 -0
- package/test/utils/unlockResponse.test.ts +57 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@use "sass:meta";
|
|
2
|
+
|
|
3
|
+
@layer components {
|
|
4
|
+
.fsPasswordProtection {
|
|
5
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Button/styles.scss");
|
|
6
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Input/styles.scss");
|
|
7
|
+
@include meta.load-css("~@faststore/ui/src/components/atoms/Loader/styles.scss");
|
|
8
|
+
@include meta.load-css("~@faststore/ui/src/components/molecules/InputField/styles.scss");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.page {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
min-height: 100vh;
|
|
18
|
+
padding: var(--fs-spacing-3);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.title {
|
|
22
|
+
margin: 0 0 var(--fs-spacing-6) 0;
|
|
23
|
+
font-size: var(--fs-text-size-title-section);
|
|
24
|
+
font-weight: var(--fs-text-weight-bold);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.subtitle {
|
|
28
|
+
margin: 0 0 var(--fs-spacing-3) 0;
|
|
29
|
+
font-size: var(--fs-text-size-body);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.form {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: var(--fs-spacing-2);
|
|
36
|
+
align-items: stretch;
|
|
37
|
+
width: 100%;
|
|
38
|
+
max-width: 20rem;
|
|
39
|
+
|
|
40
|
+
button {
|
|
41
|
+
align-self: center;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/proxy.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
isValidLocale,
|
|
8
8
|
} from 'src/utils/localization/bindingPaths'
|
|
9
9
|
|
|
10
|
+
import { PasswordProtectionService } from './server/password-protection-service'
|
|
11
|
+
|
|
10
12
|
type RewriteRule = {
|
|
11
13
|
regex: RegExp
|
|
12
14
|
locale: string
|
|
@@ -93,7 +95,7 @@ function rewriteSubdomainRequest(
|
|
|
93
95
|
return null
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
function localizationRewrite(request: NextRequest): NextResponse {
|
|
97
99
|
if (!storeConfig.localization?.enabled) {
|
|
98
100
|
return NextResponse.next()
|
|
99
101
|
}
|
|
@@ -146,9 +148,52 @@ export function proxy(request: NextRequest) {
|
|
|
146
148
|
return NextResponse.next()
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
export async function proxy(request: NextRequest) {
|
|
152
|
+
let storeProtectionResult: Awaited<
|
|
153
|
+
ReturnType<PasswordProtectionService['checkStoreProtection']>
|
|
154
|
+
>
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const protectionService = new PasswordProtectionService()
|
|
158
|
+
storeProtectionResult =
|
|
159
|
+
await protectionService.checkStoreProtection(request)
|
|
160
|
+
} catch {
|
|
161
|
+
return NextResponse.error()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (storeProtectionResult.response.status !== 200) {
|
|
165
|
+
return storeProtectionResult.response
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const response = localizationRewrite(request)
|
|
169
|
+
|
|
170
|
+
for (const cookie of storeProtectionResult.response.cookies.getAll()) {
|
|
171
|
+
response.cookies.set(cookie)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return response
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
export const config = {
|
|
150
178
|
matcher: [
|
|
151
|
-
|
|
179
|
+
/*
|
|
180
|
+
* Explicit root entry. Required because Next.js' negative-lookahead
|
|
181
|
+
* matcher pattern does not catch `/` on its own (the trailing `(.*)`
|
|
182
|
+
* makes path-to-regexp treat the root as unmatched). Without this,
|
|
183
|
+
* password protection silently bypasses the homepage.
|
|
184
|
+
* See: https://github.com/vercel/next.js/issues/62078
|
|
185
|
+
*/
|
|
186
|
+
'/',
|
|
187
|
+
/*
|
|
188
|
+
* Match all other paths. Exclude:
|
|
189
|
+
* - api/fs/password-protection/unlock (password-protection unlock endpoint)
|
|
190
|
+
* - _next/static, _next/image
|
|
191
|
+
* - favicon.ico
|
|
192
|
+
* - password-protection (password-protection page)
|
|
193
|
+
* - ~partytown (partytown scripts)
|
|
194
|
+
* - paths ending with a file extension (static assets)
|
|
195
|
+
*/
|
|
196
|
+
'/((?!api/fs/password-protection/unlock$|_next/static|_next/image|favicon.ico|password-protection|~partytown|.*[.][^/]+$).*)',
|
|
152
197
|
'/_next/data/:path*',
|
|
153
198
|
],
|
|
154
199
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import discoveryConfig from 'discovery.config'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_WEBOPS_ORIGIN = 'https://faststore.vtex.com'
|
|
4
|
+
|
|
5
|
+
function getWebopsOrigin(): string {
|
|
6
|
+
const hostFromEnv = process.env.WEBOPS_API_URL?.trim() ?? ''
|
|
7
|
+
const hostWithoutTrailingSlash = hostFromEnv.replace(/\/+$/, '')
|
|
8
|
+
const origin =
|
|
9
|
+
hostWithoutTrailingSlash.length > 0
|
|
10
|
+
? hostWithoutTrailingSlash
|
|
11
|
+
: DEFAULT_WEBOPS_ORIGIN
|
|
12
|
+
|
|
13
|
+
return /^https?:\/\//i.test(origin) ? origin : `https://${origin}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function publicKeyUrl(): URL {
|
|
17
|
+
return new URL('/api/v1/password-protection/public-key', getWebopsOrigin())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function protectionStatusUrl(): URL {
|
|
21
|
+
const url = new URL('/api/v1/password-protection/status', getWebopsOrigin())
|
|
22
|
+
url.searchParams.set('storeId', discoveryConfig.api.storeId)
|
|
23
|
+
return url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sessionUrl(): URL {
|
|
27
|
+
return new URL('/api/v1/password-protection/session', getWebopsOrigin())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renewUrl(): URL {
|
|
31
|
+
return new URL('/api/v1/password-protection/renew', getWebopsOrigin())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Timeouts for WebOps password-protection calls (middleware / API routes). */
|
|
35
|
+
export const passwordProtectionTimeouts = {
|
|
36
|
+
publicKeyMs: 5_000,
|
|
37
|
+
defaultMs: 10_000,
|
|
38
|
+
} as const
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { NextRequest } from 'next/server'
|
|
3
|
+
import { jwtVerify, decodeJwt, importSPKI, errors } from 'jose'
|
|
4
|
+
import type { CryptoKey } from 'jose'
|
|
5
|
+
|
|
6
|
+
import storeConfig from 'discovery.config'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
publicKeyUrl,
|
|
10
|
+
renewUrl,
|
|
11
|
+
protectionStatusUrl,
|
|
12
|
+
passwordProtectionTimeouts,
|
|
13
|
+
} from './password-protection/webops-api'
|
|
14
|
+
|
|
15
|
+
export const COOKIE_NAME = '__fs_password_protection'
|
|
16
|
+
export const TOKEN_TTL_SECONDS = 10 * 60
|
|
17
|
+
/** How long to reuse the RSA public key fetched from WebOps */
|
|
18
|
+
const PUBLIC_KEY_CACHE_MS = 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
interface TokenPayload {
|
|
21
|
+
storeId: string
|
|
22
|
+
protected: boolean
|
|
23
|
+
passwordVersion?: string
|
|
24
|
+
scope?: string
|
|
25
|
+
exp?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface StoreProtectionResult {
|
|
29
|
+
response: NextResponse
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let publicKeyCache: {
|
|
33
|
+
pem: string
|
|
34
|
+
key: CryptoKey
|
|
35
|
+
fetchedAt: number
|
|
36
|
+
} | null = null
|
|
37
|
+
|
|
38
|
+
async function fetchPublicKeyPemFromWebOps(): Promise<string> {
|
|
39
|
+
const res = await fetch(publicKeyUrl(), {
|
|
40
|
+
signal: AbortSignal.timeout(passwordProtectionTimeouts.publicKeyMs),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(`WebOps public-key returned ${res.status}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const body = (await res.json()) as { publicKey?: string }
|
|
48
|
+
const pem = body.publicKey?.trim()
|
|
49
|
+
|
|
50
|
+
if (!pem) {
|
|
51
|
+
throw new Error('WebOps public-key response missing publicKey')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return pem
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getPublicVerificationKey(): Promise<CryptoKey> {
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
|
|
60
|
+
if (publicKeyCache && now - publicKeyCache.fetchedAt < PUBLIC_KEY_CACHE_MS) {
|
|
61
|
+
return publicKeyCache.key
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pem = await fetchPublicKeyPemFromWebOps()
|
|
65
|
+
const key = await importSPKI(pem, 'RS256')
|
|
66
|
+
publicKeyCache = { pem, key, fetchedAt: now }
|
|
67
|
+
|
|
68
|
+
return key
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clears the cached WebOps public key (for unit tests only). */
|
|
72
|
+
export function resetPasswordProtectionPublicKeyCacheForTests(): void {
|
|
73
|
+
publicKeyCache = null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class PasswordProtectionService {
|
|
77
|
+
private readonly storeId: string
|
|
78
|
+
|
|
79
|
+
constructor() {
|
|
80
|
+
this.storeId = storeConfig.api.storeId
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private getNormalizedHost(request: NextRequest): string {
|
|
84
|
+
const host = request.headers.get('host') || ''
|
|
85
|
+
|
|
86
|
+
if (host.startsWith('[')) {
|
|
87
|
+
const closingBracket = host.indexOf(']')
|
|
88
|
+
|
|
89
|
+
if (closingBracket !== -1) {
|
|
90
|
+
return host.slice(1, closingBracket).toLowerCase()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return host.split(':')[0].toLowerCase()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async checkStoreProtection(
|
|
98
|
+
request: NextRequest
|
|
99
|
+
): Promise<StoreProtectionResult> {
|
|
100
|
+
if (!this.shouldCheckProtection(request)) {
|
|
101
|
+
return { response: NextResponse.next() }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const token = request.cookies.get(COOKIE_NAME)?.value
|
|
105
|
+
|
|
106
|
+
if (token) {
|
|
107
|
+
const verification = await this.verifyToken(token)
|
|
108
|
+
|
|
109
|
+
if (verification.valid && verification.payload) {
|
|
110
|
+
return { response: NextResponse.next() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (verification.expired && verification.payload) {
|
|
114
|
+
return await this.tryRenewSession(request, token, verification.payload)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return await this.handleProtectionCheck(request)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private shouldCheckProtection(request: NextRequest): boolean {
|
|
122
|
+
if (process.env.NODE_ENV === 'development') {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hostname = this.getNormalizedHost(request)
|
|
127
|
+
const isDefaultDomain = hostname.endsWith('.vtex.app')
|
|
128
|
+
|
|
129
|
+
if (isDefaultDomain) {
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return process.env.CUSTOM_DOMAINS_PROTECTION_ENABLED === 'true'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private shouldProtectDomain(request: NextRequest, scope?: string): boolean {
|
|
137
|
+
const hostname = this.getNormalizedHost(request)
|
|
138
|
+
const isDefaultDomain = hostname.endsWith('.vtex.app')
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
scope === 'ALL_DOMAINS' ||
|
|
142
|
+
(scope === 'DEFAULT_DOMAINS' && isDefaultDomain) ||
|
|
143
|
+
(scope === 'CUSTOM_DOMAINS' && !isDefaultDomain)
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private payloadMatchesStore(payload: TokenPayload): boolean {
|
|
148
|
+
return payload.storeId === this.storeId
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private isJwtExpiredError(error: unknown): boolean {
|
|
152
|
+
return (
|
|
153
|
+
error instanceof errors.JWTExpired ||
|
|
154
|
+
(error instanceof Error && error.name === 'JWTExpired')
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async verifyToken(token: string): Promise<{
|
|
159
|
+
valid: boolean
|
|
160
|
+
expired: boolean
|
|
161
|
+
payload?: TokenPayload
|
|
162
|
+
}> {
|
|
163
|
+
try {
|
|
164
|
+
const key = await getPublicVerificationKey()
|
|
165
|
+
const { payload } = await jwtVerify(token, key)
|
|
166
|
+
const p = payload as unknown as TokenPayload
|
|
167
|
+
|
|
168
|
+
if (!this.payloadMatchesStore(p)) {
|
|
169
|
+
return { valid: false, expired: false }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
valid: true,
|
|
174
|
+
expired: false,
|
|
175
|
+
payload: p,
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (this.isJwtExpiredError(error)) {
|
|
179
|
+
const payload = decodeJwt(token) as unknown as TokenPayload
|
|
180
|
+
|
|
181
|
+
if (!this.payloadMatchesStore(payload)) {
|
|
182
|
+
return { valid: false, expired: false }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { valid: false, expired: true, payload }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { valid: false, expired: false }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async tryRenewSession(
|
|
193
|
+
request: NextRequest,
|
|
194
|
+
expiredToken: string,
|
|
195
|
+
payload: TokenPayload
|
|
196
|
+
): Promise<StoreProtectionResult> {
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetch(renewUrl(), {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
storeId: this.storeId,
|
|
203
|
+
expiredToken,
|
|
204
|
+
}),
|
|
205
|
+
signal: AbortSignal.timeout(passwordProtectionTimeouts.defaultMs),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
if (res.ok) {
|
|
209
|
+
const data = await res.json()
|
|
210
|
+
|
|
211
|
+
if (data.valid && data.token) {
|
|
212
|
+
const response = NextResponse.next()
|
|
213
|
+
this.setProtectionCookie(response, data.token)
|
|
214
|
+
|
|
215
|
+
return { response }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Fall through to redirect or fail-open below
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!this.shouldProtectDomain(request, payload.scope)) {
|
|
223
|
+
return { response: NextResponse.next() }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { response: this.redirectToUnlockPage(request) }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async handleProtectionCheck(
|
|
230
|
+
request: NextRequest
|
|
231
|
+
): Promise<StoreProtectionResult> {
|
|
232
|
+
try {
|
|
233
|
+
const res = await fetch(protectionStatusUrl(), {
|
|
234
|
+
signal: AbortSignal.timeout(passwordProtectionTimeouts.defaultMs),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
return { response: this.redirectToUnlockPage(request) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const status = await res.json()
|
|
242
|
+
|
|
243
|
+
if (!status.protected) {
|
|
244
|
+
const response = NextResponse.next()
|
|
245
|
+
|
|
246
|
+
if (status.token) {
|
|
247
|
+
this.setProtectionCookie(response, status.token)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { response }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!this.shouldProtectDomain(request, status.scope)) {
|
|
254
|
+
return { response: NextResponse.next() }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { response: this.redirectToUnlockPage(request) }
|
|
258
|
+
} catch {
|
|
259
|
+
return { response: this.redirectToUnlockPage(request) }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private setProtectionCookie(response: NextResponse, token: string): void {
|
|
264
|
+
response.cookies.set(COOKIE_NAME, token, {
|
|
265
|
+
httpOnly: true,
|
|
266
|
+
secure: true,
|
|
267
|
+
sameSite: 'lax',
|
|
268
|
+
path: '/',
|
|
269
|
+
maxAge: TOKEN_TTL_SECONDS,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private redirectToUnlockPage(request: NextRequest): NextResponse {
|
|
274
|
+
const loginUrl = new URL('/password-protection', request.url)
|
|
275
|
+
const returnTo = `${request.nextUrl.pathname}${request.nextUrl.search}`
|
|
276
|
+
loginUrl.searchParams.set('returnTo', returnTo)
|
|
277
|
+
|
|
278
|
+
const response = NextResponse.redirect(loginUrl)
|
|
279
|
+
response.headers.set('Cache-Control', 'no-store')
|
|
280
|
+
|
|
281
|
+
return response
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface UnlockResponse {
|
|
2
|
+
success?: boolean
|
|
3
|
+
redirectUrl?: string
|
|
4
|
+
error?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const isUnlockResponse = (data: unknown): data is UnlockResponse => {
|
|
8
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const payload = data as Record<string, unknown>
|
|
13
|
+
const hasUnlockResponseField =
|
|
14
|
+
payload.success !== undefined ||
|
|
15
|
+
payload.redirectUrl !== undefined ||
|
|
16
|
+
payload.error !== undefined
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
hasUnlockResponseField &&
|
|
20
|
+
(payload.success === undefined || typeof payload.success === 'boolean') &&
|
|
21
|
+
(payload.redirectUrl === undefined ||
|
|
22
|
+
typeof payload.redirectUrl === 'string') &&
|
|
23
|
+
(payload.error === undefined || typeof payload.error === 'string')
|
|
24
|
+
)
|
|
25
|
+
}
|