@synqauth/nextjs 0.2.0 → 0.4.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.
@@ -1,32 +1,38 @@
1
- import { SynqError, createSynqClient, isSynqError, memoryStorage } from "@synqauth/js";
1
+ import { SynqError, createSynqClient, isSynqError, memoryStorage, readMethodList, signInEndingOf, 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
- /** The sealing key for a session secret, derived once per secret. */
8
- function sealingKey(secret) {
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 = (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
- })();
17
+ key = deriveKey(secret, VERSION);
24
18
  keys.set(secret, key);
25
19
  }
26
20
  return key;
27
21
  }
28
- async function sealingKeys(current, previous = []) {
29
- const open = await Promise.all([current, ...previous].map((secret) => sealingKey(secret)));
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
- 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");
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 = options.account === false ? void 0 : `${issuer}/me`;
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: options.postLogoutRedirectUri ?? `${origin}/`,
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." : "Sign-in didn’t finish. Close this window and try again from the app.";
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
- const INTENTS = /* @__PURE__ */ new Set(["add-passkey"]);
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
- const keysFor = () => sealingKeys(config.sessionSecret, config.previousSessionSecrets);
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
- /** 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) {
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,122 @@ 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 select_account");
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 && !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);
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: prompt ?? (intent ? "login" : void 0),
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: intent ? { synq_intent: intent } : void 0
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
- const code = isSynqError(error) ? error.code : "server_error";
805
- return redirect(withError(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), code), 303);
1227
+ const code = isSynqError(error) ? signInEndingOf(error.description) ?? error.code : "server_error";
1228
+ if (!QUIET_SIGN_IN_ERRORS.has(code)) console.error("@synqauth/nextjs: sign-in callback failed", {
1229
+ code,
1230
+ status: isSynqError(error) ? error.status : void 0,
1231
+ requestId: isSynqError(error) ? error.requestId : void 0
1232
+ });
1233
+ return failed(code);
1234
+ }
1235
+ if (tx.found.reauthenticate !== void 0) {
1236
+ const refused = await reauthenticationRefused(jar, handle, tx.found.reauthenticate);
1237
+ if (refused) {
1238
+ await discard(handle);
1239
+ return failed(refused);
1240
+ }
806
1241
  }
807
1242
  await startSession(jar, handle);
808
- return redirect(safeReturnTo(tx.found.returnTo ?? null, config.appUrl), 303);
1243
+ await keepSignIn(jar, handle, { event: tx.found.reauthenticate === void 0 && tx.found.link !== true });
1244
+ if (tx.found.popup) return popupPage({
1245
+ attempt: tx.found.popup,
1246
+ ok: true
1247
+ });
1248
+ return redirect(back(), 303);
809
1249
  }
810
1250
  async function signOut(request, jar) {
1251
+ const stay = await staysOnPage(request);
1252
+ if (stay instanceof Response) return stay;
811
1253
  const handle = await load(jar);
812
1254
  let url = null;
813
- if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut();
1255
+ if (handle) url = await client(handle.storage(config.primary, config.primary), config.primary).signOut(stay ? { stay: true } : {});
814
1256
  await endSession(jar, handle, config.secure, config.store);
1257
+ forgetEvent(jar, config.secure);
1258
+ if (stay) return json({ redirect: null });
815
1259
  const target = url?.href ?? config.postLogoutRedirectUri;
816
1260
  if ((request.headers.get("accept") ?? "").includes("application/json")) return json({ redirect: target });
817
1261
  return redirect(target, 303);
818
1262
  }
819
1263
  /**
1264
+ * Whether a sign-out asks to stay on the page (AERO-2684): `{ stay: true }`
1265
+ * as JSON. A form's post, or no body, goes to the brand's sign-out page.
1266
+ */
1267
+ async function staysOnPage(request) {
1268
+ if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return false;
1269
+ const length = Number(request.headers.get("content-length") ?? 0);
1270
+ if (request.headers.has("transfer-encoding")) return problem(411, "length_required", "Send the body with a Content-Length");
1271
+ if (!Number.isFinite(length) || length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1272
+ try {
1273
+ const body = await request.text();
1274
+ if (body === "") return false;
1275
+ const input = JSON.parse(body);
1276
+ if (input === null || typeof input !== "object" || Array.isArray(input)) throw new TypeError();
1277
+ if (input.stay !== void 0 && typeof input.stay !== "boolean") throw new TypeError();
1278
+ return input.stay === true;
1279
+ } catch {
1280
+ return problem(400, "invalid_request", "Send { stay: true } as JSON, or no body");
1281
+ }
1282
+ }
1283
+ /** The ways the app's sign-in offers, for its own panel (AERO-2690): the issuer's list, kept a minute. */
1284
+ async function signInMethodsRoute() {
1285
+ const list = await methods();
1286
+ return Response.json(list, { headers: { "cache-control": "private, max-age=60" } });
1287
+ }
1288
+ /**
820
1289
  * Adding a way to sign in (AERO-1397): the account API makes a link request
821
1290
  * for the person with the session's token, and its ticket goes to Synq in
822
1291
  * a sign-in like any other, which asks them to sign in again first. A POST
@@ -837,6 +1306,8 @@ function createSynqCore(config) {
837
1306
  return problem(400, "invalid_request", "Send { method, returnTo } as JSON");
838
1307
  }
839
1308
  if (typeof input?.method !== "string" || !METHOD.test(input.method)) return problem(400, "invalid_request", "method names the way to sign in to add");
1309
+ const display = displayOf(textOrNull(input.display), textOrNull(input.attempt));
1310
+ if (display instanceof Response) return display;
840
1311
  const returnTo = safeReturnTo(typeof input.returnTo === "string" ? input.returnTo : null, config.appUrl);
841
1312
  let token;
842
1313
  try {
@@ -849,6 +1320,23 @@ function createSynqCore(config) {
849
1320
  ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
850
1321
  throw error;
851
1322
  }
1323
+ if (input.method === "passkey") {
1324
+ const methods = await upstream(`${config.accountApi}/v1/link-methods?limit=200`, { headers: {
1325
+ authorization: `Bearer ${token}`,
1326
+ accept: "application/json"
1327
+ } });
1328
+ if (!methods.ok) return new Response(methods.body, {
1329
+ status: methods.status,
1330
+ headers: { "content-type": methods.headers.get("content-type") ?? "application/problem+json" }
1331
+ });
1332
+ if (!(await methods.json()).data?.some((one) => one.method === "passkey")) return problem(409, "sign_in_method_unavailable", "This brand doesn’t offer passkeys");
1333
+ const tx = await transactions(jar, returnTo, display, void 0, true);
1334
+ return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
1335
+ prompt: "login",
1336
+ additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
1337
+ params: { synq_intent: "add-passkey" }
1338
+ })).href });
1339
+ }
852
1340
  const answer = await upstream(`${config.accountApi}/v1/link-requests`, {
853
1341
  method: "POST",
854
1342
  headers: {
@@ -864,7 +1352,7 @@ function createSynqCore(config) {
864
1352
  });
865
1353
  const ticket = (await answer.json()).authorization_params?.synq_link;
866
1354
  if (typeof ticket !== "string") return problem(502, "invalid_response", "Synq answered without a link ticket");
867
- const tx = await transactions(jar, returnTo);
1355
+ const tx = await transactions(jar, returnTo, display, void 0, true);
868
1356
  return json({ redirect: (await client(memoryStorage(), config.primary, tx.storage).authorizationUrl({
869
1357
  prompt: "login",
870
1358
  additionalResources: config.accountApi !== config.primary ? [config.accountApi] : [],
@@ -941,9 +1429,16 @@ function createSynqCore(config) {
941
1429
  warnedAboutAddress = true;
942
1430
  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
1431
  }
1432
+ const reauthenticate = input.reauthenticate === true;
1433
+ if (reauthenticate && step !== "wallet") return problem(400, "invalid_request", "reauthenticate goes with step wallet");
1434
+ const signedIn = reauthenticate ? await idTokenOf(await load(jar)) : void 0;
1435
+ if (reauthenticate && !signedIn) return json({
1436
+ status: "other-account",
1437
+ message: "Nobody is signed in to confirm."
1438
+ });
944
1439
  const handle = newSession(config.sessionMaxAge, config.store);
945
1440
  const synq = client(handle.storage(config.primary, config.primary), config.primary);
946
- const flow = saved ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
1441
+ const flow = saved && !reauthenticate ? synq.resumeSignIn(saved, { endUserIp }) : synq.startSignIn({
947
1442
  endUserIp,
948
1443
  additionalResources: config.accountApi && config.accountApi !== config.primary ? [config.accountApi] : []
949
1444
  });
@@ -981,7 +1476,13 @@ function createSynqCore(config) {
981
1476
  result = await flow.passkey.signIn(credential, { related: input.related === true });
982
1477
  break;
983
1478
  }
984
- default: return problem(400, "invalid_request", "step is password, email, code, resend, skip, passkey-options, passkey or restart");
1479
+ case "wallet": {
1480
+ const siws = input.siws;
1481
+ if (typeof siws !== "string" || !SIWS.test(siws)) return problem(400, "invalid_request", "siws is what the wallet signed, as encodeSiws makes it");
1482
+ result = await flow.wallet.signIn(siws, signedIn ? { reauthenticate: signedIn } : {});
1483
+ break;
1484
+ }
1485
+ default: return problem(400, "invalid_request", "step is password, email, code, resend, skip, passkey-options, passkey, wallet or restart");
985
1486
  }
986
1487
  } catch (error) {
987
1488
  if (isSynqError(error, "invalid_session")) {
@@ -1002,7 +1503,21 @@ function createSynqCore(config) {
1002
1503
  }
1003
1504
  if (result.status === "signed-in") {
1004
1505
  await tx.storage.remove(FLOW_KEY);
1506
+ if (reauthenticate) {
1507
+ const previous = await load(jar);
1508
+ const [before, after] = await Promise.all([previous ? issuerSessionOf(previous) : void 0, issuerSessionOf(handle)]);
1509
+ if (!before || before.sub !== after?.sub) {
1510
+ try {
1511
+ await client(handle.storage(config.primary, config.primary), config.primary).signOut();
1512
+ } catch {}
1513
+ return json({
1514
+ status: "other-account",
1515
+ message: before ? "That wallet signs in to another account. Use a wallet of the account signed in here." : "Nobody is signed in to confirm."
1516
+ });
1517
+ }
1518
+ }
1005
1519
  await startSession(jar, handle);
1520
+ await keepSignIn(jar, handle, { event: !reauthenticate });
1006
1521
  return json({
1007
1522
  status: "signed-in",
1008
1523
  returnTo: tx.found.returnTo ?? returnTo
@@ -1050,11 +1565,16 @@ function createSynqCore(config) {
1050
1565
  ...result.loginHint ? { loginHint: result.loginHint } : {}
1051
1566
  }
1052
1567
  });
1053
- case "refused": return json({
1054
- status: result.status,
1055
- reason: result.reason,
1056
- message: result.message
1057
- });
1568
+ case "refused":
1569
+ if (reauthenticate && result.reason === "other_account") return json({
1570
+ status: "other-account",
1571
+ message: "That wallet signs in to another account, or to none. Use a wallet of the account signed in here."
1572
+ });
1573
+ return json({
1574
+ status: result.status,
1575
+ reason: result.reason,
1576
+ message: result.message
1577
+ });
1058
1578
  }
1059
1579
  }
1060
1580
  /**
@@ -1126,6 +1646,85 @@ function createSynqCore(config) {
1126
1646
  headers: out
1127
1647
  });
1128
1648
  }
1649
+ /**
1650
+ * The person's picture (AERO-2700): the browser posts the picture's own
1651
+ * bytes here, and this server runs the account API's upload for them,
1652
+ * asking for an upload, PUTting the bytes to Synq's storage, and choosing
1653
+ * it, so the storage needs no CORS for the app, and no bytes pass through
1654
+ * Synq's API. Or it posts `{ "identity": … }`, a linked account whose
1655
+ * picture to use: a POST, which every app's route file already serves. A
1656
+ * body that says it is too large, or not a picture, or won't say how long
1657
+ * it is, is refused before it is read.
1658
+ */
1659
+ async function picture(request, jar) {
1660
+ if (!config.accountApi) return problem(404, "not_found", "The session holds no account API token: synqAuth()'s account is off");
1661
+ const declared = request.headers.get("content-length");
1662
+ if (request.headers.has("transfer-encoding") || declared === null) return problem(411, "length_required", "Send the picture with a Content-Length");
1663
+ const length = Number(declared);
1664
+ if (!Number.isSafeInteger(length) || length < 1) return problem(411, "length_required", "Send the picture with a Content-Length");
1665
+ if (length > PICTURE_BODY_LIMIT) return problem(413, "payload_too_large", "A picture is at most 5 MB.");
1666
+ const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
1667
+ const linked = type === "application/json";
1668
+ if (linked && length > LINK_BODY_LIMIT) return problem(413, "payload_too_large", "The body is too large");
1669
+ if (!linked && !PICTURE_TYPES.has(type)) return problem(415, "unsupported_media_type", "Send a PNG, JPEG, WebP or GIF picture");
1670
+ let token;
1671
+ try {
1672
+ token = await getAccessToken(jar, { resource: config.accountApi });
1673
+ } catch (error) {
1674
+ if (isSynqError(error) && [
1675
+ "not_signed_in",
1676
+ "invalid_grant",
1677
+ "no_refresh_token"
1678
+ ].includes(error.code)) return problem(401, "not_signed_in", "Nobody is signed in");
1679
+ throw error;
1680
+ }
1681
+ const bytes = new Uint8Array(await request.arrayBuffer());
1682
+ if (bytes.byteLength !== length) return problem(400, "validation_failed", "The picture isn’t the length it said");
1683
+ const key = request.headers.get("idempotency-key");
1684
+ const call = (method, path, body, step) => upstream(`${config.accountApi}/${path}`, {
1685
+ method,
1686
+ headers: {
1687
+ authorization: `Bearer ${token}`,
1688
+ accept: "application/json",
1689
+ "content-type": "application/json",
1690
+ ...key ? { "idempotency-key": `${key}:${step}` } : {}
1691
+ },
1692
+ body: JSON.stringify(body),
1693
+ redirect: "manual"
1694
+ });
1695
+ const relay = async (answer) => new Response(answer.status === 204 ? null : await answer.text(), {
1696
+ status: answer.status,
1697
+ headers: {
1698
+ "cache-control": "no-store",
1699
+ "content-type": answer.headers.get("content-type") ?? "application/json"
1700
+ }
1701
+ });
1702
+ if (linked) {
1703
+ let identity;
1704
+ try {
1705
+ identity = JSON.parse(new TextDecoder().decode(bytes)).identity;
1706
+ } catch {
1707
+ identity = void 0;
1708
+ }
1709
+ if (typeof identity !== "string" || !/^idn_[0-9a-z]{1,64}$/.test(identity)) return problem(400, "validation_failed", "Name the linked account: { \"identity\": \"idn_…\" }");
1710
+ return relay(await call("PUT", "v1/profile/picture", { identity }, "choose"));
1711
+ }
1712
+ const started = await call("POST", "v1/profile/picture/uploads", {
1713
+ content_type: type,
1714
+ content_length: length
1715
+ }, "upload");
1716
+ if (started.status !== 201) return relay(started);
1717
+ const upload = await started.json();
1718
+ const stored = await upstream(upload.url, {
1719
+ method: "PUT",
1720
+ headers: upload.headers,
1721
+ body: bytes,
1722
+ redirect: "manual"
1723
+ });
1724
+ await stored.body?.cancel();
1725
+ if (!stored.ok) return problem(502, "upload_failed", `Synq's storage refused the picture (${stored.status})`);
1726
+ return relay(await call("PUT", "v1/profile/picture", { file_id: upload.file_id }, "choose"));
1727
+ }
1129
1728
  async function plugin(request, jar, id, rest) {
1130
1729
  const route = config.plugins.find((candidate) => candidate.id === id)?.routes?.[`${request.method} /${rest}`];
1131
1730
  if (!route) return void 0;
@@ -1144,20 +1743,38 @@ function createSynqCore(config) {
1144
1743
  const rest = url.pathname.slice(prefix.length);
1145
1744
  const [first = "", ...tail] = rest.split("/");
1146
1745
  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");
1746
+ if (!["GET", "HEAD"].includes(request.method) && !sameOrigin(request, config.appUrl)) {
1747
+ reportOriginMismatch(request, config.appUrl);
1748
+ return problem(403, "origin_mismatch", "This change came from another address than the app's own pages (appUrl)");
1749
+ }
1148
1750
  let response;
1149
1751
  try {
1150
1752
  if (rest === "sign-in" && request.method === "GET") response = await signIn(request, jar);
1151
1753
  else if (rest === "sign-in" && request.method === "POST") response = await walletSignIn(request, jar);
1152
1754
  else if (rest === "sign-in/flow" && request.method === "POST") response = await signInFlow(request, jar);
1755
+ else if (rest === "sign-in/wallet" && request.method === "GET") response = json({
1756
+ issuer: config.issuer,
1757
+ clientId: config.clientId
1758
+ });
1153
1759
  else if (rest === "callback" && request.method === "GET") response = await callback(request, jar);
1154
1760
  else if (rest === "sign-out" && request.method === "POST") response = await signOut(request, jar);
1155
1761
  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)));
1762
+ else if (rest === "sign-in/methods" && request.method === "GET") response = await signInMethodsRoute();
1763
+ else if (rest === "session" && request.method === "GET") response = json(await sessionInfo(jar));
1764
+ else if (rest === "session/event" && request.method === "DELETE") {
1765
+ forgetEvent(jar, config.secure);
1766
+ response = new Response(null, { status: 204 });
1767
+ } else if (rest === "account/picture" && request.method === "POST") response = await picture(request, jar);
1157
1768
  else if (first === "account") response = await account(request, jar, tail.join("/"));
1158
1769
  else if (!RESERVED.has(first)) response = await plugin(request, jar, first, tail.join("/"));
1159
1770
  } catch (error) {
1160
1771
  if (!isSynqError(error)) throw error;
1772
+ if (error.status === void 0 || error.status >= 500) console.error("@synqauth/nextjs: a route under the mount failed", {
1773
+ route: rest,
1774
+ code: error.code,
1775
+ status: error.status,
1776
+ requestId: error.requestId
1777
+ });
1161
1778
  response = problem(error.status && error.status >= 400 ? 502 : 500, error.code, error.message);
1162
1779
  }
1163
1780
  response ??= problem(404, "not_found", "No such route under the mount");
@@ -1186,12 +1803,28 @@ function createSynqCore(config) {
1186
1803
  return {
1187
1804
  handle,
1188
1805
  user: async (jar) => (await userOf(await load(jar))).user,
1189
- session: async (jar) => userOf(await load(jar)),
1806
+ session: sessionInfo,
1190
1807
  getAccessToken,
1191
1808
  refreshIfDue,
1192
1809
  config
1193
1810
  };
1194
1811
  }
1812
+ /** A body field as text: null when it is left out, and anything but text kept as a mark that it was there. */
1813
+ function textOrNull(value) {
1814
+ if (value === void 0) return null;
1815
+ return typeof value === "string" ? value : "?";
1816
+ }
1817
+ /**
1818
+ * The popup a sign-in or link runs in (AERO-2692): the page's attempt id for
1819
+ * `display=popup`, undefined for `redirect` or none, or the problem for
1820
+ * anything else.
1821
+ */
1822
+ function displayOf(display, attempt) {
1823
+ if (display === null || display === "redirect") return attempt === null ? void 0 : problem(400, "invalid_request", "attempt goes with display=popup");
1824
+ if (display !== "popup") return problem(400, "invalid_request", "display is popup or redirect");
1825
+ 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 _ -");
1826
+ return attempt;
1827
+ }
1195
1828
  /**
1196
1829
  * Whether a request is a prefetch, not a person going there: Next.js's
1197
1830
  * router (`Next-Router-Prefetch`) and browsers' speculative loads (`Purpose`,
@@ -1225,6 +1858,30 @@ function sameOrigin(request, appUrl) {
1225
1858
  if (origin) return origin === appUrl.origin;
1226
1859
  return request.headers.get("sec-fetch-site") === "same-origin";
1227
1860
  }
1861
+ /**
1862
+ * Sign-in errors that are the person's own doing or an expected step, which
1863
+ * no server log needs to hear of (AERO-2757).
1864
+ */
1865
+ const QUIET_SIGN_IN_ERRORS = /* @__PURE__ */ new Set([
1866
+ "access_denied",
1867
+ "login_required",
1868
+ "interaction_required",
1869
+ "consent_required",
1870
+ "account_selection_required",
1871
+ "cancelled",
1872
+ "consent_denied",
1873
+ "token_gate_refused",
1874
+ "passkey_required",
1875
+ "unknown_state"
1876
+ ]);
1877
+ let originMismatchReported = false;
1878
+ /** Says once per process which origin the mount refused, and what appUrl says (AERO-2757). */
1879
+ function reportOriginMismatch(request, appUrl) {
1880
+ if (originMismatchReported) return;
1881
+ originMismatchReported = true;
1882
+ const origin = request.headers.get("origin") ?? request.headers.get("sec-fetch-site") ?? "unknown";
1883
+ console.error(`@synqauth/nextjs: refused a change from ${origin}: appUrl is ${appUrl.origin}. Set appUrl (or SYNQ_APP_URL) to the origin people use.`);
1884
+ }
1228
1885
  function withError(target, code) {
1229
1886
  const url = new URL(target);
1230
1887
  url.searchParams.set("synq_error", code);
@@ -1266,17 +1923,47 @@ const SIGN_IN_ERROR_PARAM = "synq_error";
1266
1923
  /** An OAuth or SDK error code, as the route mount puts it there. */
1267
1924
  const CODE = /^[a-z0-9_]{1,64}$/;
1268
1925
  const MESSAGES = {
1269
- access_denied: "The sign-in was cancelled, or this account can’t use this app.",
1926
+ access_denied: "This account can’t use this app, or the sign-in was canceled. Sign in again, or use another account.",
1927
+ cancelled: "You canceled signing in. Sign in when you’re ready.",
1928
+ consent_denied: "You didn’t allow this app to use your account. Sign in again to allow it.",
1929
+ token_gate_refused: "This account’s wallets don’t hold what this app asks for. Use another account, or add the wallet that holds it.",
1930
+ token_gate_unavailable: "We couldn’t check your wallets. That’s a problem on Synq’s side, not yours. Try again in a minute.",
1931
+ passkey_required: "This app needs you to sign in with a passkey.",
1932
+ unmet_authentication_requirements: "This app needs you to sign in with a passkey.",
1933
+ level_not_met: "This app needs you to sign in with a passkey.",
1270
1934
  login_required: "You need to sign in to continue.",
1271
1935
  interaction_required: "You need to sign in to continue.",
1272
1936
  consent_required: "You need to allow this app to use your account to continue.",
1273
1937
  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."
1938
+ temporarily_unavailable: "Signing in is down for a moment on Synq’s side. Try again in a minute.",
1939
+ server_error: "Synq had a problem finishing your sign-in. Try again in a minute.",
1940
+ invalid_grant: "This sign-in expired or was already used. Sign in again.",
1941
+ unknown_state: "This sign-in expired, or it finished in another tab. Sign in again.",
1942
+ other_account: "That signed in to another account. Confirm with a way in of the account signed in here.",
1943
+ not_signed_in: "You were signed out. Sign in again."
1276
1944
  };
1277
- /** What a sign-in error code means, in plain words. */
1945
+ /**
1946
+ * The app's own setup, which only its team can fix: retrying gets the same
1947
+ * answer, and nothing the person does changes it.
1948
+ */
1949
+ const APP_SETUP = /* @__PURE__ */ new Set([
1950
+ "invalid_client",
1951
+ "unauthorized_client",
1952
+ "invalid_scope",
1953
+ "invalid_request",
1954
+ "invalid_response",
1955
+ "invalid_redirect_uri",
1956
+ "unsupported_response_type",
1957
+ "discovery_failed",
1958
+ "method_not_offered",
1959
+ "origin_mismatch"
1960
+ ]);
1961
+ /** What a sign-in error code means, in plain words: whose side it is on, and what to do. */
1278
1962
  function signInErrorMessage(code) {
1279
- return MESSAGES[code] ?? "Something went wrong signing in. Try again.";
1963
+ const said = MESSAGES[code];
1964
+ if (said) return said;
1965
+ if (APP_SETUP.has(code)) return `This app’s sign-in isn’t set up right, so it can’t finish. It’s not something you did: tell the app’s team (code ${code}).`;
1966
+ return `Your sign-in didn’t finish (code ${code}). Try again, and if it keeps happening, tell the app’s team.`;
1280
1967
  }
1281
1968
  /** A path and query on the app, without `synq_error`: where the ways on come back to. */
1282
1969
  function withoutSignInError(pathAndSearch) {
@@ -1346,4 +2033,129 @@ function signInErrorPage(error) {
1346
2033
  });
1347
2034
  }
1348
2035
  //#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 };
2036
+ //#region src/sites.ts
2037
+ /** Sites kept at once; past it, the one used longest ago is made again when next asked for. */
2038
+ const MAX_SITES = 500;
2039
+ /** One DNS label, as a host name has them. */
2040
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
2041
+ /** A host as a browser names it in `Host`: a DNS name or an IPv6 literal, and a port. */
2042
+ 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})?$/;
2043
+ /** A host as it's compared: lowercase, with no dot at the end of its name. */
2044
+ function normalHost(value) {
2045
+ return value.trim().toLowerCase().replace(/\.(:\d+)?$/, "$1");
2046
+ }
2047
+ /**
2048
+ * The host the browser asked for: `X-Forwarded-Host`'s first, which a proxy
2049
+ * in front of the app sets (and Next's own server does), else `Host`.
2050
+ * Undefined when there is none, or it isn't a host.
2051
+ */
2052
+ function requestHost(headers) {
2053
+ const forwarded = headers.get("x-forwarded-host")?.split(",", 1)[0]?.trim();
2054
+ const host = normalHost(forwarded || headers.get("host") || "");
2055
+ return HOST.test(host) ? host : void 0;
2056
+ }
2057
+ /**
2058
+ * `hosts` as a test: an entry is a host (`club.example.com`,
2059
+ * `localhost:3000`), or `*.` and a domain, for any one label under it, its
2060
+ * port named or none. Throws `invalid_options` for an entry that is neither.
2061
+ */
2062
+ function allowList(hosts) {
2063
+ 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");
2064
+ const exact = /* @__PURE__ */ new Set();
2065
+ const under = [];
2066
+ for (const entry of hosts) {
2067
+ const host = typeof entry === "string" ? normalHost(entry) : "";
2068
+ const wildcard = host.startsWith("*.");
2069
+ const name = wildcard ? host.slice(2) : host;
2070
+ 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`);
2071
+ if (wildcard) under.push(`.${name}`);
2072
+ else exact.add(name);
2073
+ }
2074
+ return (host) => exact.has(host) || under.some((suffix) => host.endsWith(suffix) && LABEL.test(host.slice(0, -suffix.length)));
2075
+ }
2076
+ /** Whether `host` (its name, without the port) is this machine. */
2077
+ function isLoopback(host) {
2078
+ const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":", 1)[0];
2079
+ return name === "localhost" || name.endsWith(".localhost") || name === "127.0.0.1" || name === "[::1]";
2080
+ }
2081
+ /**
2082
+ * The site's origin: `https://<host>`, or, for a host on this machine, the
2083
+ * scheme the request came over (`X-Forwarded-Proto`, which Next sets, else
2084
+ * the request's own URL).
2085
+ */
2086
+ function originOf(host, headers, url) {
2087
+ let protocol = "https:";
2088
+ if (isLoopback(host)) {
2089
+ const forwarded = headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim().toLowerCase();
2090
+ if (forwarded === "http" || forwarded === "https") protocol = `${forwarded}:`;
2091
+ else if (url) protocol = new URL(url).protocol === "https:" ? "https:" : "http:";
2092
+ else protocol = "http:";
2093
+ }
2094
+ return new URL(`${protocol}//${host}`).origin;
2095
+ }
2096
+ function siteCores(options) {
2097
+ const { resolve } = options;
2098
+ if (!resolve) throw new TypeError("siteCores needs resolve");
2099
+ let allowed;
2100
+ const cores = /* @__PURE__ */ new Map();
2101
+ return { async coreFor(headers, url) {
2102
+ allowed ??= allowList(options.hosts);
2103
+ const host = requestHost(headers);
2104
+ if (!host || !allowed(host)) return null;
2105
+ const request = {
2106
+ host,
2107
+ origin: originOf(host, headers, url),
2108
+ headers
2109
+ };
2110
+ const site = await resolve(request);
2111
+ if (site === null || site === void 0) return null;
2112
+ const key = JSON.stringify([
2113
+ request.origin,
2114
+ site.issuer,
2115
+ site.clientId,
2116
+ site.appUrl ?? null,
2117
+ site.storePrefix ?? null,
2118
+ site.account ?? null,
2119
+ site.postLogoutRedirectUri ?? null
2120
+ ]);
2121
+ const kept = cores.get(key);
2122
+ cores.delete(key);
2123
+ if (kept && kept.secret === site.clientSecret) {
2124
+ cores.set(key, kept);
2125
+ return kept.core;
2126
+ }
2127
+ const core = createSynqCore(siteConfig(options, site, request));
2128
+ cores.set(key, {
2129
+ secret: site.clientSecret,
2130
+ core
2131
+ });
2132
+ if (cores.size > MAX_SITES) cores.delete(cores.keys().next().value);
2133
+ return core;
2134
+ } };
2135
+ }
2136
+ const UNKNOWN_HOST = "This app doesn't serve the host this request was sent to: add it to synqAuth()'s hosts if it should";
2137
+ /** Why a request to a host the app doesn't serve gets nothing, as the error server code throws. */
2138
+ function unknownHostError() {
2139
+ return new SynqError("unknown_host", UNKNOWN_HOST, { status: 421 });
2140
+ }
2141
+ /**
2142
+ * The answer to a request for a host the app doesn't serve: 421 Misdirected
2143
+ * Request, as a problem, naming no host and no configuration.
2144
+ */
2145
+ function unknownHostResponse() {
2146
+ return Response.json({
2147
+ type: "about:blank",
2148
+ status: 421,
2149
+ code: "unknown_host",
2150
+ detail: UNKNOWN_HOST
2151
+ }, {
2152
+ status: 421,
2153
+ headers: {
2154
+ "content-type": "application/problem+json",
2155
+ "cache-control": "no-store",
2156
+ "x-content-type-options": "nosniff"
2157
+ }
2158
+ });
2159
+ }
2160
+ //#endregion
2161
+ 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 };