@synqauth/nextjs 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/dist/client.d.ts +13 -0
- package/dist/client.js +24 -0
- package/dist/config-CpEbAGEs.d.ts +215 -0
- package/dist/define.d.ts +6 -0
- package/dist/define.js +7 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +106 -0
- package/dist/proxy.d.ts +28 -0
- package/dist/proxy.js +59 -0
- package/dist/sign-in-error-kd56GHt_.js +1349 -0
- package/package.json +59 -3
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
import { SynqError, createSynqClient, isSynqError, memoryStorage } 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
|
+
/** The sealing key for a session secret, derived once per secret. */
|
|
8
|
+
function sealingKey(secret) {
|
|
9
|
+
if (encoder.encode(secret).length < 32) throw new TypeError(`The session secret must be at least 32 bytes`);
|
|
10
|
+
let key = keys.get(secret);
|
|
11
|
+
if (!key) {
|
|
12
|
+
key = (async () => {
|
|
13
|
+
const base = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
|
|
14
|
+
return crypto.subtle.deriveKey({
|
|
15
|
+
name: "HKDF",
|
|
16
|
+
hash: "SHA-256",
|
|
17
|
+
salt: encoder.encode("@synqauth/nextjs session"),
|
|
18
|
+
info: encoder.encode(VERSION)
|
|
19
|
+
}, base, {
|
|
20
|
+
name: "AES-GCM",
|
|
21
|
+
length: 256
|
|
22
|
+
}, false, ["encrypt", "decrypt"]);
|
|
23
|
+
})();
|
|
24
|
+
keys.set(secret, key);
|
|
25
|
+
}
|
|
26
|
+
return key;
|
|
27
|
+
}
|
|
28
|
+
async function sealingKeys(current, previous = []) {
|
|
29
|
+
const open = await Promise.all([current, ...previous].map((secret) => sealingKey(secret)));
|
|
30
|
+
return {
|
|
31
|
+
seal: open[0],
|
|
32
|
+
open
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** `plaintext` sealed for the cookie `name`: `v1.<iv>.<ciphertext>`, base64url. */
|
|
36
|
+
async function seal(key, plaintext, name) {
|
|
37
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
38
|
+
const sealed = await crypto.subtle.encrypt({
|
|
39
|
+
name: "AES-GCM",
|
|
40
|
+
iv,
|
|
41
|
+
additionalData: encoder.encode(name)
|
|
42
|
+
}, key, encoder.encode(plaintext));
|
|
43
|
+
return `${VERSION}.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
|
|
44
|
+
}
|
|
45
|
+
/** What `seal` sealed for `name` with any of `keys`, or undefined for anything else: another cookie's, tampered, or another secret's. */
|
|
46
|
+
async function unseal(keys, sealed, name) {
|
|
47
|
+
if (!sealed) return void 0;
|
|
48
|
+
const [version, iv, data, ...rest] = sealed.split(".");
|
|
49
|
+
if (version !== VERSION || !iv || !data || rest.length > 0) return void 0;
|
|
50
|
+
const candidates = Array.isArray(keys) ? keys : [keys];
|
|
51
|
+
for (const key of candidates) try {
|
|
52
|
+
const opened = await crypto.subtle.decrypt({
|
|
53
|
+
name: "AES-GCM",
|
|
54
|
+
iv: fromBase64url(iv),
|
|
55
|
+
additionalData: encoder.encode(name)
|
|
56
|
+
}, key, fromBase64url(data));
|
|
57
|
+
return decoder.decode(opened);
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
/** A random id: 128 bits, base64url. */
|
|
61
|
+
function randomId() {
|
|
62
|
+
return base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
|
|
63
|
+
}
|
|
64
|
+
function base64url(bytes) {
|
|
65
|
+
let binary = "";
|
|
66
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
67
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
68
|
+
}
|
|
69
|
+
function fromBase64url(text) {
|
|
70
|
+
const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
|
|
71
|
+
const bytes = new Uint8Array(binary.length);
|
|
72
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/config.ts
|
|
77
|
+
/** Milliseconds a request to Synq may take before it's given up: shorter than any lock a store holds for a refresh. */
|
|
78
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
79
|
+
/** Variables that say the app may run on more than one server process: serverless functions, replicas. */
|
|
80
|
+
const MULTI_PROCESS = [
|
|
81
|
+
"VERCEL",
|
|
82
|
+
"AWS_LAMBDA_FUNCTION_NAME",
|
|
83
|
+
"NETLIFY",
|
|
84
|
+
"K_SERVICE",
|
|
85
|
+
"KUBERNETES_SERVICE_HOST"
|
|
86
|
+
];
|
|
87
|
+
/** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
|
|
88
|
+
function resolveConfig(options = {}, env = processEnv()) {
|
|
89
|
+
const issuer = required(options.issuer ?? env.SYNQ_ISSUER, "SYNQ_ISSUER").replace(/\/+$/, "");
|
|
90
|
+
checkUrl(issuer, "SYNQ_ISSUER");
|
|
91
|
+
const clientId = required(options.clientId ?? env.SYNQ_CLIENT_ID, "SYNQ_CLIENT_ID");
|
|
92
|
+
const clientSecret = required(options.clientSecret ?? env.SYNQ_CLIENT_SECRET, "SYNQ_CLIENT_SECRET");
|
|
93
|
+
const appUrl = checkUrl(required(options.appUrl ?? env.SYNQ_APP_URL, "SYNQ_APP_URL"), "SYNQ_APP_URL");
|
|
94
|
+
const sessionSecret = required(options.sessionSecret ?? env.SYNQ_SESSION_SECRET, "SYNQ_SESSION_SECRET");
|
|
95
|
+
const previousSessionSecrets = options.previousSessionSecrets ?? (env.SYNQ_SESSION_SECRET_PREVIOUS ?? "").split(",").map((secret) => secret.trim()).filter(Boolean);
|
|
96
|
+
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\``);
|
|
97
|
+
const hint = MULTI_PROCESS.find((name) => env[name]);
|
|
98
|
+
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`);
|
|
99
|
+
const basePath = normalizePath(options.basePath ?? "/api/auth");
|
|
100
|
+
const accountApi = options.account === false ? void 0 : `${issuer}/me`;
|
|
101
|
+
const primary = options.resource ?? accountApi;
|
|
102
|
+
const scopes = /* @__PURE__ */ new Set([
|
|
103
|
+
"openid",
|
|
104
|
+
"profile",
|
|
105
|
+
"email",
|
|
106
|
+
"offline_access"
|
|
107
|
+
]);
|
|
108
|
+
if (accountApi) for (const scope of ["me:read", "me:write"]) scopes.add(scope);
|
|
109
|
+
for (const scope of (options.scope ?? "").split(/\s+/)) if (scope) scopes.add(scope);
|
|
110
|
+
const origin = appUrl.origin;
|
|
111
|
+
return {
|
|
112
|
+
issuer,
|
|
113
|
+
clientId,
|
|
114
|
+
clientSecret,
|
|
115
|
+
appUrl,
|
|
116
|
+
sessionSecret,
|
|
117
|
+
previousSessionSecrets,
|
|
118
|
+
basePath,
|
|
119
|
+
primary,
|
|
120
|
+
accountApi,
|
|
121
|
+
scope: [...scopes].join(" "),
|
|
122
|
+
redirectUri: `${origin}${basePath}/callback`,
|
|
123
|
+
postLogoutRedirectUri: options.postLogoutRedirectUri ?? `${origin}/`,
|
|
124
|
+
store: options.store,
|
|
125
|
+
sessionMaxAge: options.sessionMaxAge ?? 2592e3,
|
|
126
|
+
refreshLeewaySeconds: options.refreshLeewaySeconds ?? 30,
|
|
127
|
+
plugins: options.plugins ?? [],
|
|
128
|
+
fetch: options.fetch,
|
|
129
|
+
clientAddress: options.clientAddress ?? ((request) => request.headers.get("x-real-ip")?.trim() || void 0),
|
|
130
|
+
secure: appUrl.protocol === "https:"
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function processEnv() {
|
|
134
|
+
return globalThis.process?.env ?? {};
|
|
135
|
+
}
|
|
136
|
+
function required(value, name) {
|
|
137
|
+
if (!value) throw new SynqError("invalid_options", `${name} is missing`);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
/** An https URL, or http on this machine only. */
|
|
141
|
+
function checkUrl(value, name) {
|
|
142
|
+
let url;
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(value);
|
|
145
|
+
} catch {
|
|
146
|
+
throw new SynqError("invalid_options", `${name} isn't a URL`);
|
|
147
|
+
}
|
|
148
|
+
const loopback = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
149
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new SynqError("invalid_options", `${name} must be https (http only on this machine)`);
|
|
150
|
+
return url;
|
|
151
|
+
}
|
|
152
|
+
/** The route mount's path, `/api/auth` style; throws `invalid_options` for anything else. */
|
|
153
|
+
function normalizePath(path) {
|
|
154
|
+
const trimmed = `/${path.replace(/^\/+|\/+$/g, "")}`;
|
|
155
|
+
if (!/^\/[A-Za-z0-9._~\-/]*$/.test(trimmed) || trimmed.includes("//")) throw new SynqError("invalid_options", "basePath must be a plain path, such as /api/auth");
|
|
156
|
+
return trimmed;
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/cookies.ts
|
|
160
|
+
/** Characters per chunk, under the 4096 bytes a browser keeps for one cookie with its attributes. */
|
|
161
|
+
const CHUNK_SIZE = 3800;
|
|
162
|
+
/** A cookie's full name: `__Host-<base>` over https, `<base>` on a plain-http stack on this machine. */
|
|
163
|
+
function cookieName(base, secure) {
|
|
164
|
+
return secure ? `__Host-${base}` : base;
|
|
165
|
+
}
|
|
166
|
+
/** A value kept under `name`, joined from its chunks; undefined when there is none. */
|
|
167
|
+
function readChunked(jar, name) {
|
|
168
|
+
const whole = jar.get(name);
|
|
169
|
+
if (whole !== void 0) return whole;
|
|
170
|
+
let value = "";
|
|
171
|
+
for (let i = 0;; i++) {
|
|
172
|
+
const chunk = jar.get(`${name}.${i}`);
|
|
173
|
+
if (chunk === void 0) return i === 0 ? void 0 : value;
|
|
174
|
+
value += chunk;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Keeps `value` under `name`, in chunks when it needs them, and deletes chunks a longer value left behind. */
|
|
178
|
+
function writeChunked(jar, name, value, options) {
|
|
179
|
+
const existing = chunkNames(jar, name);
|
|
180
|
+
if (value.length <= 3800) {
|
|
181
|
+
jar.set(name, value, options);
|
|
182
|
+
for (const old of existing) if (old !== name) jar.delete(old, options);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const written = /* @__PURE__ */ new Set();
|
|
186
|
+
for (let i = 0; i * CHUNK_SIZE < value.length; i++) {
|
|
187
|
+
const chunk = `${name}.${i}`;
|
|
188
|
+
jar.set(chunk, value.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE), options);
|
|
189
|
+
written.add(chunk);
|
|
190
|
+
}
|
|
191
|
+
for (const old of existing) if (!written.has(old)) jar.delete(old, options);
|
|
192
|
+
}
|
|
193
|
+
/** Deletes `name` and every chunk of it. */
|
|
194
|
+
function deleteChunked(jar, name, options) {
|
|
195
|
+
for (const old of chunkNames(jar, name)) jar.delete(old, options);
|
|
196
|
+
}
|
|
197
|
+
function chunkNames(jar, name) {
|
|
198
|
+
const pattern = new RegExp(`^${escapeRegExp(name)}(\\.\\d+)?$`);
|
|
199
|
+
return [...jar.names()].filter((candidate) => pattern.test(candidate));
|
|
200
|
+
}
|
|
201
|
+
/** The Set-Cookie header for a cookie: `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` over https. */
|
|
202
|
+
function setCookieHeader(name, value, options) {
|
|
203
|
+
const parts = [
|
|
204
|
+
`${name}=${value}`,
|
|
205
|
+
"Path=/",
|
|
206
|
+
"HttpOnly",
|
|
207
|
+
"SameSite=Lax"
|
|
208
|
+
];
|
|
209
|
+
if (options.maxAge !== void 0) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
210
|
+
if (options.secure) parts.push("Secure");
|
|
211
|
+
return parts.join("; ");
|
|
212
|
+
}
|
|
213
|
+
/** The cookies a `Cookie` header carries. */
|
|
214
|
+
function parseCookieHeader(header) {
|
|
215
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
216
|
+
for (const pair of (header ?? "").split(";")) {
|
|
217
|
+
const at = pair.indexOf("=");
|
|
218
|
+
if (at <= 0) continue;
|
|
219
|
+
const name = pair.slice(0, at).trim();
|
|
220
|
+
if (!cookies.has(name)) cookies.set(name, pair.slice(at + 1).trim());
|
|
221
|
+
}
|
|
222
|
+
return cookies;
|
|
223
|
+
}
|
|
224
|
+
/** A jar over a request's cookies that collects the Set-Cookie headers its response sends. */
|
|
225
|
+
function requestJar(request) {
|
|
226
|
+
const cookies = parseCookieHeader(request.headers.get("cookie"));
|
|
227
|
+
const out = /* @__PURE__ */ new Map();
|
|
228
|
+
return {
|
|
229
|
+
get: (name) => cookies.get(name),
|
|
230
|
+
names: () => cookies.keys(),
|
|
231
|
+
set(name, value, options) {
|
|
232
|
+
cookies.set(name, value);
|
|
233
|
+
out.set(name, setCookieHeader(name, value, options));
|
|
234
|
+
},
|
|
235
|
+
delete(name, options) {
|
|
236
|
+
cookies.delete(name);
|
|
237
|
+
out.set(name, setCookieHeader(name, "", {
|
|
238
|
+
...options,
|
|
239
|
+
maxAge: 0
|
|
240
|
+
}));
|
|
241
|
+
},
|
|
242
|
+
headers: () => [...out.values()],
|
|
243
|
+
/** The `Cookie` header the request carries now, changes made: for what runs after Next's proxy. */
|
|
244
|
+
cookieHeader: () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function escapeRegExp(text) {
|
|
248
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/plugins.ts
|
|
252
|
+
const PLUGIN_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
253
|
+
const ROUTE_KEY = /^(GET|POST|PATCH|DELETE) \/[A-Za-z0-9._~\-/]*$/;
|
|
254
|
+
/** Declares a server plugin, checking its id and routes once, when the module loads. */
|
|
255
|
+
function defineSynqPlugin(plugin) {
|
|
256
|
+
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}`);
|
|
257
|
+
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}`);
|
|
258
|
+
return plugin;
|
|
259
|
+
}
|
|
260
|
+
/** The route mount's own paths, which no plugin can take. */
|
|
261
|
+
const RESERVED = /* @__PURE__ */ new Set([
|
|
262
|
+
"sign-in",
|
|
263
|
+
"callback",
|
|
264
|
+
"sign-out",
|
|
265
|
+
"session",
|
|
266
|
+
"account",
|
|
267
|
+
"link"
|
|
268
|
+
]);
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/session-name.ts
|
|
271
|
+
const SESSION_COOKIE = "synq.session";
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/store.ts
|
|
274
|
+
/**
|
|
275
|
+
* A store in this process's memory: for tests and a single server process,
|
|
276
|
+
* where the default cookie does as well.
|
|
277
|
+
*/
|
|
278
|
+
function memorySessionStore() {
|
|
279
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
280
|
+
const locks = /* @__PURE__ */ new Map();
|
|
281
|
+
return {
|
|
282
|
+
get(id) {
|
|
283
|
+
const entry = sessions.get(id);
|
|
284
|
+
if (!entry || entry.expires <= Date.now()) {
|
|
285
|
+
sessions.delete(id);
|
|
286
|
+
return Promise.resolve(null);
|
|
287
|
+
}
|
|
288
|
+
return Promise.resolve({ ...entry.values });
|
|
289
|
+
},
|
|
290
|
+
set(id, values, maxAgeSeconds) {
|
|
291
|
+
sessions.set(id, {
|
|
292
|
+
values: { ...values },
|
|
293
|
+
expires: Date.now() + maxAgeSeconds * 1e3
|
|
294
|
+
});
|
|
295
|
+
return Promise.resolve();
|
|
296
|
+
},
|
|
297
|
+
delete(id) {
|
|
298
|
+
sessions.delete(id);
|
|
299
|
+
return Promise.resolve();
|
|
300
|
+
},
|
|
301
|
+
lock: (id, fn) => chain(locks, id, fn)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Sessions in a key-value store every server process shares, such as Redis
|
|
306
|
+
* or Valkey: the store an app on several processes (replicas, serverless
|
|
307
|
+
* functions) needs. Its lock is `SET … NX PX` with a token, polled until
|
|
308
|
+
* free, and released only by its holder; it expires on its own, so a dead
|
|
309
|
+
* process never holds a session for ever.
|
|
310
|
+
*
|
|
311
|
+
* ```ts
|
|
312
|
+
* keyValueSessionStore({
|
|
313
|
+
* get: (key) => redis.get(key),
|
|
314
|
+
* set: async (key, value, { ttlMs, onlyIfAbsent }) =>
|
|
315
|
+
* (await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
|
|
316
|
+
* delete: async (key) => void (await redis.del(key)),
|
|
317
|
+
* })
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
function keyValueSessionStore(kv, options = {}) {
|
|
321
|
+
const prefix = options.prefix ?? "synq:";
|
|
322
|
+
const lockTtlMs = options.lockTtlMs ?? 3e4;
|
|
323
|
+
const lockWaitMs = options.lockWaitMs ?? 35e3;
|
|
324
|
+
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`);
|
|
325
|
+
const sessionKey = (id) => `${prefix}session:${id}`;
|
|
326
|
+
const lockKey = (id) => `${prefix}lock:${id}`;
|
|
327
|
+
return {
|
|
328
|
+
async get(id) {
|
|
329
|
+
const raw = await kv.get(sessionKey(id));
|
|
330
|
+
if (!raw) return null;
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(raw);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
async set(id, values, maxAgeSeconds) {
|
|
338
|
+
await kv.set(sessionKey(id), JSON.stringify(values), { ttlMs: maxAgeSeconds * 1e3 });
|
|
339
|
+
},
|
|
340
|
+
async delete(id) {
|
|
341
|
+
await kv.delete(sessionKey(id));
|
|
342
|
+
},
|
|
343
|
+
async lock(id, fn) {
|
|
344
|
+
const key = lockKey(id);
|
|
345
|
+
const token = crypto.randomUUID();
|
|
346
|
+
const deadline = Date.now() + lockWaitMs;
|
|
347
|
+
let wait = 25;
|
|
348
|
+
while (!await kv.set(key, token, {
|
|
349
|
+
ttlMs: lockTtlMs,
|
|
350
|
+
onlyIfAbsent: true
|
|
351
|
+
})) {
|
|
352
|
+
if (Date.now() >= deadline) throw new SynqError("lock_timeout", "Another request held this session's lock too long");
|
|
353
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
354
|
+
wait = Math.min(wait * 2, 250);
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
return await fn();
|
|
358
|
+
} finally {
|
|
359
|
+
if (kv.deleteIfEquals) await kv.deleteIfEquals(key, token);
|
|
360
|
+
else if (await kv.get(key) === token) await kv.delete(key);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Runs `fn` after every earlier holder of `name` in `locks`, failed or not. */
|
|
366
|
+
function chain(locks, name, fn) {
|
|
367
|
+
const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
|
|
368
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
369
|
+
locks.set(name, settled);
|
|
370
|
+
settled.then(() => {
|
|
371
|
+
if (locks.get(name) === settled) locks.delete(name);
|
|
372
|
+
});
|
|
373
|
+
return run;
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/session.ts
|
|
377
|
+
/**
|
|
378
|
+
* @synqauth/js's keys in its store (client.ts), which the adapter maps onto
|
|
379
|
+
* a session's values: the session per API, and the refresh token.
|
|
380
|
+
*/
|
|
381
|
+
const JS_SESSION_KEY = "synq.session";
|
|
382
|
+
const JS_REFRESH_KEY = "synq.refresh";
|
|
383
|
+
const REFRESH = "refresh";
|
|
384
|
+
const SHARED = Symbol.for("@synqauth/nextjs sessions");
|
|
385
|
+
const { newest, locks } = globalThis[SHARED] ??= {
|
|
386
|
+
newest: /* @__PURE__ */ new Map(),
|
|
387
|
+
locks: /* @__PURE__ */ new Map()
|
|
388
|
+
};
|
|
389
|
+
const OVERLAY_MAX = 1e4;
|
|
390
|
+
const OVERLAY_TTL_MS = 18e5;
|
|
391
|
+
var SessionHandle = class {
|
|
392
|
+
id;
|
|
393
|
+
created;
|
|
394
|
+
#backend;
|
|
395
|
+
/** Whether the cookie must be written: a new session, or values newer than the cookie's. */
|
|
396
|
+
#cookieRev;
|
|
397
|
+
#fresh;
|
|
398
|
+
constructor(id, created, backend, cookieRev, fresh) {
|
|
399
|
+
this.id = id;
|
|
400
|
+
this.created = created;
|
|
401
|
+
this.#backend = backend;
|
|
402
|
+
this.#cookieRev = cookieRev;
|
|
403
|
+
this.#fresh = fresh;
|
|
404
|
+
}
|
|
405
|
+
read() {
|
|
406
|
+
return this.#backend.read();
|
|
407
|
+
}
|
|
408
|
+
/** `@synqauth/js`'s store for this session's token for `resource`; `primary` is the API whose session holds the ID token. */
|
|
409
|
+
storage(resource, primary) {
|
|
410
|
+
const own = tokenKey(resource);
|
|
411
|
+
const main = tokenKey(primary);
|
|
412
|
+
const backend = this.#backend;
|
|
413
|
+
return {
|
|
414
|
+
holdsRefreshTokens: true,
|
|
415
|
+
async get(key) {
|
|
416
|
+
const values = await backend.read();
|
|
417
|
+
if (key === JS_REFRESH_KEY) return values[REFRESH];
|
|
418
|
+
if (key !== JS_SESSION_KEY) return void 0;
|
|
419
|
+
const mine = values[own];
|
|
420
|
+
const first = values[main];
|
|
421
|
+
if (own === main) return mine;
|
|
422
|
+
if (!first) return mine;
|
|
423
|
+
const { idToken, claims } = JSON.parse(first);
|
|
424
|
+
if (mine) return JSON.stringify({
|
|
425
|
+
...JSON.parse(mine),
|
|
426
|
+
idToken,
|
|
427
|
+
claims
|
|
428
|
+
});
|
|
429
|
+
return JSON.stringify({
|
|
430
|
+
accessToken: "",
|
|
431
|
+
tokenType: "Bearer",
|
|
432
|
+
expiresAt: 0,
|
|
433
|
+
idToken,
|
|
434
|
+
claims
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
async set(key, value) {
|
|
438
|
+
if (key === JS_REFRESH_KEY) return backend.write((values) => void (values[REFRESH] = value));
|
|
439
|
+
if (key !== JS_SESSION_KEY) return;
|
|
440
|
+
let kept = value;
|
|
441
|
+
if (own !== main) {
|
|
442
|
+
const rest = JSON.parse(value);
|
|
443
|
+
delete rest.idToken;
|
|
444
|
+
delete rest.claims;
|
|
445
|
+
kept = JSON.stringify(rest);
|
|
446
|
+
}
|
|
447
|
+
await backend.write((values) => void (values[own] = kept));
|
|
448
|
+
},
|
|
449
|
+
async remove(key) {
|
|
450
|
+
if (key === JS_REFRESH_KEY) return backend.write((values) => void delete values[REFRESH]);
|
|
451
|
+
if (key !== JS_SESSION_KEY) return;
|
|
452
|
+
await backend.write((values) => {
|
|
453
|
+
if (own === main) for (const name of Object.keys(values)) delete values[name];
|
|
454
|
+
else delete values[own];
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
lock: (_name, fn) => backend.lock(fn)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/** Writes the session's cookie when it's new or this process holds newer values than it. */
|
|
461
|
+
async commit(jar, keys, secure, maxAge, store) {
|
|
462
|
+
const name = cookieName(SESSION_COOKIE, secure);
|
|
463
|
+
const remaining = Math.max(0, this.created + maxAge - now$1());
|
|
464
|
+
if (store) {
|
|
465
|
+
if (!this.#fresh) return;
|
|
466
|
+
const sealed = {
|
|
467
|
+
v: 1,
|
|
468
|
+
id: this.id,
|
|
469
|
+
created: this.created
|
|
470
|
+
};
|
|
471
|
+
writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
|
|
472
|
+
secure,
|
|
473
|
+
maxAge: remaining
|
|
474
|
+
});
|
|
475
|
+
this.#fresh = false;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const current = newest.get(this.id);
|
|
479
|
+
const rev = current?.rev ?? 0;
|
|
480
|
+
if (!this.#fresh && rev <= this.#cookieRev) return;
|
|
481
|
+
const sealed = {
|
|
482
|
+
v: 1,
|
|
483
|
+
id: this.id,
|
|
484
|
+
created: this.created,
|
|
485
|
+
rev,
|
|
486
|
+
values: current?.values ?? {}
|
|
487
|
+
};
|
|
488
|
+
writeChunked(jar, name, await seal(keys.seal, JSON.stringify(sealed), name), {
|
|
489
|
+
secure,
|
|
490
|
+
maxAge: remaining
|
|
491
|
+
});
|
|
492
|
+
this.#cookieRev = rev;
|
|
493
|
+
this.#fresh = false;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
/** The session the request's cookie names, or null: none, unreadable, past its age, or gone from the store. */
|
|
497
|
+
async function loadSession(jar, keys, secure, maxAge, store) {
|
|
498
|
+
const name = cookieName(SESSION_COOKIE, secure);
|
|
499
|
+
const opened = await unseal(keys.open, readChunked(jar, name), name);
|
|
500
|
+
if (!opened) return null;
|
|
501
|
+
let sealed;
|
|
502
|
+
try {
|
|
503
|
+
sealed = JSON.parse(opened);
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
if (sealed.v !== 1 || typeof sealed.id !== "string" || sealed.created + maxAge <= now$1()) return null;
|
|
508
|
+
if (store) {
|
|
509
|
+
if (!await store.get(sealed.id)) return null;
|
|
510
|
+
return new SessionHandle(sealed.id, sealed.created, storeBackend(store, sealed.id, sealed.created, maxAge), 0, false);
|
|
511
|
+
}
|
|
512
|
+
const rev = sealed.rev ?? 0;
|
|
513
|
+
remember(sealed.id, rev, sealed.values ?? {});
|
|
514
|
+
return new SessionHandle(sealed.id, sealed.created, cookieBackend(sealed.id), rev, false);
|
|
515
|
+
}
|
|
516
|
+
/** A new, empty session: what a sign-in fills. */
|
|
517
|
+
function newSession(maxAge, store) {
|
|
518
|
+
const id = randomId();
|
|
519
|
+
const created = now$1();
|
|
520
|
+
if (store) return new SessionHandle(id, created, storeBackend(store, id, created, maxAge), 0, true);
|
|
521
|
+
remember(id, 0, {});
|
|
522
|
+
return new SessionHandle(id, created, cookieBackend(id), -1, true);
|
|
523
|
+
}
|
|
524
|
+
/** Ends a session: its cookie, its stored values, and what this process remembers of it. */
|
|
525
|
+
async function endSession(jar, handle, secure, store) {
|
|
526
|
+
if (handle) {
|
|
527
|
+
newest.delete(handle.id);
|
|
528
|
+
if (store) await store.delete(handle.id);
|
|
529
|
+
}
|
|
530
|
+
deleteChunked(jar, cookieName(SESSION_COOKIE, secure), { secure });
|
|
531
|
+
}
|
|
532
|
+
/** The key a session keeps `resource`'s token under. */
|
|
533
|
+
function tokenKey(resource) {
|
|
534
|
+
return `token:${resource ?? ""}`;
|
|
535
|
+
}
|
|
536
|
+
function cookieBackend(id) {
|
|
537
|
+
return {
|
|
538
|
+
read() {
|
|
539
|
+
const entry = newest.get(id);
|
|
540
|
+
return Promise.resolve({ ...entry?.values ?? {} });
|
|
541
|
+
},
|
|
542
|
+
write(mutate) {
|
|
543
|
+
const entry = newest.get(id);
|
|
544
|
+
const values = { ...entry?.values ?? {} };
|
|
545
|
+
mutate(values);
|
|
546
|
+
remember(id, (entry?.rev ?? 0) + 1, values);
|
|
547
|
+
return Promise.resolve();
|
|
548
|
+
},
|
|
549
|
+
lock: (fn) => chain(locks, id, fn)
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function storeBackend(store, id, created, maxAge) {
|
|
553
|
+
return {
|
|
554
|
+
async read() {
|
|
555
|
+
return await store.get(id) ?? {};
|
|
556
|
+
},
|
|
557
|
+
async write(mutate) {
|
|
558
|
+
const values = await store.get(id) ?? {};
|
|
559
|
+
mutate(values);
|
|
560
|
+
await store.set(id, values, Math.max(1, created + maxAge - now$1()));
|
|
561
|
+
},
|
|
562
|
+
lock: (fn) => store.lock(id, fn)
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/** Keeps the newer of what's remembered and `rev`'s values. */
|
|
566
|
+
function remember(id, rev, values) {
|
|
567
|
+
const entry = newest.get(id);
|
|
568
|
+
const time = Date.now();
|
|
569
|
+
if (entry && entry.rev >= rev) {
|
|
570
|
+
entry.seen = time;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
newest.delete(id);
|
|
574
|
+
newest.set(id, {
|
|
575
|
+
rev,
|
|
576
|
+
values,
|
|
577
|
+
seen: time
|
|
578
|
+
});
|
|
579
|
+
if (newest.size > OVERLAY_MAX) for (const [old, kept] of newest) {
|
|
580
|
+
if (newest.size <= OVERLAY_MAX && time - kept.seen < OVERLAY_TTL_MS) break;
|
|
581
|
+
newest.delete(old);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function now$1() {
|
|
585
|
+
return Math.floor(Date.now() / 1e3);
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/core.ts
|
|
589
|
+
const PROMPTS = /* @__PURE__ */ new Set([
|
|
590
|
+
"login",
|
|
591
|
+
"none",
|
|
592
|
+
"consent",
|
|
593
|
+
"select_account"
|
|
594
|
+
]);
|
|
595
|
+
const INTENTS = /* @__PURE__ */ new Set(["add-passkey"]);
|
|
596
|
+
/** A sign-in method's name, as `synq_method` and the account API's link requests take it. */
|
|
597
|
+
const METHOD = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
598
|
+
const TRANSACTION_SECONDS = 3600;
|
|
599
|
+
const TRANSACTION_PREFIX = "synq.tx.";
|
|
600
|
+
const ACCOUNT_BODY_LIMIT = 65536;
|
|
601
|
+
const LINK_BODY_LIMIT = 4096;
|
|
602
|
+
/** A step of an own-UI sign-in: a passkey's assertion is the most it carries. */
|
|
603
|
+
const FLOW_BODY_LIMIT = 16384;
|
|
604
|
+
/** Where an own-UI sign-in's flow is kept, sealed: one a browser at a time. */
|
|
605
|
+
const FLOW_KEY = `${TRANSACTION_PREFIX}flow`;
|
|
606
|
+
/** `synq_siws` as @synqauth/solana's `encodeSiws` writes it: base64url, well under the body's limit. */
|
|
607
|
+
const SIWS = /^[A-Za-z0-9_-]{1,3072}$/;
|
|
608
|
+
/** Claims of the ID token that say nothing about the person. */
|
|
609
|
+
const PROTOCOL_CLAIMS = /* @__PURE__ */ new Set([
|
|
610
|
+
"iss",
|
|
611
|
+
"aud",
|
|
612
|
+
"azp",
|
|
613
|
+
"exp",
|
|
614
|
+
"iat",
|
|
615
|
+
"nbf",
|
|
616
|
+
"nonce",
|
|
617
|
+
"at_hash",
|
|
618
|
+
"c_hash",
|
|
619
|
+
"sid",
|
|
620
|
+
"jti"
|
|
621
|
+
]);
|
|
622
|
+
/**
|
|
623
|
+
* The framework-neutral core `synqAuth()` runs on, over any cookie jar: for
|
|
624
|
+
* a server that answers the same routes without Next. Most apps use
|
|
625
|
+
* `synqAuth()` instead.
|
|
626
|
+
*/
|
|
627
|
+
function createSynqCore(config) {
|
|
628
|
+
/** Whether the missing end-user address was logged already: once is enough. */
|
|
629
|
+
let warnedAboutAddress = false;
|
|
630
|
+
const keysFor = () => sealingKeys(config.sessionSecret, config.previousSessionSecrets);
|
|
631
|
+
/** Every request to Synq: never cached, and given up after REQUEST_TIMEOUT_MS. */
|
|
632
|
+
const upstream = (input, init = {}) => {
|
|
633
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
634
|
+
return (config.fetch ?? fetch)(input, {
|
|
635
|
+
...init,
|
|
636
|
+
cache: "no-store",
|
|
637
|
+
signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout
|
|
638
|
+
});
|
|
639
|
+
};
|
|
640
|
+
function client(storage, resource, transactions) {
|
|
641
|
+
return createSynqClient({
|
|
642
|
+
issuer: config.issuer,
|
|
643
|
+
clientId: config.clientId,
|
|
644
|
+
clientSecret: config.clientSecret,
|
|
645
|
+
redirectUri: config.redirectUri,
|
|
646
|
+
postLogoutRedirectUri: config.postLogoutRedirectUri,
|
|
647
|
+
scope: config.scope,
|
|
648
|
+
resource,
|
|
649
|
+
storage,
|
|
650
|
+
transactionStorage: transactions ?? memoryStorage(),
|
|
651
|
+
fetch: upstream,
|
|
652
|
+
refreshLeewaySeconds: config.refreshLeewaySeconds
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
async function load(jar) {
|
|
656
|
+
return loadSession(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
|
|
657
|
+
}
|
|
658
|
+
async function commit(jar, handle) {
|
|
659
|
+
await handle.commit(jar, await keysFor(), config.secure, config.sessionMaxAge, config.store);
|
|
660
|
+
}
|
|
661
|
+
/** Who signed in (the ID token's `sub`), and in which session at the issuer (its `sid`, when Synq sent one). */
|
|
662
|
+
async function issuerSessionOf(handle) {
|
|
663
|
+
const raw = (await handle.read())[tokenKey(config.primary)];
|
|
664
|
+
if (!raw) return void 0;
|
|
665
|
+
const { claims } = JSON.parse(raw);
|
|
666
|
+
if (typeof claims?.sub !== "string") return void 0;
|
|
667
|
+
return {
|
|
668
|
+
sub: claims.sub,
|
|
669
|
+
sid: typeof claims.sid === "string" ? claims.sid : void 0
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** The person the session's main token was issued with, while the session can still give a token. */
|
|
673
|
+
async function userOf(handle) {
|
|
674
|
+
if (!handle) return {
|
|
675
|
+
user: null,
|
|
676
|
+
expiresAt: null
|
|
677
|
+
};
|
|
678
|
+
const values = await handle.read();
|
|
679
|
+
const raw = values[tokenKey(config.primary)];
|
|
680
|
+
if (!raw) return {
|
|
681
|
+
user: null,
|
|
682
|
+
expiresAt: null
|
|
683
|
+
};
|
|
684
|
+
const stored = JSON.parse(raw);
|
|
685
|
+
const expiresAt = stored.expiresAt ?? null;
|
|
686
|
+
if (!values.refresh && expiresAt !== null && expiresAt <= now()) return {
|
|
687
|
+
user: null,
|
|
688
|
+
expiresAt: null
|
|
689
|
+
};
|
|
690
|
+
if (!stored.claims || typeof stored.claims.sub !== "string") return {
|
|
691
|
+
user: null,
|
|
692
|
+
expiresAt
|
|
693
|
+
};
|
|
694
|
+
const user = {};
|
|
695
|
+
for (const [name, value] of Object.entries(stored.claims)) if (!PROTOCOL_CLAIMS.has(name)) user[name] = value;
|
|
696
|
+
return {
|
|
697
|
+
user,
|
|
698
|
+
expiresAt
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
async function getAccessToken(jar, options = {}) {
|
|
702
|
+
const resource = options.resource ?? config.primary;
|
|
703
|
+
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`);
|
|
704
|
+
const handle = await load(jar);
|
|
705
|
+
if (!handle) throw new SynqError("not_signed_in", "Nobody is signed in");
|
|
706
|
+
if (!config.store && jar.writable?.() === false) {
|
|
707
|
+
const raw = (await handle.read())[tokenKey(resource)];
|
|
708
|
+
const stored = raw ? JSON.parse(raw) : {};
|
|
709
|
+
if (stored.accessToken && (stored.expiresAt === void 0 || stored.expiresAt > now())) return stored.accessToken;
|
|
710
|
+
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");
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
const token = await client(handle.storage(resource, config.primary), resource).getAccessToken({ minValiditySeconds: options.minValiditySeconds });
|
|
714
|
+
await commit(jar, handle);
|
|
715
|
+
return token;
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) await endSession(jar, handle, config.secure, config.store);
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/** A store for one sign-in's transaction: a sealed cookie named after its state, with where to go afterwards. */
|
|
722
|
+
async function transactions(jar, returnTo) {
|
|
723
|
+
const keys = await keysFor();
|
|
724
|
+
const options = {
|
|
725
|
+
secure: config.secure,
|
|
726
|
+
maxAge: TRANSACTION_SECONDS
|
|
727
|
+
};
|
|
728
|
+
const found = {};
|
|
729
|
+
return {
|
|
730
|
+
storage: {
|
|
731
|
+
async get(name) {
|
|
732
|
+
const cookie = cookieName(name, config.secure);
|
|
733
|
+
const opened = await unseal(keys.open, jar.get(cookie), cookie);
|
|
734
|
+
if (!opened) return void 0;
|
|
735
|
+
const { v, r } = JSON.parse(opened);
|
|
736
|
+
found.returnTo = r;
|
|
737
|
+
return v;
|
|
738
|
+
},
|
|
739
|
+
async set(name, value) {
|
|
740
|
+
await dropOldTransactions(jar, keys.open);
|
|
741
|
+
const cookie = cookieName(name, config.secure);
|
|
742
|
+
const sealed = JSON.stringify({
|
|
743
|
+
v: value,
|
|
744
|
+
r: returnTo,
|
|
745
|
+
t: Date.now()
|
|
746
|
+
});
|
|
747
|
+
jar.set(cookie, await seal(keys.seal, sealed, cookie), options);
|
|
748
|
+
},
|
|
749
|
+
remove(name) {
|
|
750
|
+
jar.delete(cookieName(name, config.secure), options);
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
found
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/** Keeps a browser's sign-ins under way to MAX_TRANSACTIONS, the newest, before it starts another. */
|
|
757
|
+
async function dropOldTransactions(jar, keys) {
|
|
758
|
+
const prefix = cookieName(TRANSACTION_PREFIX, config.secure);
|
|
759
|
+
const started = [];
|
|
760
|
+
for (const name of [...jar.names()]) {
|
|
761
|
+
if (!name.startsWith(prefix)) continue;
|
|
762
|
+
const opened = await unseal(keys, jar.get(name), name);
|
|
763
|
+
let at;
|
|
764
|
+
try {
|
|
765
|
+
at = opened ? JSON.parse(opened).t ?? 0 : 0;
|
|
766
|
+
} catch {
|
|
767
|
+
at = 0;
|
|
768
|
+
}
|
|
769
|
+
started.push({
|
|
770
|
+
name,
|
|
771
|
+
at,
|
|
772
|
+
order: started.length
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
started.sort((a, b) => b.at - a.at || b.order - a.order);
|
|
776
|
+
for (const { name } of started.slice(4)) jar.delete(name, { secure: config.secure });
|
|
777
|
+
}
|
|
778
|
+
async function signIn(request, jar) {
|
|
779
|
+
if (isPrefetch(request)) return new Response(null, { status: 204 });
|
|
780
|
+
const params = new URL(request.url).searchParams;
|
|
781
|
+
const returnTo = safeReturnTo(params.get("returnTo"), config.appUrl);
|
|
782
|
+
const prompt = params.get("prompt") ?? void 0;
|
|
783
|
+
const maxAge = params.get("max_age");
|
|
784
|
+
const intent = params.get("intent") ?? void 0;
|
|
785
|
+
if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent or select_account");
|
|
786
|
+
if (maxAge !== null && !/^\d{1,9}$/.test(maxAge)) return problem(400, "invalid_request", "max_age is a number of seconds");
|
|
787
|
+
if (intent && !INTENTS.has(intent)) return problem(400, "invalid_request", "intent is add-passkey (POST link adds a way to sign in)");
|
|
788
|
+
const tx = await transactions(jar, returnTo);
|
|
789
|
+
return redirect((await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
790
|
+
method: params.get("method") ?? void 0,
|
|
791
|
+
loginHint: params.get("login_hint") ?? void 0,
|
|
792
|
+
prompt: prompt ?? (intent ? "login" : void 0),
|
|
793
|
+
maxAge: maxAge === null ? void 0 : Number(maxAge),
|
|
794
|
+
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
795
|
+
params: intent ? { synq_intent: intent } : void 0
|
|
796
|
+
})).href, 302);
|
|
797
|
+
}
|
|
798
|
+
async function callback(request, jar) {
|
|
799
|
+
const tx = await transactions(jar);
|
|
800
|
+
const handle = newSession(config.sessionMaxAge, config.store);
|
|
801
|
+
try {
|
|
802
|
+
await client(handle.storage(config.primary, config.primary), config.primary, tx.storage).handleCallback(request.url);
|
|
803
|
+
} catch (error) {
|
|
804
|
+
const code = isSynqError(error) ? error.code : "server_error";
|
|
805
|
+
return redirect(withError(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), code), 303);
|
|
806
|
+
}
|
|
807
|
+
await startSession(jar, handle);
|
|
808
|
+
return redirect(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), 303);
|
|
809
|
+
}
|
|
810
|
+
async function signOut(request, jar) {
|
|
811
|
+
const handle = await load(jar);
|
|
812
|
+
let url = null;
|
|
813
|
+
if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut();
|
|
814
|
+
await endSession(jar, handle, config.secure, config.store);
|
|
815
|
+
const target = url?.href ?? config.postLogoutRedirectUri;
|
|
816
|
+
if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
|
|
817
|
+
return redirect(target, 303);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Adding a way to sign in (AERO-1397): the account API makes a link request
|
|
821
|
+
* for the person with the session's token, and its ticket goes to Synq in
|
|
822
|
+
* a sign-in like any other, which asks them to sign in again first. A POST
|
|
823
|
+
* from the app's own pages, answered `{ redirect }` for the browser to
|
|
824
|
+
* follow, so another site can't start one.
|
|
825
|
+
*/
|
|
826
|
+
async function link(request, jar) {
|
|
827
|
+
if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
|
|
828
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
829
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
830
|
+
if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
831
|
+
let input;
|
|
832
|
+
try {
|
|
833
|
+
const body = await request.text();
|
|
834
|
+
if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
835
|
+
input = JSON.parse(body);
|
|
836
|
+
} catch {
|
|
837
|
+
return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
|
|
838
|
+
}
|
|
839
|
+
if (typeof input?.method !== "string" || !METHOD.test(input.method)) return problem(400, "invalid_request", "method names the way to sign in to add");
|
|
840
|
+
const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
|
|
841
|
+
let token;
|
|
842
|
+
try {
|
|
843
|
+
token = await getAccessToken(jar, { resource: config.accountApi });
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (isSynqError(error) && [
|
|
846
|
+
"not_signed_in",
|
|
847
|
+
"invalid_grant",
|
|
848
|
+
"no_refresh_token"
|
|
849
|
+
].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
|
|
853
|
+
method: "POST",
|
|
854
|
+
headers: {
|
|
855
|
+
authorization: `Bearer ${token}`,
|
|
856
|
+
accept: "application/json",
|
|
857
|
+
"content-type": "application/json"
|
|
858
|
+
},
|
|
859
|
+
body: JSON.stringify({ method: input.method })
|
|
860
|
+
});
|
|
861
|
+
if (!answer.ok) return new Response(answer.body, {
|
|
862
|
+
status: answer.status,
|
|
863
|
+
headers: { "content-type": answer.headers.get("content-type") ?? "application/problem+json" }
|
|
864
|
+
});
|
|
865
|
+
const ticket = (await answer.json()).authorization_params?.synq_link;
|
|
866
|
+
if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
|
|
867
|
+
const tx = await transactions(jar, returnTo);
|
|
868
|
+
return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
869
|
+
prompt: "login",
|
|
870
|
+
additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
871
|
+
params: {
|
|
872
|
+
synq_intent: "link",
|
|
873
|
+
synq_link: ticket
|
|
874
|
+
}
|
|
875
|
+
})).href });
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Signing in with a wallet from the app's own page (AERO-1423, over the
|
|
879
|
+
* issuer's headless wallet sign-in, AERO-1410): `{ siws, returnTo }`, the
|
|
880
|
+
* `synq_siws` @synqauth/solana's `encodeSiws` makes of what the wallet
|
|
881
|
+
* signed. It goes to Synq in a pushed authorization request with
|
|
882
|
+
* `prompt=login`, sent from this server with the app's credentials, so the
|
|
883
|
+
* signature never lands in a URL; answered `{ redirect }` for the browser
|
|
884
|
+
* to follow. A POST from the app's own pages.
|
|
885
|
+
*/
|
|
886
|
+
async function walletSignIn(request, jar) {
|
|
887
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
888
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
889
|
+
if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
890
|
+
let input;
|
|
891
|
+
try {
|
|
892
|
+
const body = await request.text();
|
|
893
|
+
if (new TextEncoder().encode(body).length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
894
|
+
input = JSON.parse(body);
|
|
895
|
+
} catch {
|
|
896
|
+
return problem(400, "invalid_request", "Send { siws, returnTo } as JSON");
|
|
897
|
+
}
|
|
898
|
+
if (typeof input?.siws !== "string" || !SIWS.test(input.siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
|
|
899
|
+
const tx = await transactions(jar, safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl));
|
|
900
|
+
return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
901
|
+
prompt: "login",
|
|
902
|
+
pushed: true,
|
|
903
|
+
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
904
|
+
params: { synq_siws: input.siws }
|
|
905
|
+
})).href });
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* One step of a sign-in on the app's own screens (AERO-1411): the page
|
|
909
|
+
* posts what the person typed, `{ step, … }`, and this server takes it to
|
|
910
|
+
* Synq's Authorization Challenge Endpoint as the app, with where the
|
|
911
|
+
* person is (`clientAddress`). The flow (its PKCE verifier and Synq's auth
|
|
912
|
+
* session) stays sealed in a cookie; the page hears only what to show
|
|
913
|
+
* next, never a token, a code or the auth session. A sign-in that ends
|
|
914
|
+
* starts the session as the callback does.
|
|
915
|
+
*/
|
|
916
|
+
async function signInFlow(request, jar) {
|
|
917
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return problem(415, "unsupported_media_type", "Send the step as JSON");
|
|
918
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
919
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
920
|
+
if (!Number.isFinite(length) || length > FLOW_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
921
|
+
let input;
|
|
922
|
+
try {
|
|
923
|
+
const body = await request.text();
|
|
924
|
+
if (new TextEncoder().encode(body).length > FLOW_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
925
|
+
input = JSON.parse(body);
|
|
926
|
+
if (!input || typeof input !== "object") throw new TypeError("not an object");
|
|
927
|
+
} catch {
|
|
928
|
+
return problem(400, "invalid_request", "Send { step, … } as JSON");
|
|
929
|
+
}
|
|
930
|
+
const text = (name, max = 512) => typeof input[name] === "string" ? input[name].slice(0, max) : "";
|
|
931
|
+
const step = text("step", 32);
|
|
932
|
+
const returnTo = safeReturnTo(text("returnTo", 2048) || null, config.appUrl);
|
|
933
|
+
const tx = await transactions(jar, returnTo);
|
|
934
|
+
const saved = await tx.storage.get(FLOW_KEY);
|
|
935
|
+
if (step === "restart") {
|
|
936
|
+
await tx.storage.remove(FLOW_KEY);
|
|
937
|
+
return json({ status: "restarted" });
|
|
938
|
+
}
|
|
939
|
+
const endUserIp = config.clientAddress(request);
|
|
940
|
+
if (!endUserIp && !warnedAboutAddress) {
|
|
941
|
+
warnedAboutAddress = true;
|
|
942
|
+
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");
|
|
943
|
+
}
|
|
944
|
+
const handle = newSession(config.sessionMaxAge, config.store);
|
|
945
|
+
const synq = client(handle.storage(config.primary, config.primary), config.primary);
|
|
946
|
+
const flow = saved ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
|
|
947
|
+
endUserIp,
|
|
948
|
+
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : []
|
|
949
|
+
});
|
|
950
|
+
const email = text("email", 254);
|
|
951
|
+
let result;
|
|
952
|
+
try {
|
|
953
|
+
switch (step) {
|
|
954
|
+
case "password":
|
|
955
|
+
result = text("screen") === "sign-up" ? await flow.password.signUp({
|
|
956
|
+
email,
|
|
957
|
+
password: text("password", 1024)
|
|
958
|
+
}) : await flow.password.signIn({
|
|
959
|
+
email,
|
|
960
|
+
password: text("password", 1024)
|
|
961
|
+
});
|
|
962
|
+
break;
|
|
963
|
+
case "email":
|
|
964
|
+
result = await flow.email.send(email);
|
|
965
|
+
break;
|
|
966
|
+
case "code":
|
|
967
|
+
result = flow.step === "verify-email" ? await flow.verifyEmail.verify(text("code", 32)) : await flow.email.verify(text("code", 32));
|
|
968
|
+
break;
|
|
969
|
+
case "resend":
|
|
970
|
+
result = flow.step === "verify-email" ? await flow.verifyEmail.resend() : await flow.email.resend();
|
|
971
|
+
break;
|
|
972
|
+
case "skip":
|
|
973
|
+
result = await flow.verifyEmail.skip();
|
|
974
|
+
break;
|
|
975
|
+
case "passkey-options":
|
|
976
|
+
result = await flow.passkey.options();
|
|
977
|
+
break;
|
|
978
|
+
case "passkey": {
|
|
979
|
+
const credential = input.credential;
|
|
980
|
+
if (!credential || typeof credential !== "object") return problem(400, "invalid_request", "credential is the passkey’s assertion");
|
|
981
|
+
result = await flow.passkey.signIn(credential, { related: input.related === true });
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
default: return problem(400, "invalid_request", "step is password, email, code, resend, skip, passkey-options, passkey or restart");
|
|
985
|
+
}
|
|
986
|
+
} catch (error) {
|
|
987
|
+
if (isSynqError(error, "invalid_session")) {
|
|
988
|
+
await tx.storage.remove(FLOW_KEY);
|
|
989
|
+
return json({
|
|
990
|
+
status: "expired",
|
|
991
|
+
message: error.message
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
if (isSynqError(error, "too_many_requests")) return json({
|
|
995
|
+
status: "retry",
|
|
996
|
+
step: flow.step,
|
|
997
|
+
problem: "limited",
|
|
998
|
+
message: error.message,
|
|
999
|
+
...error.retryAfter === void 0 ? {} : { retryAfter: error.retryAfter }
|
|
1000
|
+
});
|
|
1001
|
+
throw error;
|
|
1002
|
+
}
|
|
1003
|
+
if (result.status === "signed-in") {
|
|
1004
|
+
await tx.storage.remove(FLOW_KEY);
|
|
1005
|
+
await startSession(jar, handle);
|
|
1006
|
+
return json({
|
|
1007
|
+
status: "signed-in",
|
|
1008
|
+
returnTo: tx.found.returnTo ?? returnTo
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
if (flow.step === "done") await tx.storage.remove(FLOW_KEY);
|
|
1012
|
+
else await tx.storage.set(FLOW_KEY, JSON.stringify(flow));
|
|
1013
|
+
switch (result.status) {
|
|
1014
|
+
case "code-sent": return json({
|
|
1015
|
+
status: result.status,
|
|
1016
|
+
step: flow.step,
|
|
1017
|
+
email: result.email,
|
|
1018
|
+
expiresAt: result.expiresAt.toISOString()
|
|
1019
|
+
});
|
|
1020
|
+
case "verify-email": return json({
|
|
1021
|
+
status: result.status,
|
|
1022
|
+
step: flow.step,
|
|
1023
|
+
email: result.email,
|
|
1024
|
+
canSkip: result.canSkip
|
|
1025
|
+
});
|
|
1026
|
+
case "passkey": return json({
|
|
1027
|
+
status: result.status,
|
|
1028
|
+
step: flow.step,
|
|
1029
|
+
passkey: {
|
|
1030
|
+
options: result.options,
|
|
1031
|
+
related: result.related
|
|
1032
|
+
},
|
|
1033
|
+
...result.problem ? {
|
|
1034
|
+
problem: result.problem,
|
|
1035
|
+
message: result.message
|
|
1036
|
+
} : {}
|
|
1037
|
+
});
|
|
1038
|
+
case "retry": return json({
|
|
1039
|
+
status: result.status,
|
|
1040
|
+
step: flow.step,
|
|
1041
|
+
problem: result.problem,
|
|
1042
|
+
message: result.message,
|
|
1043
|
+
...result.retryAfter === void 0 ? {} : { retryAfter: result.retryAfter }
|
|
1044
|
+
});
|
|
1045
|
+
case "hosted": return json({
|
|
1046
|
+
status: result.status,
|
|
1047
|
+
hosted: {
|
|
1048
|
+
reason: result.reason,
|
|
1049
|
+
...result.method ? { method: result.method } : {},
|
|
1050
|
+
...result.loginHint ? { loginHint: result.loginHint } : {}
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
case "refused": return json({
|
|
1054
|
+
status: result.status,
|
|
1055
|
+
reason: result.reason,
|
|
1056
|
+
message: result.message
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Starts the session `handle` holds the new sign-in's tokens of. A
|
|
1062
|
+
* sign-in replaces whatever session the browser had, and its refresh
|
|
1063
|
+
* token is revoked: an offline grant outlives the brand's session. But
|
|
1064
|
+
* the same person signing in again within their session at the issuer,
|
|
1065
|
+
* as adding a passkey or a way in asks, gets the grant the replaced
|
|
1066
|
+
* session had, and Synq ends a grant with any refresh token of it
|
|
1067
|
+
* revoked: that one is dropped instead. Same person: the ID tokens'
|
|
1068
|
+
* `sub`. Same issuer session: their `sid`, which Synq includes for every
|
|
1069
|
+
* app (AERO-1624) but one whose tenant turned it off; without it, the
|
|
1070
|
+
* same `sub` is taken for the same session (a sign-in after it lapsed
|
|
1071
|
+
* leaves the old grant to expire).
|
|
1072
|
+
*/
|
|
1073
|
+
async function startSession(jar, handle) {
|
|
1074
|
+
const previous = await load(jar);
|
|
1075
|
+
if (previous) {
|
|
1076
|
+
const [before, after] = await Promise.all([issuerSessionOf(previous), issuerSessionOf(handle)]);
|
|
1077
|
+
if (!(before !== void 0 && after !== void 0 && before.sub === after.sub && (before.sid === void 0 || after.sid === void 0 || before.sid === after.sid))) try {
|
|
1078
|
+
await client(previous.storage(config.primary, config.primary), config.primary).signOut();
|
|
1079
|
+
} catch {}
|
|
1080
|
+
await endSession(jar, previous, config.secure, config.store);
|
|
1081
|
+
}
|
|
1082
|
+
await commit(jar, handle);
|
|
1083
|
+
}
|
|
1084
|
+
async function account(request, jar, path) {
|
|
1085
|
+
if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
|
|
1086
|
+
if (!/^v1(\/[A-Za-z0-9._~-]+)+$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) return problem(404, "not_found", "No such account API route");
|
|
1087
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
1088
|
+
if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
|
|
1089
|
+
if (!Number.isFinite(length) || length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1090
|
+
let token;
|
|
1091
|
+
try {
|
|
1092
|
+
token = await getAccessToken(jar, { resource: config.accountApi });
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (isSynqError(error) && [
|
|
1095
|
+
"not_signed_in",
|
|
1096
|
+
"invalid_grant",
|
|
1097
|
+
"no_refresh_token"
|
|
1098
|
+
].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
const headers = new Headers({
|
|
1102
|
+
authorization: `Bearer ${token}`,
|
|
1103
|
+
accept: "application/json"
|
|
1104
|
+
});
|
|
1105
|
+
for (const name of ["content-type", "idempotency-key"]) {
|
|
1106
|
+
const value = request.headers.get(name);
|
|
1107
|
+
if (value) headers.set(name, value);
|
|
1108
|
+
}
|
|
1109
|
+
let body;
|
|
1110
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1111
|
+
body = await request.text();
|
|
1112
|
+
if (new TextEncoder().encode(body).length > ACCOUNT_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
|
|
1113
|
+
}
|
|
1114
|
+
const search = new URL(request.url).search;
|
|
1115
|
+
const answer = await upstream(`${config.accountApi}/${path}${search}`, {
|
|
1116
|
+
method: request.method,
|
|
1117
|
+
headers,
|
|
1118
|
+
body,
|
|
1119
|
+
redirect: "manual"
|
|
1120
|
+
});
|
|
1121
|
+
const out = new Headers({ "cache-control": "no-store" });
|
|
1122
|
+
const type = answer.headers.get("content-type");
|
|
1123
|
+
if (type) out.set("content-type", type);
|
|
1124
|
+
return new Response(answer.status === 204 ? null : await answer.text(), {
|
|
1125
|
+
status: answer.status,
|
|
1126
|
+
headers: out
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
async function plugin(request, jar, id, rest) {
|
|
1130
|
+
const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
|
|
1131
|
+
if (!route) return void 0;
|
|
1132
|
+
return route({
|
|
1133
|
+
user: async () => (await userOf(await load(jar))).user,
|
|
1134
|
+
getAccessToken: (options) => getAccessToken(jar, options),
|
|
1135
|
+
issuer: config.issuer,
|
|
1136
|
+
clientId: config.clientId,
|
|
1137
|
+
basePath: config.basePath
|
|
1138
|
+
}, request);
|
|
1139
|
+
}
|
|
1140
|
+
async function handle(request) {
|
|
1141
|
+
const url = new URL(request.url);
|
|
1142
|
+
const prefix = `${config.basePath}/`;
|
|
1143
|
+
if (!url.pathname.startsWith(prefix)) return problem(404, "not_found", "Not under the route mount");
|
|
1144
|
+
const rest = url.pathname.slice(prefix.length);
|
|
1145
|
+
const [first = "", ...tail] = rest.split("/");
|
|
1146
|
+
const jar = requestJar(request);
|
|
1147
|
+
if (!["GET", "HEAD"].includes(request.method) && !sameOrigin(request, config.appUrl)) return problem(403, "forbidden", "A change must come from the app's own pages");
|
|
1148
|
+
let response;
|
|
1149
|
+
try {
|
|
1150
|
+
if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
|
|
1151
|
+
else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
|
|
1152
|
+
else if (rest === "sign-in/flow" && request.method === "POST") response = await signInFlow(request, jar);
|
|
1153
|
+
else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
|
|
1154
|
+
else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
|
|
1155
|
+
else if (rest === "link" && request.method === "POST") response = await link(request, jar);
|
|
1156
|
+
else if (rest === "session" && request.method === "GET") response = json(await userOf(await load(jar)));
|
|
1157
|
+
else if (first === "account") response = await account(request, jar, tail.join("/"));
|
|
1158
|
+
else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
if (!isSynqError(error)) throw error;
|
|
1161
|
+
response = problem(error.status && error.status >= 400 ? 502 : 500, error.code, error.message);
|
|
1162
|
+
}
|
|
1163
|
+
response ??= problem(404, "not_found", "No such route under the mount");
|
|
1164
|
+
const headers = new Headers(response.headers);
|
|
1165
|
+
for (const cookie of jar.headers()) headers.append("set-cookie", cookie);
|
|
1166
|
+
if (!headers.has("cache-control")) headers.set("cache-control", "no-store");
|
|
1167
|
+
headers.set("x-content-type-options", "nosniff");
|
|
1168
|
+
return new Response(response.body, {
|
|
1169
|
+
status: response.status,
|
|
1170
|
+
headers
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
async function refreshIfDue(jar) {
|
|
1174
|
+
const handle = await load(jar);
|
|
1175
|
+
const { user, expiresAt } = await userOf(handle);
|
|
1176
|
+
if (!handle || !user) return false;
|
|
1177
|
+
if (config.store || expiresAt === null || expiresAt - config.refreshLeewaySeconds > now()) return true;
|
|
1178
|
+
try {
|
|
1179
|
+
await getAccessToken(jar);
|
|
1180
|
+
return true;
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
if (isSynqError(error, "invalid_grant") || isSynqError(error, "no_refresh_token")) return false;
|
|
1183
|
+
return expiresAt > now();
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
handle,
|
|
1188
|
+
user: async (jar) => (await userOf(await load(jar))).user,
|
|
1189
|
+
session: async (jar) => userOf(await load(jar)),
|
|
1190
|
+
getAccessToken,
|
|
1191
|
+
refreshIfDue,
|
|
1192
|
+
config
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Whether a request is a prefetch, not a person going there: Next.js's
|
|
1197
|
+
* router (`Next-Router-Prefetch`) and browsers' speculative loads (`Purpose`,
|
|
1198
|
+
* `Sec-Purpose`: prefetch or prerender).
|
|
1199
|
+
*/
|
|
1200
|
+
function isPrefetch(request) {
|
|
1201
|
+
const h = request.headers;
|
|
1202
|
+
if (h.get("next-router-prefetch") !== null) return true;
|
|
1203
|
+
const purpose = `${h.get("purpose") ?? ""} ${h.get("sec-purpose") ?? ""}`.toLowerCase();
|
|
1204
|
+
return /\b(prefetch|prerender)\b/.test(purpose);
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Where to send the browser back to on the app, as an absolute URL, never
|
|
1208
|
+
* another site: the app's root for anything else. It checks where the value
|
|
1209
|
+
* resolves to, not how it's spelled, since dot segments and encodings
|
|
1210
|
+
* (`/.//evil.example`, `/%2e//evil.example`) collapse into another host.
|
|
1211
|
+
*/
|
|
1212
|
+
function safeReturnTo(value, appUrl) {
|
|
1213
|
+
const home = new URL("/", appUrl).href;
|
|
1214
|
+
if (!value || value.includes("\\")) return home;
|
|
1215
|
+
try {
|
|
1216
|
+
const url = new URL(value, appUrl);
|
|
1217
|
+
return url.origin === appUrl.origin && !url.pathname.startsWith("//") ? url.href : home;
|
|
1218
|
+
} catch {
|
|
1219
|
+
return home;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/** A request from the app's own pages: its `Origin`, or else the browser's `Sec-Fetch-Site`, says so. */
|
|
1223
|
+
function sameOrigin(request, appUrl) {
|
|
1224
|
+
const origin = request.headers.get("origin");
|
|
1225
|
+
if (origin) return origin === appUrl.origin;
|
|
1226
|
+
return request.headers.get("sec-fetch-site") === "same-origin";
|
|
1227
|
+
}
|
|
1228
|
+
function withError(target, code) {
|
|
1229
|
+
const url = new URL(target);
|
|
1230
|
+
url.searchParams.set("synq_error", code);
|
|
1231
|
+
return url.href;
|
|
1232
|
+
}
|
|
1233
|
+
function redirect(location, status) {
|
|
1234
|
+
return new Response(null, {
|
|
1235
|
+
status,
|
|
1236
|
+
headers: { location }
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
function json(body, status = 200) {
|
|
1240
|
+
return Response.json(body, {
|
|
1241
|
+
status,
|
|
1242
|
+
headers: { "cache-control": "no-store" }
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
function problem(status, code, detail) {
|
|
1246
|
+
return Response.json({
|
|
1247
|
+
type: "about:blank",
|
|
1248
|
+
status,
|
|
1249
|
+
code,
|
|
1250
|
+
detail
|
|
1251
|
+
}, {
|
|
1252
|
+
status,
|
|
1253
|
+
headers: {
|
|
1254
|
+
"content-type": "application/problem+json",
|
|
1255
|
+
"cache-control": "no-store"
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
function now() {
|
|
1260
|
+
return Math.floor(Date.now() / 1e3);
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/sign-in-error.ts
|
|
1264
|
+
/** The query parameter the route mount adds to `returnTo` when a sign-in fails. */
|
|
1265
|
+
const SIGN_IN_ERROR_PARAM = "synq_error";
|
|
1266
|
+
/** An OAuth or SDK error code, as the route mount puts it there. */
|
|
1267
|
+
const CODE = /^[a-z0-9_]{1,64}$/;
|
|
1268
|
+
const MESSAGES = {
|
|
1269
|
+
access_denied: "The sign-in was cancelled, or this account can’t use this app.",
|
|
1270
|
+
login_required: "You need to sign in to continue.",
|
|
1271
|
+
interaction_required: "You need to sign in to continue.",
|
|
1272
|
+
consent_required: "You need to allow this app to use your account to continue.",
|
|
1273
|
+
account_selection_required: "Choose an account to continue.",
|
|
1274
|
+
temporarily_unavailable: "Signing in isn’t available just now. Try again in a moment.",
|
|
1275
|
+
unknown_state: "This sign-in expired, or it finished in another tab. Try again."
|
|
1276
|
+
};
|
|
1277
|
+
/** What a sign-in error code means, in plain words. */
|
|
1278
|
+
function signInErrorMessage(code) {
|
|
1279
|
+
return MESSAGES[code] ?? "Something went wrong signing in. Try again.";
|
|
1280
|
+
}
|
|
1281
|
+
/** A path and query on the app, without `synq_error`: where the ways on come back to. */
|
|
1282
|
+
function withoutSignInError(pathAndSearch) {
|
|
1283
|
+
const url = new URL(pathAndSearch, "http://app.invalid");
|
|
1284
|
+
url.searchParams.delete(SIGN_IN_ERROR_PARAM);
|
|
1285
|
+
return `${url.pathname}${url.search}`;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* The sign-in error `code` names, coming back to `returnTo` (a path on the
|
|
1289
|
+
* app), or null when there is none or it isn't a code.
|
|
1290
|
+
*/
|
|
1291
|
+
function signInErrorOf(code, basePath, returnTo) {
|
|
1292
|
+
if (!code || !CODE.test(code)) return null;
|
|
1293
|
+
const back = withoutSignInError(returnTo.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/");
|
|
1294
|
+
const signIn = (prompt) => {
|
|
1295
|
+
const params = new URLSearchParams({ returnTo: back });
|
|
1296
|
+
if (prompt) params.set("prompt", prompt);
|
|
1297
|
+
return `${basePath}/sign-in?${params.toString()}`;
|
|
1298
|
+
};
|
|
1299
|
+
return {
|
|
1300
|
+
code,
|
|
1301
|
+
message: signInErrorMessage(code),
|
|
1302
|
+
retryPath: signIn(),
|
|
1303
|
+
anotherAccountPath: signIn("select_account")
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function escape(text) {
|
|
1307
|
+
return text.replace(/[&<>"']/g, (c) => ({
|
|
1308
|
+
"&": "&",
|
|
1309
|
+
"<": "<",
|
|
1310
|
+
">": ">",
|
|
1311
|
+
"\"": """,
|
|
1312
|
+
"'": "'"
|
|
1313
|
+
})[c]);
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* The proxy's own page for a sign-in error, for an app that has none of its
|
|
1317
|
+
* own (`synqProxy`'s `signInError`): plain HTML, no script and no style, so
|
|
1318
|
+
* any Content-Security-Policy the app sends allows it.
|
|
1319
|
+
*/
|
|
1320
|
+
function signInErrorPage(error) {
|
|
1321
|
+
const html = `<!doctype html>
|
|
1322
|
+
<html lang="en">
|
|
1323
|
+
<head>
|
|
1324
|
+
<meta charset="utf-8">
|
|
1325
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1326
|
+
<meta name="robots" content="noindex">
|
|
1327
|
+
<title>Sign-in didn’t finish</title>
|
|
1328
|
+
</head>
|
|
1329
|
+
<body>
|
|
1330
|
+
<main>
|
|
1331
|
+
<h1>Sign-in didn’t finish</h1>
|
|
1332
|
+
<p>${escape(error.message)}</p>
|
|
1333
|
+
<p><a href="${escape(error.retryPath)}">Try again</a></p>
|
|
1334
|
+
<p><a href="${escape(error.anotherAccountPath)}">Use another account</a></p>
|
|
1335
|
+
</main>
|
|
1336
|
+
</body>
|
|
1337
|
+
</html>
|
|
1338
|
+
`;
|
|
1339
|
+
return new Response(html, {
|
|
1340
|
+
status: 200,
|
|
1341
|
+
headers: {
|
|
1342
|
+
"content-type": "text/html; charset=utf-8",
|
|
1343
|
+
"cache-control": "no-store",
|
|
1344
|
+
"x-content-type-options": "nosniff"
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
export { withoutSignInError as a, memorySessionStore as c, normalizePath as d, resolveConfig as f, signInErrorPage as i, defineSynqPlugin as l, signInErrorMessage as n, createSynqCore as o, signInErrorOf as r, keyValueSessionStore as s, SIGN_IN_ERROR_PARAM as t, requestJar as u };
|