@synqauth/nextjs 0.2.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 +34 -2
- package/dist/{config-CpEbAGEs.d.ts → config-a7tT3KtP.d.ts} +96 -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/{sign-in-error-kd56GHt_.js → sites-JUnMBDbp.js} +799 -54
- package/package.json +3 -3
|
@@ -1,32 +1,38 @@
|
|
|
1
|
-
import { SynqError, createSynqClient, isSynqError, memoryStorage } from "@synqauth/js";
|
|
1
|
+
import { SynqError, createSynqClient, isSynqError, memoryStorage, readMethodList, signInMethods } from "@synqauth/js";
|
|
2
2
|
//#region src/crypto.ts
|
|
3
3
|
const encoder = new TextEncoder();
|
|
4
4
|
const decoder = new TextDecoder();
|
|
5
5
|
const VERSION = "v1";
|
|
6
6
|
const keys = /* @__PURE__ */ new Map();
|
|
7
|
-
/**
|
|
8
|
-
|
|
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 = "") {
|
|
9
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}`);
|
|
10
15
|
let key = keys.get(secret);
|
|
11
16
|
if (!key) {
|
|
12
|
-
key = (
|
|
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
|
-
})();
|
|
17
|
+
key = deriveKey(secret, VERSION);
|
|
24
18
|
keys.set(secret, key);
|
|
25
19
|
}
|
|
26
20
|
return key;
|
|
27
21
|
}
|
|
28
|
-
async function
|
|
29
|
-
const
|
|
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)));
|
|
30
36
|
return {
|
|
31
37
|
seal: open[0],
|
|
32
38
|
open
|
|
@@ -86,18 +92,82 @@ const MULTI_PROCESS = [
|
|
|
86
92
|
];
|
|
87
93
|
/** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
|
|
88
94
|
function resolveConfig(options = {}, env = processEnv()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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);
|
|
94
160
|
const sessionSecret = required(options.sessionSecret ?? env.SYNQ_SESSION_SECRET, "SYNQ_SESSION_SECRET");
|
|
95
161
|
const previousSessionSecrets = options.previousSessionSecrets ?? (env.SYNQ_SESSION_SECRET_PREVIOUS ?? "").split(",").map((secret) => secret.trim()).filter(Boolean);
|
|
96
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\``);
|
|
97
163
|
const hint = MULTI_PROCESS.find((name) => env[name]);
|
|
98
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
|
+
}
|
|
99
169
|
const basePath = normalizePath(options.basePath ?? "/api/auth");
|
|
100
|
-
const accountApi =
|
|
170
|
+
const accountApi = fields.account === false ? void 0 : `${issuer}/me`;
|
|
101
171
|
const primary = options.resource ?? accountApi;
|
|
102
172
|
const scopes = /* @__PURE__ */ new Set([
|
|
103
173
|
"openid",
|
|
@@ -120,14 +190,28 @@ function resolveConfig(options = {}, env = processEnv()) {
|
|
|
120
190
|
accountApi,
|
|
121
191
|
scope: [...scopes].join(" "),
|
|
122
192
|
redirectUri: `${origin}${basePath}/callback`,
|
|
123
|
-
postLogoutRedirectUri:
|
|
124
|
-
store: options.store,
|
|
193
|
+
postLogoutRedirectUri: fields.postLogoutRedirectUri ?? `${origin}/`,
|
|
194
|
+
store: options.store && fields.storePrefix ? prefixedStore(options.store, fields.storePrefix) : options.store,
|
|
125
195
|
sessionMaxAge: options.sessionMaxAge ?? 2592e3,
|
|
126
196
|
refreshLeewaySeconds: options.refreshLeewaySeconds ?? 30,
|
|
127
197
|
plugins: options.plugins ?? [],
|
|
128
198
|
fetch: options.fetch,
|
|
129
199
|
clientAddress: options.clientAddress ?? ((request) => request.headers.get("x-real-ip")?.trim() || void 0),
|
|
130
|
-
secure: appUrl.protocol === "https:"
|
|
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)
|
|
131
215
|
};
|
|
132
216
|
}
|
|
133
217
|
function processEnv() {
|
|
@@ -156,6 +240,16 @@ function normalizePath(path) {
|
|
|
156
240
|
return trimmed;
|
|
157
241
|
}
|
|
158
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
|
|
159
253
|
//#region src/cookies.ts
|
|
160
254
|
/** Characters per chunk, under the 4096 bytes a browser keeps for one cookie with its attributes. */
|
|
161
255
|
const CHUNK_SIZE = 3800;
|
|
@@ -267,6 +361,52 @@ const RESERVED = /* @__PURE__ */ new Set([
|
|
|
267
361
|
"link"
|
|
268
362
|
]);
|
|
269
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
|
|
270
410
|
//#region src/session-name.ts
|
|
271
411
|
const SESSION_COOKIE = "synq.session";
|
|
272
412
|
//#endregion
|
|
@@ -585,19 +725,174 @@ function now$1() {
|
|
|
585
725
|
return Math.floor(Date.now() / 1e3);
|
|
586
726
|
}
|
|
587
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
|
|
588
870
|
//#region src/core.ts
|
|
589
871
|
const PROMPTS = /* @__PURE__ */ new Set([
|
|
590
872
|
"login",
|
|
591
873
|
"none",
|
|
592
874
|
"consent",
|
|
593
|
-
"select_account"
|
|
875
|
+
"select_account",
|
|
876
|
+
"create"
|
|
594
877
|
]);
|
|
595
|
-
|
|
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}$/;
|
|
596
882
|
/** A sign-in method's name, as `synq_method` and the account API's link requests take it. */
|
|
597
883
|
const METHOD = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
598
884
|
const TRANSACTION_SECONDS = 3600;
|
|
599
885
|
const TRANSACTION_PREFIX = "synq.tx.";
|
|
600
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
|
+
]);
|
|
601
896
|
const LINK_BODY_LIMIT = 4096;
|
|
602
897
|
/** A step of an own-UI sign-in: a passkey's assertion is the most it carries. */
|
|
603
898
|
const FLOW_BODY_LIMIT = 16384;
|
|
@@ -627,7 +922,9 @@ const PROTOCOL_CLAIMS = /* @__PURE__ */ new Set([
|
|
|
627
922
|
function createSynqCore(config) {
|
|
628
923
|
/** Whether the missing end-user address was logged already: once is enough. */
|
|
629
924
|
let warnedAboutAddress = false;
|
|
630
|
-
|
|
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);
|
|
631
928
|
/** Every request to Synq: never cached, and given up after REQUEST_TIMEOUT_MS. */
|
|
632
929
|
const upstream = (input, init = {}) => {
|
|
633
930
|
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
@@ -669,6 +966,43 @@ function createSynqCore(config) {
|
|
|
669
966
|
sid: typeof claims.sid === "string" ? claims.sid : void 0
|
|
670
967
|
};
|
|
671
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
|
+
}
|
|
672
1006
|
/** The person the session's main token was issued with, while the session can still give a token. */
|
|
673
1007
|
async function userOf(handle) {
|
|
674
1008
|
if (!handle) return {
|
|
@@ -698,6 +1032,48 @@ function createSynqCore(config) {
|
|
|
698
1032
|
expiresAt
|
|
699
1033
|
};
|
|
700
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
|
+
}
|
|
701
1077
|
async function getAccessToken(jar, options = {}) {
|
|
702
1078
|
const resource = options.resource ?? config.primary;
|
|
703
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`);
|
|
@@ -718,8 +1094,14 @@ function createSynqCore(config) {
|
|
|
718
1094
|
throw error;
|
|
719
1095
|
}
|
|
720
1096
|
}
|
|
721
|
-
/**
|
|
722
|
-
|
|
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) {
|
|
723
1105
|
const keys = await keysFor();
|
|
724
1106
|
const options = {
|
|
725
1107
|
secure: config.secure,
|
|
@@ -732,8 +1114,11 @@ function createSynqCore(config) {
|
|
|
732
1114
|
const cookie = cookieName(name, config.secure);
|
|
733
1115
|
const opened = await unseal(keys.open, jar.get(cookie), cookie);
|
|
734
1116
|
if (!opened) return void 0;
|
|
735
|
-
const { v, r } = JSON.parse(opened);
|
|
1117
|
+
const { v, r, p, s, l } = JSON.parse(opened);
|
|
736
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;
|
|
737
1122
|
return v;
|
|
738
1123
|
},
|
|
739
1124
|
async set(name, value) {
|
|
@@ -742,7 +1127,10 @@ function createSynqCore(config) {
|
|
|
742
1127
|
const sealed = JSON.stringify({
|
|
743
1128
|
v: value,
|
|
744
1129
|
r: returnTo,
|
|
745
|
-
t: Date.now()
|
|
1130
|
+
t: Date.now(),
|
|
1131
|
+
p: popup,
|
|
1132
|
+
s: reauthenticate,
|
|
1133
|
+
...link ? { l: true } : {}
|
|
746
1134
|
});
|
|
747
1135
|
jar.set(cookie, await seal(keys.seal, sealed, cookie), options);
|
|
748
1136
|
},
|
|
@@ -782,41 +1170,116 @@ function createSynqCore(config) {
|
|
|
782
1170
|
const prompt = params.get("prompt") ?? void 0;
|
|
783
1171
|
const maxAge = params.get("max_age");
|
|
784
1172
|
const intent = params.get("intent") ?? void 0;
|
|
785
|
-
if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent or
|
|
1173
|
+
if (prompt && !PROMPTS.has(prompt)) return problem(400, "invalid_request", "prompt is login, none, consent, select_account or create");
|
|
786
1174
|
if (maxAge !== null && !/^\d{1,9}$/.test(maxAge)) return problem(400, "invalid_request", "max_age is a number of seconds");
|
|
787
|
-
if (intent && !
|
|
788
|
-
const
|
|
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);
|
|
789
1201
|
return redirect((await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
790
1202
|
method: params.get("method") ?? void 0,
|
|
791
1203
|
loginHint: params.get("login_hint") ?? void 0,
|
|
792
|
-
prompt:
|
|
1204
|
+
prompt: reauthenticate ? "login" : intentPrompt(intent, prompt),
|
|
793
1205
|
maxAge: maxAge === null ? void 0 : Number(maxAge),
|
|
794
1206
|
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
795
|
-
params:
|
|
1207
|
+
params: {
|
|
1208
|
+
...intent ? { synq_intent: intent } : {},
|
|
1209
|
+
...plan ? { synq_plan: plan } : {},
|
|
1210
|
+
...interval ? { synq_interval: interval } : {},
|
|
1211
|
+
...onboarding ? { synq_onboarding: onboarding } : {}
|
|
1212
|
+
}
|
|
796
1213
|
})).href, 302);
|
|
797
1214
|
}
|
|
798
1215
|
async function callback(request, jar) {
|
|
799
1216
|
const tx = await transactions(jar);
|
|
800
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);
|
|
801
1224
|
try {
|
|
802
1225
|
await client(handle.storage(config.primary, config.primary), config.primary, tx.storage).handleCallback(request.url);
|
|
803
1226
|
} catch (error) {
|
|
804
|
-
|
|
805
|
-
|
|
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
|
+
}
|
|
806
1235
|
}
|
|
807
1236
|
await startSession(jar, handle);
|
|
808
|
-
|
|
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);
|
|
809
1243
|
}
|
|
810
1244
|
async function signOut(request, jar) {
|
|
1245
|
+
const stay = await staysOnPage(request);
|
|
1246
|
+
if (stay instanceof Response) return stay;
|
|
811
1247
|
const handle = await load(jar);
|
|
812
1248
|
let url = null;
|
|
813
|
-
if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut();
|
|
1249
|
+
if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut(stay ? { stay: true } : {});
|
|
814
1250
|
await endSession(jar, handle, config.secure, config.store);
|
|
1251
|
+
forgetEvent(jar, config.secure);
|
|
1252
|
+
if (stay) return json({ redirect: null });
|
|
815
1253
|
const target = url?.href ?? config.postLogoutRedirectUri;
|
|
816
1254
|
if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
|
|
817
1255
|
return redirect(target, 303);
|
|
818
1256
|
}
|
|
819
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
|
+
/**
|
|
820
1283
|
* Adding a way to sign in (AERO-1397): the account API makes a link request
|
|
821
1284
|
* for the person with the session's token, and its ticket goes to Synq in
|
|
822
1285
|
* a sign-in like any other, which asks them to sign in again first. A POST
|
|
@@ -837,6 +1300,8 @@ function createSynqCore(config) {
|
|
|
837
1300
|
return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
|
|
838
1301
|
}
|
|
839
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;
|
|
840
1305
|
const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
|
|
841
1306
|
let token;
|
|
842
1307
|
try {
|
|
@@ -849,6 +1314,23 @@ function createSynqCore(config) {
|
|
|
849
1314
|
].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
|
|
850
1315
|
throw error;
|
|
851
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
|
+
}
|
|
852
1334
|
const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
|
|
853
1335
|
method: "POST",
|
|
854
1336
|
headers: {
|
|
@@ -864,7 +1346,7 @@ function createSynqCore(config) {
|
|
|
864
1346
|
});
|
|
865
1347
|
const ticket = (await answer.json()).authorization_params?.synq_link;
|
|
866
1348
|
if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
|
|
867
|
-
const tx = await transactions(jar, returnTo);
|
|
1349
|
+
const tx = await transactions(jar, returnTo, display, void 0, true);
|
|
868
1350
|
return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
|
|
869
1351
|
prompt: "login",
|
|
870
1352
|
additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
|
|
@@ -941,9 +1423,16 @@ function createSynqCore(config) {
|
|
|
941
1423
|
warnedAboutAddress = true;
|
|
942
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");
|
|
943
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
|
+
});
|
|
944
1433
|
const handle = newSession(config.sessionMaxAge, config.store);
|
|
945
1434
|
const synq = client(handle.storage(config.primary, config.primary), config.primary);
|
|
946
|
-
const flow = saved ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
|
|
1435
|
+
const flow = saved && !reauthenticate ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
|
|
947
1436
|
endUserIp,
|
|
948
1437
|
additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : []
|
|
949
1438
|
});
|
|
@@ -981,7 +1470,13 @@ function createSynqCore(config) {
|
|
|
981
1470
|
result = await flow.passkey.signIn(credential, { related: input.related === true });
|
|
982
1471
|
break;
|
|
983
1472
|
}
|
|
984
|
-
|
|
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");
|
|
985
1480
|
}
|
|
986
1481
|
} catch (error) {
|
|
987
1482
|
if (isSynqError(error, "invalid_session")) {
|
|
@@ -1002,7 +1497,21 @@ function createSynqCore(config) {
|
|
|
1002
1497
|
}
|
|
1003
1498
|
if (result.status === "signed-in") {
|
|
1004
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
|
+
}
|
|
1005
1513
|
await startSession(jar, handle);
|
|
1514
|
+
await keepSignIn(jar, handle, { event: !reauthenticate });
|
|
1006
1515
|
return json({
|
|
1007
1516
|
status: "signed-in",
|
|
1008
1517
|
returnTo: tx.found.returnTo ?? returnTo
|
|
@@ -1050,11 +1559,16 @@ function createSynqCore(config) {
|
|
|
1050
1559
|
...result.loginHint ? { loginHint: result.loginHint } : {}
|
|
1051
1560
|
}
|
|
1052
1561
|
});
|
|
1053
|
-
case "refused":
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
+
});
|
|
1058
1572
|
}
|
|
1059
1573
|
}
|
|
1060
1574
|
/**
|
|
@@ -1126,6 +1640,85 @@ function createSynqCore(config) {
|
|
|
1126
1640
|
headers: out
|
|
1127
1641
|
});
|
|
1128
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
|
+
}
|
|
1129
1722
|
async function plugin(request, jar, id, rest) {
|
|
1130
1723
|
const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
|
|
1131
1724
|
if (!route) return void 0;
|
|
@@ -1150,10 +1743,19 @@ function createSynqCore(config) {
|
|
|
1150
1743
|
if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
|
|
1151
1744
|
else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
|
|
1152
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
|
+
});
|
|
1153
1750
|
else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
|
|
1154
1751
|
else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
|
|
1155
1752
|
else if (rest === "link" && request.method === "POST") response = await link(request, jar);
|
|
1156
|
-
else if (rest === "
|
|
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);
|
|
1157
1759
|
else if (first === "account") response = await account(request, jar, tail.join("/"));
|
|
1158
1760
|
else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
|
|
1159
1761
|
} catch (error) {
|
|
@@ -1186,12 +1788,28 @@ function createSynqCore(config) {
|
|
|
1186
1788
|
return {
|
|
1187
1789
|
handle,
|
|
1188
1790
|
user: async (jar) => (await userOf(await load(jar))).user,
|
|
1189
|
-
session:
|
|
1791
|
+
session: sessionInfo,
|
|
1190
1792
|
getAccessToken,
|
|
1191
1793
|
refreshIfDue,
|
|
1192
1794
|
config
|
|
1193
1795
|
};
|
|
1194
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
|
+
}
|
|
1195
1813
|
/**
|
|
1196
1814
|
* Whether a request is a prefetch, not a person going there: Next.js's
|
|
1197
1815
|
* router (`Next-Router-Prefetch`) and browsers' speculative loads (`Purpose`,
|
|
@@ -1272,7 +1890,9 @@ const MESSAGES = {
|
|
|
1272
1890
|
consent_required: "You need to allow this app to use your account to continue.",
|
|
1273
1891
|
account_selection_required: "Choose an account to continue.",
|
|
1274
1892
|
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."
|
|
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."
|
|
1276
1896
|
};
|
|
1277
1897
|
/** What a sign-in error code means, in plain words. */
|
|
1278
1898
|
function signInErrorMessage(code) {
|
|
@@ -1346,4 +1966,129 @@ function signInErrorPage(error) {
|
|
|
1346
1966
|
});
|
|
1347
1967
|
}
|
|
1348
1968
|
//#endregion
|
|
1349
|
-
|
|
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 };
|