@synqauth/nextjs 0.0.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/dist/{config-BNh2Aznt.d.ts → config-a7tT3KtP.d.ts} +107 -1
- package/dist/define.d.ts +2 -2
- package/dist/index.d.ts +55 -5
- package/dist/index.js +22 -7
- package/dist/proxy.d.ts +29 -1
- package/dist/proxy.js +19 -4
- package/dist/sites-JUnMBDbp.js +2094 -0
- package/package.json +3 -3
- package/dist/sign-in-error-DSCgrk0b.js +0 -1160
|
@@ -0,0 +1,2094 @@
|
|
|
1
|
+
import { SynqError, createSynqClient, isSynqError, memoryStorage, readMethodList, signInMethods } from "@synqauth/js";
|
|
2
|
+
//#region src/crypto.ts
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const decoder = new TextDecoder();
|
|
5
|
+
const VERSION = "v1";
|
|
6
|
+
const keys = /* @__PURE__ */ new Map();
|
|
7
|
+
/**
|
|
8
|
+
* The sealing key for a session secret, derived once per secret; with
|
|
9
|
+
* `site`, the key for that site alone (`siteBinding`), which the site's core
|
|
10
|
+
* keeps rather than this module.
|
|
11
|
+
*/
|
|
12
|
+
function sealingKey(secret, site = "") {
|
|
13
|
+
if (encoder.encode(secret).length < 32) throw new TypeError(`The session secret must be at least 32 bytes`);
|
|
14
|
+
if (site) return deriveKey(secret, `${VERSION}\nsite ${site}`);
|
|
15
|
+
let key = keys.get(secret);
|
|
16
|
+
if (!key) {
|
|
17
|
+
key = deriveKey(secret, VERSION);
|
|
18
|
+
keys.set(secret, key);
|
|
19
|
+
}
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
async function deriveKey(secret, info) {
|
|
23
|
+
const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
|
|
24
|
+
return crypto.subtle.deriveKey({
|
|
25
|
+
name: "HKDF",
|
|
26
|
+
hash: "SHA-256",
|
|
27
|
+
salt: encoder.encode("@synqauth/nextjs session"),
|
|
28
|
+
info: encoder.encode(info)
|
|
29
|
+
}, base, {
|
|
30
|
+
name: "AES-GCM",
|
|
31
|
+
length: 256
|
|
32
|
+
}, false, ["encrypt", "decrypt"]);
|
|
33
|
+
}
|
|
34
|
+
async function sealingKeys(current, previous = [], site = "") {
|
|
35
|
+
const open = await Promise.all([current, ...previous].map((secret) => sealingKey(secret, site)));
|
|
36
|
+
return {
|
|
37
|
+
seal: open[0],
|
|
38
|
+
open
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** `plaintext` sealed for the cookie `name`: `v1.<iv>.<ciphertext>`, base64url. */
|
|
42
|
+
async function seal(key, plaintext, name) {
|
|
43
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
44
|
+
const sealed = await crypto.subtle.encrypt({
|
|
45
|
+
name: "AES-GCM",
|
|
46
|
+
iv,
|
|
47
|
+
additionalData: encoder.encode(name)
|
|
48
|
+
}, key, encoder.encode(plaintext));
|
|
49
|
+
return `${VERSION}.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
|
|
50
|
+
}
|
|
51
|
+
/** What `seal` sealed for `name` with any of `keys`, or undefined for anything else: another cookie's, tampered, or another secret's. */
|
|
52
|
+
async function unseal(keys, sealed, name) {
|
|
53
|
+
if (!sealed) return void 0;
|
|
54
|
+
const [version, iv, data, ...rest] = sealed.split(".");
|
|
55
|
+
if (version !== VERSION || !iv || !data || rest.length > 0) return void 0;
|
|
56
|
+
const candidates = Array.isArray(keys) ? keys : [keys];
|
|
57
|
+
for (const key of candidates) try {
|
|
58
|
+
const opened = await crypto.subtle.decrypt({
|
|
59
|
+
name: "AES-GCM",
|
|
60
|
+
iv: fromBase64url(iv),
|
|
61
|
+
additionalData: encoder.encode(name)
|
|
62
|
+
}, key, fromBase64url(data));
|
|
63
|
+
return decoder.decode(opened);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
/** A random id: 128 bits, base64url. */
|
|
67
|
+
function randomId() {
|
|
68
|
+
return base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
|
|
69
|
+
}
|
|
70
|
+
function base64url(bytes) {
|
|
71
|
+
let binary = "";
|
|
72
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
73
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
74
|
+
}
|
|
75
|
+
function fromBase64url(text) {
|
|
76
|
+
const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
|
|
77
|
+
const bytes = new Uint8Array(binary.length);
|
|
78
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
79
|
+
return bytes;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/config.ts
|
|
83
|
+
/** Milliseconds a request to Synq may take before it's given up: shorter than any lock a store holds for a refresh. */
|
|
84
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
85
|
+
/** Variables that say the app may run on more than one server process: serverless functions, replicas. */
|
|
86
|
+
const MULTI_PROCESS = [
|
|
87
|
+
"VERCEL",
|
|
88
|
+
"AWS_LAMBDA_FUNCTION_NAME",
|
|
89
|
+
"NETLIFY",
|
|
90
|
+
"K_SERVICE",
|
|
91
|
+
"KUBERNETES_SERVICE_HOST"
|
|
92
|
+
];
|
|
93
|
+
/** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
|
|
94
|
+
function resolveConfig(options = {}, env = processEnv()) {
|
|
95
|
+
if (options.resolve) throw new SynqError("invalid_options", "This configuration has resolve, so each request picks its own: synqAuth() and synqProxy() read it per request");
|
|
96
|
+
if (options.hosts !== void 0) throw new SynqError("invalid_options", "hosts goes with resolve: the hosts it answers for");
|
|
97
|
+
return build(options, {
|
|
98
|
+
issuer: options.issuer ?? env.SYNQ_ISSUER,
|
|
99
|
+
clientId: options.clientId ?? env.SYNQ_CLIENT_ID,
|
|
100
|
+
clientSecret: options.clientSecret ?? env.SYNQ_CLIENT_SECRET,
|
|
101
|
+
appUrl: options.appUrl ?? env.SYNQ_APP_URL,
|
|
102
|
+
account: options.account,
|
|
103
|
+
postLogoutRedirectUri: options.postLogoutRedirectUri,
|
|
104
|
+
storePrefix: void 0
|
|
105
|
+
}, {
|
|
106
|
+
issuer: "SYNQ_ISSUER",
|
|
107
|
+
clientId: "SYNQ_CLIENT_ID",
|
|
108
|
+
clientSecret: "SYNQ_CLIENT_SECRET",
|
|
109
|
+
appUrl: "SYNQ_APP_URL"
|
|
110
|
+
}, env, false);
|
|
111
|
+
}
|
|
112
|
+
/** The options `resolve` answers for each site, which the top level never gives alongside it. */
|
|
113
|
+
const SITE_OPTIONS = [
|
|
114
|
+
"issuer",
|
|
115
|
+
"clientId",
|
|
116
|
+
"clientSecret",
|
|
117
|
+
"appUrl",
|
|
118
|
+
"postLogoutRedirectUri"
|
|
119
|
+
];
|
|
120
|
+
/** A site's prefix in the store: letters, digits and `.:_~-`, as a key-value store's key takes them. */
|
|
121
|
+
const STORE_PREFIX = /^[A-Za-z0-9._:~-]{1,128}$/;
|
|
122
|
+
/**
|
|
123
|
+
* One site's configuration (AERO-2655): what `resolve` answered for
|
|
124
|
+
* `request`, over `options`' shared settings. Throws `invalid_options` for
|
|
125
|
+
* anything missing or unsafe, naming the field, never its value.
|
|
126
|
+
*/
|
|
127
|
+
function siteConfig(options, site, request, env = processEnv()) {
|
|
128
|
+
const given = SITE_OPTIONS.filter((name) => options[name] !== void 0);
|
|
129
|
+
if (given.length > 0) throw new SynqError("invalid_options", `With resolve, each site's ${given.join(", ")} come from resolve: take ${given.length === 1 ? "it" : "them"} out of the options`);
|
|
130
|
+
if (site === null || typeof site !== "object") throw new SynqError("invalid_options", "resolve answers a site, or null for a host the app doesn’t serve");
|
|
131
|
+
const config = build(options, {
|
|
132
|
+
issuer: text(site.issuer, "resolve()'s issuer"),
|
|
133
|
+
clientId: text(site.clientId, "resolve()'s clientId"),
|
|
134
|
+
clientSecret: text(site.clientSecret, "resolve()'s clientSecret"),
|
|
135
|
+
appUrl: site.appUrl === void 0 ? request.origin : text(site.appUrl, "resolve()'s appUrl"),
|
|
136
|
+
account: site.account ?? options.account,
|
|
137
|
+
postLogoutRedirectUri: site.postLogoutRedirectUri === void 0 ? void 0 : text(site.postLogoutRedirectUri, "resolve()'s postLogoutRedirectUri"),
|
|
138
|
+
storePrefix: site.storePrefix
|
|
139
|
+
}, {
|
|
140
|
+
issuer: "resolve()'s issuer",
|
|
141
|
+
clientId: "resolve()'s clientId",
|
|
142
|
+
clientSecret: "resolve()'s clientSecret",
|
|
143
|
+
appUrl: "resolve()'s appUrl"
|
|
144
|
+
}, env, true);
|
|
145
|
+
if (config.appUrl.origin !== request.origin) throw new SynqError("invalid_options", "resolve()'s appUrl must be the origin the request came to (its https://<host>): leave it out to use that");
|
|
146
|
+
return config;
|
|
147
|
+
}
|
|
148
|
+
/** A site's field: a string, or the error that names it. */
|
|
149
|
+
function text(value, name) {
|
|
150
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
151
|
+
if (typeof value !== "string") throw new SynqError("invalid_options", `${name} must be a string`);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function build(options, fields, names, env, perSite) {
|
|
155
|
+
const issuer = required(fields.issuer, names.issuer).replace(/\/+$/, "");
|
|
156
|
+
checkUrl(issuer, names.issuer);
|
|
157
|
+
const clientId = required(fields.clientId, names.clientId);
|
|
158
|
+
const clientSecret = required(fields.clientSecret, names.clientSecret);
|
|
159
|
+
const appUrl = checkUrl(required(fields.appUrl, names.appUrl), names.appUrl);
|
|
160
|
+
const sessionSecret = required(options.sessionSecret ?? env.SYNQ_SESSION_SECRET, "SYNQ_SESSION_SECRET");
|
|
161
|
+
const previousSessionSecrets = options.previousSessionSecrets ?? (env.SYNQ_SESSION_SECRET_PREVIOUS ?? "").split(",").map((secret) => secret.trim()).filter(Boolean);
|
|
162
|
+
for (const secret of [sessionSecret, ...previousSessionSecrets]) if (new TextEncoder().encode(secret).length < 32) throw new SynqError("invalid_options", `Each session secret must be at least 32 bytes: make one with \`openssl rand -base64 32\``);
|
|
163
|
+
const hint = MULTI_PROCESS.find((name) => env[name]);
|
|
164
|
+
if (!options.store && !options.allowSingleProcessCookieMode && hint) throw new SynqError("invalid_options", `${hint} is set, so this app may run on more than one server process, where sessions in cookies can't refresh one at a time and Synq would sign people out: give synqAuth() a store every process shares (keyValueSessionStore over Redis or Valkey), or allowSingleProcessCookieMode if it truly runs one`);
|
|
165
|
+
if (fields.storePrefix !== void 0) {
|
|
166
|
+
if (typeof fields.storePrefix !== "string" || !STORE_PREFIX.test(fields.storePrefix)) throw new SynqError("invalid_options", "resolve()'s storePrefix is 1 to 128 of A-Z a-z 0-9 . : _ ~ -");
|
|
167
|
+
if (!options.store) throw new SynqError("invalid_options", "resolve()'s storePrefix places a site's sessions in synqAuth()'s store: give it a store");
|
|
168
|
+
}
|
|
169
|
+
const basePath = normalizePath(options.basePath ?? "/api/auth");
|
|
170
|
+
const accountApi = fields.account === false ? void 0 : `${issuer}/me`;
|
|
171
|
+
const primary = options.resource ?? accountApi;
|
|
172
|
+
const scopes = /* @__PURE__ */ new Set([
|
|
173
|
+
"openid",
|
|
174
|
+
"profile",
|
|
175
|
+
"email",
|
|
176
|
+
"offline_access"
|
|
177
|
+
]);
|
|
178
|
+
if (accountApi) for (const scope of ["me:read", "me:write"]) scopes.add(scope);
|
|
179
|
+
for (const scope of (options.scope ?? "").split(/\s+/)) if (scope) scopes.add(scope);
|
|
180
|
+
const origin = appUrl.origin;
|
|
181
|
+
return {
|
|
182
|
+
issuer,
|
|
183
|
+
clientId,
|
|
184
|
+
clientSecret,
|
|
185
|
+
appUrl,
|
|
186
|
+
sessionSecret,
|
|
187
|
+
previousSessionSecrets,
|
|
188
|
+
basePath,
|
|
189
|
+
primary,
|
|
190
|
+
accountApi,
|
|
191
|
+
scope: [...scopes].join(" "),
|
|
192
|
+
redirectUri: `${origin}${basePath}/callback`,
|
|
193
|
+
postLogoutRedirectUri: fields.postLogoutRedirectUri ?? `${origin}/`,
|
|
194
|
+
store: options.store && fields.storePrefix ? prefixedStore(options.store, fields.storePrefix) : options.store,
|
|
195
|
+
sessionMaxAge: options.sessionMaxAge ?? 2592e3,
|
|
196
|
+
refreshLeewaySeconds: options.refreshLeewaySeconds ?? 30,
|
|
197
|
+
plugins: options.plugins ?? [],
|
|
198
|
+
fetch: options.fetch,
|
|
199
|
+
clientAddress: options.clientAddress ?? ((request) => request.headers.get("x-real-ip")?.trim() || void 0),
|
|
200
|
+
secure: appUrl.protocol === "https:",
|
|
201
|
+
site: perSite ? JSON.stringify([
|
|
202
|
+
issuer,
|
|
203
|
+
clientId,
|
|
204
|
+
origin
|
|
205
|
+
]) : ""
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** `store` with every session id under `prefix`: one site's sessions, apart from the others'. */
|
|
209
|
+
function prefixedStore(store, prefix) {
|
|
210
|
+
return {
|
|
211
|
+
get: (id) => store.get(`${prefix}${id}`),
|
|
212
|
+
set: (id, values, maxAgeSeconds) => store.set(`${prefix}${id}`, values, maxAgeSeconds),
|
|
213
|
+
delete: (id) => store.delete(`${prefix}${id}`),
|
|
214
|
+
lock: (id, fn) => store.lock(`${prefix}${id}`, fn)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function processEnv() {
|
|
218
|
+
return globalThis.process?.env ?? {};
|
|
219
|
+
}
|
|
220
|
+
function required(value, name) {
|
|
221
|
+
if (!value) throw new SynqError("invalid_options", `${name} is missing`);
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
/** An https URL, or http on this machine only. */
|
|
225
|
+
function checkUrl(value, name) {
|
|
226
|
+
let url;
|
|
227
|
+
try {
|
|
228
|
+
url = new URL(value);
|
|
229
|
+
} catch {
|
|
230
|
+
throw new SynqError("invalid_options", `${name} isn't a URL`);
|
|
231
|
+
}
|
|
232
|
+
const loopback = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
233
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new SynqError("invalid_options", `${name} must be https (http only on this machine)`);
|
|
234
|
+
return url;
|
|
235
|
+
}
|
|
236
|
+
/** The route mount's path, `/api/auth` style; throws `invalid_options` for anything else. */
|
|
237
|
+
function normalizePath(path) {
|
|
238
|
+
const trimmed = `/${path.replace(/^\/+|\/+$/g, "")}`;
|
|
239
|
+
if (!/^\/[A-Za-z0-9._~\-/]*$/.test(trimmed) || trimmed.includes("//")) throw new SynqError("invalid_options", "basePath must be a plain path, such as /api/auth");
|
|
240
|
+
return trimmed;
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/intent.ts
|
|
244
|
+
/** The hosted flow an app asks for after identifying the account. */
|
|
245
|
+
const SIGN_IN_INTENTS = /* @__PURE__ */ new Set(["add-passkey", "verify-email"]);
|
|
246
|
+
/** Verification keeps the issuer session; adding a credential still needs a fresh sign-in. */
|
|
247
|
+
function intentPrompt(intent, prompt) {
|
|
248
|
+
if (prompt != null) return prompt;
|
|
249
|
+
if (intent === "verify-email") return "select_account";
|
|
250
|
+
return prompt ?? (intent ? "login" : void 0);
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/cookies.ts
|
|
254
|
+
/** Characters per chunk, under the 4096 bytes a browser keeps for one cookie with its attributes. */
|
|
255
|
+
const CHUNK_SIZE = 3800;
|
|
256
|
+
/** A cookie's full name: `__Host-<base>` over https, `<base>` on a plain-http stack on this machine. */
|
|
257
|
+
function cookieName(base, secure) {
|
|
258
|
+
return secure ? `__Host-${base}` : base;
|
|
259
|
+
}
|
|
260
|
+
/** A value kept under `name`, joined from its chunks; undefined when there is none. */
|
|
261
|
+
function readChunked(jar, name) {
|
|
262
|
+
const whole = jar.get(name);
|
|
263
|
+
if (whole !== void 0) return whole;
|
|
264
|
+
let value = "";
|
|
265
|
+
for (let i = 0;; i++) {
|
|
266
|
+
const chunk = jar.get(`${name}.${i}`);
|
|
267
|
+
if (chunk === void 0) return i === 0 ? void 0 : value;
|
|
268
|
+
value += chunk;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Keeps `value` under `name`, in chunks when it needs them, and deletes chunks a longer value left behind. */
|
|
272
|
+
function writeChunked(jar, name, value, options) {
|
|
273
|
+
const existing = chunkNames(jar, name);
|
|
274
|
+
if (value.length <= 3800) {
|
|
275
|
+
jar.set(name, value, options);
|
|
276
|
+
for (const old of existing) if (old !== name) jar.delete(old, options);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const written = /* @__PURE__ */ new Set();
|
|
280
|
+
for (let i = 0; i * CHUNK_SIZE < value.length; i++) {
|
|
281
|
+
const chunk = `${name}.${i}`;
|
|
282
|
+
jar.set(chunk, value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE), options);
|
|
283
|
+
written.add(chunk);
|
|
284
|
+
}
|
|
285
|
+
for (const old of existing) if (!written.has(old)) jar.delete(old, options);
|
|
286
|
+
}
|
|
287
|
+
/** Deletes `name` and every chunk of it. */
|
|
288
|
+
function deleteChunked(jar, name, options) {
|
|
289
|
+
for (const old of chunkNames(jar, name)) jar.delete(old, options);
|
|
290
|
+
}
|
|
291
|
+
function chunkNames(jar, name) {
|
|
292
|
+
const pattern = new RegExp(`^${escapeRegExp(name)}(\\.\\d+)?$`);
|
|
293
|
+
return [...jar.names()].filter((candidate) => pattern.test(candidate));
|
|
294
|
+
}
|
|
295
|
+
/** The Set-Cookie header for a cookie: `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` over https. */
|
|
296
|
+
function setCookieHeader(name, value, options) {
|
|
297
|
+
const parts = [
|
|
298
|
+
`${name}=${value}`,
|
|
299
|
+
"Path=/",
|
|
300
|
+
"HttpOnly",
|
|
301
|
+
"SameSite=Lax"
|
|
302
|
+
];
|
|
303
|
+
if (options.maxAge !== void 0) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
304
|
+
if (options.secure) parts.push("Secure");
|
|
305
|
+
return parts.join("; ");
|
|
306
|
+
}
|
|
307
|
+
/** The cookies a `Cookie` header carries. */
|
|
308
|
+
function parseCookieHeader(header) {
|
|
309
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
310
|
+
for (const pair of (header ?? "").split(";")) {
|
|
311
|
+
const at = pair.indexOf("=");
|
|
312
|
+
if (at <= 0) continue;
|
|
313
|
+
const name = pair.slice(0, at).trim();
|
|
314
|
+
if (!cookies.has(name)) cookies.set(name, pair.slice(at + 1).trim());
|
|
315
|
+
}
|
|
316
|
+
return cookies;
|
|
317
|
+
}
|
|
318
|
+
/** A jar over a request's cookies that collects the Set-Cookie headers its response sends. */
|
|
319
|
+
function requestJar(request) {
|
|
320
|
+
const cookies = parseCookieHeader(request.headers.get("cookie"));
|
|
321
|
+
const out = /* @__PURE__ */ new Map();
|
|
322
|
+
return {
|
|
323
|
+
get: (name) => cookies.get(name),
|
|
324
|
+
names: () => cookies.keys(),
|
|
325
|
+
set(name, value, options) {
|
|
326
|
+
cookies.set(name, value);
|
|
327
|
+
out.set(name, setCookieHeader(name, value, options));
|
|
328
|
+
},
|
|
329
|
+
delete(name, options) {
|
|
330
|
+
cookies.delete(name);
|
|
331
|
+
out.set(name, setCookieHeader(name, "", {
|
|
332
|
+
...options,
|
|
333
|
+
maxAge: 0
|
|
334
|
+
}));
|
|
335
|
+
},
|
|
336
|
+
headers: () => [...out.values()],
|
|
337
|
+
/** The `Cookie` header the request carries now, changes made: for what runs after Next's proxy. */
|
|
338
|
+
cookieHeader: () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function escapeRegExp(text) {
|
|
342
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/plugins.ts
|
|
346
|
+
const PLUGIN_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
347
|
+
const ROUTE_KEY = /^(GET|POST|PATCH|DELETE) \/[A-Za-z0-9._~\-/]*$/;
|
|
348
|
+
/** Declares a server plugin, checking its id and routes once, when the module loads. */
|
|
349
|
+
function defineSynqPlugin(plugin) {
|
|
350
|
+
if (!PLUGIN_ID.test(plugin.id) || RESERVED.has(plugin.id)) throw new TypeError(`A Synq plugin's id must be lowercase letters, digits and dashes, and not one of the mount's own routes: ${plugin.id}`);
|
|
351
|
+
for (const key of Object.keys(plugin.routes ?? {})) if (!ROUTE_KEY.test(key)) throw new TypeError(`A Synq plugin's route is "<METHOD> /<path>": ${key}`);
|
|
352
|
+
return plugin;
|
|
353
|
+
}
|
|
354
|
+
/** The route mount's own paths, which no plugin can take. */
|
|
355
|
+
const RESERVED = /* @__PURE__ */ new Set([
|
|
356
|
+
"sign-in",
|
|
357
|
+
"callback",
|
|
358
|
+
"sign-out",
|
|
359
|
+
"session",
|
|
360
|
+
"account",
|
|
361
|
+
"link"
|
|
362
|
+
]);
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/popup.ts
|
|
365
|
+
/** The page's id for one popup sign-in, which its message carries back: what `attempt` takes. */
|
|
366
|
+
const POPUP_ATTEMPT = /^[A-Za-z0-9_-]{8,64}$/;
|
|
367
|
+
const POPUP_MESSAGE = "synq:popup";
|
|
368
|
+
/**
|
|
369
|
+
* The page's one script, constant so the Content-Security-Policy names it by
|
|
370
|
+
* hash (`POPUP_SCRIPT_HASH`): it reads the result from the JSON block beside
|
|
371
|
+
* it, posts it, and closes the window.
|
|
372
|
+
*/
|
|
373
|
+
const POPUP_SCRIPT = "(function(){var d=document.getElementById('synq-result');var r=d?JSON.parse(d.textContent||'null'):null;if(r){try{var c=new BroadcastChannel('synq');c.postMessage(r);c.close()}catch(e){}}window.close()})()";
|
|
374
|
+
/** `POPUP_SCRIPT`'s SHA-256, as CSP's `script-src` takes it; its test holds the two together. */
|
|
375
|
+
const POPUP_SCRIPT_HASH = "sha256-OpmVimyyrlgsewsXQaWdNVXCa4ywJlk1XZL1thsfWk0=";
|
|
376
|
+
/** The popup's last page: it reports `result` and closes, or says the person may close it. */
|
|
377
|
+
function popupPage(result) {
|
|
378
|
+
const message = {
|
|
379
|
+
type: POPUP_MESSAGE,
|
|
380
|
+
...result
|
|
381
|
+
};
|
|
382
|
+
const data = JSON.stringify(message).replaceAll("<", "\\u003c");
|
|
383
|
+
const words = result.ok ? "You’re signed in. You can close this window." : "That didn’t finish. You can close this window and try again.";
|
|
384
|
+
const html = `<!doctype html>
|
|
385
|
+
<html lang="en">
|
|
386
|
+
<head>
|
|
387
|
+
<meta charset="utf-8">
|
|
388
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
389
|
+
<meta name="robots" content="noindex">
|
|
390
|
+
<title>${result.ok ? "Signed in" : "Not signed in"}</title>
|
|
391
|
+
</head>
|
|
392
|
+
<body>
|
|
393
|
+
<p>${words}</p>
|
|
394
|
+
<script type="application/json" id="synq-result">${data}<\/script>
|
|
395
|
+
<script>${POPUP_SCRIPT}<\/script>
|
|
396
|
+
</body>
|
|
397
|
+
</html>
|
|
398
|
+
`;
|
|
399
|
+
return new Response(html, {
|
|
400
|
+
status: 200,
|
|
401
|
+
headers: {
|
|
402
|
+
"content-type": "text/html; charset=utf-8",
|
|
403
|
+
"cache-control": "no-store",
|
|
404
|
+
"content-security-policy": `default-src 'none'; script-src '${POPUP_SCRIPT_HASH}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
405
|
+
"referrer-policy": "no-referrer"
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/session-name.ts
|
|
411
|
+
const SESSION_COOKIE = "synq.session";
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region src/store.ts
|
|
414
|
+
/**
|
|
415
|
+
* A store in this process's memory: for tests and a single server process,
|
|
416
|
+
* where the default cookie does as well.
|
|
417
|
+
*/
|
|
418
|
+
function memorySessionStore() {
|
|
419
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
420
|
+
const locks = /* @__PURE__ */ new Map();
|
|
421
|
+
return {
|
|
422
|
+
get(id) {
|
|
423
|
+
const entry = sessions.get(id);
|
|
424
|
+
if (!entry || entry.expires <= Date.now()) {
|
|
425
|
+
sessions.delete(id);
|
|
426
|
+
return Promise.resolve(null);
|
|
427
|
+
}
|
|
428
|
+
return Promise.resolve({ ...entry.values });
|
|
429
|
+
},
|
|
430
|
+
set(id, values, maxAgeSeconds) {
|
|
431
|
+
sessions.set(id, {
|
|
432
|
+
values: { ...values },
|
|
433
|
+
expires: Date.now() + maxAgeSeconds * 1e3
|
|
434
|
+
});
|
|
435
|
+
return Promise.resolve();
|
|
436
|
+
},
|
|
437
|
+
delete(id) {
|
|
438
|
+
sessions.delete(id);
|
|
439
|
+
return Promise.resolve();
|
|
440
|
+
},
|
|
441
|
+
lock: (id, fn) => chain(locks, id, fn)
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Sessions in a key-value store every server process shares, such as Redis
|
|
446
|
+
* or Valkey: the store an app on several processes (replicas, serverless
|
|
447
|
+
* functions) needs. Its lock is `SET … NX PX` with a token, polled until
|
|
448
|
+
* free, and released only by its holder; it expires on its own, so a dead
|
|
449
|
+
* process never holds a session for ever.
|
|
450
|
+
*
|
|
451
|
+
* ```ts
|
|
452
|
+
* keyValueSessionStore({
|
|
453
|
+
* get: (key) => redis.get(key),
|
|
454
|
+
* set: async (key, value, { ttlMs, onlyIfAbsent }) =>
|
|
455
|
+
* (await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
|
|
456
|
+
* delete: async (key) => void (await redis.del(key)),
|
|
457
|
+
* })
|
|
458
|
+
* ```
|
|
459
|
+
*/
|
|
460
|
+
function keyValueSessionStore(kv, options = {}) {
|
|
461
|
+
const prefix = options.prefix ?? "synq:";
|
|
462
|
+
const lockTtlMs = options.lockTtlMs ?? 3e4;
|
|
463
|
+
const lockWaitMs = options.lockWaitMs ?? 35e3;
|
|
464
|
+
if (lockTtlMs <= 2e4) throw new TypeError(`lockTtlMs must be over ${REQUEST_TIMEOUT_MS * 2}: a refresh can make two requests to Synq (discovery, then the token) of up to ${REQUEST_TIMEOUT_MS} ms each`);
|
|
465
|
+
const sessionKey = (id) => `${prefix}session:${id}`;
|
|
466
|
+
const lockKey = (id) => `${prefix}lock:${id}`;
|
|
467
|
+
return {
|
|
468
|
+
async get(id) {
|
|
469
|
+
const raw = await kv.get(sessionKey(id));
|
|
470
|
+
if (!raw) return null;
|
|
471
|
+
try {
|
|
472
|
+
return JSON.parse(raw);
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
async set(id, values, maxAgeSeconds) {
|
|
478
|
+
await kv.set(sessionKey(id), JSON.stringify(values), { ttlMs: maxAgeSeconds * 1e3 });
|
|
479
|
+
},
|
|
480
|
+
async delete(id) {
|
|
481
|
+
await kv.delete(sessionKey(id));
|
|
482
|
+
},
|
|
483
|
+
async lock(id, fn) {
|
|
484
|
+
const key = lockKey(id);
|
|
485
|
+
const token = crypto.randomUUID();
|
|
486
|
+
const deadline = Date.now() + lockWaitMs;
|
|
487
|
+
let wait = 25;
|
|
488
|
+
while (!await kv.set(key, token, {
|
|
489
|
+
ttlMs: lockTtlMs,
|
|
490
|
+
onlyIfAbsent: true
|
|
491
|
+
})) {
|
|
492
|
+
if (Date.now() >= deadline) throw new SynqError("lock_timeout", "Another request held this session's lock too long");
|
|
493
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
494
|
+
wait = Math.min(wait * 2, 250);
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
return await fn();
|
|
498
|
+
} finally {
|
|
499
|
+
if (kv.deleteIfEquals) await kv.deleteIfEquals(key, token);
|
|
500
|
+
else if (await kv.get(key) === token) await kv.delete(key);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** Runs `fn` after every earlier holder of `name` in `locks`, failed or not. */
|
|
506
|
+
function chain(locks, name, fn) {
|
|
507
|
+
const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
|
|
508
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
509
|
+
locks.set(name, settled);
|
|
510
|
+
settled.then(() => {
|
|
511
|
+
if (locks.get(name) === settled) locks.delete(name);
|
|
512
|
+
});
|
|
513
|
+
return run;
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/session.ts
|
|
517
|
+
/**
|
|
518
|
+
* @synqauth/js's keys in its store (client.ts), which the adapter maps onto
|
|
519
|
+
* a session's values: the session per API, and the refresh token.
|
|
520
|
+
*/
|
|
521
|
+
const JS_SESSION_KEY = "synq.session";
|
|
522
|
+
const JS_REFRESH_KEY = "synq.refresh";
|
|
523
|
+
const REFRESH = "refresh";
|
|
524
|
+
const SHARED = Symbol.for("@synqauth/nextjs sessions");
|
|
525
|
+
const { newest, locks } = globalThis[SHARED] ??= {
|
|
526
|
+
newest: /* @__PURE__ */ new Map(),
|
|
527
|
+
locks: /* @__PURE__ */ new Map()
|
|
528
|
+
};
|
|
529
|
+
const OVERLAY_MAX = 1e4;
|
|
530
|
+
const OVERLAY_TTL_MS = 18e5;
|
|
531
|
+
var SessionHandle = class {
|
|
532
|
+
id;
|
|
533
|
+
created;
|
|
534
|
+
#backend;
|
|
535
|
+
/** Whether the cookie must be written: a new session, or values newer than the cookie's. */
|
|
536
|
+
#cookieRev;
|
|
537
|
+
#fresh;
|
|
538
|
+
constructor(id, created, backend, cookieRev, fresh) {
|
|
539
|
+
this.id = id;
|
|
540
|
+
this.created = created;
|
|
541
|
+
this.#backend = backend;
|
|
542
|
+
this.#cookieRev = cookieRev;
|
|
543
|
+
this.#fresh = fresh;
|
|
544
|
+
}
|
|
545
|
+
read() {
|
|
546
|
+
return this.#backend.read();
|
|
547
|
+
}
|
|
548
|
+
/** `@synqauth/js`'s store for this session's token for `resource`; `primary` is the API whose session holds the ID token. */
|
|
549
|
+
storage(resource, primary) {
|
|
550
|
+
const own = tokenKey(resource);
|
|
551
|
+
const main = tokenKey(primary);
|
|
552
|
+
const backend = this.#backend;
|
|
553
|
+
return {
|
|
554
|
+
holdsRefreshTokens: true,
|
|
555
|
+
async get(key) {
|
|
556
|
+
const values = await backend.read();
|
|
557
|
+
if (key === JS_REFRESH_KEY) return values[REFRESH];
|
|
558
|
+
if (key !== JS_SESSION_KEY) return void 0;
|
|
559
|
+
const mine = values[own];
|
|
560
|
+
const first = values[main];
|
|
561
|
+
if (own === main) return mine;
|
|
562
|
+
if (!first) return mine;
|
|
563
|
+
const { idToken, claims } = JSON.parse(first);
|
|
564
|
+
if (mine) return JSON.stringify({
|
|
565
|
+
...JSON.parse(mine),
|
|
566
|
+
idToken,
|
|
567
|
+
claims
|
|
568
|
+
});
|
|
569
|
+
return JSON.stringify({
|
|
570
|
+
accessToken: "",
|
|
571
|
+
tokenType: "Bearer",
|
|
572
|
+
expiresAt: 0,
|
|
573
|
+
idToken,
|
|
574
|
+
claims
|
|
575
|
+
});
|
|
576
|
+
},
|
|
577
|
+
async set(key, value) {
|
|
578
|
+
if (key === JS_REFRESH_KEY) return backend.write((values) => void (values[REFRESH] = value));
|
|
579
|
+
if (key !== JS_SESSION_KEY) return;
|
|
580
|
+
let kept = value;
|
|
581
|
+
if (own !== main) {
|
|
582
|
+
const rest = JSON.parse(value);
|
|
583
|
+
delete rest.idToken;
|
|
584
|
+
delete rest.claims;
|
|
585
|
+
kept = JSON.stringify(rest);
|
|
586
|
+
}
|
|
587
|
+
await backend.write((values) => void (values[own] = kept));
|
|
588
|
+
},
|
|
589
|
+
async remove(key) {
|
|
590
|
+
if (key === JS_REFRESH_KEY) return backend.write((values) => void delete values[REFRESH]);
|
|
591
|
+
if (key !== JS_SESSION_KEY) return;
|
|
592
|
+
await backend.write((values) => {
|
|
593
|
+
if (own === main) for (const name of Object.keys(values)) delete values[name];
|
|
594
|
+
else delete values[own];
|
|
595
|
+
});
|
|
596
|
+
},
|
|
597
|
+
lock: (_name, fn) => backend.lock(fn)
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/** Writes the session's cookie when it's new or this process holds newer values than it. */
|
|
601
|
+
async commit(jar, keys, secure, maxAge, store) {
|
|
602
|
+
const name = cookieName(SESSION_COOKIE, secure);
|
|
603
|
+
const remaining = Math.max(0, this.created + maxAge - now$1());
|
|
604
|
+
if (store) {
|
|
605
|
+
if (!this.#fresh) return;
|
|
606
|
+
const sealed = {
|
|
607
|
+
v: 1,
|
|
608
|
+
id: this.id,
|
|
609
|
+
created: this.created
|
|
610
|
+
};
|
|
611
|
+
writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
|
|
612
|
+
secure,
|
|
613
|
+
maxAge: remaining
|
|
614
|
+
});
|
|
615
|
+
this.#fresh = false;
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const current = newest.get(this.id);
|
|
619
|
+
const rev = current?.rev ?? 0;
|
|
620
|
+
if (!this.#fresh && rev <= this.#cookieRev) return;
|
|
621
|
+
const sealed = {
|
|
622
|
+
v: 1,
|
|
623
|
+
id: this.id,
|
|
624
|
+
created: this.created,
|
|
625
|
+
rev,
|
|
626
|
+
values: current?.values ?? {}
|
|
627
|
+
};
|
|
628
|
+
writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
|
|
629
|
+
secure,
|
|
630
|
+
maxAge: remaining
|
|
631
|
+
});
|
|
632
|
+
this.#cookieRev = rev;
|
|
633
|
+
this.#fresh = false;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
/** The session the request's cookie names, or null: none, unreadable, past its age, or gone from the store. */
|
|
637
|
+
async function loadSession(jar, keys, secure, maxAge, store) {
|
|
638
|
+
const name = cookieName(SESSION_COOKIE, secure);
|
|
639
|
+
const opened = await unseal(keys.open, readChunked(jar, name), name);
|
|
640
|
+
if (!opened) return null;
|
|
641
|
+
let sealed;
|
|
642
|
+
try {
|
|
643
|
+
sealed = JSON.parse(opened);
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
if (sealed.v !== 1 || typeof sealed.id !== "string" || sealed.created + maxAge <= now$1()) return null;
|
|
648
|
+
if (store) {
|
|
649
|
+
if (!await store.get(sealed.id)) return null;
|
|
650
|
+
return new SessionHandle(sealed.id, sealed.created, storeBackend(store, sealed.id, sealed.created, maxAge), 0, false);
|
|
651
|
+
}
|
|
652
|
+
const rev = sealed.rev ?? 0;
|
|
653
|
+
remember(sealed.id, rev, sealed.values ?? {});
|
|
654
|
+
return new SessionHandle(sealed.id, sealed.created, cookieBackend(sealed.id), rev, false);
|
|
655
|
+
}
|
|
656
|
+
/** A new, empty session: what a sign-in fills. */
|
|
657
|
+
function newSession(maxAge, store) {
|
|
658
|
+
const id = randomId();
|
|
659
|
+
const created = now$1();
|
|
660
|
+
if (store) return new SessionHandle(id, created, storeBackend(store, id, created, maxAge), 0, true);
|
|
661
|
+
remember(id, 0, {});
|
|
662
|
+
return new SessionHandle(id, created, cookieBackend(id), -1, true);
|
|
663
|
+
}
|
|
664
|
+
/** Ends a session: its cookie, its stored values, and what this process remembers of it. */
|
|
665
|
+
async function endSession(jar, handle, secure, store) {
|
|
666
|
+
if (handle) {
|
|
667
|
+
newest.delete(handle.id);
|
|
668
|
+
if (store) await store.delete(handle.id);
|
|
669
|
+
}
|
|
670
|
+
deleteChunked(jar, cookieName(SESSION_COOKIE, secure), { secure });
|
|
671
|
+
}
|
|
672
|
+
/** The key a session keeps `resource`'s token under. */
|
|
673
|
+
function tokenKey(resource) {
|
|
674
|
+
return `token:${resource ?? ""}`;
|
|
675
|
+
}
|
|
676
|
+
function cookieBackend(id) {
|
|
677
|
+
return {
|
|
678
|
+
read() {
|
|
679
|
+
const entry = newest.get(id);
|
|
680
|
+
return Promise.resolve({ ...entry?.values ?? {} });
|
|
681
|
+
},
|
|
682
|
+
write(mutate) {
|
|
683
|
+
const entry = newest.get(id);
|
|
684
|
+
const values = { ...entry?.values ?? {} };
|
|
685
|
+
mutate(values);
|
|
686
|
+
remember(id, (entry?.rev ?? 0) + 1, values);
|
|
687
|
+
return Promise.resolve();
|
|
688
|
+
},
|
|
689
|
+
lock: (fn) => chain(locks, id, fn)
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function storeBackend(store, id, created, maxAge) {
|
|
693
|
+
return {
|
|
694
|
+
async read() {
|
|
695
|
+
return await store.get(id) ?? {};
|
|
696
|
+
},
|
|
697
|
+
async write(mutate) {
|
|
698
|
+
const values = await store.get(id) ?? {};
|
|
699
|
+
mutate(values);
|
|
700
|
+
await store.set(id, values, Math.max(1, created + maxAge - now$1()));
|
|
701
|
+
},
|
|
702
|
+
lock: (fn) => store.lock(id, fn)
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** Keeps the newer of what's remembered and `rev`'s values. */
|
|
706
|
+
function remember(id, rev, values) {
|
|
707
|
+
const entry = newest.get(id);
|
|
708
|
+
const time = Date.now();
|
|
709
|
+
if (entry && entry.rev >= rev) {
|
|
710
|
+
entry.seen = time;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
newest.delete(id);
|
|
714
|
+
newest.set(id, {
|
|
715
|
+
rev,
|
|
716
|
+
values,
|
|
717
|
+
seen: time
|
|
718
|
+
});
|
|
719
|
+
if (newest.size > OVERLAY_MAX) for (const [old, kept] of newest) {
|
|
720
|
+
if (newest.size <= OVERLAY_MAX && time - kept.seen < OVERLAY_TTL_MS) break;
|
|
721
|
+
newest.delete(old);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function now$1() {
|
|
725
|
+
return Math.floor(Date.now() / 1e3);
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/sign-in-context.ts
|
|
729
|
+
/** The cookie that remembers the way in: the browser keeps it as long as Chrome allows, 400 days. */
|
|
730
|
+
const LAST_USED = "synq.last-used";
|
|
731
|
+
const LAST_USED_MAX_AGE = 3456e4;
|
|
732
|
+
/** The cookie that holds a sign-in until the page hears of it: five minutes at most. */
|
|
733
|
+
const EVENT = "synq.event";
|
|
734
|
+
const EVENT_MAX_AGE = 300;
|
|
735
|
+
/** A method's id, as the issuer names them. */
|
|
736
|
+
const METHOD$1 = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
737
|
+
/** How long a list of methods is kept: what the issuer allows. */
|
|
738
|
+
const METHODS_TTL_MS = 6e4;
|
|
739
|
+
/**
|
|
740
|
+
* How long the callback waits for the issuer's list to learn whether the
|
|
741
|
+
* brand shows "Last used": past it, the sign-in goes on and "Last used"
|
|
742
|
+
* waits for the next one.
|
|
743
|
+
*/
|
|
744
|
+
const METHODS_WAIT_MS = 2e3;
|
|
745
|
+
/**
|
|
746
|
+
* A wallet app's name as the issuer keeps it: letters, digits, spaces and a
|
|
747
|
+
* little punctuation, at most 32 characters. Anything else isn't kept.
|
|
748
|
+
*/
|
|
749
|
+
function walletAppName(value) {
|
|
750
|
+
if (typeof value !== "string") return void 0;
|
|
751
|
+
const name = value.normalize("NFC").trim().replace(/\s+/g, " ");
|
|
752
|
+
return name.length > 0 && name.length <= 32 && /^[\p{L}\p{N} .'_-]+$/u.test(name) ? name : void 0;
|
|
753
|
+
}
|
|
754
|
+
/** What the session's ID token says of its sign-in, when the app turned the claims on. */
|
|
755
|
+
function signInOf(claims) {
|
|
756
|
+
const method = claims?.sign_in_method;
|
|
757
|
+
const at = claims?.auth_time;
|
|
758
|
+
if (typeof method !== "string" || !METHOD$1.test(method) || typeof at !== "number") return void 0;
|
|
759
|
+
const wallet = claims?.sign_in_wallet;
|
|
760
|
+
return {
|
|
761
|
+
id: crypto.randomUUID(),
|
|
762
|
+
isNewUser: claims?.new_user === true,
|
|
763
|
+
method,
|
|
764
|
+
wallet: wallet && typeof wallet.address === "string" ? {
|
|
765
|
+
address: wallet.address,
|
|
766
|
+
app: walletAppName(wallet.app) ?? null
|
|
767
|
+
} : null,
|
|
768
|
+
at
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/** Remembers on this browser the way it signed in: the method, and a wallet app's name. */
|
|
772
|
+
function rememberLastUsed(jar, event, secure) {
|
|
773
|
+
const app = event.wallet?.app ?? void 0;
|
|
774
|
+
const value = app ? `${event.method}.${encodeURIComponent(app)}` : event.method;
|
|
775
|
+
jar.set(cookieName(LAST_USED, secure), value, {
|
|
776
|
+
secure,
|
|
777
|
+
maxAge: LAST_USED_MAX_AGE
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/** The way this browser last signed in to the app, as its cookie holds it, or null. */
|
|
781
|
+
function lastUsedOf(jar, secure) {
|
|
782
|
+
const value = jar.get(cookieName(LAST_USED, secure));
|
|
783
|
+
if (!value || value.length > 160) return null;
|
|
784
|
+
const dot = value.indexOf(".");
|
|
785
|
+
const method = dot < 0 ? value : value.slice(0, dot);
|
|
786
|
+
if (!METHOD$1.test(method)) return null;
|
|
787
|
+
if (dot < 0) return { method };
|
|
788
|
+
let app;
|
|
789
|
+
try {
|
|
790
|
+
app = walletAppName(decodeURIComponent(value.slice(dot + 1)));
|
|
791
|
+
} catch {
|
|
792
|
+
app = void 0;
|
|
793
|
+
}
|
|
794
|
+
return app ? {
|
|
795
|
+
method,
|
|
796
|
+
walletApp: app
|
|
797
|
+
} : { method };
|
|
798
|
+
}
|
|
799
|
+
/** Forgets the way in: where the brand turned "Last used" off. */
|
|
800
|
+
function forgetLastUsed(jar, secure) {
|
|
801
|
+
if (jar.get(cookieName(LAST_USED, secure)) !== void 0) jar.delete(cookieName(LAST_USED, secure), { secure });
|
|
802
|
+
}
|
|
803
|
+
/** Keeps `event` for the page, sealed, bound to the person it is for (`sub`). */
|
|
804
|
+
async function keepEvent(jar, key, sub, event, secure) {
|
|
805
|
+
const name = cookieName(EVENT, secure);
|
|
806
|
+
const until = Math.floor(Date.now() / 1e3) + EVENT_MAX_AGE;
|
|
807
|
+
jar.set(name, await seal(key, JSON.stringify({
|
|
808
|
+
sub,
|
|
809
|
+
event,
|
|
810
|
+
until
|
|
811
|
+
}), name), {
|
|
812
|
+
secure,
|
|
813
|
+
maxAge: EVENT_MAX_AGE
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/** The sign-in kept for the page, when it is `sub`'s and still fresh, or null. */
|
|
817
|
+
async function eventOf(jar, keys, sub, secure, now) {
|
|
818
|
+
if (!sub) return null;
|
|
819
|
+
const name = cookieName(EVENT, secure);
|
|
820
|
+
const opened = await unseal(keys, jar.get(name), name);
|
|
821
|
+
if (!opened) return null;
|
|
822
|
+
try {
|
|
823
|
+
const kept = JSON.parse(opened);
|
|
824
|
+
if (kept.sub !== sub || typeof kept.event?.id !== "string" || typeof kept.until !== "number" || kept.until < now) return null;
|
|
825
|
+
return kept.event;
|
|
826
|
+
} catch {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/** Forgets the kept sign-in: the page heard of it, or the person signed out. */
|
|
831
|
+
function forgetEvent(jar, secure) {
|
|
832
|
+
if (jar.get(cookieName(EVENT, secure)) !== void 0) jar.delete(cookieName(EVENT, secure), { secure });
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* `promise`'s value, or undefined when `ms` milliseconds pass first: the
|
|
836
|
+
* caller goes on, and the promise settles on its own.
|
|
837
|
+
*/
|
|
838
|
+
async function within(promise, ms) {
|
|
839
|
+
let timer;
|
|
840
|
+
try {
|
|
841
|
+
return await Promise.race([promise, new Promise((resolve) => {
|
|
842
|
+
timer = setTimeout(resolve, ms);
|
|
843
|
+
})]);
|
|
844
|
+
} finally {
|
|
845
|
+
clearTimeout(timer);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* The app's sign-in methods from its issuer (`GET /sign-in/methods`), kept a
|
|
850
|
+
* minute in this process, as the issuer allows; one read at a time.
|
|
851
|
+
*/
|
|
852
|
+
function methodsReader(options) {
|
|
853
|
+
let kept;
|
|
854
|
+
let reading;
|
|
855
|
+
return () => {
|
|
856
|
+
if (kept && kept.until > Date.now()) return Promise.resolve(kept.list);
|
|
857
|
+
reading ??= signInMethods(options).then((list) => {
|
|
858
|
+
kept = {
|
|
859
|
+
list: readMethodList(list),
|
|
860
|
+
until: Date.now() + METHODS_TTL_MS
|
|
861
|
+
};
|
|
862
|
+
return kept.list;
|
|
863
|
+
}).finally(() => {
|
|
864
|
+
reading = void 0;
|
|
865
|
+
});
|
|
866
|
+
return reading;
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/core.ts
|
|
871
|
+
const PROMPTS = /* @__PURE__ */ new Set([
|
|
872
|
+
"login",
|
|
873
|
+
"none",
|
|
874
|
+
"consent",
|
|
875
|
+
"select_account",
|
|
876
|
+
"create"
|
|
877
|
+
]);
|
|
878
|
+
/** A plan's code, as Synq's catalog has it: what `plan` may hold (AERO-2160). */
|
|
879
|
+
const PLAN_CODE = /^[a-z][a-z0-9-]{1,30}$/;
|
|
880
|
+
/** An onboarding visit's id: 22 base62 characters (AERO-2160). */
|
|
881
|
+
const ONBOARDING_VISIT = /^[0-9A-Za-z]{22}$/;
|
|
882
|
+
/** A sign-in method's name, as `synq_method` and the account API's link requests take it. */
|
|
883
|
+
const METHOD = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
884
|
+
const TRANSACTION_SECONDS = 3600;
|
|
885
|
+
const TRANSACTION_PREFIX = "synq.tx.";
|
|
886
|
+
const ACCOUNT_BODY_LIMIT = 65536;
|
|
887
|
+
/** A picture the person uploads: the account API's own limit (AERO-2700). */
|
|
888
|
+
const PICTURE_BODY_LIMIT = 5242880;
|
|
889
|
+
/** The pictures Synq takes, by the request's `Content-Type`; Synq knows them again by their bytes. */
|
|
890
|
+
const PICTURE_TYPES = /* @__PURE__ */ new Set([
|
|
891
|
+
"image/png",
|
|
892
|
+
"image/jpeg",
|
|
893
|
+
"image/webp",
|
|
894
|
+
"image/gif"
|
|
895
|
+
]);
|
|
896
|
+
const LINK_BODY_LIMIT = 4096;
|
|
897
|
+
/** A step of an own-UI sign-in: a passkey's assertion is the most it carries. */
|
|
898
|
+
const FLOW_BODY_LIMIT = 16384;
|
|
899
|
+
/** Where an own-UI sign-in's flow is kept, sealed: one a browser at a time. */
|
|
900
|
+
const FLOW_KEY = `${TRANSACTION_PREFIX}flow`;
|
|
901
|
+
/** `synq_siws` as @synqauth/solana's `encodeSiws` writes it: base64url, well under the body's limit. */
|
|
902
|
+
const SIWS = /^[A-Za-z0-9_-]{1,3072}$/;
|
|
903
|
+
/** Claims of the ID token that say nothing about the person. */
|
|
904
|
+
const PROTOCOL_CLAIMS = /* @__PURE__ */ new Set([
|
|
905
|
+
"iss",
|
|
906
|
+
"aud",
|
|
907
|
+
"azp",
|
|
908
|
+
"exp",
|
|
909
|
+
"iat",
|
|
910
|
+
"nbf",
|
|
911
|
+
"nonce",
|
|
912
|
+
"at_hash",
|
|
913
|
+
"c_hash",
|
|
914
|
+
"sid",
|
|
915
|
+
"jti"
|
|
916
|
+
]);
|
|
917
|
+
/**
|
|
918
|
+
* The framework-neutral core `synqAuth()` runs on, over any cookie jar: for
|
|
919
|
+
* a server that answers the same routes without Next. Most apps use
|
|
920
|
+
* `synqAuth()` instead.
|
|
921
|
+
*/
|
|
922
|
+
function createSynqCore(config) {
|
|
923
|
+
/** Whether the missing end-user address was logged already: once is enough. */
|
|
924
|
+
let warnedAboutAddress = false;
|
|
925
|
+
/** The keys this app's (or site's, `config.site`) cookies are sealed with, derived once. */
|
|
926
|
+
let keys;
|
|
927
|
+
const keysFor = () => keys ??= sealingKeys(config.sessionSecret, config.previousSessionSecrets, config.site);
|
|
928
|
+
/** Every request to Synq: never cached, and given up after REQUEST_TIMEOUT_MS. */
|
|
929
|
+
const upstream = (input, init = {}) => {
|
|
930
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
931
|
+
return (config.fetch ?? fetch)(input, {
|
|
932
|
+
...init,
|
|
933
|
+
cache: "no-store",
|
|
934
|
+
signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
function client(storage, resource, transactions) {
|
|
938
|
+
return createSynqClient({
|
|
939
|
+
issuer: config.issuer,
|
|
940
|
+
clientId: config.clientId,
|
|
941
|
+
clientSecret: config.clientSecret,
|
|
942
|
+
redirectUri: config.redirectUri,
|
|
943
|
+
postLogoutRedirectUri: config.postLogoutRedirectUri,
|
|
944
|
+
scope: config.scope,
|
|
945
|
+
resource,
|
|
946
|
+
storage,
|
|
947
|
+
transactionStorage: transactions ?? memoryStorage(),
|
|
948
|
+
fetch: upstream,
|
|
949
|
+
refreshLeewaySeconds: config.refreshLeewaySeconds
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
async function load(jar) {
|
|
953
|
+
return loadSession(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
|
|
954
|
+
}
|
|
955
|
+
async function commit(jar, handle) {
|
|
956
|
+
await handle.commit(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
|
|
957
|
+
}
|
|
958
|
+
/** Who signed in (the ID token's `sub`), and in which session at the issuer (its `sid`, when Synq sent one). */
|
|
959
|
+
async function issuerSessionOf(handle) {
|
|
960
|
+
const raw = (await handle.read())[tokenKey(config.primary)];
|
|
961
|
+
if (!raw) return void 0;
|
|
962
|
+
const { claims } = JSON.parse(raw);
|
|
963
|
+
if (typeof claims?.sub !== "string") return void 0;
|
|
964
|
+
return {
|
|
965
|
+
sub: claims.sub,
|
|
966
|
+
sid: typeof claims.sid === "string" ? claims.sid : void 0
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Why a sign-in again (`reauthenticate`), for `sub`, can't be kept, or
|
|
971
|
+
* `undefined` when it can: `not_signed_in` when `sub` isn't the one signed
|
|
972
|
+
* in here any more (signed out in another tab, say, or someone else signed
|
|
973
|
+
* in since), `other_account` when someone else signed in at the brand.
|
|
974
|
+
*/
|
|
975
|
+
async function reauthenticationRefused(jar, handle, sub) {
|
|
976
|
+
const previous = await load(jar);
|
|
977
|
+
const [before, after] = await Promise.all([previous ? issuerSessionOf(previous) : void 0, issuerSessionOf(handle)]);
|
|
978
|
+
if (before?.sub !== sub) return "not_signed_in";
|
|
979
|
+
if (after?.sub !== sub) return "other_account";
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Ends a sign-in the callback won't keep: the browser's session at the
|
|
983
|
+
* brand that it made, from this server, then its grant. Signed in with
|
|
984
|
+
* `prompt=login`, it replaced the brand's session in this browser, and
|
|
985
|
+
* left alive it would sign whoever it was in to any later sign-in that
|
|
986
|
+
* asks for no fresh one, at this app or another of the brand's.
|
|
987
|
+
*/
|
|
988
|
+
async function discard(handle) {
|
|
989
|
+
const theirs = client(handle.storage(config.primary, config.primary), config.primary);
|
|
990
|
+
try {
|
|
991
|
+
await theirs.signOut({ stay: true });
|
|
992
|
+
} catch {
|
|
993
|
+
try {
|
|
994
|
+
await theirs.signOut();
|
|
995
|
+
} catch {}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/** The ID token the session's main token came with: who is signed in, as the issuer said. */
|
|
999
|
+
async function idTokenOf(handle) {
|
|
1000
|
+
if (!handle) return void 0;
|
|
1001
|
+
const raw = (await handle.read())[tokenKey(config.primary)];
|
|
1002
|
+
if (!raw) return void 0;
|
|
1003
|
+
const { idToken } = JSON.parse(raw);
|
|
1004
|
+
return typeof idToken === "string" ? idToken : void 0;
|
|
1005
|
+
}
|
|
1006
|
+
/** The person the session's main token was issued with, while the session can still give a token. */
|
|
1007
|
+
async function userOf(handle) {
|
|
1008
|
+
if (!handle) return {
|
|
1009
|
+
user: null,
|
|
1010
|
+
expiresAt: null
|
|
1011
|
+
};
|
|
1012
|
+
const values = await handle.read();
|
|
1013
|
+
const raw = values[tokenKey(config.primary)];
|
|
1014
|
+
if (!raw) return {
|
|
1015
|
+
user: null,
|
|
1016
|
+
expiresAt: null
|
|
1017
|
+
};
|
|
1018
|
+
const stored = JSON.parse(raw);
|
|
1019
|
+
const expiresAt = stored.expiresAt ?? null;
|
|
1020
|
+
if (!values.refresh && expiresAt !== null && expiresAt <= now()) return {
|
|
1021
|
+
user: null,
|
|
1022
|
+
expiresAt: null
|
|
1023
|
+
};
|
|
1024
|
+
if (!stored.claims || typeof stored.claims.sub !== "string") return {
|
|
1025
|
+
user: null,
|
|
1026
|
+
expiresAt
|
|
1027
|
+
};
|
|
1028
|
+
const user = {};
|
|
1029
|
+
for (const [name, value] of Object.entries(stored.claims)) if (!PROTOCOL_CLAIMS.has(name)) user[name] = value;
|
|
1030
|
+
return {
|
|
1031
|
+
user,
|
|
1032
|
+
expiresAt
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/** Who is signed in, the way in this browser last used, and the sign-in the page hasn't heard of. */
|
|
1036
|
+
async function sessionInfo(jar) {
|
|
1037
|
+
const { user, expiresAt } = await userOf(await load(jar));
|
|
1038
|
+
return {
|
|
1039
|
+
user,
|
|
1040
|
+
expiresAt,
|
|
1041
|
+
lastUsed: lastUsedOf(jar, config.secure),
|
|
1042
|
+
event: await eventOf(jar, (await keysFor()).open, user?.sub, config.secure, now())
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
/** The app's sign-in methods (AERO-2690), from the issuer, kept a minute. */
|
|
1046
|
+
const methods = methodsReader({
|
|
1047
|
+
issuer: config.issuer,
|
|
1048
|
+
clientId: config.clientId,
|
|
1049
|
+
fetch: upstream
|
|
1050
|
+
});
|
|
1051
|
+
/**
|
|
1052
|
+
* What the new session's ID token says of its sign-in, kept on this
|
|
1053
|
+
* browser (AERO-2691): the way in, for "Last used" where the brand shows
|
|
1054
|
+
* it, and the sign-in itself, for the page to hear of once (`event`).
|
|
1055
|
+
* Nothing without the app's sign-in claims. Best effort: the sign-in
|
|
1056
|
+
* stands whatever happens here, and waits at most METHODS_WAIT_MS for the
|
|
1057
|
+
* issuer's list.
|
|
1058
|
+
*
|
|
1059
|
+
* A link, or the person signed in now confirming it's them, is no sign-in
|
|
1060
|
+
* for the page to hear of (`event: false`): the person signed in is the
|
|
1061
|
+
* same, and a panel open on its onboarding or its profile carries on there
|
|
1062
|
+
* rather than greeting them again.
|
|
1063
|
+
*/
|
|
1064
|
+
async function keepSignIn(jar, handle, { event: keep = true } = {}) {
|
|
1065
|
+
const raw = (await handle.read())[tokenKey(config.primary)];
|
|
1066
|
+
const claims = raw ? JSON.parse(raw).claims : void 0;
|
|
1067
|
+
const event = signInOf(claims);
|
|
1068
|
+
if (!event || typeof claims?.sub !== "string") return;
|
|
1069
|
+
if (keep) await keepEvent(jar, (await keysFor()).seal, claims.sub, event, config.secure);
|
|
1070
|
+
try {
|
|
1071
|
+
const list = await within(methods(), METHODS_WAIT_MS);
|
|
1072
|
+
if (!list) return;
|
|
1073
|
+
if (list.show_last_used) rememberLastUsed(jar, event, config.secure);
|
|
1074
|
+
else forgetLastUsed(jar, config.secure);
|
|
1075
|
+
} catch {}
|
|
1076
|
+
}
|
|
1077
|
+
async function getAccessToken(jar, options = {}) {
|
|
1078
|
+
const resource = options.resource ?? config.primary;
|
|
1079
|
+
if (resource !== config.primary && resource !== config.accountApi) throw new SynqError("invalid_options", `The session has no token for ${resource ?? "userinfo"}: name it in synqAuth()'s resource`);
|
|
1080
|
+
const handle = await load(jar);
|
|
1081
|
+
if (!handle) throw new SynqError("not_signed_in", "Nobody is signed in");
|
|
1082
|
+
if (!config.store && jar.writable?.() === false) {
|
|
1083
|
+
const raw = (await handle.read())[tokenKey(resource)];
|
|
1084
|
+
const stored = raw ? JSON.parse(raw) : {};
|
|
1085
|
+
if (stored.accessToken && (stored.expiresAt === void 0 || stored.expiresAt > now())) return stored.accessToken;
|
|
1086
|
+
throw new SynqError("refresh_required", "The session's token ran out where its cookie can't be written (a server component): let synqProxy() refresh it before the page renders");
|
|
1087
|
+
}
|
|
1088
|
+
try {
|
|
1089
|
+
const token = await client(handle.storage(resource, config.primary), resource).getAccessToken({ minValiditySeconds: options.minValiditySeconds });
|
|
1090
|
+
await commit(jar, handle);
|
|
1091
|
+
return token;
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) await endSession(jar, handle, config.secure, config.store);
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* A store for one sign-in's transaction: a sealed cookie named after its
|
|
1099
|
+
* state, with where to go afterwards, or the popup it runs in (`popup`, the
|
|
1100
|
+
* page's attempt id: popup.ts), for a sign-in again, who must sign in
|
|
1101
|
+
* (`reauthenticate`, their `sub`), and whether it adds a way in to the
|
|
1102
|
+
* person signed in (`link`) rather than signing someone in.
|
|
1103
|
+
*/
|
|
1104
|
+
async function transactions(jar, returnTo, popup, reauthenticate, link) {
|
|
1105
|
+
const keys = await keysFor();
|
|
1106
|
+
const options = {
|
|
1107
|
+
secure: config.secure,
|
|
1108
|
+
maxAge: TRANSACTION_SECONDS
|
|
1109
|
+
};
|
|
1110
|
+
const found = {};
|
|
1111
|
+
return {
|
|
1112
|
+
storage: {
|
|
1113
|
+
async get(name) {
|
|
1114
|
+
const cookie = cookieName(name, config.secure);
|
|
1115
|
+
const opened = await unseal(keys.open, jar.get(cookie), cookie);
|
|
1116
|
+
if (!opened) return void 0;
|
|
1117
|
+
const { v, r, p, s, l } = JSON.parse(opened);
|
|
1118
|
+
found.returnTo = r;
|
|
1119
|
+
if (typeof p === "string" && POPUP_ATTEMPT.test(p)) found.popup = p;
|
|
1120
|
+
if (typeof s === "string") found.reauthenticate = s;
|
|
1121
|
+
if (l === true) found.link = true;
|
|
1122
|
+
return v;
|
|
1123
|
+
},
|
|
1124
|
+
async set(name, value) {
|
|
1125
|
+
await dropOldTransactions(jar, keys.open);
|
|
1126
|
+
const cookie = cookieName(name, config.secure);
|
|
1127
|
+
const sealed = JSON.stringify({
|
|
1128
|
+
v: value,
|
|
1129
|
+
r: returnTo,
|
|
1130
|
+
t: Date.now(),
|
|
1131
|
+
p: popup,
|
|
1132
|
+
s: reauthenticate,
|
|
1133
|
+
...link ? { l: true } : {}
|
|
1134
|
+
});
|
|
1135
|
+
jar.set(cookie, await seal(keys.seal, sealed, cookie), options);
|
|
1136
|
+
},
|
|
1137
|
+
remove(name) {
|
|
1138
|
+
jar.delete(cookieName(name, config.secure), options);
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
found
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/** Keeps a browser's sign-ins under way to MAX_TRANSACTIONS, the newest, before it starts another. */
|
|
1145
|
+
async function dropOldTransactions(jar, keys) {
|
|
1146
|
+
const prefix = cookieName(TRANSACTION_PREFIX, config.secure);
|
|
1147
|
+
const started = [];
|
|
1148
|
+
for (const name of [...jar.names()]) {
|
|
1149
|
+
if (!name.startsWith(prefix)) continue;
|
|
1150
|
+
const opened = await unseal(keys, jar.get(name), name);
|
|
1151
|
+
let at;
|
|
1152
|
+
try {
|
|
1153
|
+
at = opened ? JSON.parse(opened).t ?? 0 : 0;
|
|
1154
|
+
} catch {
|
|
1155
|
+
at = 0;
|
|
1156
|
+
}
|
|
1157
|
+
started.push({
|
|
1158
|
+
name,
|
|
1159
|
+
at,
|
|
1160
|
+
order: started.length
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
started.sort((a, b) => b.at - a.at || b.order - a.order);
|
|
1164
|
+
for (const { name } of started.slice(4)) jar.delete(name, { secure: config.secure });
|
|
1165
|
+
}
|
|
1166
|
+
async function signIn(request, jar) {
|
|
1167
|
+
if (isPrefetch(request)) return new Response(null, { status: 204 });
|
|
1168
|
+
const params = new URL(request.url).searchParams;
|
|
1169
|
+
const returnTo = safeReturnTo(params.get("returnTo"), config.appUrl);
|
|
1170
|
+
const prompt = params.get("prompt") ?? void 0;
|
|
1171
|
+
const maxAge = params.get("max_age");
|
|
1172
|
+
const intent = params.get("intent") ?? void 0;
|
|
1173
|
+
if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent, select_account or create");
|
|
1174
|
+
if (maxAge !== null && !/^\d{1,9}$/.test(maxAge)) return problem(400, "invalid_request", "max_age is a number of seconds");
|
|
1175
|
+
if (intent && !SIGN_IN_INTENTS.has(intent)) return problem(400, "invalid_request", "intent is add-passkey or verify-email (POST link adds a way to sign in)");
|
|
1176
|
+
const plan = params.get("plan") ?? void 0;
|
|
1177
|
+
const interval = params.get("interval") ?? void 0;
|
|
1178
|
+
if (plan !== void 0 && !PLAN_CODE.test(plan)) return problem(400, "invalid_request", "plan is a plan code, such as pro");
|
|
1179
|
+
if (interval !== void 0 && (plan === void 0 || interval !== "month" && interval !== "year")) return problem(400, "invalid_request", "interval is month or year, with a plan");
|
|
1180
|
+
const onboarding = params.get("onboarding") ?? void 0;
|
|
1181
|
+
if (onboarding !== void 0 && !ONBOARDING_VISIT.test(onboarding)) return problem(400, "invalid_request", "onboarding is an onboarding visit id");
|
|
1182
|
+
const display = displayOf(params.get("display"), params.get("attempt"));
|
|
1183
|
+
if (display instanceof Response) return display;
|
|
1184
|
+
const again = params.get("reauthenticate");
|
|
1185
|
+
let reauthenticate;
|
|
1186
|
+
if (again !== null) {
|
|
1187
|
+
if (again !== "true") return problem(400, "invalid_request", "reauthenticate is true, or left out");
|
|
1188
|
+
if (prompt && prompt !== "login" || intent) return problem(400, "invalid_request", "reauthenticate is a fresh sign-in: it takes no other prompt, and no intent");
|
|
1189
|
+
const signedIn = await load(jar);
|
|
1190
|
+
reauthenticate = signedIn ? (await issuerSessionOf(signedIn))?.sub : void 0;
|
|
1191
|
+
if (!reauthenticate) {
|
|
1192
|
+
if (display) return popupPage({
|
|
1193
|
+
attempt: display,
|
|
1194
|
+
ok: false,
|
|
1195
|
+
error: "not_signed_in"
|
|
1196
|
+
});
|
|
1197
|
+
return redirect(withError(returnTo, "not_signed_in"), 302);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const tx = await transactions(jar, returnTo, display, reauthenticate);
|
|
1201
|
+
return redirect((await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
1202
|
+
method: params.get("method") ?? void 0,
|
|
1203
|
+
loginHint: params.get("login_hint") ?? void 0,
|
|
1204
|
+
prompt: reauthenticate ? "login" : intentPrompt(intent, prompt),
|
|
1205
|
+
maxAge: maxAge === null ? void 0 : Number(maxAge),
|
|
1206
|
+
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
1207
|
+
params: {
|
|
1208
|
+
...intent ? { synq_intent: intent } : {},
|
|
1209
|
+
...plan ? { synq_plan: plan } : {},
|
|
1210
|
+
...interval ? { synq_interval: interval } : {},
|
|
1211
|
+
...onboarding ? { synq_onboarding: onboarding } : {}
|
|
1212
|
+
}
|
|
1213
|
+
})).href, 302);
|
|
1214
|
+
}
|
|
1215
|
+
async function callback(request, jar) {
|
|
1216
|
+
const tx = await transactions(jar);
|
|
1217
|
+
const handle = newSession(config.sessionMaxAge, config.store);
|
|
1218
|
+
const back = () => safeReturnTo(tx.found.returnTo ?? null, config.appUrl);
|
|
1219
|
+
const failed = (code) => tx.found.popup ? popupPage({
|
|
1220
|
+
attempt: tx.found.popup,
|
|
1221
|
+
ok: false,
|
|
1222
|
+
error: code
|
|
1223
|
+
}) : redirect(withError(back(), code), 303);
|
|
1224
|
+
try {
|
|
1225
|
+
await client(handle.storage(config.primary, config.primary), config.primary, tx.storage).handleCallback(request.url);
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
return failed(isSynqError(error) ? error.code : "server_error");
|
|
1228
|
+
}
|
|
1229
|
+
if (tx.found.reauthenticate !== void 0) {
|
|
1230
|
+
const refused = await reauthenticationRefused(jar, handle, tx.found.reauthenticate);
|
|
1231
|
+
if (refused) {
|
|
1232
|
+
await discard(handle);
|
|
1233
|
+
return failed(refused);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
await startSession(jar, handle);
|
|
1237
|
+
await keepSignIn(jar, handle, { event: tx.found.reauthenticate === void 0 && tx.found.link !== true });
|
|
1238
|
+
if (tx.found.popup) return popupPage({
|
|
1239
|
+
attempt: tx.found.popup,
|
|
1240
|
+
ok: true
|
|
1241
|
+
});
|
|
1242
|
+
return redirect(back(), 303);
|
|
1243
|
+
}
|
|
1244
|
+
async function signOut(request, jar) {
|
|
1245
|
+
const stay = await staysOnPage(request);
|
|
1246
|
+
if (stay instanceof Response) return stay;
|
|
1247
|
+
const handle = await load(jar);
|
|
1248
|
+
let url = null;
|
|
1249
|
+
if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut(stay ? { stay: true } : {});
|
|
1250
|
+
await endSession(jar, handle, config.secure, config.store);
|
|
1251
|
+
forgetEvent(jar, config.secure);
|
|
1252
|
+
if (stay) return json({ redirect: null });
|
|
1253
|
+
const target = url?.href ?? config.postLogoutRedirectUri;
|
|
1254
|
+
if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
|
|
1255
|
+
return redirect(target, 303);
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Whether a sign-out asks to stay on the page (AERO-2684): `{ stay: true }`
|
|
1259
|
+
* as JSON. A form's post, or no body, goes to the brand's sign-out page.
|
|
1260
|
+
*/
|
|
1261
|
+
async function staysOnPage(request) {
|
|
1262
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return false;
|
|
1263
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
1264
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
1265
|
+
if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1266
|
+
try {
|
|
1267
|
+
const body = await request.text();
|
|
1268
|
+
if (body === "") return false;
|
|
1269
|
+
const input = JSON.parse(body);
|
|
1270
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) throw new TypeError();
|
|
1271
|
+
if (input.stay !== void 0 && typeof input.stay !== "boolean") throw new TypeError();
|
|
1272
|
+
return input.stay === true;
|
|
1273
|
+
} catch {
|
|
1274
|
+
return problem(400, "invalid_request", "Send { stay: true } as JSON, or no body");
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/** The ways the app's sign-in offers, for its own panel (AERO-2690): the issuer's list, kept a minute. */
|
|
1278
|
+
async function signInMethodsRoute() {
|
|
1279
|
+
const list = await methods();
|
|
1280
|
+
return Response.json(list, { headers: { "cache-control": "private, max-age=60" } });
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Adding a way to sign in (AERO-1397): the account API makes a link request
|
|
1284
|
+
* for the person with the session's token, and its ticket goes to Synq in
|
|
1285
|
+
* a sign-in like any other, which asks them to sign in again first. A POST
|
|
1286
|
+
* from the app's own pages, answered `{ redirect }` for the browser to
|
|
1287
|
+
* follow, so another site can't start one.
|
|
1288
|
+
*/
|
|
1289
|
+
async function link(request, jar) {
|
|
1290
|
+
if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
|
|
1291
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
1292
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
1293
|
+
if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1294
|
+
let input;
|
|
1295
|
+
try {
|
|
1296
|
+
const body = await request.text();
|
|
1297
|
+
if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1298
|
+
input = JSON.parse(body);
|
|
1299
|
+
} catch {
|
|
1300
|
+
return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
|
|
1301
|
+
}
|
|
1302
|
+
if (typeof input?.method !== "string" || !METHOD.test(input.method)) return problem(400, "invalid_request", "method names the way to sign in to add");
|
|
1303
|
+
const display = displayOf(textOrNull(input.display), textOrNull(input.attempt));
|
|
1304
|
+
if (display instanceof Response) return display;
|
|
1305
|
+
const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
|
|
1306
|
+
let token;
|
|
1307
|
+
try {
|
|
1308
|
+
token = await getAccessToken(jar, { resource: config.accountApi });
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
if (isSynqError(error) && [
|
|
1311
|
+
"not_signed_in",
|
|
1312
|
+
"invalid_grant",
|
|
1313
|
+
"no_refresh_token"
|
|
1314
|
+
].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
|
|
1315
|
+
throw error;
|
|
1316
|
+
}
|
|
1317
|
+
if (input.method === "passkey") {
|
|
1318
|
+
const methods = await upstream(`${config.accountApi}/v1/link-methods?limit=200`, { headers: {
|
|
1319
|
+
authorization: `Bearer ${token}`,
|
|
1320
|
+
accept: "application/json"
|
|
1321
|
+
} });
|
|
1322
|
+
if (!methods.ok) return new Response(methods.body, {
|
|
1323
|
+
status: methods.status,
|
|
1324
|
+
headers: { "content-type": methods.headers.get("content-type") ?? "application/problem+json" }
|
|
1325
|
+
});
|
|
1326
|
+
if (!(await methods.json()).data?.some((one) => one.method === "passkey")) return problem(409, "sign_in_method_unavailable", "This brand doesn’t offer passkeys");
|
|
1327
|
+
const tx = await transactions(jar, returnTo, display, void 0, true);
|
|
1328
|
+
return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
1329
|
+
prompt: "login",
|
|
1330
|
+
additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
1331
|
+
params: { synq_intent: "add-passkey" }
|
|
1332
|
+
})).href });
|
|
1333
|
+
}
|
|
1334
|
+
const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
|
|
1335
|
+
method: "POST",
|
|
1336
|
+
headers: {
|
|
1337
|
+
authorization: `Bearer ${token}`,
|
|
1338
|
+
accept: "application/json",
|
|
1339
|
+
"content-type": "application/json"
|
|
1340
|
+
},
|
|
1341
|
+
body: JSON.stringify({ method: input.method })
|
|
1342
|
+
});
|
|
1343
|
+
if (!answer.ok) return new Response(answer.body, {
|
|
1344
|
+
status: answer.status,
|
|
1345
|
+
headers: { "content-type": answer.headers.get("content-type") ?? "application/problem+json" }
|
|
1346
|
+
});
|
|
1347
|
+
const ticket = (await answer.json()).authorization_params?.synq_link;
|
|
1348
|
+
if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
|
|
1349
|
+
const tx = await transactions(jar, returnTo, display, void 0, true);
|
|
1350
|
+
return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
1351
|
+
prompt: "login",
|
|
1352
|
+
additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
1353
|
+
params: {
|
|
1354
|
+
synq_intent: "link",
|
|
1355
|
+
synq_link: ticket
|
|
1356
|
+
}
|
|
1357
|
+
})).href });
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Signing in with a wallet from the app's own page (AERO-1423, over the
|
|
1361
|
+
* issuer's headless wallet sign-in, AERO-1410): `{ siws, returnTo }`, the
|
|
1362
|
+
* `synq_siws` @synqauth/solana's `encodeSiws` makes of what the wallet
|
|
1363
|
+
* signed. It goes to Synq in a pushed authorization request with
|
|
1364
|
+
* `prompt=login`, sent from this server with the app's credentials, so the
|
|
1365
|
+
* signature never lands in a URL; answered `{ redirect }` for the browser
|
|
1366
|
+
* to follow. A POST from the app's own pages.
|
|
1367
|
+
*/
|
|
1368
|
+
async function walletSignIn(request, jar) {
|
|
1369
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
1370
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
1371
|
+
if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1372
|
+
let input;
|
|
1373
|
+
try {
|
|
1374
|
+
const body = await request.text();
|
|
1375
|
+
if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1376
|
+
input = JSON.parse(body);
|
|
1377
|
+
} catch {
|
|
1378
|
+
return problem(400, "invalid_request", "Send { siws, returnTo } as JSON");
|
|
1379
|
+
}
|
|
1380
|
+
if (typeof input?.siws !== "string" || !SIWS.test(input.siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
|
|
1381
|
+
const tx = await transactions(jar, safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl));
|
|
1382
|
+
return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
1383
|
+
prompt: "login",
|
|
1384
|
+
pushed: true,
|
|
1385
|
+
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
1386
|
+
params: { synq_siws: input.siws }
|
|
1387
|
+
})).href });
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* One step of a sign-in on the app's own screens (AERO-1411): the page
|
|
1391
|
+
* posts what the person typed, `{ step, … }`, and this server takes it to
|
|
1392
|
+
* Synq's Authorization Challenge Endpoint as the app, with where the
|
|
1393
|
+
* person is (`clientAddress`). The flow (its PKCE verifier and Synq's auth
|
|
1394
|
+
* session) stays sealed in a cookie; the page hears only what to show
|
|
1395
|
+
* next, never a token, a code or the auth session. A sign-in that ends
|
|
1396
|
+
* starts the session as the callback does.
|
|
1397
|
+
*/
|
|
1398
|
+
async function signInFlow(request, jar) {
|
|
1399
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return problem(415, "unsupported_media_type", "Send the step as JSON");
|
|
1400
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
1401
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
1402
|
+
if (!Number.isFinite(length) || length > FLOW_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1403
|
+
let input;
|
|
1404
|
+
try {
|
|
1405
|
+
const body = await request.text();
|
|
1406
|
+
if (new TextEncoder().encode(body).length > FLOW_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1407
|
+
input = JSON.parse(body);
|
|
1408
|
+
if (!input || typeof input !== "object") throw new TypeError("not an object");
|
|
1409
|
+
} catch {
|
|
1410
|
+
return problem(400, "invalid_request", "Send { step, … } as JSON");
|
|
1411
|
+
}
|
|
1412
|
+
const text = (name, max = 512) => typeof input[name] === "string" ? input[name].slice(0, max) : "";
|
|
1413
|
+
const step = text("step", 32);
|
|
1414
|
+
const returnTo = safeReturnTo(text("returnTo", 2048) || null, config.appUrl);
|
|
1415
|
+
const tx = await transactions(jar, returnTo);
|
|
1416
|
+
const saved = await tx.storage.get(FLOW_KEY);
|
|
1417
|
+
if (step === "restart") {
|
|
1418
|
+
await tx.storage.remove(FLOW_KEY);
|
|
1419
|
+
return json({ status: "restarted" });
|
|
1420
|
+
}
|
|
1421
|
+
const endUserIp = config.clientAddress(request);
|
|
1422
|
+
if (!endUserIp && !warnedAboutAddress) {
|
|
1423
|
+
warnedAboutAddress = true;
|
|
1424
|
+
console.warn("@synqauth/nextjs: own-UI sign-in can't tell where people sign in from (no x-real-ip): give synqAuth() a clientAddress, or per-address protection is off for this app");
|
|
1425
|
+
}
|
|
1426
|
+
const reauthenticate = input.reauthenticate === true;
|
|
1427
|
+
if (reauthenticate && step !== "wallet") return problem(400, "invalid_request", "reauthenticate goes with step wallet");
|
|
1428
|
+
const signedIn = reauthenticate ? await idTokenOf(await load(jar)) : void 0;
|
|
1429
|
+
if (reauthenticate && !signedIn) return json({
|
|
1430
|
+
status: "other-account",
|
|
1431
|
+
message: "Nobody is signed in to confirm."
|
|
1432
|
+
});
|
|
1433
|
+
const handle = newSession(config.sessionMaxAge, config.store);
|
|
1434
|
+
const synq = client(handle.storage(config.primary, config.primary), config.primary);
|
|
1435
|
+
const flow = saved && !reauthenticate ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
|
|
1436
|
+
endUserIp,
|
|
1437
|
+
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : []
|
|
1438
|
+
});
|
|
1439
|
+
const email = text("email", 254);
|
|
1440
|
+
let result;
|
|
1441
|
+
try {
|
|
1442
|
+
switch (step) {
|
|
1443
|
+
case "password":
|
|
1444
|
+
result = text("screen") === "sign-up" ? await flow.password.signUp({
|
|
1445
|
+
email,
|
|
1446
|
+
password: text("password", 1024)
|
|
1447
|
+
}) : await flow.password.signIn({
|
|
1448
|
+
email,
|
|
1449
|
+
password: text("password", 1024)
|
|
1450
|
+
});
|
|
1451
|
+
break;
|
|
1452
|
+
case "email":
|
|
1453
|
+
result = await flow.email.send(email);
|
|
1454
|
+
break;
|
|
1455
|
+
case "code":
|
|
1456
|
+
result = flow.step === "verify-email" ? await flow.verifyEmail.verify(text("code", 32)) : await flow.email.verify(text("code", 32));
|
|
1457
|
+
break;
|
|
1458
|
+
case "resend":
|
|
1459
|
+
result = flow.step === "verify-email" ? await flow.verifyEmail.resend() : await flow.email.resend();
|
|
1460
|
+
break;
|
|
1461
|
+
case "skip":
|
|
1462
|
+
result = await flow.verifyEmail.skip();
|
|
1463
|
+
break;
|
|
1464
|
+
case "passkey-options":
|
|
1465
|
+
result = await flow.passkey.options();
|
|
1466
|
+
break;
|
|
1467
|
+
case "passkey": {
|
|
1468
|
+
const credential = input.credential;
|
|
1469
|
+
if (!credential || typeof credential !== "object") return problem(400, "invalid_request", "credential is the passkey’s assertion");
|
|
1470
|
+
result = await flow.passkey.signIn(credential, { related: input.related === true });
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
case "wallet": {
|
|
1474
|
+
const siws = input.siws;
|
|
1475
|
+
if (typeof siws !== "string" || !SIWS.test(siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
|
|
1476
|
+
result = await flow.wallet.signIn(siws, signedIn ? { reauthenticate: signedIn } : {});
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
default: return problem(400, "invalid_request", "step is password, email, code, resend, skip, passkey-options, passkey, wallet or restart");
|
|
1480
|
+
}
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
if (isSynqError(error, "invalid_session")) {
|
|
1483
|
+
await tx.storage.remove(FLOW_KEY);
|
|
1484
|
+
return json({
|
|
1485
|
+
status: "expired",
|
|
1486
|
+
message: error.message
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
if (isSynqError(error, "too_many_requests")) return json({
|
|
1490
|
+
status: "retry",
|
|
1491
|
+
step: flow.step,
|
|
1492
|
+
problem: "limited",
|
|
1493
|
+
message: error.message,
|
|
1494
|
+
...error.retryAfter === void 0 ? {} : { retryAfter: error.retryAfter }
|
|
1495
|
+
});
|
|
1496
|
+
throw error;
|
|
1497
|
+
}
|
|
1498
|
+
if (result.status === "signed-in") {
|
|
1499
|
+
await tx.storage.remove(FLOW_KEY);
|
|
1500
|
+
if (reauthenticate) {
|
|
1501
|
+
const previous = await load(jar);
|
|
1502
|
+
const [before, after] = await Promise.all([previous ? issuerSessionOf(previous) : void 0, issuerSessionOf(handle)]);
|
|
1503
|
+
if (!before || before.sub !== after?.sub) {
|
|
1504
|
+
try {
|
|
1505
|
+
await client(handle.storage(config.primary, config.primary), config.primary).signOut();
|
|
1506
|
+
} catch {}
|
|
1507
|
+
return json({
|
|
1508
|
+
status: "other-account",
|
|
1509
|
+
message: before ? "That wallet signs in to another account. Use a wallet of the account signed in here." : "Nobody is signed in to confirm."
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
await startSession(jar, handle);
|
|
1514
|
+
await keepSignIn(jar, handle, { event: !reauthenticate });
|
|
1515
|
+
return json({
|
|
1516
|
+
status: "signed-in",
|
|
1517
|
+
returnTo: tx.found.returnTo ?? returnTo
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
if (flow.step === "done") await tx.storage.remove(FLOW_KEY);
|
|
1521
|
+
else await tx.storage.set(FLOW_KEY, JSON.stringify(flow));
|
|
1522
|
+
switch (result.status) {
|
|
1523
|
+
case "code-sent": return json({
|
|
1524
|
+
status: result.status,
|
|
1525
|
+
step: flow.step,
|
|
1526
|
+
email: result.email,
|
|
1527
|
+
expiresAt: result.expiresAt.toISOString()
|
|
1528
|
+
});
|
|
1529
|
+
case "verify-email": return json({
|
|
1530
|
+
status: result.status,
|
|
1531
|
+
step: flow.step,
|
|
1532
|
+
email: result.email,
|
|
1533
|
+
canSkip: result.canSkip
|
|
1534
|
+
});
|
|
1535
|
+
case "passkey": return json({
|
|
1536
|
+
status: result.status,
|
|
1537
|
+
step: flow.step,
|
|
1538
|
+
passkey: {
|
|
1539
|
+
options: result.options,
|
|
1540
|
+
related: result.related
|
|
1541
|
+
},
|
|
1542
|
+
...result.problem ? {
|
|
1543
|
+
problem: result.problem,
|
|
1544
|
+
message: result.message
|
|
1545
|
+
} : {}
|
|
1546
|
+
});
|
|
1547
|
+
case "retry": return json({
|
|
1548
|
+
status: result.status,
|
|
1549
|
+
step: flow.step,
|
|
1550
|
+
problem: result.problem,
|
|
1551
|
+
message: result.message,
|
|
1552
|
+
...result.retryAfter === void 0 ? {} : { retryAfter: result.retryAfter }
|
|
1553
|
+
});
|
|
1554
|
+
case "hosted": return json({
|
|
1555
|
+
status: result.status,
|
|
1556
|
+
hosted: {
|
|
1557
|
+
reason: result.reason,
|
|
1558
|
+
...result.method ? { method: result.method } : {},
|
|
1559
|
+
...result.loginHint ? { loginHint: result.loginHint } : {}
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
case "refused":
|
|
1563
|
+
if (reauthenticate && result.reason === "other_account") return json({
|
|
1564
|
+
status: "other-account",
|
|
1565
|
+
message: "That wallet signs in to another account, or to none. Use a wallet of the account signed in here."
|
|
1566
|
+
});
|
|
1567
|
+
return json({
|
|
1568
|
+
status: result.status,
|
|
1569
|
+
reason: result.reason,
|
|
1570
|
+
message: result.message
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Starts the session `handle` holds the new sign-in's tokens of. A
|
|
1576
|
+
* sign-in replaces whatever session the browser had, and its refresh
|
|
1577
|
+
* token is revoked: an offline grant outlives the brand's session. But
|
|
1578
|
+
* the same person signing in again within their session at the issuer,
|
|
1579
|
+
* as adding a passkey or a way in asks, gets the grant the replaced
|
|
1580
|
+
* session had, and Synq ends a grant with any refresh token of it
|
|
1581
|
+
* revoked: that one is dropped instead. Same person: the ID tokens'
|
|
1582
|
+
* `sub`. Same issuer session: their `sid`, which Synq includes for every
|
|
1583
|
+
* app (AERO-1624) but one whose tenant turned it off; without it, the
|
|
1584
|
+
* same `sub` is taken for the same session (a sign-in after it lapsed
|
|
1585
|
+
* leaves the old grant to expire).
|
|
1586
|
+
*/
|
|
1587
|
+
async function startSession(jar, handle) {
|
|
1588
|
+
const previous = await load(jar);
|
|
1589
|
+
if (previous) {
|
|
1590
|
+
const [before, after] = await Promise.all([issuerSessionOf(previous), issuerSessionOf(handle)]);
|
|
1591
|
+
if (!(before !== void 0 && after !== void 0 && before.sub === after.sub && (before.sid === void 0 || after.sid === void 0 || before.sid === after.sid))) try {
|
|
1592
|
+
await client(previous.storage(config.primary, config.primary), config.primary).signOut();
|
|
1593
|
+
} catch {}
|
|
1594
|
+
await endSession(jar, previous, config.secure, config.store);
|
|
1595
|
+
}
|
|
1596
|
+
await commit(jar, handle);
|
|
1597
|
+
}
|
|
1598
|
+
async function account(request, jar, path) {
|
|
1599
|
+
if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
|
|
1600
|
+
if (!/^v1(\/[A-Za-z0-9._~-]+)+$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) return problem(404, "not_found", "No such account API route");
|
|
1601
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
1602
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
1603
|
+
if (!Number.isFinite(length) || length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1604
|
+
let token;
|
|
1605
|
+
try {
|
|
1606
|
+
token = await getAccessToken(jar, { resource: config.accountApi });
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
if (isSynqError(error) && [
|
|
1609
|
+
"not_signed_in",
|
|
1610
|
+
"invalid_grant",
|
|
1611
|
+
"no_refresh_token"
|
|
1612
|
+
].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
|
|
1613
|
+
throw error;
|
|
1614
|
+
}
|
|
1615
|
+
const headers = new Headers({
|
|
1616
|
+
authorization: `Bearer ${token}`,
|
|
1617
|
+
accept: "application/json"
|
|
1618
|
+
});
|
|
1619
|
+
for (const name of ["content-type", "idempotency-key"]) {
|
|
1620
|
+
const value = request.headers.get(name);
|
|
1621
|
+
if (value) headers.set(name, value);
|
|
1622
|
+
}
|
|
1623
|
+
let body;
|
|
1624
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1625
|
+
body = await request.text();
|
|
1626
|
+
if (new TextEncoder().encode(body).length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1627
|
+
}
|
|
1628
|
+
const search = new URL(request.url).search;
|
|
1629
|
+
const answer = await upstream(`${config.accountApi}/${path}${search}`, {
|
|
1630
|
+
method: request.method,
|
|
1631
|
+
headers,
|
|
1632
|
+
body,
|
|
1633
|
+
redirect: "manual"
|
|
1634
|
+
});
|
|
1635
|
+
const out = new Headers({ "cache-control": "no-store" });
|
|
1636
|
+
const type = answer.headers.get("content-type");
|
|
1637
|
+
if (type) out.set("content-type", type);
|
|
1638
|
+
return new Response(answer.status === 204 ? null : await answer.text(), {
|
|
1639
|
+
status: answer.status,
|
|
1640
|
+
headers: out
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* The person's picture (AERO-2700): the browser posts the picture's own
|
|
1645
|
+
* bytes here, and this server runs the account API's upload for them,
|
|
1646
|
+
* asking for an upload, PUTting the bytes to Synq's storage, and choosing
|
|
1647
|
+
* it, so the storage needs no CORS for the app, and no bytes pass through
|
|
1648
|
+
* Synq's API. Or it posts `{ "identity": … }`, a linked account whose
|
|
1649
|
+
* picture to use: a POST, which every app's route file already serves. A
|
|
1650
|
+
* body that says it is too large, or not a picture, or won't say how long
|
|
1651
|
+
* it is, is refused before it is read.
|
|
1652
|
+
*/
|
|
1653
|
+
async function picture(request, jar) {
|
|
1654
|
+
if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
|
|
1655
|
+
const declared = request.headers.get("content-length");
|
|
1656
|
+
if (request.headers.has("transfer-encoding") || declared === null) return problem(411, "length_required", "Send the picture with a Content-Length");
|
|
1657
|
+
const length = Number(declared);
|
|
1658
|
+
if (!Number.isSafeInteger(length) || length < 1) return problem(411, "length_required", "Send the picture with a Content-Length");
|
|
1659
|
+
if (length > PICTURE_BODY_LIMIT) return problem(413, "payload_too_large", "A picture is at most 5 MB.");
|
|
1660
|
+
const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1661
|
+
const linked = type === "application/json";
|
|
1662
|
+
if (linked && length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1663
|
+
if (!linked && !PICTURE_TYPES.has(type)) return problem(415, "unsupported_media_type", "Send a PNG, JPEG, WebP or GIF picture");
|
|
1664
|
+
let token;
|
|
1665
|
+
try {
|
|
1666
|
+
token = await getAccessToken(jar, { resource: config.accountApi });
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
if (isSynqError(error) && [
|
|
1669
|
+
"not_signed_in",
|
|
1670
|
+
"invalid_grant",
|
|
1671
|
+
"no_refresh_token"
|
|
1672
|
+
].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
|
|
1673
|
+
throw error;
|
|
1674
|
+
}
|
|
1675
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
1676
|
+
if (bytes.byteLength !== length) return problem(400, "validation_failed", "The picture isn’t the length it said");
|
|
1677
|
+
const key = request.headers.get("idempotency-key");
|
|
1678
|
+
const call = (method, path, body, step) => upstream(`${config.accountApi}/${path}`, {
|
|
1679
|
+
method,
|
|
1680
|
+
headers: {
|
|
1681
|
+
authorization: `Bearer ${token}`,
|
|
1682
|
+
accept: "application/json",
|
|
1683
|
+
"content-type": "application/json",
|
|
1684
|
+
...key ? { "idempotency-key": `${key}:${step}` } : {}
|
|
1685
|
+
},
|
|
1686
|
+
body: JSON.stringify(body),
|
|
1687
|
+
redirect: "manual"
|
|
1688
|
+
});
|
|
1689
|
+
const relay = async (answer) => new Response(answer.status === 204 ? null : await answer.text(), {
|
|
1690
|
+
status: answer.status,
|
|
1691
|
+
headers: {
|
|
1692
|
+
"cache-control": "no-store",
|
|
1693
|
+
"content-type": answer.headers.get("content-type") ?? "application/json"
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
if (linked) {
|
|
1697
|
+
let identity;
|
|
1698
|
+
try {
|
|
1699
|
+
identity = JSON.parse(new TextDecoder().decode(bytes)).identity;
|
|
1700
|
+
} catch {
|
|
1701
|
+
identity = void 0;
|
|
1702
|
+
}
|
|
1703
|
+
if (typeof identity !== "string" || !/^idn_[0-9a-z]{1,64}$/.test(identity)) return problem(400, "validation_failed", "Name the linked account: { \"identity\": \"idn_…\" }");
|
|
1704
|
+
return relay(await call("PUT", "v1/profile/picture", { identity }, "choose"));
|
|
1705
|
+
}
|
|
1706
|
+
const started = await call("POST", "v1/profile/picture/uploads", {
|
|
1707
|
+
content_type: type,
|
|
1708
|
+
content_length: length
|
|
1709
|
+
}, "upload");
|
|
1710
|
+
if (started.status !== 201) return relay(started);
|
|
1711
|
+
const upload = await started.json();
|
|
1712
|
+
const stored = await upstream(upload.url, {
|
|
1713
|
+
method: "PUT",
|
|
1714
|
+
headers: upload.headers,
|
|
1715
|
+
body: bytes,
|
|
1716
|
+
redirect: "manual"
|
|
1717
|
+
});
|
|
1718
|
+
await stored.body?.cancel();
|
|
1719
|
+
if (!stored.ok) return problem(502, "upload_failed", `Synq's storage refused the picture (${stored.status})`);
|
|
1720
|
+
return relay(await call("PUT", "v1/profile/picture", { file_id: upload.file_id }, "choose"));
|
|
1721
|
+
}
|
|
1722
|
+
async function plugin(request, jar, id, rest) {
|
|
1723
|
+
const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
|
|
1724
|
+
if (!route) return void 0;
|
|
1725
|
+
return route({
|
|
1726
|
+
user: async () => (await userOf(await load(jar))).user,
|
|
1727
|
+
getAccessToken: (options) => getAccessToken(jar, options),
|
|
1728
|
+
issuer: config.issuer,
|
|
1729
|
+
clientId: config.clientId,
|
|
1730
|
+
basePath: config.basePath
|
|
1731
|
+
}, request);
|
|
1732
|
+
}
|
|
1733
|
+
async function handle(request) {
|
|
1734
|
+
const url = new URL(request.url);
|
|
1735
|
+
const prefix = `${config.basePath}/`;
|
|
1736
|
+
if (!url.pathname.startsWith(prefix)) return problem(404, "not_found", "Not under the route mount");
|
|
1737
|
+
const rest = url.pathname.slice(prefix.length);
|
|
1738
|
+
const [first = "", ...tail] = rest.split("/");
|
|
1739
|
+
const jar = requestJar(request);
|
|
1740
|
+
if (!["GET", "HEAD"].includes(request.method) && !sameOrigin(request, config.appUrl)) return problem(403, "forbidden", "A change must come from the app's own pages");
|
|
1741
|
+
let response;
|
|
1742
|
+
try {
|
|
1743
|
+
if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
|
|
1744
|
+
else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
|
|
1745
|
+
else if (rest === "sign-in/flow" && request.method === "POST") response = await signInFlow(request, jar);
|
|
1746
|
+
else if (rest === "sign-in/wallet" && request.method === "GET") response = json({
|
|
1747
|
+
issuer: config.issuer,
|
|
1748
|
+
clientId: config.clientId
|
|
1749
|
+
});
|
|
1750
|
+
else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
|
|
1751
|
+
else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
|
|
1752
|
+
else if (rest === "link" && request.method === "POST") response = await link(request, jar);
|
|
1753
|
+
else if (rest === "sign-in/methods" && request.method === "GET") response = await signInMethodsRoute();
|
|
1754
|
+
else if (rest === "session" && request.method === "GET") response = json(await sessionInfo(jar));
|
|
1755
|
+
else if (rest === "session/event" && request.method === "DELETE") {
|
|
1756
|
+
forgetEvent(jar, config.secure);
|
|
1757
|
+
response = new Response(null, { status: 204 });
|
|
1758
|
+
} else if (rest === "account/picture" && request.method === "POST") response = await picture(request, jar);
|
|
1759
|
+
else if (first === "account") response = await account(request, jar, tail.join("/"));
|
|
1760
|
+
else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
if (!isSynqError(error)) throw error;
|
|
1763
|
+
response = problem(error.status && error.status >= 400 ? 502 : 500, error.code, error.message);
|
|
1764
|
+
}
|
|
1765
|
+
response ??= problem(404, "not_found", "No such route under the mount");
|
|
1766
|
+
const headers = new Headers(response.headers);
|
|
1767
|
+
for (const cookie of jar.headers()) headers.append("set-cookie", cookie);
|
|
1768
|
+
if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
|
|
1769
|
+
headers.set("x-content-type-options", "nosniff");
|
|
1770
|
+
return new Response(response.body, {
|
|
1771
|
+
status: response.status,
|
|
1772
|
+
headers
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
async function refreshIfDue(jar) {
|
|
1776
|
+
const handle = await load(jar);
|
|
1777
|
+
const { user, expiresAt } = await userOf(handle);
|
|
1778
|
+
if (!handle || !user) return false;
|
|
1779
|
+
if (config.store || expiresAt === null || expiresAt - config.refreshLeewaySeconds > now()) return true;
|
|
1780
|
+
try {
|
|
1781
|
+
await getAccessToken(jar);
|
|
1782
|
+
return true;
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) return false;
|
|
1785
|
+
return expiresAt > now();
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
handle,
|
|
1790
|
+
user: async (jar) => (await userOf(await load(jar))).user,
|
|
1791
|
+
session: sessionInfo,
|
|
1792
|
+
getAccessToken,
|
|
1793
|
+
refreshIfDue,
|
|
1794
|
+
config
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
/** A body field as text: null when it is left out, and anything but text kept as a mark that it was there. */
|
|
1798
|
+
function textOrNull(value) {
|
|
1799
|
+
if (value === void 0) return null;
|
|
1800
|
+
return typeof value === "string" ? value : "?";
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* The popup a sign-in or link runs in (AERO-2692): the page's attempt id for
|
|
1804
|
+
* `display=popup`, undefined for `redirect` or none, or the problem for
|
|
1805
|
+
* anything else.
|
|
1806
|
+
*/
|
|
1807
|
+
function displayOf(display, attempt) {
|
|
1808
|
+
if (display === null || display === "redirect") return attempt === null ? void 0 : problem(400, "invalid_request", "attempt goes with display=popup");
|
|
1809
|
+
if (display !== "popup") return problem(400, "invalid_request", "display is popup or redirect");
|
|
1810
|
+
if (attempt === null || !POPUP_ATTEMPT.test(attempt)) return problem(400, "invalid_request", "attempt is the popup's id: 8 to 64 of A-Z a-z 0-9 _ -");
|
|
1811
|
+
return attempt;
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Whether a request is a prefetch, not a person going there: Next.js's
|
|
1815
|
+
* router (`Next-Router-Prefetch`) and browsers' speculative loads (`Purpose`,
|
|
1816
|
+
* `Sec-Purpose`: prefetch or prerender).
|
|
1817
|
+
*/
|
|
1818
|
+
function isPrefetch(request) {
|
|
1819
|
+
const h = request.headers;
|
|
1820
|
+
if (h.get("next-router-prefetch") !== null) return true;
|
|
1821
|
+
const purpose = `${h.get("purpose") ?? ""} ${h.get("sec-purpose") ?? ""}`.toLowerCase();
|
|
1822
|
+
return /\b(prefetch|prerender)\b/.test(purpose);
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Where to send the browser back to on the app, as an absolute URL, never
|
|
1826
|
+
* another site: the app's root for anything else. It checks where the value
|
|
1827
|
+
* resolves to, not how it's spelled, since dot segments and encodings
|
|
1828
|
+
* (`/.//evil.example`, `/%2e//evil.example`) collapse into another host.
|
|
1829
|
+
*/
|
|
1830
|
+
function safeReturnTo(value, appUrl) {
|
|
1831
|
+
const home = new URL("/", appUrl).href;
|
|
1832
|
+
if (!value || value.includes("\\")) return home;
|
|
1833
|
+
try {
|
|
1834
|
+
const url = new URL(value, appUrl);
|
|
1835
|
+
return url.origin === appUrl.origin && !url.pathname.startsWith("//") ? url.href : home;
|
|
1836
|
+
} catch {
|
|
1837
|
+
return home;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
/** A request from the app's own pages: its `Origin`, or else the browser's `Sec-Fetch-Site`, says so. */
|
|
1841
|
+
function sameOrigin(request, appUrl) {
|
|
1842
|
+
const origin = request.headers.get("origin");
|
|
1843
|
+
if (origin) return origin === appUrl.origin;
|
|
1844
|
+
return request.headers.get("sec-fetch-site") === "same-origin";
|
|
1845
|
+
}
|
|
1846
|
+
function withError(target, code) {
|
|
1847
|
+
const url = new URL(target);
|
|
1848
|
+
url.searchParams.set("synq_error", code);
|
|
1849
|
+
return url.href;
|
|
1850
|
+
}
|
|
1851
|
+
function redirect(location, status) {
|
|
1852
|
+
return new Response(null, {
|
|
1853
|
+
status,
|
|
1854
|
+
headers: { location }
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
function json(body, status = 200) {
|
|
1858
|
+
return Response.json(body, {
|
|
1859
|
+
status,
|
|
1860
|
+
headers: { "cache-control": "no-store" }
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
function problem(status, code, detail) {
|
|
1864
|
+
return Response.json({
|
|
1865
|
+
type: "about:blank",
|
|
1866
|
+
status,
|
|
1867
|
+
code,
|
|
1868
|
+
detail
|
|
1869
|
+
}, {
|
|
1870
|
+
status,
|
|
1871
|
+
headers: {
|
|
1872
|
+
"content-type": "application/problem+json",
|
|
1873
|
+
"cache-control": "no-store"
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
function now() {
|
|
1878
|
+
return Math.floor(Date.now() / 1e3);
|
|
1879
|
+
}
|
|
1880
|
+
//#endregion
|
|
1881
|
+
//#region src/sign-in-error.ts
|
|
1882
|
+
/** The query parameter the route mount adds to `returnTo` when a sign-in fails. */
|
|
1883
|
+
const SIGN_IN_ERROR_PARAM = "synq_error";
|
|
1884
|
+
/** An OAuth or SDK error code, as the route mount puts it there. */
|
|
1885
|
+
const CODE = /^[a-z0-9_]{1,64}$/;
|
|
1886
|
+
const MESSAGES = {
|
|
1887
|
+
access_denied: "The sign-in was cancelled, or this account can’t use this app.",
|
|
1888
|
+
login_required: "You need to sign in to continue.",
|
|
1889
|
+
interaction_required: "You need to sign in to continue.",
|
|
1890
|
+
consent_required: "You need to allow this app to use your account to continue.",
|
|
1891
|
+
account_selection_required: "Choose an account to continue.",
|
|
1892
|
+
temporarily_unavailable: "Signing in isn’t available just now. Try again in a moment.",
|
|
1893
|
+
unknown_state: "This sign-in expired, or it finished in another tab. Try again.",
|
|
1894
|
+
other_account: "That signed in to another account. Confirm with a way in of the account signed in here.",
|
|
1895
|
+
not_signed_in: "You were signed out. Sign in again."
|
|
1896
|
+
};
|
|
1897
|
+
/** What a sign-in error code means, in plain words. */
|
|
1898
|
+
function signInErrorMessage(code) {
|
|
1899
|
+
return MESSAGES[code] ?? "Something went wrong signing in. Try again.";
|
|
1900
|
+
}
|
|
1901
|
+
/** A path and query on the app, without `synq_error`: where the ways on come back to. */
|
|
1902
|
+
function withoutSignInError(pathAndSearch) {
|
|
1903
|
+
const url = new URL(pathAndSearch, "http://app.invalid");
|
|
1904
|
+
url.searchParams.delete(SIGN_IN_ERROR_PARAM);
|
|
1905
|
+
return `${url.pathname}${url.search}`;
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* The sign-in error `code` names, coming back to `returnTo` (a path on the
|
|
1909
|
+
* app), or null when there is none or it isn't a code.
|
|
1910
|
+
*/
|
|
1911
|
+
function signInErrorOf(code, basePath, returnTo) {
|
|
1912
|
+
if (!code || !CODE.test(code)) return null;
|
|
1913
|
+
const back = withoutSignInError(returnTo.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/");
|
|
1914
|
+
const signIn = (prompt) => {
|
|
1915
|
+
const params = new URLSearchParams({ returnTo: back });
|
|
1916
|
+
if (prompt) params.set("prompt", prompt);
|
|
1917
|
+
return `${basePath}/sign-in?${params.toString()}`;
|
|
1918
|
+
};
|
|
1919
|
+
return {
|
|
1920
|
+
code,
|
|
1921
|
+
message: signInErrorMessage(code),
|
|
1922
|
+
retryPath: signIn(),
|
|
1923
|
+
anotherAccountPath: signIn("select_account")
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
function escape(text) {
|
|
1927
|
+
return text.replace(/[&<>"']/g, (c) => ({
|
|
1928
|
+
"&": "&",
|
|
1929
|
+
"<": "<",
|
|
1930
|
+
">": ">",
|
|
1931
|
+
"\"": """,
|
|
1932
|
+
"'": "'"
|
|
1933
|
+
})[c]);
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* The proxy's own page for a sign-in error, for an app that has none of its
|
|
1937
|
+
* own (`synqProxy`'s `signInError`): plain HTML, no script and no style, so
|
|
1938
|
+
* any Content-Security-Policy the app sends allows it.
|
|
1939
|
+
*/
|
|
1940
|
+
function signInErrorPage(error) {
|
|
1941
|
+
const html = `<!doctype html>
|
|
1942
|
+
<html lang="en">
|
|
1943
|
+
<head>
|
|
1944
|
+
<meta charset="utf-8">
|
|
1945
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1946
|
+
<meta name="robots" content="noindex">
|
|
1947
|
+
<title>Sign-in didn’t finish</title>
|
|
1948
|
+
</head>
|
|
1949
|
+
<body>
|
|
1950
|
+
<main>
|
|
1951
|
+
<h1>Sign-in didn’t finish</h1>
|
|
1952
|
+
<p>${escape(error.message)}</p>
|
|
1953
|
+
<p><a href="${escape(error.retryPath)}">Try again</a></p>
|
|
1954
|
+
<p><a href="${escape(error.anotherAccountPath)}">Use another account</a></p>
|
|
1955
|
+
</main>
|
|
1956
|
+
</body>
|
|
1957
|
+
</html>
|
|
1958
|
+
`;
|
|
1959
|
+
return new Response(html, {
|
|
1960
|
+
status: 200,
|
|
1961
|
+
headers: {
|
|
1962
|
+
"content-type": "text/html; charset=utf-8",
|
|
1963
|
+
"cache-control": "no-store",
|
|
1964
|
+
"x-content-type-options": "nosniff"
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
//#endregion
|
|
1969
|
+
//#region src/sites.ts
|
|
1970
|
+
/** Sites kept at once; past it, the one used longest ago is made again when next asked for. */
|
|
1971
|
+
const MAX_SITES = 500;
|
|
1972
|
+
/** One DNS label, as a host name has them. */
|
|
1973
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
1974
|
+
/** A host as a browser names it in `Host`: a DNS name or an IPv6 literal, and a port. */
|
|
1975
|
+
const HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*|\[[0-9a-f:.]+\])(?::\d{1,5})?$/;
|
|
1976
|
+
/** A host as it's compared: lowercase, with no dot at the end of its name. */
|
|
1977
|
+
function normalHost(value) {
|
|
1978
|
+
return value.trim().toLowerCase().replace(/\.(:\d+)?$/, "$1");
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* The host the browser asked for: `X-Forwarded-Host`'s first, which a proxy
|
|
1982
|
+
* in front of the app sets (and Next's own server does), else `Host`.
|
|
1983
|
+
* Undefined when there is none, or it isn't a host.
|
|
1984
|
+
*/
|
|
1985
|
+
function requestHost(headers) {
|
|
1986
|
+
const forwarded = headers.get("x-forwarded-host")?.split(",", 1)[0]?.trim();
|
|
1987
|
+
const host = normalHost(forwarded || headers.get("host") || "");
|
|
1988
|
+
return HOST.test(host) ? host : void 0;
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* `hosts` as a test: an entry is a host (`club.example.com`,
|
|
1992
|
+
* `localhost:3000`), or `*.` and a domain, for any one label under it, its
|
|
1993
|
+
* port named or none. Throws `invalid_options` for an entry that is neither.
|
|
1994
|
+
*/
|
|
1995
|
+
function allowList(hosts) {
|
|
1996
|
+
if (!Array.isArray(hosts) || hosts.length === 0) throw new SynqError("invalid_options", "With resolve, hosts lists every host the app serves (club.example.com, *.example.com): any other is refused");
|
|
1997
|
+
const exact = /* @__PURE__ */ new Set();
|
|
1998
|
+
const under = [];
|
|
1999
|
+
for (const entry of hosts) {
|
|
2000
|
+
const host = typeof entry === "string" ? normalHost(entry) : "";
|
|
2001
|
+
const wildcard = host.startsWith("*.");
|
|
2002
|
+
const name = wildcard ? host.slice(2) : host;
|
|
2003
|
+
if (!HOST.test(name) || name.startsWith("[") || wildcard && !name.includes(".")) throw new SynqError("invalid_options", `hosts: ${JSON.stringify(entry)} isn't a host, nor *. and a domain`);
|
|
2004
|
+
if (wildcard) under.push(`.${name}`);
|
|
2005
|
+
else exact.add(name);
|
|
2006
|
+
}
|
|
2007
|
+
return (host) => exact.has(host) || under.some((suffix) => host.endsWith(suffix) && LABEL.test(host.slice(0, -suffix.length)));
|
|
2008
|
+
}
|
|
2009
|
+
/** Whether `host` (its name, without the port) is this machine. */
|
|
2010
|
+
function isLoopback(host) {
|
|
2011
|
+
const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":", 1)[0];
|
|
2012
|
+
return name === "localhost" || name.endsWith(".localhost") || name === "127.0.0.1" || name === "[::1]";
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* The site's origin: `https://<host>`, or, for a host on this machine, the
|
|
2016
|
+
* scheme the request came over (`X-Forwarded-Proto`, which Next sets, else
|
|
2017
|
+
* the request's own URL).
|
|
2018
|
+
*/
|
|
2019
|
+
function originOf(host, headers, url) {
|
|
2020
|
+
let protocol = "https:";
|
|
2021
|
+
if (isLoopback(host)) {
|
|
2022
|
+
const forwarded = headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim().toLowerCase();
|
|
2023
|
+
if (forwarded === "http" || forwarded === "https") protocol = `${forwarded}:`;
|
|
2024
|
+
else if (url) protocol = new URL(url).protocol === "https:" ? "https:" : "http:";
|
|
2025
|
+
else protocol = "http:";
|
|
2026
|
+
}
|
|
2027
|
+
return new URL(`${protocol}//${host}`).origin;
|
|
2028
|
+
}
|
|
2029
|
+
function siteCores(options) {
|
|
2030
|
+
const { resolve } = options;
|
|
2031
|
+
if (!resolve) throw new TypeError("siteCores needs resolve");
|
|
2032
|
+
let allowed;
|
|
2033
|
+
const cores = /* @__PURE__ */ new Map();
|
|
2034
|
+
return { async coreFor(headers, url) {
|
|
2035
|
+
allowed ??= allowList(options.hosts);
|
|
2036
|
+
const host = requestHost(headers);
|
|
2037
|
+
if (!host || !allowed(host)) return null;
|
|
2038
|
+
const request = {
|
|
2039
|
+
host,
|
|
2040
|
+
origin: originOf(host, headers, url),
|
|
2041
|
+
headers
|
|
2042
|
+
};
|
|
2043
|
+
const site = await resolve(request);
|
|
2044
|
+
if (site === null || site === void 0) return null;
|
|
2045
|
+
const key = JSON.stringify([
|
|
2046
|
+
request.origin,
|
|
2047
|
+
site.issuer,
|
|
2048
|
+
site.clientId,
|
|
2049
|
+
site.appUrl ?? null,
|
|
2050
|
+
site.storePrefix ?? null,
|
|
2051
|
+
site.account ?? null,
|
|
2052
|
+
site.postLogoutRedirectUri ?? null
|
|
2053
|
+
]);
|
|
2054
|
+
const kept = cores.get(key);
|
|
2055
|
+
cores.delete(key);
|
|
2056
|
+
if (kept && kept.secret === site.clientSecret) {
|
|
2057
|
+
cores.set(key, kept);
|
|
2058
|
+
return kept.core;
|
|
2059
|
+
}
|
|
2060
|
+
const core = createSynqCore(siteConfig(options, site, request));
|
|
2061
|
+
cores.set(key, {
|
|
2062
|
+
secret: site.clientSecret,
|
|
2063
|
+
core
|
|
2064
|
+
});
|
|
2065
|
+
if (cores.size > MAX_SITES) cores.delete(cores.keys().next().value);
|
|
2066
|
+
return core;
|
|
2067
|
+
} };
|
|
2068
|
+
}
|
|
2069
|
+
const UNKNOWN_HOST = "This app doesn't serve the host this request was sent to: add it to synqAuth()'s hosts if it should";
|
|
2070
|
+
/** Why a request to a host the app doesn't serve gets nothing, as the error server code throws. */
|
|
2071
|
+
function unknownHostError() {
|
|
2072
|
+
return new SynqError("unknown_host", UNKNOWN_HOST, { status: 421 });
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* The answer to a request for a host the app doesn't serve: 421 Misdirected
|
|
2076
|
+
* Request, as a problem, naming no host and no configuration.
|
|
2077
|
+
*/
|
|
2078
|
+
function unknownHostResponse() {
|
|
2079
|
+
return Response.json({
|
|
2080
|
+
type: "about:blank",
|
|
2081
|
+
status: 421,
|
|
2082
|
+
code: "unknown_host",
|
|
2083
|
+
detail: UNKNOWN_HOST
|
|
2084
|
+
}, {
|
|
2085
|
+
status: 421,
|
|
2086
|
+
headers: {
|
|
2087
|
+
"content-type": "application/problem+json",
|
|
2088
|
+
"cache-control": "no-store",
|
|
2089
|
+
"x-content-type-options": "nosniff"
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
//#endregion
|
|
2094
|
+
export { signInErrorMessage as a, withoutSignInError as c, memorySessionStore as d, defineSynqPlugin as f, resolveConfig as h, SIGN_IN_ERROR_PARAM as i, createSynqCore as l, normalizePath as m, unknownHostError as n, signInErrorOf as o, requestJar as p, unknownHostResponse as r, signInErrorPage as s, siteCores as t, keyValueSessionStore as u };
|