@server/next 0.47.2 → 0.48.1

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.
Files changed (3) hide show
  1. package/index.d.ts +4 -9
  2. package/index.js +116 -91
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -203,7 +203,7 @@ type RedirectOption = string | ((user: any, ctx: Context) => Awaitable<string>)
203
203
  };
204
204
  type AuthConfig<U = AuthProfile> = {
205
205
  providers: string | readonly string[] | Record<string, string | ProviderOptions>;
206
- strategy?: Strategy | readonly Strategy[];
206
+ strategy?: Strategy;
207
207
  expires?: string;
208
208
  redirect?: RedirectOption;
209
209
  onLogin?: (profile: AuthProfile, ctx: Context) => Awaitable<string | number | undefined>;
@@ -224,8 +224,8 @@ type AuthInstance = {
224
224
  user?: (ctx: Context) => Awaitable<any>;
225
225
  };
226
226
  type AuthFunction<U = any> = (ctx: Context<any>) => Awaitable<U>;
227
- type AuthOption = string | AuthFunction | AuthConfig<any> | AuthVerify<any> | AuthInstance | readonly AuthOption[];
228
- type UserOf<A> = A extends readonly (infer M)[] ? UserOf<M> : A extends (...args: any[]) => infer R ? NonNullable<Awaited<R>> : A extends {
227
+ type AuthOption = string | AuthFunction | AuthConfig<any> | AuthVerify<any> | AuthInstance;
228
+ type UserOf<A> = A extends (...args: any[]) => infer R ? NonNullable<Awaited<R>> : A extends {
229
229
  getUser: (...args: any[]) => infer R;
230
230
  } ? NonNullable<Awaited<R>> : A extends {
231
231
  issuer: any;
@@ -237,7 +237,7 @@ type AuthEntry = {
237
237
  user: (ctx: Context) => Promise<any>;
238
238
  routes?: (app: Server) => void;
239
239
  };
240
- type AuthSettings = AuthEntry[];
240
+ type AuthSettings = AuthEntry;
241
241
  type LogLevel = "info";
242
242
  type Logger = {
243
243
  level?: LogLevel;
@@ -596,11 +596,6 @@ declare function server<U>(options: Omit<Options, "auth"> & {
596
596
  }): Server<{
597
597
  user: NonNullable<Awaited<U>>;
598
598
  }>;
599
- declare function server<A extends readonly AuthOption[]>(options: Omit<Options, "auth"> & {
600
- auth: A;
601
- }): Server<{
602
- user: UserOf<A[number]>;
603
- }>;
604
599
  declare function server<C extends ContextTypes = {}>(options?: Options): Server<C>;
605
600
 
606
601
  export { type AuthClaims, type AuthConfig, type AuthEntry, type AuthFunction, type AuthInstance, type AuthMeta, type AuthOption, type AuthProfile, type AuthSettings, type AuthVerify, type BasicValue, type Body, type BodyMode, type Bucket, type BucketFile, type BunEnv, type CacheOption, type Context, type ContextExtension, type ContextTypes, type Cookie, type CorsSettings, type ExtractPathParams, type FileInfo, type InferParamType, type InlineReply, type LogLevel, type Logger, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type ProviderOptions, type RedirectOption, type Route, type RouteOptions, type RouteSchema, type RouterMethod, type SchemaOutput, type SecurityOptions, type SecuritySettings, type SerializableValue, Server, TypedServerError as ServerError, type Settings, type StandardIssue, type StandardSchemaV1, type Strategy, type Time, type UploadOptions, type UploadedFile, type UserOf, ValidationError, cache, cookies, server as default, download, file, headers, json, redirect, router, send, status, type };
package/index.js CHANGED
@@ -1016,29 +1016,43 @@ async function verifyJwt(token, secret) {
1016
1016
  var NAME = "session";
1017
1017
  var inCookie = (s) => s === "session" || s === "cookie";
1018
1018
  var isSigned = (s) => s === "cookie" || s === "jwt";
1019
- var UNITS2 = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
1020
1019
  function seconds(expires) {
1021
- const match = /^(\d+)([smhdw])$/.exec(expires);
1022
- if (!match) throw new Error(`Invalid \`expires\`: "${expires}"`);
1023
- return Number(match[1]) * UNITS2[match[2]];
1020
+ const ms = parse(expires);
1021
+ if (!ms) throw new Error(`Invalid \`expires\`: "${expires}"`);
1022
+ return Math.round(ms / 1e3);
1024
1023
  }
1024
+ var looksLikeOurs = (token) => {
1025
+ const parts = token.split(".");
1026
+ if (parts.length !== 3) return false;
1027
+ try {
1028
+ const header = JSON.parse(atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")));
1029
+ return header?.alg === "HS256";
1030
+ } catch {
1031
+ return false;
1032
+ }
1033
+ };
1025
1034
  var bearer = (ctx) => {
1026
1035
  const header = ctx.headers.authorization;
1027
1036
  if (!header) return;
1028
1037
  const [type2, token] = header.trim().split(" ");
1029
- if (type2?.toLowerCase() !== "bearer" || !token) {
1030
- throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
1031
- }
1038
+ if (type2?.toLowerCase() !== "bearer") return;
1039
+ if (!token) throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
1032
1040
  return token;
1033
1041
  };
1034
- async function read(ctx, strategies) {
1035
- for (const strategy of strategies) {
1036
- const token = inCookie(strategy) ? ctx.cookies[NAME] : bearer(ctx);
1037
- if (!token) continue;
1038
- const payload = await verifyJwt(token, ctx.options.secrets);
1039
- if (!payload) throw ServerError_default.AUTH_INVALID_TOKEN();
1040
- return { payload, strategy };
1042
+ async function read(ctx, strategy) {
1043
+ const token = inCookie(strategy) ? ctx.cookies[NAME] : bearer(ctx);
1044
+ if (!token) return;
1045
+ const payload = await verifyJwt(token, ctx.options.secrets);
1046
+ if (!payload) {
1047
+ if (!inCookie(strategy)) throw ServerError_default.AUTH_INVALID_TOKEN();
1048
+ ctx.clearCookie = NAME;
1049
+ ctx.options.log?.message(
1050
+ "auth",
1051
+ looksLikeOurs(token) ? "discarded a session cookie signed with a key that is not in SECRETS. If you rotated it, keep the previous value: secrets: [current, previous]" : "discarded a session cookie that was not issued by this app"
1052
+ );
1053
+ return;
1041
1054
  }
1055
+ return payload;
1042
1056
  }
1043
1057
  var meta = (payload, strategy) => ({
1044
1058
  issuedAt: new Date(payload.iat * 1e3),
@@ -1395,31 +1409,28 @@ function parseProviders(given) {
1395
1409
  var target = async (where, fallback, user, ctx) => typeof where === "function" ? where(user, ctx) : where ?? fallback;
1396
1410
  function entry(config2) {
1397
1411
  const list = parseProviders(config2.providers);
1398
- const strategies = Array.isArray(config2.strategy) ? config2.strategy : [config2.strategy ?? "session"];
1399
- for (const one of strategies) {
1400
- if (!["session", "cookie", "token", "jwt"].includes(one)) {
1401
- throw new Error(
1402
- `Unknown strategy "${one}"; it takes 'session', 'cookie', 'token' or 'jwt'.`
1403
- );
1404
- }
1412
+ const strategy = config2.strategy ?? "session";
1413
+ if (!["session", "cookie", "token", "jwt"].includes(strategy)) {
1414
+ throw new Error(
1415
+ `Unknown strategy "${strategy}"; it takes 'session', 'cookie', 'token' or 'jwt'.`
1416
+ );
1405
1417
  }
1406
1418
  const expires = config2.expires ?? "30d";
1419
+ seconds(expires);
1407
1420
  const { onLogin, getUser, toPublicUser, onLogout } = config2;
1408
1421
  if (onLogin && !getUser) {
1409
1422
  throw new Error("`onLogin` needs a `getUser`: something has to resolve the id it returns.");
1410
1423
  }
1411
- for (const strategy of strategies) {
1412
- if (isSigned(strategy)) {
1413
- if (getUser && !toPublicUser) {
1414
- throw new Error(
1415
- `The \`${strategy}\` strategy signs the user into the credential, so it needs a \`toPublicUser\` to say what goes in. Signing the whole row would publish whatever else is on it.`
1416
- );
1417
- }
1418
- } else if (!getUser) {
1424
+ if (isSigned(strategy)) {
1425
+ if (getUser && !toPublicUser) {
1419
1426
  throw new Error(
1420
- `The \`${strategy}\` strategy puts an id in the credential, so it needs a \`getUser\` to resolve it. With no database, use \`cookie\` or \`jwt\`.`
1427
+ `The \`${strategy}\` strategy signs the user into the credential, so it needs a \`toPublicUser\` to say what goes in. Signing the whole row would publish whatever else is on it.`
1421
1428
  );
1422
1429
  }
1430
+ } else if (!getUser) {
1431
+ throw new Error(
1432
+ `The \`${strategy}\` strategy puts an id in the credential, so it needs a \`getUser\` to resolve it. With no database, use \`cookie\` or \`jwt\`.`
1433
+ );
1423
1434
  }
1424
1435
  const publicProfile = ({ id, email, name, avatar }) => ({
1425
1436
  id,
@@ -1431,19 +1442,28 @@ function entry(config2) {
1431
1442
  const loginTo = typeof config2.redirect === "object" ? redirects.login : config2.redirect;
1432
1443
  const finish = async (ctx, profile) => {
1433
1444
  const payload = getUser ? await (async () => {
1434
- const id = await onLogin(profile, ctx);
1445
+ let id;
1446
+ try {
1447
+ id = await onLogin(profile, ctx);
1448
+ } catch (error) {
1449
+ error.expose = true;
1450
+ throw error;
1451
+ }
1435
1452
  if (id === void 0 || id === null) {
1436
1453
  throw new Error("`onLogin` must return the id the credential points at");
1437
1454
  }
1438
- if (!isSigned(strategies[0])) return { sub: String(id) };
1455
+ if (!isSigned(strategy)) return { sub: String(id) };
1439
1456
  const user2 = await getUser(String(id), ctx);
1457
+ if (user2 === void 0 || user2 === null) {
1458
+ throw new Error(`getUser returned nothing for the id "${id}" that onLogin just returned`);
1459
+ }
1440
1460
  return { user: await toPublicUser(user2) };
1441
1461
  })() : { user: publicProfile(profile) };
1442
1462
  const signed = { ...payload, provider: profile.provider };
1443
1463
  const token = await issue(ctx, signed, expires);
1444
1464
  const user = signed.user ?? await getUser(signed.sub, ctx);
1445
1465
  const to = await target(loginTo, "/", user, ctx);
1446
- if (inCookie(strategies[0])) {
1466
+ if (inCookie(strategy)) {
1447
1467
  return cookies("session", {
1448
1468
  value: token,
1449
1469
  path: "/",
@@ -1458,9 +1478,8 @@ function entry(config2) {
1458
1478
  return {
1459
1479
  name: "flow",
1460
1480
  async user(ctx) {
1461
- const found = await read(ctx, strategies);
1462
- if (!found) return;
1463
- const { payload, strategy } = found;
1481
+ const payload = await read(ctx, strategy);
1482
+ if (!payload) return;
1464
1483
  ctx.auth = meta(payload, strategy);
1465
1484
  if (payload.user) return payload.user;
1466
1485
  if (!payload.sub) return;
@@ -1495,8 +1514,10 @@ function entry(config2) {
1495
1514
  res = await finish(ctx, profile);
1496
1515
  } catch (error) {
1497
1516
  const to = await target(redirects.error, "/", null, ctx);
1498
- const message = error.message;
1499
- res = redirect(`${to}?error=${encodeURIComponent(message)}`);
1517
+ let message = "Could not sign you in";
1518
+ if (error?.expose) message = error.message;
1519
+ else console.error(`[server:auth] ${name} callback failed:`, error);
1520
+ res = await redirect(`${to}?error=${encodeURIComponent(message)}`);
1500
1521
  }
1501
1522
  res.headers.append(
1502
1523
  "set-cookie",
@@ -1507,12 +1528,10 @@ function entry(config2) {
1507
1528
  app.get(`/auth/callback/${name}`, SPEC, callback);
1508
1529
  }
1509
1530
  app.post("/auth/logout", SPEC, async (ctx) => {
1510
- const found = await read(ctx, strategies).catch(() => void 0);
1511
- if (onLogout && found?.payload.sub) {
1512
- await onLogout(found.payload.sub, ctx);
1513
- }
1531
+ const payload = await read(ctx, strategy).catch(() => void 0);
1532
+ if (onLogout && payload?.sub) await onLogout(payload.sub, ctx);
1514
1533
  const to = await target(redirects.logout, "/", null, ctx);
1515
- if (!inCookie(strategies[0])) return status(204);
1534
+ if (!inCookie(strategy)) return status(204);
1516
1535
  return cookies("session", { value: null }).redirect(to);
1517
1536
  });
1518
1537
  }
@@ -1566,9 +1585,8 @@ var bearer2 = (ctx) => {
1566
1585
  const header = ctx.headers.authorization;
1567
1586
  if (!header) return;
1568
1587
  const [type2, token] = header.trim().split(" ");
1569
- if (type2?.toLowerCase() !== "bearer" || !token) {
1570
- throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
1571
- }
1588
+ if (type2?.toLowerCase() !== "bearer") return;
1589
+ if (!token) throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
1572
1590
  return token;
1573
1591
  };
1574
1592
  function entry2(options) {
@@ -1585,7 +1603,18 @@ function entry2(options) {
1585
1603
  async user(ctx) {
1586
1604
  const token = options.cookie ? ctx.cookies[options.cookie] : bearer2(ctx);
1587
1605
  if (!token) return;
1588
- const claims2 = await check(token, issuer, allowed, claimNames);
1606
+ let claims2;
1607
+ try {
1608
+ claims2 = await check(token, issuer, allowed, claimNames);
1609
+ } catch (error) {
1610
+ if (!options.cookie) throw error;
1611
+ ctx.clearCookie = options.cookie;
1612
+ ctx.options.log?.message(
1613
+ "auth",
1614
+ `discarded a ${options.cookie} cookie that ${issuer} did not sign, or that has expired`
1615
+ );
1616
+ return;
1617
+ }
1589
1618
  ctx.auth = {
1590
1619
  issuedAt: new Date((claims2.iat ?? 0) * 1e3),
1591
1620
  expiresAt: claims2.exp ? new Date(claims2.exp * 1e3) : void 0,
@@ -1668,8 +1697,12 @@ var vendors_default = VENDORS;
1668
1697
  // src/auth/parse.ts
1669
1698
  function parseAuth(auth2) {
1670
1699
  if (!auth2) return null;
1671
- const list = Array.isArray(auth2) ? auth2 : [auth2];
1672
- return list.flatMap((one) => toEntry(one));
1700
+ if (Array.isArray(auth2)) {
1701
+ throw new Error(
1702
+ "`auth` takes one method. For several login options, list them under `providers` instead: auth: { providers: ['github', 'google'], ... }."
1703
+ );
1704
+ }
1705
+ return toEntry(auth2);
1673
1706
  }
1674
1707
  function vendorEntry(strategy, name) {
1675
1708
  const vendor = vendors_default[name];
@@ -1711,15 +1744,15 @@ function toEntry(auth2) {
1711
1744
  `Invalid auth "${auth2}": the string form is "<strategy>:<name>", like "cookie:github" to log people in, or "jwt:clerk" to check a token a vendor issued.`
1712
1745
  );
1713
1746
  }
1714
- if (vendors_default[name]) return [vendorEntry(strategy, name)];
1715
- return [entry({ strategy, providers: name })];
1747
+ if (vendors_default[name]) return vendorEntry(strategy, name);
1748
+ return entry({ strategy, providers: name });
1716
1749
  }
1717
1750
  if (typeof auth2 === "function") {
1718
- return [{ name: "function", user: async (ctx) => auth2(ctx) }];
1751
+ return { name: "function", user: async (ctx) => auth2(ctx) };
1719
1752
  }
1720
1753
  if (auth2 && typeof auth2 === "object") {
1721
- if ("issuer" in auth2) return [entry2(auth2)];
1722
- if ("providers" in auth2) return [entry(auth2)];
1754
+ if ("issuer" in auth2) return entry2(auth2);
1755
+ if ("providers" in auth2) return entry(auth2);
1723
1756
  if ("handler" in auth2) {
1724
1757
  const instance = auth2;
1725
1758
  const path = (instance.path ?? "/api/auth").replace(/\/$/, "");
@@ -1733,20 +1766,18 @@ function toEntry(auth2) {
1733
1766
  ...ctx.body ? { duplex: "half" } : {}
1734
1767
  })
1735
1768
  );
1736
- return [
1737
- {
1738
- name: `instance:${path}`,
1739
- user: async (ctx) => instance.user?.(ctx),
1740
- routes: (app) => {
1741
- const wildcard = `${path}/*`;
1742
- app.get(wildcard, raw, forward);
1743
- app.post(wildcard, raw, forward);
1744
- app.put(wildcard, raw, forward);
1745
- app.patch(wildcard, raw, forward);
1746
- app.delete(wildcard, raw, forward);
1747
- }
1769
+ return {
1770
+ name: `instance:${path}`,
1771
+ user: async (ctx) => instance.user?.(ctx),
1772
+ routes: (app) => {
1773
+ const wildcard = `${path}/*`;
1774
+ app.get(wildcard, raw, forward);
1775
+ app.post(wildcard, raw, forward);
1776
+ app.put(wildcard, raw, forward);
1777
+ app.patch(wildcard, raw, forward);
1778
+ app.delete(wildcard, raw, forward);
1748
1779
  }
1749
- ];
1780
+ };
1750
1781
  }
1751
1782
  }
1752
1783
  throw new Error(
@@ -1816,16 +1847,16 @@ var STATUS_TEXT = {
1816
1847
  502: "Bad Gateway",
1817
1848
  503: "Service Unavailable"
1818
1849
  };
1819
- var UNITS3 = ["b", "kb", "mb", "gb", "tb"];
1850
+ var UNITS2 = ["b", "kb", "mb", "gb", "tb"];
1820
1851
  function formatBytes(bytes) {
1821
1852
  if (!bytes || bytes < 0) return "0b";
1822
1853
  const i = Math.min(
1823
1854
  Math.floor(Math.log(bytes) / Math.log(1024)),
1824
- UNITS3.length - 1
1855
+ UNITS2.length - 1
1825
1856
  );
1826
1857
  const value = bytes / 1024 ** i;
1827
1858
  const rounded = i === 0 ? Math.round(value) : Math.round(value * 10) / 10;
1828
- return `${rounded}${UNITS3[i]}`;
1859
+ return `${rounded}${UNITS2[i]}`;
1829
1860
  }
1830
1861
  var SCOPE_COLORS = {
1831
1862
  start: "green",
@@ -2014,10 +2045,10 @@ function config(options = {}) {
2014
2045
  options.auth || env2.AUTH || null
2015
2046
  );
2016
2047
  }
2017
- if (settings.auth?.some((one) => one.name === "flow") && settings.secrets[0].startsWith("unsafe-")) {
2018
- console.warn(
2019
- "[server:auth] auth with no SECRETS set: credentials are signed with a random per-process secret, so they break on restart and across instances. Set the SECRETS environment variable (or the `secrets` option)."
2020
- );
2048
+ if (settings.auth?.name === "flow" && settings.secrets[0].startsWith("unsafe-")) {
2049
+ const message = "Auth needs a stable secret: credentials are signed with it, and the random per-process fallback breaks them on restart and across instances. Set the SECRETS environment variable (or the `secrets` option).";
2050
+ if (env2.NODE_ENV === "production") throw new Error(message);
2051
+ console.warn(`[server:auth] ${message}`);
2021
2052
  }
2022
2053
  if (options.openapi) {
2023
2054
  const o = options.openapi;
@@ -2032,10 +2063,7 @@ function config(options = {}) {
2032
2063
  });
2033
2064
  settings.onResponse = options.onResponse;
2034
2065
  const loc = (v) => typeof v === "string" ? v : "enabled";
2035
- if (settings.auth) {
2036
- const names = settings.auth.map((one) => one.name).join(", ");
2037
- log.message("auth", ` enabled`);
2038
- }
2066
+ if (settings.auth) log.message("auth", `${settings.auth.name} enabled`);
2039
2067
  if (settings.public) log.message("public", loc(options.public));
2040
2068
  if (settings.uploads) log.message("uploads", loc(options.uploads));
2041
2069
  if (settings.cors) {
@@ -2162,6 +2190,12 @@ async function parseResponse(out, ctx) {
2162
2190
  applyCors(out, ctx);
2163
2191
  applySecurity(out, ctx);
2164
2192
  out = await applyCache(out, ctx);
2193
+ if (ctx.clearCookie) {
2194
+ out.headers.append(
2195
+ "set-cookie",
2196
+ `${ctx.clearCookie}=; Path=/; Max-Age=0; HttpOnly`
2197
+ );
2198
+ }
2165
2199
  if (ctx.time?.times?.length > 1) {
2166
2200
  out.headers.set("Server-Timing", ctx.time.headers());
2167
2201
  }
@@ -2412,17 +2446,11 @@ function toWeb(nodeStream) {
2412
2446
 
2413
2447
  // src/auth/index.ts
2414
2448
  function auth(app) {
2415
- const entries = app.settings.auth;
2449
+ const entry3 = app.settings.auth;
2416
2450
  app.use(async function middle(ctx) {
2417
- for (const entry3 of entries) {
2418
- const user = await entry3.user(ctx);
2419
- if (user) {
2420
- ctx.user = user;
2421
- return;
2422
- }
2423
- }
2451
+ ctx.user = await entry3.user(ctx);
2424
2452
  });
2425
- for (const entry3 of entries) entry3.routes?.(app);
2453
+ entry3.routes?.(app);
2426
2454
  }
2427
2455
 
2428
2456
  // src/helpers/parseRange.ts
@@ -2664,10 +2692,7 @@ function timer(ctx) {
2664
2692
  async function socketUser(app, headers2, cookies2) {
2665
2693
  if (!app.settings.auth) return void 0;
2666
2694
  const ctx = { options: app.settings, headers: headers2, cookies: cookies2 };
2667
- for (const entry3 of app.settings.auth) {
2668
- const user = await entry3.user(ctx);
2669
- if (user) return user;
2670
- }
2695
+ return app.settings.auth.user(ctx);
2671
2696
  }
2672
2697
 
2673
2698
  // src/helpers/wsNode.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.47.2",
3
+ "version": "0.48.1",
4
4
  "description": "A fully-fledged web server with routing, file uploads, sessions, static files, schema validation, websockets, testing, etc.",
5
5
  "homepage": "https://server-js.com/",
6
6
  "repository": "github:franciscop/server-next",