@tenderprompt/accounts 0.2.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/README.md +84 -0
- package/dist/api.d.ts +27 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +73 -0
- package/dist/api.js.map +1 -0
- package/dist/artifact.d.ts +8 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +205 -0
- package/dist/artifact.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/contracts.d.ts +96 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +5 -0
- package/dist/contracts.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +21 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +263 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +565 -0
- package/dist/main.js.map +1 -0
- package/dist/processes.d.ts +12 -0
- package/dist/processes.d.ts.map +1 -0
- package/dist/processes.js +44 -0
- package/dist/processes.js.map +1 -0
- package/dist/starter.d.ts +20 -0
- package/dist/starter.d.ts.map +1 -0
- package/dist/starter.js +159 -0
- package/dist/starter.js.map +1 -0
- package/package.json +32 -0
- package/skill/tender-accounts/SKILL.md +111 -0
- package/templates/shopify-customer-account/AGENTS.md +14 -0
- package/templates/shopify-customer-account/README.md +44 -0
- package/templates/shopify-customer-account/apps/gateway/AGENTS.md +9 -0
- package/templates/shopify-customer-account/apps/gateway/README.md +9 -0
- package/templates/shopify-customer-account/apps/gateway/database-schema.json +49 -0
- package/templates/shopify-customer-account/apps/gateway/dot.dev.vars.example +11 -0
- package/templates/shopify-customer-account/apps/gateway/migrations/0001_customer_auth.sql +76 -0
- package/templates/shopify-customer-account/apps/gateway/package.json +25 -0
- package/templates/shopify-customer-account/apps/gateway/scripts/build-worker.mjs +15 -0
- package/templates/shopify-customer-account/apps/gateway/scripts/pack-tender-artifact.mjs +84 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/hostname-policy.ts +66 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/schema.ts +90 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/session-store.ts +864 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/shopify-route-adapter.ts +1157 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/runtime-hostname.ts +46 -0
- package/templates/shopify-customer-account/apps/gateway/src/portal-service.test.ts +18 -0
- package/templates/shopify-customer-account/apps/gateway/src/portal-service.ts +51 -0
- package/templates/shopify-customer-account/apps/gateway/src/runtime-config.test.ts +30 -0
- package/templates/shopify-customer-account/apps/gateway/src/runtime-config.ts +56 -0
- package/templates/shopify-customer-account/apps/gateway/src/worker.ts +85 -0
- package/templates/shopify-customer-account/apps/gateway/tender-accounts.json +10 -0
- package/templates/shopify-customer-account/apps/gateway/tender-app.config.json +40 -0
- package/templates/shopify-customer-account/apps/gateway/tsconfig.json +13 -0
- package/templates/shopify-customer-account/apps/gateway/vitest.config.ts +8 -0
- package/templates/shopify-customer-account/apps/gateway/worker-configuration.d.ts +26 -0
- package/templates/shopify-customer-account/apps/gateway/wrangler.jsonc +42 -0
- package/templates/shopify-customer-account/apps/portal/AGENTS.md +9 -0
- package/templates/shopify-customer-account/apps/portal/README.md +5 -0
- package/templates/shopify-customer-account/apps/portal/index.html +13 -0
- package/templates/shopify-customer-account/apps/portal/package.json +33 -0
- package/templates/shopify-customer-account/apps/portal/scripts/build-worker.mjs +11 -0
- package/templates/shopify-customer-account/apps/portal/scripts/pack-tender-artifact.mjs +63 -0
- package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.mjs +84 -0
- package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.test.mjs +14 -0
- package/templates/shopify-customer-account/apps/portal/src/App.tsx +111 -0
- package/templates/shopify-customer-account/apps/portal/src/main.tsx +10 -0
- package/templates/shopify-customer-account/apps/portal/src/styles.css +54 -0
- package/templates/shopify-customer-account/apps/portal/tender-accounts.json +10 -0
- package/templates/shopify-customer-account/apps/portal/tender-app.config.json +19 -0
- package/templates/shopify-customer-account/apps/portal/tsconfig.json +14 -0
- package/templates/shopify-customer-account/apps/portal/vite.config.ts +7 -0
- package/templates/shopify-customer-account/apps/portal/worker-configuration.d.ts +11 -0
- package/templates/shopify-customer-account/apps/portal/wrangler.jsonc +22 -0
- package/templates/shopify-customer-account/dot.dev.vars.example +3 -0
- package/templates/shopify-customer-account/dot.gitignore +9 -0
- package/templates/shopify-customer-account/package.json +22 -0
- package/templates/shopify-customer-account/scripts/artifact-contract.mjs +109 -0
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
import type { CustomerAuthHostnamePolicy } from "./session-store";
|
|
2
|
+
import {
|
|
3
|
+
CustomerAuthFlowCapacityError,
|
|
4
|
+
PublicCustomerAuthSessionStore,
|
|
5
|
+
} from "./session-store";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LOGIN_PATH = "/auth/login";
|
|
8
|
+
const DEFAULT_CALLBACK_PATH = "/auth/callback";
|
|
9
|
+
const DEFAULT_HANDOFF_PATH = "/auth/handoff";
|
|
10
|
+
const DEFAULT_SESSION_PATH = "/api/session";
|
|
11
|
+
const DEFAULT_LOGOUT_PATH = "/auth/logout";
|
|
12
|
+
const DEFAULT_POST_LOGOUT_PATH = "/auth/logout/callback";
|
|
13
|
+
const DEFAULT_SIGNED_IN_PATH = "/account";
|
|
14
|
+
const DEFAULT_SIGNED_OUT_PATH = "/";
|
|
15
|
+
const FLOW_TTL_SECONDS = 5 * 60;
|
|
16
|
+
const HANDOFF_TTL_SECONDS = 60;
|
|
17
|
+
const MAX_REQUEST_URL_LENGTH = 8_192;
|
|
18
|
+
const MAX_FORM_BODY_BYTES = 1_024;
|
|
19
|
+
const MAX_COOKIE_HEADER_LENGTH = 8_192;
|
|
20
|
+
const MAX_DISCOVERY_BYTES = 16_384;
|
|
21
|
+
const MAX_TOKEN_BYTES = 65_536;
|
|
22
|
+
const MAX_GRAPHQL_BYTES = 65_536;
|
|
23
|
+
const MAX_ENDPOINT_LENGTH = 2_048;
|
|
24
|
+
const MAX_ACCESS_TOKEN_LENGTH = 32_768;
|
|
25
|
+
const MAX_ID_TOKEN_LENGTH = 16_384;
|
|
26
|
+
const MAX_SESSION_TTL_SECONDS = 31 * 24 * 60 * 60;
|
|
27
|
+
const SHOPPER_SESSION_RECORD_TTL_SECONDS = MAX_SESSION_TTL_SECONDS;
|
|
28
|
+
const PROVIDER_FETCH_TIMEOUT_MS = 10_000;
|
|
29
|
+
const DEFAULT_PROVIDER_USER_AGENT = "TenderAccountsCustomerAuth/0.1";
|
|
30
|
+
const PROVIDER_REDIRECT_MODE = "manual" as const;
|
|
31
|
+
|
|
32
|
+
export const PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE = "__Host-tender_accounts_session";
|
|
33
|
+
export const PUBLIC_CUSTOMER_AUTH_FLOW_COOKIE = "__Host-tender_accounts_auth_flow";
|
|
34
|
+
|
|
35
|
+
export const SHOPIFY_CUSTOMER_IDENTITY_QUERY = `query CustomerIdentity {
|
|
36
|
+
customer {
|
|
37
|
+
id
|
|
38
|
+
displayName
|
|
39
|
+
emailAddress {
|
|
40
|
+
emailAddress
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}`;
|
|
44
|
+
|
|
45
|
+
export interface ShopifyCustomerAccountRouteConfig {
|
|
46
|
+
projectId: string;
|
|
47
|
+
canonicalHostname: string;
|
|
48
|
+
additionalCallbackHostnames?: readonly string[];
|
|
49
|
+
storefrontDomain: string;
|
|
50
|
+
clientId: string;
|
|
51
|
+
providerUserAgent?: string;
|
|
52
|
+
additionalProviderHostnames?: readonly string[];
|
|
53
|
+
loginPath?: string;
|
|
54
|
+
callbackPath?: string;
|
|
55
|
+
handoffPath?: string;
|
|
56
|
+
sessionPath?: string;
|
|
57
|
+
logoutPath?: string;
|
|
58
|
+
postLogoutPath?: string;
|
|
59
|
+
signedInPath?: string;
|
|
60
|
+
signedOutPath?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type CustomerAuthProviderFetch = (
|
|
64
|
+
input: RequestInfo | URL,
|
|
65
|
+
init?: RequestInit,
|
|
66
|
+
) => Promise<Response>;
|
|
67
|
+
|
|
68
|
+
export type CustomerAuthClientKey = (request: Request) => string | Promise<string>;
|
|
69
|
+
|
|
70
|
+
export interface ShopifyCustomerAccountRouteAdapter {
|
|
71
|
+
/** Returns null when the request does not target one of the adapter's exact paths. */
|
|
72
|
+
handle(request: Request): Promise<Response | null>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reusable merchant-Worker routes for a Shopify Customer Account public client.
|
|
77
|
+
* The adapter intentionally has no client-secret option: it performs the public
|
|
78
|
+
* authorization-code flow with S256 PKCE and keeps every provider token in D1.
|
|
79
|
+
*/
|
|
80
|
+
export function createShopifyCustomerAccountRouteAdapter(input: {
|
|
81
|
+
config: ShopifyCustomerAccountRouteConfig;
|
|
82
|
+
sessionStore: PublicCustomerAuthSessionStore;
|
|
83
|
+
hostnamePolicy: CustomerAuthHostnamePolicy;
|
|
84
|
+
providerFetch?: CustomerAuthProviderFetch;
|
|
85
|
+
authorizationClientKey?: CustomerAuthClientKey;
|
|
86
|
+
}): ShopifyCustomerAccountRouteAdapter {
|
|
87
|
+
if (!(input.sessionStore instanceof PublicCustomerAuthSessionStore)) {
|
|
88
|
+
throw new Error("A public customer authentication session store is required.");
|
|
89
|
+
}
|
|
90
|
+
if (typeof input.hostnamePolicy !== "function") {
|
|
91
|
+
throw new Error("A public customer authentication hostname policy is required.");
|
|
92
|
+
}
|
|
93
|
+
const config = validateConfig(input.config);
|
|
94
|
+
const providerFetch = input.providerFetch ?? fetch;
|
|
95
|
+
if (typeof providerFetch !== "function") {
|
|
96
|
+
throw new Error("A Shopify provider fetch implementation is required.");
|
|
97
|
+
}
|
|
98
|
+
const authorizationClientKey = input.authorizationClientKey ?? cloudflareAuthorizationClientKey;
|
|
99
|
+
if (typeof authorizationClientKey !== "function") {
|
|
100
|
+
throw new Error("A customer authentication client-key implementation is required.");
|
|
101
|
+
}
|
|
102
|
+
const routeInput = {
|
|
103
|
+
sessionStore: input.sessionStore,
|
|
104
|
+
hostnamePolicy: input.hostnamePolicy,
|
|
105
|
+
authorizationClientKey,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
async handle(request: Request): Promise<Response | null> {
|
|
110
|
+
try {
|
|
111
|
+
const routeUrl = parsedRequestUrl(request);
|
|
112
|
+
const route = routeFor(config, routeUrl.pathname);
|
|
113
|
+
if (!route) return null;
|
|
114
|
+
const requestUrl = validatedRequestUrl(routeUrl);
|
|
115
|
+
if (request.method !== route.method) return methodNotAllowed(route.method);
|
|
116
|
+
|
|
117
|
+
switch (route.name) {
|
|
118
|
+
case "login":
|
|
119
|
+
return await handleLogin({ request, requestUrl, config, input: routeInput, providerFetch });
|
|
120
|
+
case "callback":
|
|
121
|
+
return await handleCallback({ request, requestUrl, config, input: routeInput, providerFetch });
|
|
122
|
+
case "handoff":
|
|
123
|
+
return await handleHandoff({ request, requestUrl, config, input: routeInput });
|
|
124
|
+
case "session":
|
|
125
|
+
return await handleSession({ request, requestUrl, config, input: routeInput, providerFetch });
|
|
126
|
+
case "logout":
|
|
127
|
+
return await handleLogout({ request, requestUrl, config, input: routeInput });
|
|
128
|
+
case "postLogout":
|
|
129
|
+
return handlePostLogout({ requestUrl, config });
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof SafeHttpError) return safeError(error.status, error.code);
|
|
133
|
+
if (error instanceof CustomerAuthFlowCapacityError) {
|
|
134
|
+
return safeError(429, "customer_authentication_busy");
|
|
135
|
+
}
|
|
136
|
+
if (error instanceof ProviderFailure) return safeError(502, "customer_authentication_unavailable");
|
|
137
|
+
return safeError(500, "customer_authentication_failed");
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function handleLogin(context: RequestRouteContext & {
|
|
144
|
+
providerFetch: CustomerAuthProviderFetch;
|
|
145
|
+
}): Promise<Response> {
|
|
146
|
+
const callbackHostname = requireCallbackHostname(context.requestUrl, context.config);
|
|
147
|
+
const returnHostname = requiredSingleQueryValue(context.requestUrl, "return_host", 253);
|
|
148
|
+
const prompt = optionalSingleQueryValue(context.requestUrl, "prompt", 4, /^(?:none)$/);
|
|
149
|
+
if (!await isAllowedHostname(context.input, context.config.projectId, returnHostname)) {
|
|
150
|
+
throw new SafeHttpError(400, "invalid_return_host");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const verifier = randomBase64Url(32);
|
|
154
|
+
const challenge = await sha256Base64Url(verifier);
|
|
155
|
+
const flow = await context.input.sessionStore.createAuthorizationFlow({
|
|
156
|
+
projectId: context.config.projectId,
|
|
157
|
+
pkceVerifier: verifier,
|
|
158
|
+
returnHostname,
|
|
159
|
+
clientKey: await requiredAuthorizationClientKey(context.input.authorizationClientKey, context.request),
|
|
160
|
+
interactionMode: prompt === "none" ? "silent" : "interactive",
|
|
161
|
+
ttlSeconds: FLOW_TTL_SECONDS,
|
|
162
|
+
});
|
|
163
|
+
let discovery: Awaited<ReturnType<typeof discoverOpenId>>;
|
|
164
|
+
try {
|
|
165
|
+
discovery = await discoverOpenId(context.config, context.providerFetch);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await context.input.sessionStore.consumeAuthorizationFlow({
|
|
168
|
+
projectId: context.config.projectId,
|
|
169
|
+
state: flow.state,
|
|
170
|
+
browserBinding: flow.browserBinding,
|
|
171
|
+
});
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
const authorizationUrl = new URL(discovery.authorizationEndpoint);
|
|
175
|
+
authorizationUrl.searchParams.set("scope", "openid email customer-account-api:full");
|
|
176
|
+
authorizationUrl.searchParams.set("client_id", context.config.clientId);
|
|
177
|
+
authorizationUrl.searchParams.set("response_type", "code");
|
|
178
|
+
authorizationUrl.searchParams.set("redirect_uri", callbackUrl(context.config, callbackHostname));
|
|
179
|
+
authorizationUrl.searchParams.set("state", flow.state);
|
|
180
|
+
authorizationUrl.searchParams.set("code_challenge", challenge);
|
|
181
|
+
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
182
|
+
if (prompt) authorizationUrl.searchParams.set("prompt", prompt);
|
|
183
|
+
return redirectResponse(authorizationUrl.toString(), 302, {
|
|
184
|
+
"Set-Cookie": authorizationFlowCookie(flow.state, flow.browserBinding),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function handleCallback(context: RequestRouteContext & {
|
|
189
|
+
providerFetch: CustomerAuthProviderFetch;
|
|
190
|
+
}): Promise<Response> {
|
|
191
|
+
const callbackHostname = requireCallbackHostname(context.requestUrl, context.config);
|
|
192
|
+
const state = requiredSingleQueryValue(context.requestUrl, "state", 43, /^[A-Za-z0-9_-]{43}$/);
|
|
193
|
+
const browserBinding = readOpaqueCookie(
|
|
194
|
+
context.request.headers.get("Cookie"),
|
|
195
|
+
authorizationFlowCookieName(state),
|
|
196
|
+
);
|
|
197
|
+
if (!browserBinding) throw new SafeHttpError(400, "invalid_authorization_state");
|
|
198
|
+
if (context.requestUrl.searchParams.has("error")) {
|
|
199
|
+
const providerError = requiredSingleQueryValue(
|
|
200
|
+
context.requestUrl,
|
|
201
|
+
"error",
|
|
202
|
+
256,
|
|
203
|
+
/^[A-Za-z0-9._-]+$/,
|
|
204
|
+
);
|
|
205
|
+
const deniedFlow = await context.input.sessionStore.consumeAuthorizationFlow({
|
|
206
|
+
projectId: context.config.projectId,
|
|
207
|
+
state,
|
|
208
|
+
browserBinding,
|
|
209
|
+
});
|
|
210
|
+
if (!deniedFlow) throw new SafeHttpError(400, "invalid_authorization_state");
|
|
211
|
+
if (providerError === "login_required" && deniedFlow.interactionMode === "silent") {
|
|
212
|
+
const interactiveUrl = new URL(`https://${callbackHostname}${context.config.loginPath}`);
|
|
213
|
+
interactiveUrl.searchParams.set("return_host", deniedFlow.returnHostname);
|
|
214
|
+
return handleLogin({
|
|
215
|
+
request: new Request(interactiveUrl, { headers: context.request.headers }),
|
|
216
|
+
requestUrl: interactiveUrl,
|
|
217
|
+
config: context.config,
|
|
218
|
+
input: context.input,
|
|
219
|
+
providerFetch: context.providerFetch,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return safeError(400, "customer_authentication_denied");
|
|
223
|
+
}
|
|
224
|
+
const code = requiredSingleQueryValue(context.requestUrl, "code", 4_096, /^[A-Za-z0-9._~-]+$/);
|
|
225
|
+
const flow = await context.input.sessionStore.consumeAuthorizationFlow({
|
|
226
|
+
projectId: context.config.projectId,
|
|
227
|
+
state,
|
|
228
|
+
browserBinding,
|
|
229
|
+
});
|
|
230
|
+
if (!flow) throw new SafeHttpError(400, "invalid_authorization_state");
|
|
231
|
+
if (!await isAllowedHostname(context.input, context.config.projectId, flow.returnHostname)) {
|
|
232
|
+
return safeError(400, "invalid_return_host");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const openId = await discoverOpenId(context.config, context.providerFetch);
|
|
237
|
+
const token = await exchangeAuthorizationCode({
|
|
238
|
+
config: context.config,
|
|
239
|
+
providerFetch: context.providerFetch,
|
|
240
|
+
tokenEndpoint: openId.tokenEndpoint,
|
|
241
|
+
code,
|
|
242
|
+
verifier: flow.pkceVerifier,
|
|
243
|
+
callbackHostname,
|
|
244
|
+
});
|
|
245
|
+
const customerApi = await discoverCustomerApi(context.config, context.providerFetch);
|
|
246
|
+
const tokenIssuedAt = currentSeconds();
|
|
247
|
+
const handoff = await context.input.sessionStore.createShopperSessionHandoff({
|
|
248
|
+
projectId: context.config.projectId,
|
|
249
|
+
returnHostname: flow.returnHostname,
|
|
250
|
+
sessionData: JSON.stringify({
|
|
251
|
+
version: 1,
|
|
252
|
+
accessToken: token.accessToken,
|
|
253
|
+
idToken: token.idToken,
|
|
254
|
+
graphqlEndpoint: customerApi.graphqlEndpoint,
|
|
255
|
+
endSessionEndpoint: openId.endSessionEndpoint,
|
|
256
|
+
accessTokenExpiresAt: tokenIssuedAt + token.expiresIn,
|
|
257
|
+
} satisfies StoredShopifySession),
|
|
258
|
+
sessionTtlSeconds: SHOPPER_SESSION_RECORD_TTL_SECONDS,
|
|
259
|
+
handoffTtlSeconds: Math.min(HANDOFF_TTL_SECONDS, token.expiresIn),
|
|
260
|
+
});
|
|
261
|
+
if (handoff.returnHostname === callbackHostname) {
|
|
262
|
+
return finalizeHandoff(context, handoff.handoff, handoff.returnHostname);
|
|
263
|
+
}
|
|
264
|
+
return handoffPostResponse(context.config, handoff.returnHostname, handoff.handoff);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof ProviderFailure) {
|
|
267
|
+
return safeError(502, "customer_authentication_unavailable");
|
|
268
|
+
}
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function handleHandoff(context: RequestRouteContext): Promise<Response> {
|
|
274
|
+
const requestHostname = normalizedHostname(context.requestUrl.hostname);
|
|
275
|
+
if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
|
|
276
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
277
|
+
}
|
|
278
|
+
requireOrigin(context.request, [
|
|
279
|
+
`https://${context.config.canonicalHostname}`,
|
|
280
|
+
...Array.from(context.config.additionalCallbackHostnames, (hostname) => `https://${hostname}`),
|
|
281
|
+
`https://${requestHostname}`,
|
|
282
|
+
]);
|
|
283
|
+
const form = await boundedForm(context.request);
|
|
284
|
+
const handoffValue = requiredSingleFormValue(form, "handoff", 43, /^[A-Za-z0-9_-]{43}$/);
|
|
285
|
+
if (Array.from(form.keys()).some((key) => key !== "handoff")) {
|
|
286
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
287
|
+
}
|
|
288
|
+
return finalizeHandoff(context, handoffValue, requestHostname);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function finalizeHandoff(
|
|
292
|
+
context: RequestRouteContext,
|
|
293
|
+
handoffValue: string,
|
|
294
|
+
requestHostname: string,
|
|
295
|
+
): Promise<Response> {
|
|
296
|
+
const handoff = await context.input.sessionStore.consumeHandoff({
|
|
297
|
+
projectId: context.config.projectId,
|
|
298
|
+
handoff: handoffValue,
|
|
299
|
+
requestHostname,
|
|
300
|
+
});
|
|
301
|
+
if (!handoff) throw new SafeHttpError(400, "invalid_handoff");
|
|
302
|
+
const previousSessionId = readSessionCookie(context.request.headers.get("Cookie"));
|
|
303
|
+
if (previousSessionId && previousSessionId !== handoff.sessionId) {
|
|
304
|
+
try {
|
|
305
|
+
await context.input.sessionStore.deleteShopperSession({
|
|
306
|
+
projectId: context.config.projectId,
|
|
307
|
+
sessionId: previousSessionId,
|
|
308
|
+
requestHostname,
|
|
309
|
+
});
|
|
310
|
+
} catch {
|
|
311
|
+
// A stale-session cleanup failure must not strand a finalized replacement
|
|
312
|
+
// before its cookie reaches the browser. Scheduled expiry remains the
|
|
313
|
+
// backstop for the old opaque session record.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return new Response(null, {
|
|
318
|
+
status: 303,
|
|
319
|
+
headers: secureHeaders({
|
|
320
|
+
Location: context.config.signedInPath,
|
|
321
|
+
"Set-Cookie": sessionCookie(handoff.sessionId),
|
|
322
|
+
}),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function handleSession(context: RequestRouteContext & {
|
|
327
|
+
providerFetch: CustomerAuthProviderFetch;
|
|
328
|
+
}): Promise<Response> {
|
|
329
|
+
const requestHostname = normalizedHostname(context.requestUrl.hostname);
|
|
330
|
+
if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
|
|
331
|
+
throw new SafeHttpError(404, "not_found");
|
|
332
|
+
}
|
|
333
|
+
const sessionId = readSessionCookie(context.request.headers.get("Cookie"));
|
|
334
|
+
if (!sessionId) return unauthenticatedResponse(true);
|
|
335
|
+
const stored = await context.input.sessionStore.readShopperSession({
|
|
336
|
+
projectId: context.config.projectId,
|
|
337
|
+
sessionId,
|
|
338
|
+
requestHostname,
|
|
339
|
+
});
|
|
340
|
+
if (!stored) return unauthenticatedResponse(true);
|
|
341
|
+
const session = parseStoredShopifySession(stored.sessionData, context.config);
|
|
342
|
+
const renewalUrl = customerRenewalUrl(context.config, requestHostname);
|
|
343
|
+
if (session.accessTokenExpiresAt <= currentSeconds()) {
|
|
344
|
+
return jsonResponse(200, {
|
|
345
|
+
authenticated: false,
|
|
346
|
+
renewalRequired: true,
|
|
347
|
+
renewalUrl,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const response = await fetchProvider(context.providerFetch, session.graphqlEndpoint, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: providerRequestHeaders(context.config, {
|
|
354
|
+
Accept: "application/json",
|
|
355
|
+
Authorization: session.accessToken,
|
|
356
|
+
"Content-Type": "application/json",
|
|
357
|
+
}, providerOrigin(context.config, requestHostname)),
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
operationName: "CustomerIdentity",
|
|
360
|
+
query: SHOPIFY_CUSTOMER_IDENTITY_QUERY,
|
|
361
|
+
variables: {},
|
|
362
|
+
}),
|
|
363
|
+
redirect: PROVIDER_REDIRECT_MODE,
|
|
364
|
+
});
|
|
365
|
+
if (response.status === 401 || response.status === 403) {
|
|
366
|
+
return jsonResponse(200, {
|
|
367
|
+
authenticated: false,
|
|
368
|
+
renewalRequired: true,
|
|
369
|
+
renewalUrl,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (!response.ok) throw new ProviderFailure();
|
|
373
|
+
const payload = await readBoundedJson(response, MAX_GRAPHQL_BYTES);
|
|
374
|
+
const customer = parseCustomerIdentity(payload);
|
|
375
|
+
return jsonResponse(200, {
|
|
376
|
+
authenticated: true,
|
|
377
|
+
customer,
|
|
378
|
+
expiresAt: session.accessTokenExpiresAt,
|
|
379
|
+
renewalUrl,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function handleLogout(context: RequestRouteContext): Promise<Response> {
|
|
384
|
+
const requestHostname = normalizedHostname(context.requestUrl.hostname);
|
|
385
|
+
if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
|
|
386
|
+
throw new SafeHttpError(404, "not_found");
|
|
387
|
+
}
|
|
388
|
+
requireOrigin(context.request, `https://${requestHostname}`);
|
|
389
|
+
const sessionId = readSessionCookie(context.request.headers.get("Cookie"));
|
|
390
|
+
if (!sessionId) return localLogoutResponse();
|
|
391
|
+
const stored = await context.input.sessionStore.readShopperSession({
|
|
392
|
+
projectId: context.config.projectId,
|
|
393
|
+
sessionId,
|
|
394
|
+
requestHostname,
|
|
395
|
+
});
|
|
396
|
+
await context.input.sessionStore.deleteShopperSession({
|
|
397
|
+
projectId: context.config.projectId,
|
|
398
|
+
sessionId,
|
|
399
|
+
requestHostname,
|
|
400
|
+
});
|
|
401
|
+
if (!stored) return localLogoutResponse();
|
|
402
|
+
|
|
403
|
+
let session: StoredShopifySession;
|
|
404
|
+
try {
|
|
405
|
+
session = parseStoredShopifySession(stored.sessionData, context.config);
|
|
406
|
+
} catch {
|
|
407
|
+
return localLogoutResponse();
|
|
408
|
+
}
|
|
409
|
+
const providerLogout = new URL(session.endSessionEndpoint);
|
|
410
|
+
providerLogout.searchParams.set("id_token_hint", session.idToken);
|
|
411
|
+
providerLogout.searchParams.set(
|
|
412
|
+
"post_logout_redirect_uri",
|
|
413
|
+
`https://${callbackHostname(context.config, requestHostname)}${context.config.postLogoutPath}`,
|
|
414
|
+
);
|
|
415
|
+
return redirectResponse(providerLogout.toString(), 303, {
|
|
416
|
+
"Set-Cookie": expiredSessionCookie(),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function handlePostLogout(context: {
|
|
421
|
+
requestUrl: URL;
|
|
422
|
+
config: ValidatedConfig;
|
|
423
|
+
}): Response {
|
|
424
|
+
requireCallbackHostname(context.requestUrl, context.config);
|
|
425
|
+
return redirectResponse(context.config.signedOutPath, 303);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
interface RouteContext {
|
|
429
|
+
requestUrl: URL;
|
|
430
|
+
config: ValidatedConfig;
|
|
431
|
+
input: {
|
|
432
|
+
sessionStore: PublicCustomerAuthSessionStore;
|
|
433
|
+
hostnamePolicy: CustomerAuthHostnamePolicy;
|
|
434
|
+
authorizationClientKey: CustomerAuthClientKey;
|
|
435
|
+
};
|
|
436
|
+
providerFetch: CustomerAuthProviderFetch;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
interface RequestRouteContext extends Omit<RouteContext, "providerFetch"> {
|
|
440
|
+
request: Request;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
interface StoredShopifySession {
|
|
444
|
+
version: 1;
|
|
445
|
+
accessToken: string;
|
|
446
|
+
idToken: string;
|
|
447
|
+
graphqlEndpoint: string;
|
|
448
|
+
endSessionEndpoint: string;
|
|
449
|
+
accessTokenExpiresAt: number;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
interface ValidatedConfig {
|
|
453
|
+
projectId: string;
|
|
454
|
+
canonicalHostname: string;
|
|
455
|
+
additionalCallbackHostnames: ReadonlySet<string>;
|
|
456
|
+
storefrontDomain: string;
|
|
457
|
+
clientId: string;
|
|
458
|
+
providerUserAgent: string;
|
|
459
|
+
additionalProviderHostnames: ReadonlySet<string>;
|
|
460
|
+
loginPath: string;
|
|
461
|
+
callbackPath: string;
|
|
462
|
+
handoffPath: string;
|
|
463
|
+
sessionPath: string;
|
|
464
|
+
logoutPath: string;
|
|
465
|
+
postLogoutPath: string;
|
|
466
|
+
signedInPath: string;
|
|
467
|
+
signedOutPath: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
type RouteName = "login" | "callback" | "handoff" | "session" | "logout" | "postLogout";
|
|
471
|
+
|
|
472
|
+
function routeFor(config: ValidatedConfig, pathname: string): {
|
|
473
|
+
name: RouteName;
|
|
474
|
+
method: "GET" | "POST";
|
|
475
|
+
} | null {
|
|
476
|
+
if (pathname === config.loginPath) return { name: "login", method: "GET" };
|
|
477
|
+
if (pathname === config.callbackPath) return { name: "callback", method: "GET" };
|
|
478
|
+
if (pathname === config.handoffPath) return { name: "handoff", method: "POST" };
|
|
479
|
+
if (pathname === config.sessionPath) return { name: "session", method: "GET" };
|
|
480
|
+
if (pathname === config.logoutPath) return { name: "logout", method: "POST" };
|
|
481
|
+
if (pathname === config.postLogoutPath) return { name: "postLogout", method: "GET" };
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function validateConfig(config: ShopifyCustomerAccountRouteConfig): ValidatedConfig {
|
|
486
|
+
if (!config || typeof config !== "object") throw new Error("Customer authentication config is required.");
|
|
487
|
+
const allowedConfigKeys = new Set([
|
|
488
|
+
"projectId",
|
|
489
|
+
"canonicalHostname",
|
|
490
|
+
"additionalCallbackHostnames",
|
|
491
|
+
"storefrontDomain",
|
|
492
|
+
"clientId",
|
|
493
|
+
"providerUserAgent",
|
|
494
|
+
"additionalProviderHostnames",
|
|
495
|
+
"loginPath",
|
|
496
|
+
"callbackPath",
|
|
497
|
+
"handoffPath",
|
|
498
|
+
"sessionPath",
|
|
499
|
+
"logoutPath",
|
|
500
|
+
"postLogoutPath",
|
|
501
|
+
"signedInPath",
|
|
502
|
+
"signedOutPath",
|
|
503
|
+
]);
|
|
504
|
+
if (Object.keys(config).some((key) => !allowedConfigKeys.has(key))) {
|
|
505
|
+
throw new Error("The customer authentication config contains an unsupported field.");
|
|
506
|
+
}
|
|
507
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(config.projectId)) {
|
|
508
|
+
throw new Error("The customer authentication project is invalid.");
|
|
509
|
+
}
|
|
510
|
+
if (typeof config.clientId !== "string" || !/^[A-Za-z0-9_-]{8,256}$/.test(config.clientId)) {
|
|
511
|
+
throw new Error("The Shopify Customer Account client ID is invalid.");
|
|
512
|
+
}
|
|
513
|
+
const canonicalHostname = normalizedHostname(config.canonicalHostname);
|
|
514
|
+
const additionalCallbackHostnames = validatedHostnameSet(
|
|
515
|
+
config.additionalCallbackHostnames,
|
|
516
|
+
"Shopify callback hostname allowlist",
|
|
517
|
+
);
|
|
518
|
+
additionalCallbackHostnames.delete(canonicalHostname);
|
|
519
|
+
const storefrontDomain = normalizedHostname(config.storefrontDomain);
|
|
520
|
+
const providerUserAgent = config.providerUserAgent ?? DEFAULT_PROVIDER_USER_AGENT;
|
|
521
|
+
if (typeof providerUserAgent !== "string"
|
|
522
|
+
|| providerUserAgent.length < 3
|
|
523
|
+
|| providerUserAgent.length > 128
|
|
524
|
+
|| /[^\u0020-\u007e]/.test(providerUserAgent)) {
|
|
525
|
+
throw new Error("The Shopify provider User-Agent is invalid.");
|
|
526
|
+
}
|
|
527
|
+
const additionalProviderHostnames = new Set<string>();
|
|
528
|
+
if (config.additionalProviderHostnames !== undefined) {
|
|
529
|
+
if (!Array.isArray(config.additionalProviderHostnames) || config.additionalProviderHostnames.length > 8) {
|
|
530
|
+
throw new Error("The Shopify provider hostname allowlist is invalid.");
|
|
531
|
+
}
|
|
532
|
+
for (const hostname of config.additionalProviderHostnames) {
|
|
533
|
+
additionalProviderHostnames.add(normalizedHostname(hostname));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const routePaths = {
|
|
537
|
+
loginPath: validatedPath(config.loginPath ?? DEFAULT_LOGIN_PATH, "login"),
|
|
538
|
+
callbackPath: validatedPath(config.callbackPath ?? DEFAULT_CALLBACK_PATH, "callback"),
|
|
539
|
+
handoffPath: validatedPath(config.handoffPath ?? DEFAULT_HANDOFF_PATH, "handoff"),
|
|
540
|
+
sessionPath: validatedPath(config.sessionPath ?? DEFAULT_SESSION_PATH, "session"),
|
|
541
|
+
logoutPath: validatedPath(config.logoutPath ?? DEFAULT_LOGOUT_PATH, "logout"),
|
|
542
|
+
postLogoutPath: validatedPath(config.postLogoutPath ?? DEFAULT_POST_LOGOUT_PATH, "post-logout"),
|
|
543
|
+
};
|
|
544
|
+
if (new Set(Object.values(routePaths)).size !== Object.keys(routePaths).length) {
|
|
545
|
+
throw new Error("Customer authentication route paths must be distinct.");
|
|
546
|
+
}
|
|
547
|
+
const signedInPath = validatedPath(config.signedInPath ?? DEFAULT_SIGNED_IN_PATH, "signed-in");
|
|
548
|
+
const signedOutPath = validatedPath(config.signedOutPath ?? DEFAULT_SIGNED_OUT_PATH, "signed-out");
|
|
549
|
+
if (signedOutPath === routePaths.postLogoutPath) {
|
|
550
|
+
throw new Error("The signed-out path cannot be the post-logout callback path.");
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
projectId: config.projectId,
|
|
554
|
+
canonicalHostname,
|
|
555
|
+
additionalCallbackHostnames,
|
|
556
|
+
storefrontDomain,
|
|
557
|
+
clientId: config.clientId,
|
|
558
|
+
providerUserAgent,
|
|
559
|
+
additionalProviderHostnames,
|
|
560
|
+
...routePaths,
|
|
561
|
+
signedInPath,
|
|
562
|
+
signedOutPath,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parsedRequestUrl(request: Request): URL {
|
|
567
|
+
if (!(request instanceof Request) || request.url.length > MAX_REQUEST_URL_LENGTH) {
|
|
568
|
+
throw new SafeHttpError(400, "invalid_request");
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
return new URL(request.url);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new SafeHttpError(400, "invalid_request");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function validatedRequestUrl(url: URL): URL {
|
|
578
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port) {
|
|
579
|
+
throw new SafeHttpError(400, "invalid_request");
|
|
580
|
+
}
|
|
581
|
+
normalizedHostname(url.hostname);
|
|
582
|
+
return url;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function validatedPath(value: string, label: string): string {
|
|
586
|
+
if (typeof value !== "string" || !/^\/[A-Za-z0-9/_-]{0,127}$/.test(value) || value.includes("//")) {
|
|
587
|
+
throw new Error(`The customer authentication ${label} path is invalid.`);
|
|
588
|
+
}
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function callbackUrl(config: ValidatedConfig, hostname = config.canonicalHostname): string {
|
|
593
|
+
return `https://${hostname}${config.callbackPath}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function customerRenewalUrl(config: ValidatedConfig, returnHostname: string): string {
|
|
597
|
+
return `https://${callbackHostname(config, returnHostname)}${config.loginPath}`
|
|
598
|
+
+ `?return_host=${encodeURIComponent(returnHostname)}&prompt=none`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function currentSeconds(): number {
|
|
602
|
+
return Math.floor(Date.now() / 1_000);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function requireCallbackHostname(url: URL, config: ValidatedConfig): string {
|
|
606
|
+
const hostname = normalizedHostname(url.hostname);
|
|
607
|
+
if (callbackHostname(config, hostname) !== hostname) {
|
|
608
|
+
throw new SafeHttpError(404, "not_found");
|
|
609
|
+
}
|
|
610
|
+
return hostname;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function callbackHostname(config: ValidatedConfig, hostname: string): string {
|
|
614
|
+
return hostname === config.canonicalHostname || config.additionalCallbackHostnames.has(hostname)
|
|
615
|
+
? hostname
|
|
616
|
+
: config.canonicalHostname;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function isAllowedHostname(
|
|
620
|
+
input: { hostnamePolicy: CustomerAuthHostnamePolicy },
|
|
621
|
+
projectId: string,
|
|
622
|
+
hostnameValue: string,
|
|
623
|
+
): Promise<boolean> {
|
|
624
|
+
let hostname: string;
|
|
625
|
+
try {
|
|
626
|
+
hostname = normalizedHostname(hostnameValue);
|
|
627
|
+
} catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
return await input.hostnamePolicy({ projectId, hostname }) === true;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function discoverOpenId(
|
|
638
|
+
config: ValidatedConfig,
|
|
639
|
+
providerFetch: CustomerAuthProviderFetch,
|
|
640
|
+
): Promise<{
|
|
641
|
+
authorizationEndpoint: string;
|
|
642
|
+
tokenEndpoint: string;
|
|
643
|
+
endSessionEndpoint: string;
|
|
644
|
+
}> {
|
|
645
|
+
const response = await fetchProvider(
|
|
646
|
+
providerFetch,
|
|
647
|
+
`https://${config.storefrontDomain}/.well-known/openid-configuration`,
|
|
648
|
+
{
|
|
649
|
+
method: "GET",
|
|
650
|
+
headers: providerRequestHeaders(config, { Accept: "application/json" }),
|
|
651
|
+
redirect: PROVIDER_REDIRECT_MODE,
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
if (!response.ok) throw new ProviderFailure();
|
|
655
|
+
const value = objectValue(await readBoundedJson(response, MAX_DISCOVERY_BYTES));
|
|
656
|
+
return {
|
|
657
|
+
authorizationEndpoint: providerEndpoint(value.authorization_endpoint, config),
|
|
658
|
+
tokenEndpoint: providerEndpoint(value.token_endpoint, config),
|
|
659
|
+
endSessionEndpoint: providerEndpoint(value.end_session_endpoint, config),
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function discoverCustomerApi(
|
|
664
|
+
config: ValidatedConfig,
|
|
665
|
+
providerFetch: CustomerAuthProviderFetch,
|
|
666
|
+
): Promise<{ graphqlEndpoint: string }> {
|
|
667
|
+
const response = await fetchProvider(
|
|
668
|
+
providerFetch,
|
|
669
|
+
`https://${config.storefrontDomain}/.well-known/customer-account-api`,
|
|
670
|
+
{
|
|
671
|
+
method: "GET",
|
|
672
|
+
headers: providerRequestHeaders(config, { Accept: "application/json" }),
|
|
673
|
+
redirect: PROVIDER_REDIRECT_MODE,
|
|
674
|
+
},
|
|
675
|
+
);
|
|
676
|
+
if (!response.ok) throw new ProviderFailure();
|
|
677
|
+
const value = objectValue(await readBoundedJson(response, MAX_DISCOVERY_BYTES));
|
|
678
|
+
return { graphqlEndpoint: providerEndpoint(value.graphql_api, config) };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function exchangeAuthorizationCode(input: {
|
|
682
|
+
config: ValidatedConfig;
|
|
683
|
+
providerFetch: CustomerAuthProviderFetch;
|
|
684
|
+
tokenEndpoint: string;
|
|
685
|
+
code: string;
|
|
686
|
+
verifier: string;
|
|
687
|
+
callbackHostname: string;
|
|
688
|
+
}): Promise<{ accessToken: string; idToken: string; expiresIn: number }> {
|
|
689
|
+
const response = await fetchProvider(input.providerFetch, input.tokenEndpoint, {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: providerRequestHeaders(input.config, {
|
|
692
|
+
Accept: "application/json",
|
|
693
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
694
|
+
}, `https://${input.callbackHostname}`),
|
|
695
|
+
body: new URLSearchParams({
|
|
696
|
+
grant_type: "authorization_code",
|
|
697
|
+
client_id: input.config.clientId,
|
|
698
|
+
redirect_uri: callbackUrl(input.config, input.callbackHostname),
|
|
699
|
+
code: input.code,
|
|
700
|
+
code_verifier: input.verifier,
|
|
701
|
+
}),
|
|
702
|
+
redirect: PROVIDER_REDIRECT_MODE,
|
|
703
|
+
});
|
|
704
|
+
if (!response.ok) throw new ProviderFailure();
|
|
705
|
+
const value = objectValue(await readBoundedJson(response, MAX_TOKEN_BYTES));
|
|
706
|
+
if (typeof value.access_token !== "string"
|
|
707
|
+
|| value.access_token.length < 1
|
|
708
|
+
|| value.access_token.length > MAX_ACCESS_TOKEN_LENGTH
|
|
709
|
+
|| /[\u0000-\u001f\u007f]/.test(value.access_token)) {
|
|
710
|
+
throw new ProviderFailure();
|
|
711
|
+
}
|
|
712
|
+
if (!Number.isSafeInteger(value.expires_in)
|
|
713
|
+
|| (value.expires_in as number) < 1
|
|
714
|
+
|| (value.expires_in as number) > MAX_SESSION_TTL_SECONDS) {
|
|
715
|
+
throw new ProviderFailure();
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
accessToken: value.access_token,
|
|
719
|
+
idToken: requiredIdToken(value.id_token),
|
|
720
|
+
expiresIn: value.expires_in as number,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function providerRequestHeaders(
|
|
725
|
+
config: ValidatedConfig,
|
|
726
|
+
values: HeadersInit,
|
|
727
|
+
origin = `https://${config.canonicalHostname}`,
|
|
728
|
+
): Headers {
|
|
729
|
+
const headers = new Headers(values);
|
|
730
|
+
headers.set("Origin", origin);
|
|
731
|
+
headers.set("User-Agent", config.providerUserAgent);
|
|
732
|
+
return headers;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function providerEndpoint(value: unknown, config: ValidatedConfig): string {
|
|
736
|
+
if (typeof value !== "string" || value.length < 1 || value.length > MAX_ENDPOINT_LENGTH) {
|
|
737
|
+
throw new ProviderFailure();
|
|
738
|
+
}
|
|
739
|
+
let url: URL;
|
|
740
|
+
try {
|
|
741
|
+
url = new URL(value);
|
|
742
|
+
} catch {
|
|
743
|
+
throw new ProviderFailure();
|
|
744
|
+
}
|
|
745
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || (url.port && url.port !== "443")) {
|
|
746
|
+
throw new ProviderFailure();
|
|
747
|
+
}
|
|
748
|
+
let hostname: string;
|
|
749
|
+
try {
|
|
750
|
+
hostname = normalizedHostname(url.hostname);
|
|
751
|
+
} catch {
|
|
752
|
+
throw new ProviderFailure();
|
|
753
|
+
}
|
|
754
|
+
if (!isSameOrSubdomain(hostname, config.storefrontDomain)
|
|
755
|
+
&& !isSameOrSubdomain(hostname, "shopify.com")
|
|
756
|
+
&& !isSameOrSubdomain(hostname, "myshopify.com")
|
|
757
|
+
&& !config.additionalProviderHostnames.has(hostname)) {
|
|
758
|
+
throw new ProviderFailure();
|
|
759
|
+
}
|
|
760
|
+
return url.toString();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function parseStoredShopifySession(value: string, config: ValidatedConfig): StoredShopifySession {
|
|
764
|
+
if (typeof value !== "string" || value.length > 65_536) throw new ProviderFailure();
|
|
765
|
+
let parsed: unknown;
|
|
766
|
+
try {
|
|
767
|
+
parsed = JSON.parse(value);
|
|
768
|
+
} catch {
|
|
769
|
+
throw new ProviderFailure();
|
|
770
|
+
}
|
|
771
|
+
const object = objectValue(parsed);
|
|
772
|
+
if (object.version !== 1
|
|
773
|
+
|| typeof object.accessToken !== "string"
|
|
774
|
+
|| object.accessToken.length < 1
|
|
775
|
+
|| object.accessToken.length > MAX_ACCESS_TOKEN_LENGTH
|
|
776
|
+
|| /[\u0000-\u001f\u007f]/.test(object.accessToken)) {
|
|
777
|
+
throw new ProviderFailure();
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
version: 1,
|
|
781
|
+
accessToken: object.accessToken,
|
|
782
|
+
idToken: requiredIdToken(object.idToken),
|
|
783
|
+
graphqlEndpoint: providerEndpoint(object.graphqlEndpoint, config),
|
|
784
|
+
endSessionEndpoint: providerEndpoint(object.endSessionEndpoint, config),
|
|
785
|
+
accessTokenExpiresAt: requiredStoredExpiry(object.accessTokenExpiresAt),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function requiredIdToken(value: unknown): string {
|
|
790
|
+
if (typeof value !== "string" || value.length < 3 || value.length > MAX_ID_TOKEN_LENGTH
|
|
791
|
+
|| !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
|
|
792
|
+
throw new ProviderFailure();
|
|
793
|
+
}
|
|
794
|
+
return value;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function requiredStoredExpiry(value: unknown): number {
|
|
798
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
|
799
|
+
throw new ProviderFailure();
|
|
800
|
+
}
|
|
801
|
+
return value;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function parseCustomerIdentity(value: unknown): {
|
|
805
|
+
id: string;
|
|
806
|
+
displayName: string;
|
|
807
|
+
emailAddress: { emailAddress: string } | null;
|
|
808
|
+
} {
|
|
809
|
+
const root = objectValue(value);
|
|
810
|
+
if (root.errors !== undefined || !root.data) throw new ProviderFailure();
|
|
811
|
+
const data = objectValue(root.data);
|
|
812
|
+
const customer = objectValue(data.customer);
|
|
813
|
+
const id = boundedString(customer.id, 512);
|
|
814
|
+
const displayName = boundedString(customer.displayName, 1_024);
|
|
815
|
+
let emailAddress: { emailAddress: string } | null = null;
|
|
816
|
+
if (customer.emailAddress !== null && customer.emailAddress !== undefined) {
|
|
817
|
+
const email = objectValue(customer.emailAddress);
|
|
818
|
+
emailAddress = { emailAddress: boundedString(email.emailAddress, 2_048) };
|
|
819
|
+
}
|
|
820
|
+
return { id, displayName, emailAddress };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function fetchProvider(
|
|
824
|
+
providerFetch: CustomerAuthProviderFetch,
|
|
825
|
+
input: RequestInfo | URL,
|
|
826
|
+
init: RequestInit,
|
|
827
|
+
): Promise<Response> {
|
|
828
|
+
try {
|
|
829
|
+
return await providerFetch(input, {
|
|
830
|
+
...init,
|
|
831
|
+
signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS),
|
|
832
|
+
});
|
|
833
|
+
} catch {
|
|
834
|
+
throw new ProviderFailure();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function readBoundedJson(response: Response, maximumBytes: number): Promise<unknown> {
|
|
839
|
+
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
840
|
+
if (contentType !== "application/json" && !contentType?.endsWith("+json")) throw new ProviderFailure();
|
|
841
|
+
const contentLength = response.headers.get("Content-Length");
|
|
842
|
+
if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > maximumBytes)) {
|
|
843
|
+
throw new ProviderFailure();
|
|
844
|
+
}
|
|
845
|
+
if (!response.body) throw new ProviderFailure();
|
|
846
|
+
const reader = response.body.getReader();
|
|
847
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
848
|
+
let bytesRead = 0;
|
|
849
|
+
let text = "";
|
|
850
|
+
try {
|
|
851
|
+
while (true) {
|
|
852
|
+
const result = await reader.read();
|
|
853
|
+
if (result.done) break;
|
|
854
|
+
bytesRead += result.value.byteLength;
|
|
855
|
+
if (bytesRead > maximumBytes) throw new ProviderFailure();
|
|
856
|
+
text += decoder.decode(result.value, { stream: true });
|
|
857
|
+
}
|
|
858
|
+
text += decoder.decode();
|
|
859
|
+
} catch (error) {
|
|
860
|
+
if (error instanceof ProviderFailure) throw error;
|
|
861
|
+
throw new ProviderFailure();
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
return JSON.parse(text) as unknown;
|
|
865
|
+
} catch {
|
|
866
|
+
throw new ProviderFailure();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function boundedForm(request: Request): Promise<URLSearchParams> {
|
|
871
|
+
const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
872
|
+
if (contentType !== "application/x-www-form-urlencoded") {
|
|
873
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
874
|
+
}
|
|
875
|
+
const contentLength = request.headers.get("Content-Length");
|
|
876
|
+
if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_FORM_BODY_BYTES)) {
|
|
877
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
878
|
+
}
|
|
879
|
+
if (!request.body) throw new SafeHttpError(400, "invalid_handoff");
|
|
880
|
+
const reader = request.body.getReader();
|
|
881
|
+
const chunks: Uint8Array[] = [];
|
|
882
|
+
let byteLength = 0;
|
|
883
|
+
try {
|
|
884
|
+
while (true) {
|
|
885
|
+
const result = await reader.read();
|
|
886
|
+
if (result.done) break;
|
|
887
|
+
byteLength += result.value.byteLength;
|
|
888
|
+
if (byteLength > MAX_FORM_BODY_BYTES) {
|
|
889
|
+
await reader.cancel();
|
|
890
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
891
|
+
}
|
|
892
|
+
chunks.push(result.value);
|
|
893
|
+
}
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (error instanceof SafeHttpError) throw error;
|
|
896
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
897
|
+
}
|
|
898
|
+
const bodyBytes = new Uint8Array(byteLength);
|
|
899
|
+
let offset = 0;
|
|
900
|
+
for (const chunk of chunks) {
|
|
901
|
+
bodyBytes.set(chunk, offset);
|
|
902
|
+
offset += chunk.byteLength;
|
|
903
|
+
}
|
|
904
|
+
let body: string;
|
|
905
|
+
try {
|
|
906
|
+
body = new TextDecoder("utf-8", { fatal: true }).decode(bodyBytes);
|
|
907
|
+
} catch {
|
|
908
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
909
|
+
}
|
|
910
|
+
return new URLSearchParams(body);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function requiredSingleQueryValue(
|
|
914
|
+
url: URL,
|
|
915
|
+
name: string,
|
|
916
|
+
maximumLength: number,
|
|
917
|
+
pattern?: RegExp,
|
|
918
|
+
): string {
|
|
919
|
+
const values = url.searchParams.getAll(name);
|
|
920
|
+
if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength
|
|
921
|
+
|| (pattern && !pattern.test(values[0]))) {
|
|
922
|
+
throw new SafeHttpError(400, "invalid_request");
|
|
923
|
+
}
|
|
924
|
+
return values[0];
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function optionalSingleQueryValue(
|
|
928
|
+
url: URL,
|
|
929
|
+
name: string,
|
|
930
|
+
maximumLength: number,
|
|
931
|
+
pattern: RegExp,
|
|
932
|
+
): string | null {
|
|
933
|
+
const values = url.searchParams.getAll(name);
|
|
934
|
+
if (values.length === 0) return null;
|
|
935
|
+
if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength
|
|
936
|
+
|| !pattern.test(values[0])) {
|
|
937
|
+
throw new SafeHttpError(400, "invalid_request");
|
|
938
|
+
}
|
|
939
|
+
return values[0];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function requiredSingleFormValue(
|
|
943
|
+
form: URLSearchParams,
|
|
944
|
+
name: string,
|
|
945
|
+
maximumLength: number,
|
|
946
|
+
pattern: RegExp,
|
|
947
|
+
): string {
|
|
948
|
+
const values = form.getAll(name);
|
|
949
|
+
if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength
|
|
950
|
+
|| !pattern.test(values[0])) {
|
|
951
|
+
throw new SafeHttpError(400, "invalid_handoff");
|
|
952
|
+
}
|
|
953
|
+
return values[0];
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function readSessionCookie(cookieHeader: string | null): string | null {
|
|
957
|
+
return readOpaqueCookie(cookieHeader, PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function readOpaqueCookie(cookieHeader: string | null, expectedName: string): string | null {
|
|
961
|
+
if (!cookieHeader || cookieHeader.length > MAX_COOKIE_HEADER_LENGTH) return null;
|
|
962
|
+
const matches: string[] = [];
|
|
963
|
+
for (const part of cookieHeader.split(";")) {
|
|
964
|
+
const separator = part.indexOf("=");
|
|
965
|
+
if (separator < 1) continue;
|
|
966
|
+
const name = part.slice(0, separator).trim();
|
|
967
|
+
if (name === expectedName) {
|
|
968
|
+
matches.push(part.slice(separator + 1).trim());
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (matches.length !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(matches[0])) return null;
|
|
972
|
+
return matches[0];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function requiredAuthorizationClientKey(
|
|
976
|
+
resolver: CustomerAuthClientKey,
|
|
977
|
+
request: Request,
|
|
978
|
+
): Promise<string> {
|
|
979
|
+
try {
|
|
980
|
+
const key = await resolver(request);
|
|
981
|
+
if (typeof key !== "string" || key.length < 1 || key.length > 512
|
|
982
|
+
|| /[\u0000-\u001f\u007f]/.test(key)) {
|
|
983
|
+
throw new Error("invalid");
|
|
984
|
+
}
|
|
985
|
+
return key;
|
|
986
|
+
} catch {
|
|
987
|
+
throw new SafeHttpError(503, "customer_authentication_unavailable");
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function cloudflareAuthorizationClientKey(request: Request): string {
|
|
992
|
+
const address = request.headers.get("CF-Connecting-IP")?.trim().toLowerCase();
|
|
993
|
+
if (!address || address.length > 64 || !/^[0-9a-f:.]+$/.test(address)) {
|
|
994
|
+
throw new Error("The Cloudflare client address is unavailable.");
|
|
995
|
+
}
|
|
996
|
+
return `cloudflare-ip:${address}`;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function requireOrigin(request: Request, expectedOrigin: string | readonly string[]): void {
|
|
1000
|
+
const expectedOrigins = typeof expectedOrigin === "string" ? [expectedOrigin] : expectedOrigin;
|
|
1001
|
+
if (!expectedOrigins.includes(request.headers.get("Origin") ?? "")) {
|
|
1002
|
+
throw new SafeHttpError(403, "invalid_origin");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function providerOrigin(config: ValidatedConfig, requestHostname: string): string {
|
|
1007
|
+
return `https://${callbackHostname(config, requestHostname)}`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function validatedHostnameSet(value: readonly string[] | undefined, label: string): Set<string> {
|
|
1011
|
+
if (value === undefined) return new Set();
|
|
1012
|
+
if (!Array.isArray(value) || value.length > 8) throw new Error(`The ${label} is invalid.`);
|
|
1013
|
+
return new Set(value.map(normalizedHostname));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function normalizedHostname(value: unknown): string {
|
|
1017
|
+
if (typeof value !== "string") throw new Error("The customer authentication hostname is invalid.");
|
|
1018
|
+
const hostname = value.toLowerCase().replace(/\.$/, "");
|
|
1019
|
+
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)
|
|
1020
|
+
|| hostname.length > 253) {
|
|
1021
|
+
throw new Error("The customer authentication hostname is invalid.");
|
|
1022
|
+
}
|
|
1023
|
+
return hostname;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function isSameOrSubdomain(hostname: string, parent: string): boolean {
|
|
1027
|
+
return hostname === parent || hostname.endsWith(`.${parent}`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function objectValue(value: unknown): Record<string, unknown> {
|
|
1031
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ProviderFailure();
|
|
1032
|
+
return value as Record<string, unknown>;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function boundedString(value: unknown, maximumLength: number): string {
|
|
1036
|
+
if (typeof value !== "string" || value.length > maximumLength) throw new ProviderFailure();
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function randomBase64Url(bytes: number): string {
|
|
1041
|
+
const value = crypto.getRandomValues(new Uint8Array(bytes));
|
|
1042
|
+
return base64Url(value);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function sha256Base64Url(value: string): Promise<string> {
|
|
1046
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1047
|
+
return base64Url(new Uint8Array(digest));
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function base64Url(value: Uint8Array): string {
|
|
1051
|
+
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
|
|
1052
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function handoffPostResponse(
|
|
1056
|
+
config: ValidatedConfig,
|
|
1057
|
+
returnHostname: string,
|
|
1058
|
+
handoff: string,
|
|
1059
|
+
): Response {
|
|
1060
|
+
const action = `https://${returnHostname}${config.handoffPath}`;
|
|
1061
|
+
const nonce = randomBase64Url(18);
|
|
1062
|
+
const html = `<!doctype html>
|
|
1063
|
+
<html lang="en">
|
|
1064
|
+
<head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Signing in</title></head>
|
|
1065
|
+
<body>
|
|
1066
|
+
<form id="customer-auth-handoff" method="post" action="${escapeHtml(action)}">
|
|
1067
|
+
<input type="hidden" name="handoff" value="${escapeHtml(handoff)}">
|
|
1068
|
+
<noscript><button type="submit">Continue</button></noscript>
|
|
1069
|
+
</form>
|
|
1070
|
+
<script nonce="${nonce}">document.getElementById("customer-auth-handoff").requestSubmit();</script>
|
|
1071
|
+
</body>
|
|
1072
|
+
</html>`;
|
|
1073
|
+
return new Response(html, {
|
|
1074
|
+
status: 200,
|
|
1075
|
+
headers: secureHeaders({
|
|
1076
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1077
|
+
"Content-Security-Policy": `default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action https://${returnHostname}; script-src 'nonce-${nonce}'`,
|
|
1078
|
+
"Referrer-Policy": "no-referrer",
|
|
1079
|
+
}),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function escapeHtml(value: string): string {
|
|
1084
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function sessionCookie(sessionId: string): string {
|
|
1088
|
+
return `${PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE}=${sessionId}; Path=/; Secure; HttpOnly; SameSite=Lax`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function authorizationFlowCookie(state: string, browserBinding: string): string {
|
|
1092
|
+
return `${authorizationFlowCookieName(state)}=${browserBinding}; Max-Age=${FLOW_TTL_SECONDS}; Path=/; Secure; HttpOnly; SameSite=Lax`;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function authorizationFlowCookieName(state: string): string {
|
|
1096
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(state)) {
|
|
1097
|
+
throw new Error("The customer authentication state is invalid.");
|
|
1098
|
+
}
|
|
1099
|
+
return `${PUBLIC_CUSTOMER_AUTH_FLOW_COOKIE}_${state}`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function expiredSessionCookie(): string {
|
|
1103
|
+
return `${PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function localLogoutResponse(): Response {
|
|
1107
|
+
return new Response(null, {
|
|
1108
|
+
status: 204,
|
|
1109
|
+
headers: secureHeaders({ "Set-Cookie": expiredSessionCookie() }),
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function unauthenticatedResponse(clearCookie: boolean): Response {
|
|
1114
|
+
return jsonResponse(200, { authenticated: false }, clearCookie
|
|
1115
|
+
? { "Set-Cookie": expiredSessionCookie() }
|
|
1116
|
+
: undefined);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function jsonResponse(status: number, value: unknown, headers?: HeadersInit): Response {
|
|
1120
|
+
return new Response(JSON.stringify(value), {
|
|
1121
|
+
status,
|
|
1122
|
+
headers: secureHeaders({ "Content-Type": "application/json; charset=utf-8", ...headers }),
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function safeError(status: number, code: string, headers?: HeadersInit): Response {
|
|
1127
|
+
return jsonResponse(status, { error: code }, headers);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function redirectResponse(location: string, status: 302 | 303, headers?: HeadersInit): Response {
|
|
1131
|
+
const responseHeaders = new Headers(headers);
|
|
1132
|
+
responseHeaders.set("Location", location);
|
|
1133
|
+
return new Response(null, {
|
|
1134
|
+
status,
|
|
1135
|
+
headers: secureHeaders(responseHeaders),
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function methodNotAllowed(method: "GET" | "POST"): Response {
|
|
1140
|
+
return jsonResponse(405, { error: "method_not_allowed" }, { Allow: method });
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function secureHeaders(extra: HeadersInit): Headers {
|
|
1144
|
+
const headers = new Headers(extra);
|
|
1145
|
+
headers.set("Cache-Control", "no-store");
|
|
1146
|
+
headers.set("Referrer-Policy", "no-referrer");
|
|
1147
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
1148
|
+
return headers;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
class SafeHttpError extends Error {
|
|
1152
|
+
constructor(readonly status: number, readonly code: string) {
|
|
1153
|
+
super(code);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
class ProviderFailure extends Error {}
|