@withandeo/cli 0.14.0 → 0.15.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/dist/api.d.ts +8 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +9 -0
- package/dist/api.js.map +1 -1
- package/dist/contracts.d.ts +20 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/main.js +79 -5
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
- package/skill/tender-accounts/references/managed-source.md +30 -0
- package/templates/shopify-customer-account/apps/gateway/.tender/artifacts/current/tender-accounts-build.json +0 -99
- package/templates/shopify-customer-account/apps/gateway/.tender/artifacts/current/worker.mjs +0 -1785
- package/templates/shopify-customer-account/apps/gateway/.tender/build/worker.mjs +0 -1785
package/templates/shopify-customer-account/apps/gateway/.tender/artifacts/current/worker.mjs
DELETED
|
@@ -1,1785 +0,0 @@
|
|
|
1
|
-
// src/platform/customer-auth/hostname-policy.ts
|
|
2
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
// src/platform/runtime-hostname.ts
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
6
|
-
function canonicalApplicationHostname(input) {
|
|
7
|
-
return `a-${shortDigest(input.projectId, 14)}--t-${shortDigest(input.tenantId, 14)}.${normalizedDomain(input.baseDomain)}`;
|
|
8
|
-
}
|
|
9
|
-
function immutablePreviewHostname(input) {
|
|
10
|
-
return [
|
|
11
|
-
`r-${shortDigest(input.releaseId, 12)}`,
|
|
12
|
-
`p-${shortDigest(input.projectId, 10)}`,
|
|
13
|
-
`t-${shortDigest(input.tenantId, 10)}`
|
|
14
|
-
].join("--") + `.${normalizedDomain(input.baseDomain)}`;
|
|
15
|
-
}
|
|
16
|
-
function previewAliasHostname(input) {
|
|
17
|
-
const readable = input.alias.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 12);
|
|
18
|
-
if (readable.length < 2) throw new Error("The preview alias is invalid.");
|
|
19
|
-
return [
|
|
20
|
-
`b-${readable}-${rawDigest(input.alias, 8)}`,
|
|
21
|
-
`p-${shortDigest(input.projectId, 10)}`,
|
|
22
|
-
`t-${shortDigest(input.tenantId, 10)}`
|
|
23
|
-
].join("--") + `.${normalizedDomain(input.baseDomain)}`;
|
|
24
|
-
}
|
|
25
|
-
function shortDigest(value, length) {
|
|
26
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/u.test(value)) throw new Error("The runtime identity is invalid.");
|
|
27
|
-
return rawDigest(value, length);
|
|
28
|
-
}
|
|
29
|
-
function rawDigest(value, length) {
|
|
30
|
-
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, length);
|
|
31
|
-
}
|
|
32
|
-
function normalizedDomain(value) {
|
|
33
|
-
const domain = value.toLowerCase().replace(/\.$/u, "");
|
|
34
|
-
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(domain)) {
|
|
35
|
-
throw new Error("The runtime base domain is invalid.");
|
|
36
|
-
}
|
|
37
|
-
return domain;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// src/platform/customer-auth/hostname-policy.ts
|
|
41
|
-
function createTenderRuntimeHostnamePolicy(input) {
|
|
42
|
-
const canonicalHostname = canonicalApplicationHostname(input);
|
|
43
|
-
const domain = canonicalHostname.slice(canonicalHostname.indexOf(".") + 1);
|
|
44
|
-
const projectDigest = shortDigest2(input.projectId, 10);
|
|
45
|
-
const tenantDigest = shortDigest2(input.tenantId, 10);
|
|
46
|
-
const immutablePattern = new RegExp(
|
|
47
|
-
`^r-[a-f0-9]{12}--p-${projectDigest}--t-${tenantDigest}\\.${escaped(domain)}$`
|
|
48
|
-
);
|
|
49
|
-
const aliasPattern = new RegExp(
|
|
50
|
-
`^b-[a-z0-9][a-z0-9-]{1,11}-[a-f0-9]{8}--p-${projectDigest}--t-${tenantDigest}\\.${escaped(domain)}$`
|
|
51
|
-
);
|
|
52
|
-
const additionalHostnames = new Set((input.additionalHostnames ?? []).map(normalizedHostname));
|
|
53
|
-
return ({ projectId, hostname }) => projectId === input.projectId && (hostname === canonicalHostname || additionalHostnames.has(hostname) || immutablePattern.test(hostname) || aliasPattern.test(hostname));
|
|
54
|
-
}
|
|
55
|
-
function normalizedHostname(value) {
|
|
56
|
-
const hostname = value.trim().toLowerCase().replace(/\.$/u, "");
|
|
57
|
-
if (!/^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(hostname)) {
|
|
58
|
-
throw new Error("The additional runtime hostname is invalid.");
|
|
59
|
-
}
|
|
60
|
-
return hostname;
|
|
61
|
-
}
|
|
62
|
-
var PUBLIC_CUSTOMER_AUTH_RUNTIME_HOST_CONSTRUCTORS = Object.freeze({
|
|
63
|
-
canonicalApplicationHostname,
|
|
64
|
-
immutablePreviewHostname,
|
|
65
|
-
previewAliasHostname
|
|
66
|
-
});
|
|
67
|
-
function shortDigest2(value, length) {
|
|
68
|
-
return createHash2("sha256").update(value).digest("hex").slice(0, length);
|
|
69
|
-
}
|
|
70
|
-
function escaped(value) {
|
|
71
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// src/platform/customer-auth/session-store.ts
|
|
75
|
-
var FLOW_MAX_TTL_SECONDS = 10 * 60;
|
|
76
|
-
var HANDOFF_MAX_TTL_SECONDS = 5 * 60;
|
|
77
|
-
var SESSION_MAX_TTL_SECONDS = 31 * 24 * 60 * 60;
|
|
78
|
-
var EXPIRED_STATE_DELETE_LIMIT = 256;
|
|
79
|
-
var DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_PROJECT = 512;
|
|
80
|
-
var DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_CLIENT = 20;
|
|
81
|
-
var MAX_CONFIGURED_AUTHORIZATION_ATTEMPTS = 1e5;
|
|
82
|
-
var OPAQUE_VALUE_BYTES = 32;
|
|
83
|
-
var SESSION_DATA_MAX_LENGTH = 65536;
|
|
84
|
-
var SESSION_SEAL_MAX_LENGTH = 1e5;
|
|
85
|
-
var CustomerAuthFlowCapacityError = class extends Error {
|
|
86
|
-
constructor() {
|
|
87
|
-
super("The customer authentication flow capacity has been reached.");
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
var PublicCustomerAuthSessionStore = class {
|
|
91
|
-
database;
|
|
92
|
-
hostnamePolicy;
|
|
93
|
-
maxRecentAuthorizationAttemptsPerProject;
|
|
94
|
-
maxRecentAuthorizationAttemptsPerClient;
|
|
95
|
-
constructor(database, hostnamePolicy, options = {}) {
|
|
96
|
-
if (typeof hostnamePolicy !== "function") {
|
|
97
|
-
throw new Error("A customer authentication hostname policy is required.");
|
|
98
|
-
}
|
|
99
|
-
this.database = database;
|
|
100
|
-
this.hostnamePolicy = hostnamePolicy;
|
|
101
|
-
const maxRecentAuthorizationAttemptsPerProject = options.maxRecentAuthorizationAttemptsPerProject ?? DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_PROJECT;
|
|
102
|
-
const maxRecentAuthorizationAttemptsPerClient = options.maxRecentAuthorizationAttemptsPerClient ?? DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_CLIENT;
|
|
103
|
-
if (!validAuthorizationAttemptLimit(maxRecentAuthorizationAttemptsPerProject) || !validAuthorizationAttemptLimit(maxRecentAuthorizationAttemptsPerClient) || maxRecentAuthorizationAttemptsPerClient > maxRecentAuthorizationAttemptsPerProject) {
|
|
104
|
-
throw new Error("The customer authentication flow capacity is invalid.");
|
|
105
|
-
}
|
|
106
|
-
this.maxRecentAuthorizationAttemptsPerProject = maxRecentAuthorizationAttemptsPerProject;
|
|
107
|
-
this.maxRecentAuthorizationAttemptsPerClient = maxRecentAuthorizationAttemptsPerClient;
|
|
108
|
-
}
|
|
109
|
-
async createAuthorizationFlow(input) {
|
|
110
|
-
const projectId = requiredProjectId(input.projectId);
|
|
111
|
-
const pkceVerifier = requiredPkceVerifier(input.pkceVerifier);
|
|
112
|
-
const returnHostname = normalizedHostname2(input.returnHostname);
|
|
113
|
-
const clientKey = requiredClientKey(input.clientKey);
|
|
114
|
-
const interactionMode = requiredInteractionMode(input.interactionMode ?? "interactive");
|
|
115
|
-
const timing = expiration(input.nowSeconds, input.ttlSeconds, FLOW_MAX_TTL_SECONDS, "authorization flow");
|
|
116
|
-
await this.requireAllowedHostname(projectId, returnHostname);
|
|
117
|
-
await this.deleteExpiredState({ projectId, nowSeconds: timing.createdAt });
|
|
118
|
-
const state = randomOpaqueValue();
|
|
119
|
-
const browserBinding = randomOpaqueValue();
|
|
120
|
-
const stateDigest = await opaqueDigest(state);
|
|
121
|
-
const browserBindingDigest = await opaqueDigest(browserBinding);
|
|
122
|
-
const clientKeyDigest = await opaqueDigest(clientKey);
|
|
123
|
-
const result = await this.database.prepare(
|
|
124
|
-
`INSERT INTO public_customer_auth_flows (
|
|
125
|
-
project_id, state_digest, browser_binding_digest, client_key_digest, interaction_mode,
|
|
126
|
-
pkce_verifier, return_hostname, created_at, expires_at
|
|
127
|
-
)
|
|
128
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
129
|
-
WHERE (
|
|
130
|
-
SELECT COUNT(*) FROM public_customer_auth_flows
|
|
131
|
-
WHERE project_id = ? AND expires_at > ?
|
|
132
|
-
) < ?
|
|
133
|
-
AND (
|
|
134
|
-
SELECT COUNT(*) FROM public_customer_auth_flows
|
|
135
|
-
WHERE project_id = ? AND client_key_digest = ? AND expires_at > ?
|
|
136
|
-
) < ?`
|
|
137
|
-
).bind(
|
|
138
|
-
projectId,
|
|
139
|
-
stateDigest,
|
|
140
|
-
browserBindingDigest,
|
|
141
|
-
clientKeyDigest,
|
|
142
|
-
interactionMode,
|
|
143
|
-
pkceVerifier,
|
|
144
|
-
returnHostname,
|
|
145
|
-
timing.createdAt,
|
|
146
|
-
timing.expiresAt,
|
|
147
|
-
projectId,
|
|
148
|
-
timing.createdAt,
|
|
149
|
-
this.maxRecentAuthorizationAttemptsPerProject,
|
|
150
|
-
projectId,
|
|
151
|
-
clientKeyDigest,
|
|
152
|
-
timing.createdAt,
|
|
153
|
-
this.maxRecentAuthorizationAttemptsPerClient
|
|
154
|
-
).run();
|
|
155
|
-
if (result.success === false) {
|
|
156
|
-
throw new Error("The customer authentication state was not stored.");
|
|
157
|
-
}
|
|
158
|
-
if (result.meta?.changes === 0) throw new CustomerAuthFlowCapacityError();
|
|
159
|
-
if (result.meta?.changes !== 1) {
|
|
160
|
-
throw new Error("The customer authentication state was not stored.");
|
|
161
|
-
}
|
|
162
|
-
return { state, browserBinding, returnHostname, expiresAt: timing.expiresAt };
|
|
163
|
-
}
|
|
164
|
-
async consumeAuthorizationFlow(input) {
|
|
165
|
-
const projectId = requiredProjectId(input.projectId);
|
|
166
|
-
const stateDigest = await opaqueDigest(requiredOpaqueValue(input.state, "authorization state"));
|
|
167
|
-
const browserBindingDigest = await opaqueDigest(
|
|
168
|
-
requiredOpaqueValue(input.browserBinding, "authorization browser binding")
|
|
169
|
-
);
|
|
170
|
-
const now = requiredNow(input.nowSeconds);
|
|
171
|
-
const row = await this.database.prepare(
|
|
172
|
-
`UPDATE public_customer_auth_flows
|
|
173
|
-
SET consumed_at = ?
|
|
174
|
-
WHERE project_id = ? AND state_digest = ? AND browser_binding_digest = ?
|
|
175
|
-
AND consumed_at IS NULL AND expires_at > ?
|
|
176
|
-
RETURNING pkce_verifier, return_hostname, interaction_mode, expires_at`
|
|
177
|
-
).bind(now, projectId, stateDigest, browserBindingDigest, now).first();
|
|
178
|
-
if (!row) return null;
|
|
179
|
-
return {
|
|
180
|
-
pkceVerifier: requiredPkceVerifier(row.pkce_verifier),
|
|
181
|
-
returnHostname: normalizedHostname2(row.return_hostname),
|
|
182
|
-
interactionMode: requiredInteractionMode(row.interaction_mode),
|
|
183
|
-
expiresAt: requiredExpiry(row.expires_at, now)
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
async createShopperSessionHandoff(input) {
|
|
187
|
-
const projectId = requiredProjectId(input.projectId);
|
|
188
|
-
const returnHostname = normalizedHostname2(input.returnHostname);
|
|
189
|
-
const sessionData = requiredSessionData(input.sessionData);
|
|
190
|
-
const now = requiredNow(input.nowSeconds);
|
|
191
|
-
const timing = expiration(now, input.handoffTtlSeconds, HANDOFF_MAX_TTL_SECONDS, "handoff");
|
|
192
|
-
const sessionTiming = expiration(now, input.sessionTtlSeconds, SESSION_MAX_TTL_SECONDS, "shopper session");
|
|
193
|
-
if (input.handoffTtlSeconds > input.sessionTtlSeconds) {
|
|
194
|
-
throw new Error("The handoff cannot outlive its shopper session.");
|
|
195
|
-
}
|
|
196
|
-
await this.requireAllowedHostname(projectId, returnHostname);
|
|
197
|
-
await this.deleteExpiredState({ projectId, nowSeconds: now });
|
|
198
|
-
const sessionId = randomOpaqueValue();
|
|
199
|
-
const sessionDigest = await opaqueDigest(sessionId);
|
|
200
|
-
const handoff = randomOpaqueValue();
|
|
201
|
-
const handoffDigest = await opaqueDigest(handoff);
|
|
202
|
-
const sessionSeal = await sealPendingSessionPayload({
|
|
203
|
-
sessionId,
|
|
204
|
-
sessionExpiresAt: sessionTiming.expiresAt,
|
|
205
|
-
sessionData,
|
|
206
|
-
handoff,
|
|
207
|
-
projectId,
|
|
208
|
-
returnHostname,
|
|
209
|
-
handoffDigest
|
|
210
|
-
});
|
|
211
|
-
await requireInsert(this.database.prepare(
|
|
212
|
-
`INSERT INTO public_customer_auth_handoffs (
|
|
213
|
-
project_id, handoff_digest, return_hostname, session_digest, session_seal, created_at, expires_at
|
|
214
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
215
|
-
).bind(
|
|
216
|
-
projectId,
|
|
217
|
-
handoffDigest,
|
|
218
|
-
returnHostname,
|
|
219
|
-
sessionDigest,
|
|
220
|
-
sessionSeal,
|
|
221
|
-
timing.createdAt,
|
|
222
|
-
timing.expiresAt
|
|
223
|
-
));
|
|
224
|
-
return { handoff, returnHostname, expiresAt: timing.expiresAt };
|
|
225
|
-
}
|
|
226
|
-
async consumeHandoff(input) {
|
|
227
|
-
const projectId = requiredProjectId(input.projectId);
|
|
228
|
-
const handoff = requiredOpaqueValue(input.handoff, "handoff");
|
|
229
|
-
const handoffDigest = await opaqueDigest(handoff);
|
|
230
|
-
const requestHostname = normalizedHostname2(input.requestHostname);
|
|
231
|
-
const now = requiredNow(input.nowSeconds);
|
|
232
|
-
const readHandoff = () => this.database.prepare(
|
|
233
|
-
`SELECT session_seal, session_digest, return_hostname, expires_at, claimed_at
|
|
234
|
-
FROM public_customer_auth_handoffs
|
|
235
|
-
WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ? AND expires_at > ?`
|
|
236
|
-
).bind(projectId, handoffDigest, requestHostname, now).first();
|
|
237
|
-
const row = await readHandoff();
|
|
238
|
-
if (!row) return null;
|
|
239
|
-
const returnHostname = normalizedHostname2(row.return_hostname);
|
|
240
|
-
const handoffExpiresAt = requiredExpiry(row.expires_at, now);
|
|
241
|
-
const sessionDigest = requiredDigest(row.session_digest, "shopper session");
|
|
242
|
-
const sessionSeal = requiredSessionSeal(row.session_seal);
|
|
243
|
-
const claimedAt = optionalClaimedAt(row.claimed_at, now);
|
|
244
|
-
if (claimedAt !== null) {
|
|
245
|
-
return this.readClaimedHandoff({
|
|
246
|
-
projectId,
|
|
247
|
-
handoff,
|
|
248
|
-
handoffDigest,
|
|
249
|
-
requestHostname,
|
|
250
|
-
handoffExpiresAt,
|
|
251
|
-
sessionDigest,
|
|
252
|
-
sessionSeal,
|
|
253
|
-
now
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
const payload = await unsealPendingSessionPayload({
|
|
257
|
-
sessionSeal,
|
|
258
|
-
handoff,
|
|
259
|
-
projectId,
|
|
260
|
-
returnHostname,
|
|
261
|
-
handoffDigest
|
|
262
|
-
});
|
|
263
|
-
if (await opaqueDigest(payload.sessionId) !== sessionDigest) {
|
|
264
|
-
throw new Error("The customer authentication handoff is invalid.");
|
|
265
|
-
}
|
|
266
|
-
const sessionExpiresAt = requiredExpiry(payload.sessionExpiresAt, now);
|
|
267
|
-
const claimedSeal = await sealClaimedSessionId({
|
|
268
|
-
sessionId: payload.sessionId,
|
|
269
|
-
handoff,
|
|
270
|
-
projectId,
|
|
271
|
-
returnHostname,
|
|
272
|
-
handoffDigest
|
|
273
|
-
});
|
|
274
|
-
const [claimResult, insertResult] = await this.database.batch([
|
|
275
|
-
this.database.prepare(
|
|
276
|
-
`UPDATE public_customer_auth_handoffs
|
|
277
|
-
SET claimed_at = ?, session_seal = ?
|
|
278
|
-
WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ?
|
|
279
|
-
AND expires_at > ? AND claimed_at IS NULL AND session_digest = ? AND session_seal = ?`
|
|
280
|
-
).bind(
|
|
281
|
-
now,
|
|
282
|
-
claimedSeal,
|
|
283
|
-
projectId,
|
|
284
|
-
handoffDigest,
|
|
285
|
-
returnHostname,
|
|
286
|
-
now,
|
|
287
|
-
sessionDigest,
|
|
288
|
-
sessionSeal
|
|
289
|
-
),
|
|
290
|
-
this.database.prepare(
|
|
291
|
-
`INSERT INTO public_customer_auth_sessions (
|
|
292
|
-
project_id, session_digest, audience_hostname, session_data, created_at, expires_at
|
|
293
|
-
)
|
|
294
|
-
SELECT ?, ?, ?, ?, ?, ?
|
|
295
|
-
WHERE EXISTS (
|
|
296
|
-
SELECT 1 FROM public_customer_auth_handoffs
|
|
297
|
-
WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ?
|
|
298
|
-
AND expires_at > ? AND claimed_at = ? AND session_digest = ? AND session_seal = ?
|
|
299
|
-
)`
|
|
300
|
-
).bind(
|
|
301
|
-
projectId,
|
|
302
|
-
sessionDigest,
|
|
303
|
-
returnHostname,
|
|
304
|
-
payload.sessionData,
|
|
305
|
-
now,
|
|
306
|
-
sessionExpiresAt,
|
|
307
|
-
projectId,
|
|
308
|
-
handoffDigest,
|
|
309
|
-
returnHostname,
|
|
310
|
-
now,
|
|
311
|
-
now,
|
|
312
|
-
sessionDigest,
|
|
313
|
-
claimedSeal
|
|
314
|
-
)
|
|
315
|
-
]);
|
|
316
|
-
if (!claimResult || !insertResult || claimResult.success === false || insertResult.success === false) {
|
|
317
|
-
throw new Error("The customer authentication handoff could not be consumed.");
|
|
318
|
-
}
|
|
319
|
-
const claimed = claimResult.meta?.changes ?? 0;
|
|
320
|
-
const inserted = insertResult.meta?.changes ?? 0;
|
|
321
|
-
if (claimed === 0 && inserted === 0) {
|
|
322
|
-
const racedRow = await readHandoff();
|
|
323
|
-
if (!racedRow || optionalClaimedAt(racedRow.claimed_at, now) === null) {
|
|
324
|
-
throw new Error("The customer authentication handoff could not be consumed.");
|
|
325
|
-
}
|
|
326
|
-
return this.readClaimedHandoff({
|
|
327
|
-
projectId,
|
|
328
|
-
handoff,
|
|
329
|
-
handoffDigest,
|
|
330
|
-
requestHostname,
|
|
331
|
-
handoffExpiresAt: requiredExpiry(racedRow.expires_at, now),
|
|
332
|
-
sessionDigest: requiredDigest(racedRow.session_digest, "shopper session"),
|
|
333
|
-
sessionSeal: requiredSessionSeal(racedRow.session_seal),
|
|
334
|
-
now
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
if (claimed !== 1 || inserted !== 1) {
|
|
338
|
-
throw new Error("The customer authentication handoff could not be consumed.");
|
|
339
|
-
}
|
|
340
|
-
return {
|
|
341
|
-
sessionId: payload.sessionId,
|
|
342
|
-
returnHostname,
|
|
343
|
-
expiresAt: handoffExpiresAt
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
async readClaimedHandoff(input) {
|
|
347
|
-
const sessionId = await unsealClaimedSessionId({
|
|
348
|
-
sessionSeal: input.sessionSeal,
|
|
349
|
-
handoff: input.handoff,
|
|
350
|
-
projectId: input.projectId,
|
|
351
|
-
returnHostname: input.requestHostname,
|
|
352
|
-
handoffDigest: input.handoffDigest
|
|
353
|
-
});
|
|
354
|
-
if (await opaqueDigest(sessionId) !== input.sessionDigest) {
|
|
355
|
-
throw new Error("The customer authentication handoff is invalid.");
|
|
356
|
-
}
|
|
357
|
-
const session = await this.database.prepare(
|
|
358
|
-
`SELECT expires_at FROM public_customer_auth_sessions
|
|
359
|
-
WHERE project_id = ? AND session_digest = ? AND audience_hostname = ? AND expires_at > ?`
|
|
360
|
-
).bind(
|
|
361
|
-
input.projectId,
|
|
362
|
-
input.sessionDigest,
|
|
363
|
-
input.requestHostname,
|
|
364
|
-
input.now
|
|
365
|
-
).first();
|
|
366
|
-
if (!session) throw new Error("The customer authentication handoff is invalid.");
|
|
367
|
-
requiredExpiry(session.expires_at, input.now);
|
|
368
|
-
return {
|
|
369
|
-
sessionId,
|
|
370
|
-
returnHostname: input.requestHostname,
|
|
371
|
-
expiresAt: input.handoffExpiresAt
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
async createShopperSession(input) {
|
|
375
|
-
const projectId = requiredProjectId(input.projectId);
|
|
376
|
-
const audienceHostname = normalizedHostname2(input.audienceHostname);
|
|
377
|
-
const sessionData = requiredSessionData(input.sessionData);
|
|
378
|
-
const timing = expiration(input.nowSeconds, input.ttlSeconds, SESSION_MAX_TTL_SECONDS, "shopper session");
|
|
379
|
-
await this.requireAllowedHostname(projectId, audienceHostname);
|
|
380
|
-
await this.deleteExpiredState({ projectId, nowSeconds: timing.createdAt });
|
|
381
|
-
return this.insertShopperSession({
|
|
382
|
-
projectId,
|
|
383
|
-
audienceHostname,
|
|
384
|
-
sessionData,
|
|
385
|
-
timing
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
async deleteExpiredState(input) {
|
|
389
|
-
const projectId = requiredProjectId(input.projectId);
|
|
390
|
-
const now = requiredNow(input.nowSeconds);
|
|
391
|
-
const deleted = {
|
|
392
|
-
flows: 0,
|
|
393
|
-
handoffs: 0,
|
|
394
|
-
sessions: 0
|
|
395
|
-
};
|
|
396
|
-
for (const [key, table] of [
|
|
397
|
-
["flows", "public_customer_auth_flows"],
|
|
398
|
-
["handoffs", "public_customer_auth_handoffs"],
|
|
399
|
-
["sessions", "public_customer_auth_sessions"]
|
|
400
|
-
]) {
|
|
401
|
-
const statement = this.database.prepare(
|
|
402
|
-
`DELETE FROM ${table}
|
|
403
|
-
WHERE rowid IN (
|
|
404
|
-
SELECT rowid FROM ${table}
|
|
405
|
-
WHERE project_id = ? AND expires_at <= ?
|
|
406
|
-
ORDER BY expires_at
|
|
407
|
-
LIMIT ?
|
|
408
|
-
)`
|
|
409
|
-
).bind(projectId, now, EXPIRED_STATE_DELETE_LIMIT);
|
|
410
|
-
const result = await statement.run();
|
|
411
|
-
if (result.success === false) {
|
|
412
|
-
throw new Error("Expired customer authentication state could not be deleted.");
|
|
413
|
-
}
|
|
414
|
-
deleted[key] = result.meta?.changes ?? 0;
|
|
415
|
-
}
|
|
416
|
-
return deleted;
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Bounded maintenance entry point. Opportunistic request cleanup is enough
|
|
420
|
-
* for the first proof; a future trusted first-party scheduler can invoke a
|
|
421
|
-
* protected merchant route to drain inactive projects. A User Worker
|
|
422
|
-
* `scheduled` export is not itself a trigger in a dispatch namespace.
|
|
423
|
-
*/
|
|
424
|
-
async runScheduledCleanup(input) {
|
|
425
|
-
const maxBatches = input.maxBatches ?? 4;
|
|
426
|
-
if (!Number.isSafeInteger(maxBatches) || maxBatches < 1 || maxBatches > 16) {
|
|
427
|
-
throw new Error("The scheduled customer authentication cleanup bound is invalid.");
|
|
428
|
-
}
|
|
429
|
-
const total = { flows: 0, handoffs: 0, sessions: 0 };
|
|
430
|
-
let batches = 0;
|
|
431
|
-
let mayHaveMore = false;
|
|
432
|
-
while (batches < maxBatches) {
|
|
433
|
-
const deleted = await this.deleteExpiredState({
|
|
434
|
-
projectId: input.projectId,
|
|
435
|
-
nowSeconds: input.nowSeconds
|
|
436
|
-
});
|
|
437
|
-
batches += 1;
|
|
438
|
-
total.flows += deleted.flows;
|
|
439
|
-
total.handoffs += deleted.handoffs;
|
|
440
|
-
total.sessions += deleted.sessions;
|
|
441
|
-
mayHaveMore = Object.values(deleted).some((count) => count === EXPIRED_STATE_DELETE_LIMIT);
|
|
442
|
-
if (!mayHaveMore) break;
|
|
443
|
-
}
|
|
444
|
-
return { ...total, batches, mayHaveMore };
|
|
445
|
-
}
|
|
446
|
-
async insertShopperSession(input) {
|
|
447
|
-
const sessionId = input.sessionId === void 0 ? randomOpaqueValue() : requiredOpaqueValue(input.sessionId, "shopper session");
|
|
448
|
-
const sessionDigest = await opaqueDigest(sessionId);
|
|
449
|
-
await requireInsert(this.database.prepare(
|
|
450
|
-
`INSERT INTO public_customer_auth_sessions (
|
|
451
|
-
project_id, session_digest, audience_hostname, session_data, created_at, expires_at
|
|
452
|
-
) VALUES (?, ?, ?, ?, ?, ?)`
|
|
453
|
-
).bind(
|
|
454
|
-
input.projectId,
|
|
455
|
-
sessionDigest,
|
|
456
|
-
input.audienceHostname,
|
|
457
|
-
input.sessionData,
|
|
458
|
-
input.timing.createdAt,
|
|
459
|
-
input.timing.expiresAt
|
|
460
|
-
));
|
|
461
|
-
return {
|
|
462
|
-
sessionId,
|
|
463
|
-
audienceHostname: input.audienceHostname,
|
|
464
|
-
expiresAt: input.timing.expiresAt
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
async readShopperSession(input) {
|
|
468
|
-
const projectId = requiredProjectId(input.projectId);
|
|
469
|
-
const sessionDigest = await opaqueDigest(requiredOpaqueValue(input.sessionId, "shopper session"));
|
|
470
|
-
const requestHostname = normalizedHostname2(input.requestHostname);
|
|
471
|
-
const now = requiredNow(input.nowSeconds);
|
|
472
|
-
const row = await this.database.prepare(
|
|
473
|
-
`SELECT session_data, audience_hostname, expires_at
|
|
474
|
-
FROM public_customer_auth_sessions
|
|
475
|
-
WHERE project_id = ? AND session_digest = ? AND audience_hostname = ?
|
|
476
|
-
AND expires_at > ?`
|
|
477
|
-
).bind(projectId, sessionDigest, requestHostname, now).first();
|
|
478
|
-
if (!row) return null;
|
|
479
|
-
return {
|
|
480
|
-
sessionData: requiredSessionData(row.session_data),
|
|
481
|
-
audienceHostname: normalizedHostname2(row.audience_hostname),
|
|
482
|
-
expiresAt: requiredExpiry(row.expires_at, now)
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
async deleteShopperSession(input) {
|
|
486
|
-
const projectId = requiredProjectId(input.projectId);
|
|
487
|
-
const sessionDigest = await opaqueDigest(requiredOpaqueValue(input.sessionId, "shopper session"));
|
|
488
|
-
const requestHostname = normalizedHostname2(input.requestHostname);
|
|
489
|
-
const result = await this.database.prepare(
|
|
490
|
-
`DELETE FROM public_customer_auth_sessions
|
|
491
|
-
WHERE project_id = ? AND session_digest = ? AND audience_hostname = ?`
|
|
492
|
-
).bind(projectId, sessionDigest, requestHostname).run();
|
|
493
|
-
if (result.success === false) throw new Error("The shopper session deletion failed.");
|
|
494
|
-
return result.meta?.changes === 1;
|
|
495
|
-
}
|
|
496
|
-
async requireAllowedHostname(projectId, hostname) {
|
|
497
|
-
let allowed = false;
|
|
498
|
-
try {
|
|
499
|
-
allowed = await this.hostnamePolicy({ projectId, hostname });
|
|
500
|
-
} catch {
|
|
501
|
-
throw new Error("The customer authentication hostname is not allowed for this project.");
|
|
502
|
-
}
|
|
503
|
-
if (allowed !== true) {
|
|
504
|
-
throw new Error("The customer authentication hostname is not allowed for this project.");
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
};
|
|
508
|
-
async function requireInsert(statement) {
|
|
509
|
-
const result = await statement.run();
|
|
510
|
-
if (result.success === false || result.meta?.changes !== void 0 && result.meta.changes !== 1) {
|
|
511
|
-
throw new Error("The customer authentication state was not stored.");
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
function expiration(nowSeconds, ttlSeconds, maximumTtlSeconds, label) {
|
|
515
|
-
const createdAt = requiredNow(nowSeconds);
|
|
516
|
-
if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > maximumTtlSeconds) {
|
|
517
|
-
throw new Error(`The ${label} lifetime is invalid.`);
|
|
518
|
-
}
|
|
519
|
-
return { createdAt, expiresAt: createdAt + ttlSeconds };
|
|
520
|
-
}
|
|
521
|
-
function requiredNow(value) {
|
|
522
|
-
const now = value ?? Math.floor(Date.now() / 1e3);
|
|
523
|
-
if (!Number.isSafeInteger(now) || now < 0) throw new Error("The current time is invalid.");
|
|
524
|
-
return now;
|
|
525
|
-
}
|
|
526
|
-
function requiredExpiry(value, now) {
|
|
527
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= now) {
|
|
528
|
-
throw new Error("The stored expiration is invalid.");
|
|
529
|
-
}
|
|
530
|
-
return value;
|
|
531
|
-
}
|
|
532
|
-
function optionalClaimedAt(value, now) {
|
|
533
|
-
if (value === null) return null;
|
|
534
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > now) {
|
|
535
|
-
throw new Error("The customer authentication handoff claim is invalid.");
|
|
536
|
-
}
|
|
537
|
-
return value;
|
|
538
|
-
}
|
|
539
|
-
function requiredProjectId(value) {
|
|
540
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(value)) {
|
|
541
|
-
throw new Error("The customer authentication project is invalid.");
|
|
542
|
-
}
|
|
543
|
-
return value;
|
|
544
|
-
}
|
|
545
|
-
function requiredPkceVerifier(value) {
|
|
546
|
-
if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{43,128}$/.test(value)) {
|
|
547
|
-
throw new Error("The PKCE verifier is invalid.");
|
|
548
|
-
}
|
|
549
|
-
return value;
|
|
550
|
-
}
|
|
551
|
-
function requiredSessionData(value) {
|
|
552
|
-
if (typeof value !== "string" || value.length < 1 || new TextEncoder().encode(value).byteLength > SESSION_DATA_MAX_LENGTH) {
|
|
553
|
-
throw new Error("The customer session data is invalid.");
|
|
554
|
-
}
|
|
555
|
-
return value;
|
|
556
|
-
}
|
|
557
|
-
function requiredClientKey(value) {
|
|
558
|
-
if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
559
|
-
throw new Error("The customer authentication client key is invalid.");
|
|
560
|
-
}
|
|
561
|
-
return value;
|
|
562
|
-
}
|
|
563
|
-
function requiredInteractionMode(value) {
|
|
564
|
-
if (value !== "interactive" && value !== "silent") {
|
|
565
|
-
throw new Error("The customer authentication interaction mode is invalid.");
|
|
566
|
-
}
|
|
567
|
-
return value;
|
|
568
|
-
}
|
|
569
|
-
function validAuthorizationAttemptLimit(value) {
|
|
570
|
-
return Number.isSafeInteger(value) && value >= 1 && value <= MAX_CONFIGURED_AUTHORIZATION_ATTEMPTS;
|
|
571
|
-
}
|
|
572
|
-
function requiredDigest(value, label) {
|
|
573
|
-
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
|
574
|
-
throw new Error(`The ${label} digest is invalid.`);
|
|
575
|
-
}
|
|
576
|
-
return value;
|
|
577
|
-
}
|
|
578
|
-
function requiredSessionSeal(value) {
|
|
579
|
-
if (typeof value !== "string" || value.length > SESSION_SEAL_MAX_LENGTH || !/^[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{40,99980}$/.test(value)) {
|
|
580
|
-
throw new Error("The customer authentication session seal is invalid.");
|
|
581
|
-
}
|
|
582
|
-
return value;
|
|
583
|
-
}
|
|
584
|
-
function requiredOpaqueValue(value, label) {
|
|
585
|
-
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
586
|
-
throw new Error(`The ${label} is invalid.`);
|
|
587
|
-
}
|
|
588
|
-
return value;
|
|
589
|
-
}
|
|
590
|
-
function normalizedHostname2(value) {
|
|
591
|
-
if (typeof value !== "string") throw new Error("The customer authentication hostname is invalid.");
|
|
592
|
-
const hostname = value.toLowerCase().replace(/\.$/, "");
|
|
593
|
-
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname) || hostname.length > 253) {
|
|
594
|
-
throw new Error("The customer authentication hostname is invalid.");
|
|
595
|
-
}
|
|
596
|
-
return hostname;
|
|
597
|
-
}
|
|
598
|
-
function randomOpaqueValue() {
|
|
599
|
-
const bytes = crypto.getRandomValues(new Uint8Array(OPAQUE_VALUE_BYTES));
|
|
600
|
-
return base64UrlEncode(bytes);
|
|
601
|
-
}
|
|
602
|
-
async function opaqueDigest(value) {
|
|
603
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
604
|
-
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
605
|
-
}
|
|
606
|
-
async function sealPendingSessionPayload(input) {
|
|
607
|
-
const key = await handoffKey(input.handoff, ["encrypt"]);
|
|
608
|
-
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
609
|
-
const plaintext = new TextEncoder().encode(
|
|
610
|
-
`${requiredOpaqueValue(input.sessionId, "shopper session")}
|
|
611
|
-
${requiredStoredExpiry(input.sessionExpiresAt)}
|
|
612
|
-
${requiredSessionData(input.sessionData)}`
|
|
613
|
-
);
|
|
614
|
-
const ciphertext = await crypto.subtle.encrypt({
|
|
615
|
-
name: "AES-GCM",
|
|
616
|
-
iv: nonce,
|
|
617
|
-
additionalData: handoffContext(input, "pending")
|
|
618
|
-
}, key, plaintext);
|
|
619
|
-
return requiredSessionSeal(`${base64UrlEncode(nonce)}.${base64UrlEncode(new Uint8Array(ciphertext))}`);
|
|
620
|
-
}
|
|
621
|
-
async function unsealPendingSessionPayload(input) {
|
|
622
|
-
const [encodedNonce, encodedCiphertext] = input.sessionSeal.split(".");
|
|
623
|
-
if (!encodedNonce || !encodedCiphertext) throw new Error("The customer authentication session seal is invalid.");
|
|
624
|
-
try {
|
|
625
|
-
const key = await handoffKey(input.handoff, ["decrypt"]);
|
|
626
|
-
const plaintext = await crypto.subtle.decrypt({
|
|
627
|
-
name: "AES-GCM",
|
|
628
|
-
iv: base64UrlDecode(encodedNonce),
|
|
629
|
-
additionalData: handoffContext(input, "pending")
|
|
630
|
-
}, key, base64UrlDecode(encodedCiphertext));
|
|
631
|
-
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
|
|
632
|
-
const firstSeparator = decoded.indexOf("\n");
|
|
633
|
-
const secondSeparator = decoded.indexOf("\n", firstSeparator + 1);
|
|
634
|
-
if (firstSeparator < 0 || secondSeparator < 0) {
|
|
635
|
-
throw new Error("The customer authentication session seal is invalid.");
|
|
636
|
-
}
|
|
637
|
-
return {
|
|
638
|
-
sessionId: requiredOpaqueValue(decoded.slice(0, firstSeparator), "shopper session"),
|
|
639
|
-
sessionExpiresAt: requiredStoredExpiry(decoded.slice(firstSeparator + 1, secondSeparator)),
|
|
640
|
-
sessionData: requiredSessionData(decoded.slice(secondSeparator + 1))
|
|
641
|
-
};
|
|
642
|
-
} catch {
|
|
643
|
-
throw new Error("The customer authentication session seal is invalid.");
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
async function sealClaimedSessionId(input) {
|
|
647
|
-
const key = await handoffKey(input.handoff, ["encrypt"]);
|
|
648
|
-
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
649
|
-
const ciphertext = await crypto.subtle.encrypt({
|
|
650
|
-
name: "AES-GCM",
|
|
651
|
-
iv: nonce,
|
|
652
|
-
additionalData: handoffContext(input, "claimed")
|
|
653
|
-
}, key, new TextEncoder().encode(requiredOpaqueValue(input.sessionId, "shopper session")));
|
|
654
|
-
return requiredSessionSeal(`${base64UrlEncode(nonce)}.${base64UrlEncode(new Uint8Array(ciphertext))}`);
|
|
655
|
-
}
|
|
656
|
-
async function unsealClaimedSessionId(input) {
|
|
657
|
-
const [encodedNonce, encodedCiphertext] = input.sessionSeal.split(".");
|
|
658
|
-
if (!encodedNonce || !encodedCiphertext) throw new Error("The customer authentication session seal is invalid.");
|
|
659
|
-
try {
|
|
660
|
-
const key = await handoffKey(input.handoff, ["decrypt"]);
|
|
661
|
-
const plaintext = await crypto.subtle.decrypt({
|
|
662
|
-
name: "AES-GCM",
|
|
663
|
-
iv: base64UrlDecode(encodedNonce),
|
|
664
|
-
additionalData: handoffContext(input, "claimed")
|
|
665
|
-
}, key, base64UrlDecode(encodedCiphertext));
|
|
666
|
-
return requiredOpaqueValue(
|
|
667
|
-
new TextDecoder("utf-8", { fatal: true }).decode(plaintext),
|
|
668
|
-
"shopper session"
|
|
669
|
-
);
|
|
670
|
-
} catch {
|
|
671
|
-
throw new Error("The customer authentication session seal is invalid.");
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
function requiredStoredExpiry(value) {
|
|
675
|
-
const parsed = typeof value === "number" ? value : typeof value === "string" && /^(?:0|[1-9][0-9]{0,15})$/.test(value) ? Number(value) : Number.NaN;
|
|
676
|
-
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
677
|
-
throw new Error("The stored expiration is invalid.");
|
|
678
|
-
}
|
|
679
|
-
return parsed;
|
|
680
|
-
}
|
|
681
|
-
async function handoffKey(handoff, usages) {
|
|
682
|
-
const keyMaterial = await crypto.subtle.digest(
|
|
683
|
-
"SHA-256",
|
|
684
|
-
new TextEncoder().encode(`public-customer-auth-handoff:${handoff}`)
|
|
685
|
-
);
|
|
686
|
-
return crypto.subtle.importKey("raw", keyMaterial, "AES-GCM", false, usages);
|
|
687
|
-
}
|
|
688
|
-
function handoffContext(input, state) {
|
|
689
|
-
return new TextEncoder().encode(
|
|
690
|
-
`${input.projectId}\0${input.returnHostname}\0${input.handoffDigest}\0${state}`
|
|
691
|
-
);
|
|
692
|
-
}
|
|
693
|
-
function base64UrlEncode(value) {
|
|
694
|
-
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
|
|
695
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
696
|
-
}
|
|
697
|
-
function base64UrlDecode(value) {
|
|
698
|
-
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("The encoded customer authentication value is invalid.");
|
|
699
|
-
const padding = "=".repeat((4 - value.length % 4) % 4);
|
|
700
|
-
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
|
|
701
|
-
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// src/platform/customer-auth/shopify-route-adapter.ts
|
|
705
|
-
var DEFAULT_LOGIN_PATH = "/auth/login";
|
|
706
|
-
var DEFAULT_CALLBACK_PATH = "/auth/callback";
|
|
707
|
-
var DEFAULT_HANDOFF_PATH = "/auth/handoff";
|
|
708
|
-
var DEFAULT_SESSION_PATH = "/api/session";
|
|
709
|
-
var DEFAULT_LOGOUT_PATH = "/auth/logout";
|
|
710
|
-
var DEFAULT_POST_LOGOUT_PATH = "/auth/logout/callback";
|
|
711
|
-
var DEFAULT_SIGNED_IN_PATH = "/account";
|
|
712
|
-
var DEFAULT_SIGNED_OUT_PATH = "/";
|
|
713
|
-
var FLOW_TTL_SECONDS = 5 * 60;
|
|
714
|
-
var HANDOFF_TTL_SECONDS = 60;
|
|
715
|
-
var MAX_REQUEST_URL_LENGTH = 8192;
|
|
716
|
-
var MAX_FORM_BODY_BYTES = 1024;
|
|
717
|
-
var MAX_COOKIE_HEADER_LENGTH = 8192;
|
|
718
|
-
var MAX_DISCOVERY_BYTES = 16384;
|
|
719
|
-
var MAX_TOKEN_BYTES = 65536;
|
|
720
|
-
var MAX_GRAPHQL_BYTES = 65536;
|
|
721
|
-
var MAX_ENDPOINT_LENGTH = 2048;
|
|
722
|
-
var MAX_ACCESS_TOKEN_LENGTH = 32768;
|
|
723
|
-
var MAX_ID_TOKEN_LENGTH = 16384;
|
|
724
|
-
var MAX_SESSION_TTL_SECONDS = 31 * 24 * 60 * 60;
|
|
725
|
-
var SHOPPER_SESSION_RECORD_TTL_SECONDS = MAX_SESSION_TTL_SECONDS;
|
|
726
|
-
var PROVIDER_FETCH_TIMEOUT_MS = 1e4;
|
|
727
|
-
var DEFAULT_PROVIDER_USER_AGENT = "TenderAccountsCustomerAuth/0.1";
|
|
728
|
-
var PROVIDER_REDIRECT_MODE = "manual";
|
|
729
|
-
var PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE = "__Host-tender_accounts_session";
|
|
730
|
-
var PUBLIC_CUSTOMER_AUTH_FLOW_COOKIE = "__Host-tender_accounts_auth_flow";
|
|
731
|
-
var SHOPIFY_CUSTOMER_IDENTITY_QUERY = `query CustomerIdentity {
|
|
732
|
-
customer {
|
|
733
|
-
id
|
|
734
|
-
displayName
|
|
735
|
-
emailAddress {
|
|
736
|
-
emailAddress
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}`;
|
|
740
|
-
function createShopifyCustomerAccountRouteAdapter(input) {
|
|
741
|
-
if (!(input.sessionStore instanceof PublicCustomerAuthSessionStore)) {
|
|
742
|
-
throw new Error("A public customer authentication session store is required.");
|
|
743
|
-
}
|
|
744
|
-
if (typeof input.hostnamePolicy !== "function") {
|
|
745
|
-
throw new Error("A public customer authentication hostname policy is required.");
|
|
746
|
-
}
|
|
747
|
-
const config = validateConfig(input.config);
|
|
748
|
-
const providerFetch = input.providerFetch ?? fetch;
|
|
749
|
-
if (typeof providerFetch !== "function") {
|
|
750
|
-
throw new Error("A Shopify provider fetch implementation is required.");
|
|
751
|
-
}
|
|
752
|
-
const authorizationClientKey = input.authorizationClientKey ?? cloudflareAuthorizationClientKey;
|
|
753
|
-
if (typeof authorizationClientKey !== "function") {
|
|
754
|
-
throw new Error("A customer authentication client-key implementation is required.");
|
|
755
|
-
}
|
|
756
|
-
const routeInput = {
|
|
757
|
-
sessionStore: input.sessionStore,
|
|
758
|
-
hostnamePolicy: input.hostnamePolicy,
|
|
759
|
-
authorizationClientKey
|
|
760
|
-
};
|
|
761
|
-
return {
|
|
762
|
-
async handle(request) {
|
|
763
|
-
try {
|
|
764
|
-
const routeUrl = parsedRequestUrl(request);
|
|
765
|
-
const route = routeFor(config, routeUrl.pathname);
|
|
766
|
-
if (!route) return null;
|
|
767
|
-
const requestUrl = validatedRequestUrl(routeUrl);
|
|
768
|
-
if (request.method !== route.method) return methodNotAllowed(route.method);
|
|
769
|
-
switch (route.name) {
|
|
770
|
-
case "login":
|
|
771
|
-
return await handleLogin({ request, requestUrl, config, input: routeInput, providerFetch });
|
|
772
|
-
case "callback":
|
|
773
|
-
return await handleCallback({ request, requestUrl, config, input: routeInput, providerFetch });
|
|
774
|
-
case "handoff":
|
|
775
|
-
return await handleHandoff({ request, requestUrl, config, input: routeInput });
|
|
776
|
-
case "session":
|
|
777
|
-
return await handleSession({ request, requestUrl, config, input: routeInput, providerFetch });
|
|
778
|
-
case "logout":
|
|
779
|
-
return await handleLogout({ request, requestUrl, config, input: routeInput });
|
|
780
|
-
case "postLogout":
|
|
781
|
-
return handlePostLogout({ requestUrl, config });
|
|
782
|
-
}
|
|
783
|
-
} catch (error) {
|
|
784
|
-
if (error instanceof SafeHttpError) return safeError(error.status, error.code);
|
|
785
|
-
if (error instanceof CustomerAuthFlowCapacityError) {
|
|
786
|
-
return safeError(429, "customer_authentication_busy");
|
|
787
|
-
}
|
|
788
|
-
if (error instanceof ProviderFailure) return safeError(502, "customer_authentication_unavailable");
|
|
789
|
-
return safeError(500, "customer_authentication_failed");
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
};
|
|
793
|
-
}
|
|
794
|
-
async function handleLogin(context) {
|
|
795
|
-
const callbackHostname2 = requireCallbackHostname(context.requestUrl, context.config);
|
|
796
|
-
const returnHostname = requiredSingleQueryValue(context.requestUrl, "return_host", 253);
|
|
797
|
-
const prompt = optionalSingleQueryValue(context.requestUrl, "prompt", 4, /^(?:none)$/);
|
|
798
|
-
if (!await isAllowedHostname(context.input, context.config.projectId, returnHostname)) {
|
|
799
|
-
throw new SafeHttpError(400, "invalid_return_host");
|
|
800
|
-
}
|
|
801
|
-
const verifier = randomBase64Url(32);
|
|
802
|
-
const challenge = await sha256Base64Url(verifier);
|
|
803
|
-
const flow = await context.input.sessionStore.createAuthorizationFlow({
|
|
804
|
-
projectId: context.config.projectId,
|
|
805
|
-
pkceVerifier: verifier,
|
|
806
|
-
returnHostname,
|
|
807
|
-
clientKey: await requiredAuthorizationClientKey(context.input.authorizationClientKey, context.request),
|
|
808
|
-
interactionMode: prompt === "none" ? "silent" : "interactive",
|
|
809
|
-
ttlSeconds: FLOW_TTL_SECONDS
|
|
810
|
-
});
|
|
811
|
-
let discovery;
|
|
812
|
-
try {
|
|
813
|
-
discovery = await discoverOpenId(context.config, context.providerFetch);
|
|
814
|
-
} catch (error) {
|
|
815
|
-
await context.input.sessionStore.consumeAuthorizationFlow({
|
|
816
|
-
projectId: context.config.projectId,
|
|
817
|
-
state: flow.state,
|
|
818
|
-
browserBinding: flow.browserBinding
|
|
819
|
-
});
|
|
820
|
-
throw error;
|
|
821
|
-
}
|
|
822
|
-
const authorizationUrl = new URL(discovery.authorizationEndpoint);
|
|
823
|
-
authorizationUrl.searchParams.set("scope", "openid email customer-account-api:full");
|
|
824
|
-
authorizationUrl.searchParams.set("client_id", context.config.clientId);
|
|
825
|
-
authorizationUrl.searchParams.set("response_type", "code");
|
|
826
|
-
authorizationUrl.searchParams.set("redirect_uri", callbackUrl(context.config, callbackHostname2));
|
|
827
|
-
authorizationUrl.searchParams.set("state", flow.state);
|
|
828
|
-
authorizationUrl.searchParams.set("code_challenge", challenge);
|
|
829
|
-
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
830
|
-
if (prompt) authorizationUrl.searchParams.set("prompt", prompt);
|
|
831
|
-
return redirectResponse(authorizationUrl.toString(), 302, {
|
|
832
|
-
"Set-Cookie": authorizationFlowCookie(flow.state, flow.browserBinding)
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
async function handleCallback(context) {
|
|
836
|
-
const callbackHostname2 = requireCallbackHostname(context.requestUrl, context.config);
|
|
837
|
-
const state = requiredSingleQueryValue(context.requestUrl, "state", 43, /^[A-Za-z0-9_-]{43}$/);
|
|
838
|
-
const browserBinding = readOpaqueCookie(
|
|
839
|
-
context.request.headers.get("Cookie"),
|
|
840
|
-
authorizationFlowCookieName(state)
|
|
841
|
-
);
|
|
842
|
-
if (!browserBinding) throw new SafeHttpError(400, "invalid_authorization_state");
|
|
843
|
-
if (context.requestUrl.searchParams.has("error")) {
|
|
844
|
-
const providerError = requiredSingleQueryValue(
|
|
845
|
-
context.requestUrl,
|
|
846
|
-
"error",
|
|
847
|
-
256,
|
|
848
|
-
/^[A-Za-z0-9._-]+$/
|
|
849
|
-
);
|
|
850
|
-
const deniedFlow = await context.input.sessionStore.consumeAuthorizationFlow({
|
|
851
|
-
projectId: context.config.projectId,
|
|
852
|
-
state,
|
|
853
|
-
browserBinding
|
|
854
|
-
});
|
|
855
|
-
if (!deniedFlow) throw new SafeHttpError(400, "invalid_authorization_state");
|
|
856
|
-
if (providerError === "login_required" && deniedFlow.interactionMode === "silent") {
|
|
857
|
-
const interactiveUrl = new URL(`https://${callbackHostname2}${context.config.loginPath}`);
|
|
858
|
-
interactiveUrl.searchParams.set("return_host", deniedFlow.returnHostname);
|
|
859
|
-
return handleLogin({
|
|
860
|
-
request: new Request(interactiveUrl, { headers: context.request.headers }),
|
|
861
|
-
requestUrl: interactiveUrl,
|
|
862
|
-
config: context.config,
|
|
863
|
-
input: context.input,
|
|
864
|
-
providerFetch: context.providerFetch
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
return safeError(400, "customer_authentication_denied");
|
|
868
|
-
}
|
|
869
|
-
const code = requiredSingleQueryValue(context.requestUrl, "code", 4096, /^[A-Za-z0-9._~-]+$/);
|
|
870
|
-
const flow = await context.input.sessionStore.consumeAuthorizationFlow({
|
|
871
|
-
projectId: context.config.projectId,
|
|
872
|
-
state,
|
|
873
|
-
browserBinding
|
|
874
|
-
});
|
|
875
|
-
if (!flow) throw new SafeHttpError(400, "invalid_authorization_state");
|
|
876
|
-
if (!await isAllowedHostname(context.input, context.config.projectId, flow.returnHostname)) {
|
|
877
|
-
return safeError(400, "invalid_return_host");
|
|
878
|
-
}
|
|
879
|
-
try {
|
|
880
|
-
const openId = await discoverOpenId(context.config, context.providerFetch);
|
|
881
|
-
const token = await exchangeAuthorizationCode({
|
|
882
|
-
config: context.config,
|
|
883
|
-
providerFetch: context.providerFetch,
|
|
884
|
-
tokenEndpoint: openId.tokenEndpoint,
|
|
885
|
-
code,
|
|
886
|
-
verifier: flow.pkceVerifier,
|
|
887
|
-
callbackHostname: callbackHostname2
|
|
888
|
-
});
|
|
889
|
-
const customerApi = await discoverCustomerApi(context.config, context.providerFetch);
|
|
890
|
-
const tokenIssuedAt = currentSeconds();
|
|
891
|
-
const handoff = await context.input.sessionStore.createShopperSessionHandoff({
|
|
892
|
-
projectId: context.config.projectId,
|
|
893
|
-
returnHostname: flow.returnHostname,
|
|
894
|
-
sessionData: JSON.stringify({
|
|
895
|
-
version: 1,
|
|
896
|
-
accessToken: token.accessToken,
|
|
897
|
-
idToken: token.idToken,
|
|
898
|
-
graphqlEndpoint: customerApi.graphqlEndpoint,
|
|
899
|
-
endSessionEndpoint: openId.endSessionEndpoint,
|
|
900
|
-
accessTokenExpiresAt: tokenIssuedAt + token.expiresIn
|
|
901
|
-
}),
|
|
902
|
-
sessionTtlSeconds: SHOPPER_SESSION_RECORD_TTL_SECONDS,
|
|
903
|
-
handoffTtlSeconds: Math.min(HANDOFF_TTL_SECONDS, token.expiresIn)
|
|
904
|
-
});
|
|
905
|
-
if (handoff.returnHostname === callbackHostname2) {
|
|
906
|
-
return finalizeHandoff(context, handoff.handoff, handoff.returnHostname);
|
|
907
|
-
}
|
|
908
|
-
return handoffPostResponse(context.config, handoff.returnHostname, handoff.handoff);
|
|
909
|
-
} catch (error) {
|
|
910
|
-
if (error instanceof ProviderFailure) {
|
|
911
|
-
return safeError(502, "customer_authentication_unavailable");
|
|
912
|
-
}
|
|
913
|
-
throw error;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
async function handleHandoff(context) {
|
|
917
|
-
const requestHostname = normalizedHostname3(context.requestUrl.hostname);
|
|
918
|
-
if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
|
|
919
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
920
|
-
}
|
|
921
|
-
requireOrigin(context.request, [
|
|
922
|
-
`https://${context.config.canonicalHostname}`,
|
|
923
|
-
...Array.from(context.config.additionalCallbackHostnames, (hostname) => `https://${hostname}`),
|
|
924
|
-
`https://${requestHostname}`
|
|
925
|
-
]);
|
|
926
|
-
const form = await boundedForm(context.request);
|
|
927
|
-
const handoffValue = requiredSingleFormValue(form, "handoff", 43, /^[A-Za-z0-9_-]{43}$/);
|
|
928
|
-
if (Array.from(form.keys()).some((key) => key !== "handoff")) {
|
|
929
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
930
|
-
}
|
|
931
|
-
return finalizeHandoff(context, handoffValue, requestHostname);
|
|
932
|
-
}
|
|
933
|
-
async function finalizeHandoff(context, handoffValue, requestHostname) {
|
|
934
|
-
const handoff = await context.input.sessionStore.consumeHandoff({
|
|
935
|
-
projectId: context.config.projectId,
|
|
936
|
-
handoff: handoffValue,
|
|
937
|
-
requestHostname
|
|
938
|
-
});
|
|
939
|
-
if (!handoff) throw new SafeHttpError(400, "invalid_handoff");
|
|
940
|
-
const previousSessionId = readSessionCookie(context.request.headers.get("Cookie"));
|
|
941
|
-
if (previousSessionId && previousSessionId !== handoff.sessionId) {
|
|
942
|
-
try {
|
|
943
|
-
await context.input.sessionStore.deleteShopperSession({
|
|
944
|
-
projectId: context.config.projectId,
|
|
945
|
-
sessionId: previousSessionId,
|
|
946
|
-
requestHostname
|
|
947
|
-
});
|
|
948
|
-
} catch {
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
return new Response(null, {
|
|
952
|
-
status: 303,
|
|
953
|
-
headers: secureHeaders({
|
|
954
|
-
Location: context.config.signedInPath,
|
|
955
|
-
"Set-Cookie": sessionCookie(handoff.sessionId)
|
|
956
|
-
})
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
async function handleSession(context) {
|
|
960
|
-
const requestHostname = normalizedHostname3(context.requestUrl.hostname);
|
|
961
|
-
if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
|
|
962
|
-
throw new SafeHttpError(404, "not_found");
|
|
963
|
-
}
|
|
964
|
-
const sessionId = readSessionCookie(context.request.headers.get("Cookie"));
|
|
965
|
-
if (!sessionId) return unauthenticatedResponse(true);
|
|
966
|
-
const stored = await context.input.sessionStore.readShopperSession({
|
|
967
|
-
projectId: context.config.projectId,
|
|
968
|
-
sessionId,
|
|
969
|
-
requestHostname
|
|
970
|
-
});
|
|
971
|
-
if (!stored) return unauthenticatedResponse(true);
|
|
972
|
-
const session = parseStoredShopifySession(stored.sessionData, context.config);
|
|
973
|
-
const renewalUrl = customerRenewalUrl(context.config, requestHostname);
|
|
974
|
-
if (session.accessTokenExpiresAt <= currentSeconds()) {
|
|
975
|
-
return jsonResponse(200, {
|
|
976
|
-
authenticated: false,
|
|
977
|
-
renewalRequired: true,
|
|
978
|
-
renewalUrl
|
|
979
|
-
});
|
|
980
|
-
}
|
|
981
|
-
const response = await fetchProvider(context.providerFetch, session.graphqlEndpoint, {
|
|
982
|
-
method: "POST",
|
|
983
|
-
headers: providerRequestHeaders(context.config, {
|
|
984
|
-
Accept: "application/json",
|
|
985
|
-
Authorization: session.accessToken,
|
|
986
|
-
"Content-Type": "application/json"
|
|
987
|
-
}, providerOrigin(context.config, requestHostname)),
|
|
988
|
-
body: JSON.stringify({
|
|
989
|
-
operationName: "CustomerIdentity",
|
|
990
|
-
query: SHOPIFY_CUSTOMER_IDENTITY_QUERY,
|
|
991
|
-
variables: {}
|
|
992
|
-
}),
|
|
993
|
-
redirect: PROVIDER_REDIRECT_MODE
|
|
994
|
-
});
|
|
995
|
-
if (response.status === 401 || response.status === 403) {
|
|
996
|
-
return jsonResponse(200, {
|
|
997
|
-
authenticated: false,
|
|
998
|
-
renewalRequired: true,
|
|
999
|
-
renewalUrl
|
|
1000
|
-
});
|
|
1001
|
-
}
|
|
1002
|
-
if (!response.ok) throw new ProviderFailure();
|
|
1003
|
-
const payload = await readBoundedJson(response, MAX_GRAPHQL_BYTES);
|
|
1004
|
-
const customer = parseCustomerIdentity(payload);
|
|
1005
|
-
return jsonResponse(200, {
|
|
1006
|
-
authenticated: true,
|
|
1007
|
-
customer,
|
|
1008
|
-
expiresAt: session.accessTokenExpiresAt,
|
|
1009
|
-
renewalUrl
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
async function handleLogout(context) {
|
|
1013
|
-
const requestHostname = normalizedHostname3(context.requestUrl.hostname);
|
|
1014
|
-
if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
|
|
1015
|
-
throw new SafeHttpError(404, "not_found");
|
|
1016
|
-
}
|
|
1017
|
-
requireOrigin(context.request, `https://${requestHostname}`);
|
|
1018
|
-
const sessionId = readSessionCookie(context.request.headers.get("Cookie"));
|
|
1019
|
-
if (!sessionId) return localLogoutResponse();
|
|
1020
|
-
const stored = await context.input.sessionStore.readShopperSession({
|
|
1021
|
-
projectId: context.config.projectId,
|
|
1022
|
-
sessionId,
|
|
1023
|
-
requestHostname
|
|
1024
|
-
});
|
|
1025
|
-
await context.input.sessionStore.deleteShopperSession({
|
|
1026
|
-
projectId: context.config.projectId,
|
|
1027
|
-
sessionId,
|
|
1028
|
-
requestHostname
|
|
1029
|
-
});
|
|
1030
|
-
if (!stored) return localLogoutResponse();
|
|
1031
|
-
let session;
|
|
1032
|
-
try {
|
|
1033
|
-
session = parseStoredShopifySession(stored.sessionData, context.config);
|
|
1034
|
-
} catch {
|
|
1035
|
-
return localLogoutResponse();
|
|
1036
|
-
}
|
|
1037
|
-
const providerLogout = new URL(session.endSessionEndpoint);
|
|
1038
|
-
providerLogout.searchParams.set("id_token_hint", session.idToken);
|
|
1039
|
-
providerLogout.searchParams.set(
|
|
1040
|
-
"post_logout_redirect_uri",
|
|
1041
|
-
`https://${callbackHostname(context.config, requestHostname)}${context.config.postLogoutPath}`
|
|
1042
|
-
);
|
|
1043
|
-
return redirectResponse(providerLogout.toString(), 303, {
|
|
1044
|
-
"Set-Cookie": expiredSessionCookie()
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
function handlePostLogout(context) {
|
|
1048
|
-
requireCallbackHostname(context.requestUrl, context.config);
|
|
1049
|
-
return redirectResponse(context.config.signedOutPath, 303);
|
|
1050
|
-
}
|
|
1051
|
-
function routeFor(config, pathname) {
|
|
1052
|
-
if (pathname === config.loginPath) return { name: "login", method: "GET" };
|
|
1053
|
-
if (pathname === config.callbackPath) return { name: "callback", method: "GET" };
|
|
1054
|
-
if (pathname === config.handoffPath) return { name: "handoff", method: "POST" };
|
|
1055
|
-
if (pathname === config.sessionPath) return { name: "session", method: "GET" };
|
|
1056
|
-
if (pathname === config.logoutPath) return { name: "logout", method: "POST" };
|
|
1057
|
-
if (pathname === config.postLogoutPath) return { name: "postLogout", method: "GET" };
|
|
1058
|
-
return null;
|
|
1059
|
-
}
|
|
1060
|
-
function validateConfig(config) {
|
|
1061
|
-
if (!config || typeof config !== "object") throw new Error("Customer authentication config is required.");
|
|
1062
|
-
const allowedConfigKeys = /* @__PURE__ */ new Set([
|
|
1063
|
-
"projectId",
|
|
1064
|
-
"canonicalHostname",
|
|
1065
|
-
"additionalCallbackHostnames",
|
|
1066
|
-
"storefrontDomain",
|
|
1067
|
-
"clientId",
|
|
1068
|
-
"providerUserAgent",
|
|
1069
|
-
"additionalProviderHostnames",
|
|
1070
|
-
"loginPath",
|
|
1071
|
-
"callbackPath",
|
|
1072
|
-
"handoffPath",
|
|
1073
|
-
"sessionPath",
|
|
1074
|
-
"logoutPath",
|
|
1075
|
-
"postLogoutPath",
|
|
1076
|
-
"signedInPath",
|
|
1077
|
-
"signedOutPath"
|
|
1078
|
-
]);
|
|
1079
|
-
if (Object.keys(config).some((key) => !allowedConfigKeys.has(key))) {
|
|
1080
|
-
throw new Error("The customer authentication config contains an unsupported field.");
|
|
1081
|
-
}
|
|
1082
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(config.projectId)) {
|
|
1083
|
-
throw new Error("The customer authentication project is invalid.");
|
|
1084
|
-
}
|
|
1085
|
-
if (typeof config.clientId !== "string" || !/^[A-Za-z0-9_-]{8,256}$/.test(config.clientId)) {
|
|
1086
|
-
throw new Error("The Shopify Customer Account client ID is invalid.");
|
|
1087
|
-
}
|
|
1088
|
-
const canonicalHostname = normalizedHostname3(config.canonicalHostname);
|
|
1089
|
-
const additionalCallbackHostnames = validatedHostnameSet(
|
|
1090
|
-
config.additionalCallbackHostnames,
|
|
1091
|
-
"Shopify callback hostname allowlist"
|
|
1092
|
-
);
|
|
1093
|
-
additionalCallbackHostnames.delete(canonicalHostname);
|
|
1094
|
-
const storefrontDomain = normalizedHostname3(config.storefrontDomain);
|
|
1095
|
-
const providerUserAgent = config.providerUserAgent ?? DEFAULT_PROVIDER_USER_AGENT;
|
|
1096
|
-
if (typeof providerUserAgent !== "string" || providerUserAgent.length < 3 || providerUserAgent.length > 128 || /[^\u0020-\u007e]/.test(providerUserAgent)) {
|
|
1097
|
-
throw new Error("The Shopify provider User-Agent is invalid.");
|
|
1098
|
-
}
|
|
1099
|
-
const additionalProviderHostnames = /* @__PURE__ */ new Set();
|
|
1100
|
-
if (config.additionalProviderHostnames !== void 0) {
|
|
1101
|
-
if (!Array.isArray(config.additionalProviderHostnames) || config.additionalProviderHostnames.length > 8) {
|
|
1102
|
-
throw new Error("The Shopify provider hostname allowlist is invalid.");
|
|
1103
|
-
}
|
|
1104
|
-
for (const hostname of config.additionalProviderHostnames) {
|
|
1105
|
-
additionalProviderHostnames.add(normalizedHostname3(hostname));
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
const routePaths = {
|
|
1109
|
-
loginPath: validatedPath(config.loginPath ?? DEFAULT_LOGIN_PATH, "login"),
|
|
1110
|
-
callbackPath: validatedPath(config.callbackPath ?? DEFAULT_CALLBACK_PATH, "callback"),
|
|
1111
|
-
handoffPath: validatedPath(config.handoffPath ?? DEFAULT_HANDOFF_PATH, "handoff"),
|
|
1112
|
-
sessionPath: validatedPath(config.sessionPath ?? DEFAULT_SESSION_PATH, "session"),
|
|
1113
|
-
logoutPath: validatedPath(config.logoutPath ?? DEFAULT_LOGOUT_PATH, "logout"),
|
|
1114
|
-
postLogoutPath: validatedPath(config.postLogoutPath ?? DEFAULT_POST_LOGOUT_PATH, "post-logout")
|
|
1115
|
-
};
|
|
1116
|
-
if (new Set(Object.values(routePaths)).size !== Object.keys(routePaths).length) {
|
|
1117
|
-
throw new Error("Customer authentication route paths must be distinct.");
|
|
1118
|
-
}
|
|
1119
|
-
const signedInPath = validatedPath(config.signedInPath ?? DEFAULT_SIGNED_IN_PATH, "signed-in");
|
|
1120
|
-
const signedOutPath = validatedPath(config.signedOutPath ?? DEFAULT_SIGNED_OUT_PATH, "signed-out");
|
|
1121
|
-
if (signedOutPath === routePaths.postLogoutPath) {
|
|
1122
|
-
throw new Error("The signed-out path cannot be the post-logout callback path.");
|
|
1123
|
-
}
|
|
1124
|
-
return {
|
|
1125
|
-
projectId: config.projectId,
|
|
1126
|
-
canonicalHostname,
|
|
1127
|
-
additionalCallbackHostnames,
|
|
1128
|
-
storefrontDomain,
|
|
1129
|
-
clientId: config.clientId,
|
|
1130
|
-
providerUserAgent,
|
|
1131
|
-
additionalProviderHostnames,
|
|
1132
|
-
...routePaths,
|
|
1133
|
-
signedInPath,
|
|
1134
|
-
signedOutPath
|
|
1135
|
-
};
|
|
1136
|
-
}
|
|
1137
|
-
function parsedRequestUrl(request) {
|
|
1138
|
-
if (!(request instanceof Request) || request.url.length > MAX_REQUEST_URL_LENGTH) {
|
|
1139
|
-
throw new SafeHttpError(400, "invalid_request");
|
|
1140
|
-
}
|
|
1141
|
-
try {
|
|
1142
|
-
return new URL(request.url);
|
|
1143
|
-
} catch {
|
|
1144
|
-
throw new SafeHttpError(400, "invalid_request");
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
function validatedRequestUrl(url) {
|
|
1148
|
-
if (url.protocol !== "https:" || url.username || url.password || url.port) {
|
|
1149
|
-
throw new SafeHttpError(400, "invalid_request");
|
|
1150
|
-
}
|
|
1151
|
-
normalizedHostname3(url.hostname);
|
|
1152
|
-
return url;
|
|
1153
|
-
}
|
|
1154
|
-
function validatedPath(value, label) {
|
|
1155
|
-
if (typeof value !== "string" || !/^\/[A-Za-z0-9/_-]{0,127}$/.test(value) || value.includes("//")) {
|
|
1156
|
-
throw new Error(`The customer authentication ${label} path is invalid.`);
|
|
1157
|
-
}
|
|
1158
|
-
return value;
|
|
1159
|
-
}
|
|
1160
|
-
function callbackUrl(config, hostname = config.canonicalHostname) {
|
|
1161
|
-
return `https://${hostname}${config.callbackPath}`;
|
|
1162
|
-
}
|
|
1163
|
-
function customerRenewalUrl(config, returnHostname) {
|
|
1164
|
-
return `https://${callbackHostname(config, returnHostname)}${config.loginPath}?return_host=${encodeURIComponent(returnHostname)}&prompt=none`;
|
|
1165
|
-
}
|
|
1166
|
-
function currentSeconds() {
|
|
1167
|
-
return Math.floor(Date.now() / 1e3);
|
|
1168
|
-
}
|
|
1169
|
-
function requireCallbackHostname(url, config) {
|
|
1170
|
-
const hostname = normalizedHostname3(url.hostname);
|
|
1171
|
-
if (callbackHostname(config, hostname) !== hostname) {
|
|
1172
|
-
throw new SafeHttpError(404, "not_found");
|
|
1173
|
-
}
|
|
1174
|
-
return hostname;
|
|
1175
|
-
}
|
|
1176
|
-
function callbackHostname(config, hostname) {
|
|
1177
|
-
return hostname === config.canonicalHostname || config.additionalCallbackHostnames.has(hostname) ? hostname : config.canonicalHostname;
|
|
1178
|
-
}
|
|
1179
|
-
async function isAllowedHostname(input, projectId, hostnameValue) {
|
|
1180
|
-
let hostname;
|
|
1181
|
-
try {
|
|
1182
|
-
hostname = normalizedHostname3(hostnameValue);
|
|
1183
|
-
} catch {
|
|
1184
|
-
return false;
|
|
1185
|
-
}
|
|
1186
|
-
try {
|
|
1187
|
-
return await input.hostnamePolicy({ projectId, hostname }) === true;
|
|
1188
|
-
} catch {
|
|
1189
|
-
return false;
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
async function discoverOpenId(config, providerFetch) {
|
|
1193
|
-
const response = await fetchProvider(
|
|
1194
|
-
providerFetch,
|
|
1195
|
-
`https://${config.storefrontDomain}/.well-known/openid-configuration`,
|
|
1196
|
-
{
|
|
1197
|
-
method: "GET",
|
|
1198
|
-
headers: providerRequestHeaders(config, { Accept: "application/json" }),
|
|
1199
|
-
redirect: PROVIDER_REDIRECT_MODE
|
|
1200
|
-
}
|
|
1201
|
-
);
|
|
1202
|
-
if (!response.ok) throw new ProviderFailure();
|
|
1203
|
-
const value = objectValue(await readBoundedJson(response, MAX_DISCOVERY_BYTES));
|
|
1204
|
-
return {
|
|
1205
|
-
authorizationEndpoint: providerEndpoint(value.authorization_endpoint, config),
|
|
1206
|
-
tokenEndpoint: providerEndpoint(value.token_endpoint, config),
|
|
1207
|
-
endSessionEndpoint: providerEndpoint(value.end_session_endpoint, config)
|
|
1208
|
-
};
|
|
1209
|
-
}
|
|
1210
|
-
async function discoverCustomerApi(config, providerFetch) {
|
|
1211
|
-
const response = await fetchProvider(
|
|
1212
|
-
providerFetch,
|
|
1213
|
-
`https://${config.storefrontDomain}/.well-known/customer-account-api`,
|
|
1214
|
-
{
|
|
1215
|
-
method: "GET",
|
|
1216
|
-
headers: providerRequestHeaders(config, { Accept: "application/json" }),
|
|
1217
|
-
redirect: PROVIDER_REDIRECT_MODE
|
|
1218
|
-
}
|
|
1219
|
-
);
|
|
1220
|
-
if (!response.ok) throw new ProviderFailure();
|
|
1221
|
-
const value = objectValue(await readBoundedJson(response, MAX_DISCOVERY_BYTES));
|
|
1222
|
-
return { graphqlEndpoint: providerEndpoint(value.graphql_api, config) };
|
|
1223
|
-
}
|
|
1224
|
-
async function exchangeAuthorizationCode(input) {
|
|
1225
|
-
const response = await fetchProvider(input.providerFetch, input.tokenEndpoint, {
|
|
1226
|
-
method: "POST",
|
|
1227
|
-
headers: providerRequestHeaders(input.config, {
|
|
1228
|
-
Accept: "application/json",
|
|
1229
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
1230
|
-
}, `https://${input.callbackHostname}`),
|
|
1231
|
-
body: new URLSearchParams({
|
|
1232
|
-
grant_type: "authorization_code",
|
|
1233
|
-
client_id: input.config.clientId,
|
|
1234
|
-
redirect_uri: callbackUrl(input.config, input.callbackHostname),
|
|
1235
|
-
code: input.code,
|
|
1236
|
-
code_verifier: input.verifier
|
|
1237
|
-
}),
|
|
1238
|
-
redirect: PROVIDER_REDIRECT_MODE
|
|
1239
|
-
});
|
|
1240
|
-
if (!response.ok) throw new ProviderFailure();
|
|
1241
|
-
const value = objectValue(await readBoundedJson(response, MAX_TOKEN_BYTES));
|
|
1242
|
-
if (typeof value.access_token !== "string" || value.access_token.length < 1 || value.access_token.length > MAX_ACCESS_TOKEN_LENGTH || /[\u0000-\u001f\u007f]/.test(value.access_token)) {
|
|
1243
|
-
throw new ProviderFailure();
|
|
1244
|
-
}
|
|
1245
|
-
if (!Number.isSafeInteger(value.expires_in) || value.expires_in < 1 || value.expires_in > MAX_SESSION_TTL_SECONDS) {
|
|
1246
|
-
throw new ProviderFailure();
|
|
1247
|
-
}
|
|
1248
|
-
return {
|
|
1249
|
-
accessToken: value.access_token,
|
|
1250
|
-
idToken: requiredIdToken(value.id_token),
|
|
1251
|
-
expiresIn: value.expires_in
|
|
1252
|
-
};
|
|
1253
|
-
}
|
|
1254
|
-
function providerRequestHeaders(config, values, origin = `https://${config.canonicalHostname}`) {
|
|
1255
|
-
const headers = new Headers(values);
|
|
1256
|
-
headers.set("Origin", origin);
|
|
1257
|
-
headers.set("User-Agent", config.providerUserAgent);
|
|
1258
|
-
return headers;
|
|
1259
|
-
}
|
|
1260
|
-
function providerEndpoint(value, config) {
|
|
1261
|
-
if (typeof value !== "string" || value.length < 1 || value.length > MAX_ENDPOINT_LENGTH) {
|
|
1262
|
-
throw new ProviderFailure();
|
|
1263
|
-
}
|
|
1264
|
-
let url;
|
|
1265
|
-
try {
|
|
1266
|
-
url = new URL(value);
|
|
1267
|
-
} catch {
|
|
1268
|
-
throw new ProviderFailure();
|
|
1269
|
-
}
|
|
1270
|
-
if (url.protocol !== "https:" || url.username || url.password || url.hash || url.port && url.port !== "443") {
|
|
1271
|
-
throw new ProviderFailure();
|
|
1272
|
-
}
|
|
1273
|
-
let hostname;
|
|
1274
|
-
try {
|
|
1275
|
-
hostname = normalizedHostname3(url.hostname);
|
|
1276
|
-
} catch {
|
|
1277
|
-
throw new ProviderFailure();
|
|
1278
|
-
}
|
|
1279
|
-
if (!isSameOrSubdomain(hostname, config.storefrontDomain) && !isSameOrSubdomain(hostname, "shopify.com") && !isSameOrSubdomain(hostname, "myshopify.com") && !config.additionalProviderHostnames.has(hostname)) {
|
|
1280
|
-
throw new ProviderFailure();
|
|
1281
|
-
}
|
|
1282
|
-
return url.toString();
|
|
1283
|
-
}
|
|
1284
|
-
function parseStoredShopifySession(value, config) {
|
|
1285
|
-
if (typeof value !== "string" || value.length > 65536) throw new ProviderFailure();
|
|
1286
|
-
let parsed;
|
|
1287
|
-
try {
|
|
1288
|
-
parsed = JSON.parse(value);
|
|
1289
|
-
} catch {
|
|
1290
|
-
throw new ProviderFailure();
|
|
1291
|
-
}
|
|
1292
|
-
const object = objectValue(parsed);
|
|
1293
|
-
if (object.version !== 1 || typeof object.accessToken !== "string" || object.accessToken.length < 1 || object.accessToken.length > MAX_ACCESS_TOKEN_LENGTH || /[\u0000-\u001f\u007f]/.test(object.accessToken)) {
|
|
1294
|
-
throw new ProviderFailure();
|
|
1295
|
-
}
|
|
1296
|
-
return {
|
|
1297
|
-
version: 1,
|
|
1298
|
-
accessToken: object.accessToken,
|
|
1299
|
-
idToken: requiredIdToken(object.idToken),
|
|
1300
|
-
graphqlEndpoint: providerEndpoint(object.graphqlEndpoint, config),
|
|
1301
|
-
endSessionEndpoint: providerEndpoint(object.endSessionEndpoint, config),
|
|
1302
|
-
accessTokenExpiresAt: requiredStoredExpiry2(object.accessTokenExpiresAt)
|
|
1303
|
-
};
|
|
1304
|
-
}
|
|
1305
|
-
function requiredIdToken(value) {
|
|
1306
|
-
if (typeof value !== "string" || value.length < 3 || value.length > MAX_ID_TOKEN_LENGTH || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
|
|
1307
|
-
throw new ProviderFailure();
|
|
1308
|
-
}
|
|
1309
|
-
return value;
|
|
1310
|
-
}
|
|
1311
|
-
function requiredStoredExpiry2(value) {
|
|
1312
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
1313
|
-
throw new ProviderFailure();
|
|
1314
|
-
}
|
|
1315
|
-
return value;
|
|
1316
|
-
}
|
|
1317
|
-
function parseCustomerIdentity(value) {
|
|
1318
|
-
const root = objectValue(value);
|
|
1319
|
-
if (root.errors !== void 0 || !root.data) throw new ProviderFailure();
|
|
1320
|
-
const data = objectValue(root.data);
|
|
1321
|
-
const customer = objectValue(data.customer);
|
|
1322
|
-
const id = boundedString(customer.id, 512);
|
|
1323
|
-
const displayName = boundedString(customer.displayName, 1024);
|
|
1324
|
-
let emailAddress = null;
|
|
1325
|
-
if (customer.emailAddress !== null && customer.emailAddress !== void 0) {
|
|
1326
|
-
const email = objectValue(customer.emailAddress);
|
|
1327
|
-
emailAddress = { emailAddress: boundedString(email.emailAddress, 2048) };
|
|
1328
|
-
}
|
|
1329
|
-
return { id, displayName, emailAddress };
|
|
1330
|
-
}
|
|
1331
|
-
async function fetchProvider(providerFetch, input, init) {
|
|
1332
|
-
try {
|
|
1333
|
-
return await providerFetch(input, {
|
|
1334
|
-
...init,
|
|
1335
|
-
signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS)
|
|
1336
|
-
});
|
|
1337
|
-
} catch {
|
|
1338
|
-
throw new ProviderFailure();
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
async function readBoundedJson(response, maximumBytes) {
|
|
1342
|
-
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1343
|
-
if (contentType !== "application/json" && !contentType?.endsWith("+json")) throw new ProviderFailure();
|
|
1344
|
-
const contentLength = response.headers.get("Content-Length");
|
|
1345
|
-
if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > maximumBytes)) {
|
|
1346
|
-
throw new ProviderFailure();
|
|
1347
|
-
}
|
|
1348
|
-
if (!response.body) throw new ProviderFailure();
|
|
1349
|
-
const reader = response.body.getReader();
|
|
1350
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1351
|
-
let bytesRead = 0;
|
|
1352
|
-
let text = "";
|
|
1353
|
-
try {
|
|
1354
|
-
while (true) {
|
|
1355
|
-
const result = await reader.read();
|
|
1356
|
-
if (result.done) break;
|
|
1357
|
-
bytesRead += result.value.byteLength;
|
|
1358
|
-
if (bytesRead > maximumBytes) throw new ProviderFailure();
|
|
1359
|
-
text += decoder.decode(result.value, { stream: true });
|
|
1360
|
-
}
|
|
1361
|
-
text += decoder.decode();
|
|
1362
|
-
} catch (error) {
|
|
1363
|
-
if (error instanceof ProviderFailure) throw error;
|
|
1364
|
-
throw new ProviderFailure();
|
|
1365
|
-
}
|
|
1366
|
-
try {
|
|
1367
|
-
return JSON.parse(text);
|
|
1368
|
-
} catch {
|
|
1369
|
-
throw new ProviderFailure();
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
async function boundedForm(request) {
|
|
1373
|
-
const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1374
|
-
if (contentType !== "application/x-www-form-urlencoded") {
|
|
1375
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
1376
|
-
}
|
|
1377
|
-
const contentLength = request.headers.get("Content-Length");
|
|
1378
|
-
if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_FORM_BODY_BYTES)) {
|
|
1379
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
1380
|
-
}
|
|
1381
|
-
if (!request.body) throw new SafeHttpError(400, "invalid_handoff");
|
|
1382
|
-
const reader = request.body.getReader();
|
|
1383
|
-
const chunks = [];
|
|
1384
|
-
let byteLength = 0;
|
|
1385
|
-
try {
|
|
1386
|
-
while (true) {
|
|
1387
|
-
const result = await reader.read();
|
|
1388
|
-
if (result.done) break;
|
|
1389
|
-
byteLength += result.value.byteLength;
|
|
1390
|
-
if (byteLength > MAX_FORM_BODY_BYTES) {
|
|
1391
|
-
await reader.cancel();
|
|
1392
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
1393
|
-
}
|
|
1394
|
-
chunks.push(result.value);
|
|
1395
|
-
}
|
|
1396
|
-
} catch (error) {
|
|
1397
|
-
if (error instanceof SafeHttpError) throw error;
|
|
1398
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
1399
|
-
}
|
|
1400
|
-
const bodyBytes = new Uint8Array(byteLength);
|
|
1401
|
-
let offset = 0;
|
|
1402
|
-
for (const chunk of chunks) {
|
|
1403
|
-
bodyBytes.set(chunk, offset);
|
|
1404
|
-
offset += chunk.byteLength;
|
|
1405
|
-
}
|
|
1406
|
-
let body;
|
|
1407
|
-
try {
|
|
1408
|
-
body = new TextDecoder("utf-8", { fatal: true }).decode(bodyBytes);
|
|
1409
|
-
} catch {
|
|
1410
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
1411
|
-
}
|
|
1412
|
-
return new URLSearchParams(body);
|
|
1413
|
-
}
|
|
1414
|
-
function requiredSingleQueryValue(url, name, maximumLength, pattern) {
|
|
1415
|
-
const values = url.searchParams.getAll(name);
|
|
1416
|
-
if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength || pattern && !pattern.test(values[0])) {
|
|
1417
|
-
throw new SafeHttpError(400, "invalid_request");
|
|
1418
|
-
}
|
|
1419
|
-
return values[0];
|
|
1420
|
-
}
|
|
1421
|
-
function optionalSingleQueryValue(url, name, maximumLength, pattern) {
|
|
1422
|
-
const values = url.searchParams.getAll(name);
|
|
1423
|
-
if (values.length === 0) return null;
|
|
1424
|
-
if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength || !pattern.test(values[0])) {
|
|
1425
|
-
throw new SafeHttpError(400, "invalid_request");
|
|
1426
|
-
}
|
|
1427
|
-
return values[0];
|
|
1428
|
-
}
|
|
1429
|
-
function requiredSingleFormValue(form, name, maximumLength, pattern) {
|
|
1430
|
-
const values = form.getAll(name);
|
|
1431
|
-
if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength || !pattern.test(values[0])) {
|
|
1432
|
-
throw new SafeHttpError(400, "invalid_handoff");
|
|
1433
|
-
}
|
|
1434
|
-
return values[0];
|
|
1435
|
-
}
|
|
1436
|
-
function readSessionCookie(cookieHeader) {
|
|
1437
|
-
return readOpaqueCookie(cookieHeader, PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE);
|
|
1438
|
-
}
|
|
1439
|
-
function readOpaqueCookie(cookieHeader, expectedName) {
|
|
1440
|
-
if (!cookieHeader || cookieHeader.length > MAX_COOKIE_HEADER_LENGTH) return null;
|
|
1441
|
-
const matches = [];
|
|
1442
|
-
for (const part of cookieHeader.split(";")) {
|
|
1443
|
-
const separator = part.indexOf("=");
|
|
1444
|
-
if (separator < 1) continue;
|
|
1445
|
-
const name = part.slice(0, separator).trim();
|
|
1446
|
-
if (name === expectedName) {
|
|
1447
|
-
matches.push(part.slice(separator + 1).trim());
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
if (matches.length !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(matches[0])) return null;
|
|
1451
|
-
return matches[0];
|
|
1452
|
-
}
|
|
1453
|
-
async function requiredAuthorizationClientKey(resolver, request) {
|
|
1454
|
-
try {
|
|
1455
|
-
const key = await resolver(request);
|
|
1456
|
-
if (typeof key !== "string" || key.length < 1 || key.length > 512 || /[\u0000-\u001f\u007f]/.test(key)) {
|
|
1457
|
-
throw new Error("invalid");
|
|
1458
|
-
}
|
|
1459
|
-
return key;
|
|
1460
|
-
} catch {
|
|
1461
|
-
throw new SafeHttpError(503, "customer_authentication_unavailable");
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
function cloudflareAuthorizationClientKey(request) {
|
|
1465
|
-
const address = request.headers.get("CF-Connecting-IP")?.trim().toLowerCase();
|
|
1466
|
-
if (!address || address.length > 64 || !/^[0-9a-f:.]+$/.test(address)) {
|
|
1467
|
-
throw new Error("The client address is unavailable.");
|
|
1468
|
-
}
|
|
1469
|
-
return `cloudflare-ip:${address}`;
|
|
1470
|
-
}
|
|
1471
|
-
function requireOrigin(request, expectedOrigin) {
|
|
1472
|
-
const expectedOrigins = typeof expectedOrigin === "string" ? [expectedOrigin] : expectedOrigin;
|
|
1473
|
-
if (!expectedOrigins.includes(request.headers.get("Origin") ?? "")) {
|
|
1474
|
-
throw new SafeHttpError(403, "invalid_origin");
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
function providerOrigin(config, requestHostname) {
|
|
1478
|
-
return `https://${callbackHostname(config, requestHostname)}`;
|
|
1479
|
-
}
|
|
1480
|
-
function validatedHostnameSet(value, label) {
|
|
1481
|
-
if (value === void 0) return /* @__PURE__ */ new Set();
|
|
1482
|
-
if (!Array.isArray(value) || value.length > 8) throw new Error(`The ${label} is invalid.`);
|
|
1483
|
-
return new Set(value.map(normalizedHostname3));
|
|
1484
|
-
}
|
|
1485
|
-
function normalizedHostname3(value) {
|
|
1486
|
-
if (typeof value !== "string") throw new Error("The customer authentication hostname is invalid.");
|
|
1487
|
-
const hostname = value.toLowerCase().replace(/\.$/, "");
|
|
1488
|
-
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname) || hostname.length > 253) {
|
|
1489
|
-
throw new Error("The customer authentication hostname is invalid.");
|
|
1490
|
-
}
|
|
1491
|
-
return hostname;
|
|
1492
|
-
}
|
|
1493
|
-
function isSameOrSubdomain(hostname, parent) {
|
|
1494
|
-
return hostname === parent || hostname.endsWith(`.${parent}`);
|
|
1495
|
-
}
|
|
1496
|
-
function objectValue(value) {
|
|
1497
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ProviderFailure();
|
|
1498
|
-
return value;
|
|
1499
|
-
}
|
|
1500
|
-
function boundedString(value, maximumLength) {
|
|
1501
|
-
if (typeof value !== "string" || value.length > maximumLength) throw new ProviderFailure();
|
|
1502
|
-
return value;
|
|
1503
|
-
}
|
|
1504
|
-
function randomBase64Url(bytes) {
|
|
1505
|
-
const value = crypto.getRandomValues(new Uint8Array(bytes));
|
|
1506
|
-
return base64Url(value);
|
|
1507
|
-
}
|
|
1508
|
-
async function sha256Base64Url(value) {
|
|
1509
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1510
|
-
return base64Url(new Uint8Array(digest));
|
|
1511
|
-
}
|
|
1512
|
-
function base64Url(value) {
|
|
1513
|
-
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
|
|
1514
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
1515
|
-
}
|
|
1516
|
-
function handoffPostResponse(config, returnHostname, handoff) {
|
|
1517
|
-
const action = `https://${returnHostname}${config.handoffPath}`;
|
|
1518
|
-
const nonce = randomBase64Url(18);
|
|
1519
|
-
const html = `<!doctype html>
|
|
1520
|
-
<html lang="en">
|
|
1521
|
-
<head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Signing in</title></head>
|
|
1522
|
-
<body>
|
|
1523
|
-
<form id="customer-auth-handoff" method="post" action="${escapeHtml(action)}">
|
|
1524
|
-
<input type="hidden" name="handoff" value="${escapeHtml(handoff)}">
|
|
1525
|
-
<noscript><button type="submit">Continue</button></noscript>
|
|
1526
|
-
</form>
|
|
1527
|
-
<script nonce="${nonce}">document.getElementById("customer-auth-handoff").requestSubmit();</script>
|
|
1528
|
-
</body>
|
|
1529
|
-
</html>`;
|
|
1530
|
-
return new Response(html, {
|
|
1531
|
-
status: 200,
|
|
1532
|
-
headers: secureHeaders({
|
|
1533
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
1534
|
-
"Content-Security-Policy": `default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action https://${returnHostname}; script-src 'nonce-${nonce}'`,
|
|
1535
|
-
"Referrer-Policy": "no-referrer"
|
|
1536
|
-
})
|
|
1537
|
-
});
|
|
1538
|
-
}
|
|
1539
|
-
function escapeHtml(value) {
|
|
1540
|
-
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1541
|
-
}
|
|
1542
|
-
function sessionCookie(sessionId) {
|
|
1543
|
-
return `${PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE}=${sessionId}; Path=/; Secure; HttpOnly; SameSite=Lax`;
|
|
1544
|
-
}
|
|
1545
|
-
function authorizationFlowCookie(state, browserBinding) {
|
|
1546
|
-
return `${authorizationFlowCookieName(state)}=${browserBinding}; Max-Age=${FLOW_TTL_SECONDS}; Path=/; Secure; HttpOnly; SameSite=Lax`;
|
|
1547
|
-
}
|
|
1548
|
-
function authorizationFlowCookieName(state) {
|
|
1549
|
-
if (!/^[A-Za-z0-9_-]{43}$/.test(state)) {
|
|
1550
|
-
throw new Error("The customer authentication state is invalid.");
|
|
1551
|
-
}
|
|
1552
|
-
return `${PUBLIC_CUSTOMER_AUTH_FLOW_COOKIE}_${state}`;
|
|
1553
|
-
}
|
|
1554
|
-
function expiredSessionCookie() {
|
|
1555
|
-
return `${PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
1556
|
-
}
|
|
1557
|
-
function localLogoutResponse() {
|
|
1558
|
-
return new Response(null, {
|
|
1559
|
-
status: 204,
|
|
1560
|
-
headers: secureHeaders({ "Set-Cookie": expiredSessionCookie() })
|
|
1561
|
-
});
|
|
1562
|
-
}
|
|
1563
|
-
function unauthenticatedResponse(clearCookie) {
|
|
1564
|
-
return jsonResponse(200, { authenticated: false }, clearCookie ? { "Set-Cookie": expiredSessionCookie() } : void 0);
|
|
1565
|
-
}
|
|
1566
|
-
function jsonResponse(status, value, headers) {
|
|
1567
|
-
return new Response(JSON.stringify(value), {
|
|
1568
|
-
status,
|
|
1569
|
-
headers: secureHeaders({ "Content-Type": "application/json; charset=utf-8", ...headers })
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
1572
|
-
function safeError(status, code, headers) {
|
|
1573
|
-
return jsonResponse(status, { error: code }, headers);
|
|
1574
|
-
}
|
|
1575
|
-
function redirectResponse(location, status, headers) {
|
|
1576
|
-
const responseHeaders = new Headers(headers);
|
|
1577
|
-
responseHeaders.set("Location", location);
|
|
1578
|
-
return new Response(null, {
|
|
1579
|
-
status,
|
|
1580
|
-
headers: secureHeaders(responseHeaders)
|
|
1581
|
-
});
|
|
1582
|
-
}
|
|
1583
|
-
function methodNotAllowed(method) {
|
|
1584
|
-
return jsonResponse(405, { error: "method_not_allowed" }, { Allow: method });
|
|
1585
|
-
}
|
|
1586
|
-
function secureHeaders(extra) {
|
|
1587
|
-
const headers = new Headers(extra);
|
|
1588
|
-
headers.set("Cache-Control", "no-store");
|
|
1589
|
-
headers.set("Referrer-Policy", "no-referrer");
|
|
1590
|
-
headers.set("X-Content-Type-Options", "nosniff");
|
|
1591
|
-
return headers;
|
|
1592
|
-
}
|
|
1593
|
-
var SafeHttpError = class extends Error {
|
|
1594
|
-
constructor(status, code) {
|
|
1595
|
-
super(code);
|
|
1596
|
-
this.status = status;
|
|
1597
|
-
this.code = code;
|
|
1598
|
-
}
|
|
1599
|
-
status;
|
|
1600
|
-
code;
|
|
1601
|
-
};
|
|
1602
|
-
var ProviderFailure = class extends Error {
|
|
1603
|
-
};
|
|
1604
|
-
|
|
1605
|
-
// src/portal-service.ts
|
|
1606
|
-
var FORWARDED_HEADERS = [
|
|
1607
|
-
"accept",
|
|
1608
|
-
"accept-language",
|
|
1609
|
-
"cache-control",
|
|
1610
|
-
"if-modified-since",
|
|
1611
|
-
"if-none-match",
|
|
1612
|
-
"if-range",
|
|
1613
|
-
"pragma",
|
|
1614
|
-
"range",
|
|
1615
|
-
"user-agent"
|
|
1616
|
-
];
|
|
1617
|
-
var PORTAL_CONTENT_SECURITY_POLICY = [
|
|
1618
|
-
"default-src 'self'",
|
|
1619
|
-
"base-uri 'none'",
|
|
1620
|
-
"connect-src 'self'",
|
|
1621
|
-
"frame-ancestors 'none'",
|
|
1622
|
-
"img-src 'self' data:",
|
|
1623
|
-
"object-src 'none'",
|
|
1624
|
-
"script-src 'self'",
|
|
1625
|
-
"style-src 'self'"
|
|
1626
|
-
].join("; ");
|
|
1627
|
-
async function forwardPortalRequest(request, portal) {
|
|
1628
|
-
if (!portal || typeof portal.fetch !== "function") return gatewayError(503, "portal_ui_unavailable");
|
|
1629
|
-
try {
|
|
1630
|
-
const response = await portal.fetch(portalRequest(request));
|
|
1631
|
-
const headers = new Headers(response.headers);
|
|
1632
|
-
for (const name of [...headers.keys()]) {
|
|
1633
|
-
const normalized = name.toLowerCase();
|
|
1634
|
-
if (normalized === "set-cookie" || normalized === "set-cookie2" || normalized === "clear-site-data" || normalized.startsWith("cf-") || normalized.startsWith("cloudflare-") || normalized.startsWith("x-tender-")) {
|
|
1635
|
-
headers.delete(name);
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
headers.set("Content-Security-Policy", PORTAL_CONTENT_SECURITY_POLICY);
|
|
1639
|
-
headers.set("Permissions-Policy", "camera=(), geolocation=(), microphone=(), payment=()");
|
|
1640
|
-
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1641
|
-
headers.set("X-Content-Type-Options", "nosniff");
|
|
1642
|
-
headers.set("X-Frame-Options", "DENY");
|
|
1643
|
-
return new Response(request.method === "HEAD" ? null : response.body, {
|
|
1644
|
-
status: response.status,
|
|
1645
|
-
statusText: response.statusText,
|
|
1646
|
-
headers
|
|
1647
|
-
});
|
|
1648
|
-
} catch {
|
|
1649
|
-
return gatewayError(502, "portal_ui_failed");
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
function portalRequest(request) {
|
|
1653
|
-
const headers = new Headers();
|
|
1654
|
-
for (const name of FORWARDED_HEADERS) {
|
|
1655
|
-
const value = request.headers.get(name);
|
|
1656
|
-
if (value !== null) headers.set(name, value);
|
|
1657
|
-
}
|
|
1658
|
-
return new Request(request.url, { method: request.method, headers, redirect: "manual" });
|
|
1659
|
-
}
|
|
1660
|
-
function gatewayError(status, code) {
|
|
1661
|
-
return Response.json({ error: { code } }, {
|
|
1662
|
-
status,
|
|
1663
|
-
headers: {
|
|
1664
|
-
"Cache-Control": "no-store",
|
|
1665
|
-
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
1666
|
-
"Referrer-Policy": "no-referrer",
|
|
1667
|
-
"X-Content-Type-Options": "nosniff"
|
|
1668
|
-
}
|
|
1669
|
-
});
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
// src/runtime-config.ts
|
|
1673
|
-
var PUBLIC_CLIENT_ID = /^[A-Za-z0-9_-]{8,256}$/u;
|
|
1674
|
-
var RUNTIME_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/u;
|
|
1675
|
-
var HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u;
|
|
1676
|
-
function readRuntimeConfig(env) {
|
|
1677
|
-
const tenantId = requiredMatch(env.ACCOUNT_TENANT_ID, RUNTIME_ID, "tenant identity");
|
|
1678
|
-
const projectId = requiredMatch(env.ACCOUNT_PROJECT_ID, RUNTIME_ID, "project identity");
|
|
1679
|
-
const runtimeBaseDomain = requiredHostname(env.ACCOUNT_RUNTIME_BASE_DOMAIN, "runtime base domain");
|
|
1680
|
-
return {
|
|
1681
|
-
tenantId,
|
|
1682
|
-
projectId,
|
|
1683
|
-
runtimeBaseDomain,
|
|
1684
|
-
canonicalHostname: canonicalApplicationHostname({ tenantId, projectId, baseDomain: runtimeBaseDomain }),
|
|
1685
|
-
storefrontDomain: requiredHostname(env.SHOPIFY_STOREFRONT_DOMAIN, "Shopify storefront domain"),
|
|
1686
|
-
customerAccountClientId: requiredMatch(
|
|
1687
|
-
env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,
|
|
1688
|
-
PUBLIC_CLIENT_ID,
|
|
1689
|
-
"Shopify public customer-account client ID"
|
|
1690
|
-
),
|
|
1691
|
-
customerAccountDomain: requiredHostname(env.SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN, "Shopify customer-account domain"),
|
|
1692
|
-
vanityHostnames: optionalHostnameList(env.ACCOUNT_VANITY_HOSTNAMES)
|
|
1693
|
-
};
|
|
1694
|
-
}
|
|
1695
|
-
function optionalHostnameList(value) {
|
|
1696
|
-
if (value === void 0) return [];
|
|
1697
|
-
if (value.trim() === "") return [];
|
|
1698
|
-
const hostnames = [...new Set(value.split(",").map((hostname) => requiredHostname(hostname, "vanity hostname")))];
|
|
1699
|
-
if (hostnames.length > 8) throw new Error("Too many vanity hostnames are configured.");
|
|
1700
|
-
return hostnames;
|
|
1701
|
-
}
|
|
1702
|
-
function requiredHostname(value, label) {
|
|
1703
|
-
const hostname = value.trim().toLowerCase().replace(/\.$/u, "");
|
|
1704
|
-
if (!HOSTNAME.test(hostname)) throw new Error(`The ${label} is invalid.`);
|
|
1705
|
-
return hostname;
|
|
1706
|
-
}
|
|
1707
|
-
function requiredMatch(value, pattern, label) {
|
|
1708
|
-
const normalized = value.trim();
|
|
1709
|
-
if (!pattern.test(normalized)) throw new Error(`The ${label} is invalid.`);
|
|
1710
|
-
return normalized;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
// src/worker.ts
|
|
1714
|
-
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1715
|
-
var worker_default = {
|
|
1716
|
-
async fetch(request, env) {
|
|
1717
|
-
let config;
|
|
1718
|
-
try {
|
|
1719
|
-
config = readRuntimeConfig(env);
|
|
1720
|
-
} catch {
|
|
1721
|
-
return jsonResponse2(503, "application_not_configured");
|
|
1722
|
-
}
|
|
1723
|
-
const url = new URL(request.url);
|
|
1724
|
-
if (request.method === "GET" && url.pathname === "/api/health") {
|
|
1725
|
-
return Response.json({ ok: true, service: "shopify-customer-account-gateway" });
|
|
1726
|
-
}
|
|
1727
|
-
const localDevelopment = (url.protocol === "http:" || url.protocol === "https:") && LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase());
|
|
1728
|
-
if (localDevelopment && request.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
1729
|
-
return Response.json({ canonicalHostname: config.canonicalHostname, loginUrl: null, localDevelopment: true });
|
|
1730
|
-
}
|
|
1731
|
-
if (localDevelopment && request.method === "GET" && url.pathname === "/api/session") {
|
|
1732
|
-
return Response.json({ authenticated: false, localDevelopment: true });
|
|
1733
|
-
}
|
|
1734
|
-
if (localDevelopment && url.pathname.startsWith("/auth/")) {
|
|
1735
|
-
return jsonResponse2(409, "authentication_requires_https_preview");
|
|
1736
|
-
}
|
|
1737
|
-
const hostnamePolicy = createTenderRuntimeHostnamePolicy({
|
|
1738
|
-
tenantId: config.tenantId,
|
|
1739
|
-
projectId: config.projectId,
|
|
1740
|
-
baseDomain: config.runtimeBaseDomain,
|
|
1741
|
-
additionalHostnames: config.vanityHostnames
|
|
1742
|
-
});
|
|
1743
|
-
const auth = createShopifyCustomerAccountRouteAdapter({
|
|
1744
|
-
config: {
|
|
1745
|
-
projectId: config.projectId,
|
|
1746
|
-
canonicalHostname: config.canonicalHostname,
|
|
1747
|
-
additionalCallbackHostnames: config.vanityHostnames,
|
|
1748
|
-
storefrontDomain: config.storefrontDomain,
|
|
1749
|
-
clientId: config.customerAccountClientId,
|
|
1750
|
-
additionalProviderHostnames: [config.customerAccountDomain]
|
|
1751
|
-
},
|
|
1752
|
-
sessionStore: new PublicCustomerAuthSessionStore(env.APP_DB, hostnamePolicy),
|
|
1753
|
-
hostnamePolicy
|
|
1754
|
-
});
|
|
1755
|
-
const authResponse = await auth.handle(request);
|
|
1756
|
-
if (authResponse) return authResponse;
|
|
1757
|
-
if (request.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
1758
|
-
const hostname = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
1759
|
-
if (!await hostnamePolicy({ projectId: config.projectId, hostname })) return jsonResponse2(404, "not_found");
|
|
1760
|
-
const loginHostname = config.vanityHostnames.includes(hostname) ? hostname : config.canonicalHostname;
|
|
1761
|
-
const loginUrl = new URL(`https://${loginHostname}/auth/login`);
|
|
1762
|
-
loginUrl.searchParams.set("return_host", hostname);
|
|
1763
|
-
return Response.json({ canonicalHostname: config.canonicalHostname, loginUrl: loginUrl.toString() });
|
|
1764
|
-
}
|
|
1765
|
-
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return jsonResponse2(404, "not_found");
|
|
1766
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1767
|
-
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
1768
|
-
}
|
|
1769
|
-
return await forwardPortalRequest(request, env.PORTAL_UI);
|
|
1770
|
-
}
|
|
1771
|
-
};
|
|
1772
|
-
function jsonResponse2(status, code) {
|
|
1773
|
-
return Response.json({ error: { code } }, {
|
|
1774
|
-
status,
|
|
1775
|
-
headers: {
|
|
1776
|
-
"Cache-Control": "no-store",
|
|
1777
|
-
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
1778
|
-
"Referrer-Policy": "no-referrer",
|
|
1779
|
-
"X-Content-Type-Options": "nosniff"
|
|
1780
|
-
}
|
|
1781
|
-
});
|
|
1782
|
-
}
|
|
1783
|
-
export {
|
|
1784
|
-
worker_default as default
|
|
1785
|
-
};
|