@server/next 0.20.50 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.20.50",
3
+ "version": "0.21.0",
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": "https://github.com/franciscop/server-next.git",
@@ -34,11 +34,10 @@
34
34
  "index.d.ts",
35
35
  "jsx-dev-runtime.js"
36
36
  ],
37
- "dependencies": {},
38
37
  "devDependencies": {
39
38
  "argon2": "^0.40.3",
40
39
  "jest": "^29.7.0",
41
- "polystore": "^0.8.0"
40
+ "polystore": "^0.14.1"
42
41
  },
43
42
  "engines": {
44
43
  "node": ">=20.0.0"
package/src/auth/auth.js CHANGED
@@ -5,14 +5,14 @@ const validateToken = (authorization) => {
5
5
  if (type.toLowerCase() !== "bearer") {
6
6
  throw ServerError.AUTH_INVALID_TYPE({ type });
7
7
  }
8
- if (id.length !== 24) {
8
+ if (id.length !== 16) {
9
9
  throw ServerError.AUTH_INVALID_TOKEN();
10
10
  }
11
11
  return id;
12
12
  };
13
13
 
14
14
  const validateCookie = (authorization) => {
15
- if (authorization.length !== 24) {
15
+ if (authorization.length !== 16) {
16
16
  throw ServerError.AUTH_INVALID_COOKIE();
17
17
  }
18
18
  return authorization;
@@ -31,15 +31,15 @@ const findSessionId = (ctx) => {
31
31
 
32
32
  if (type === "cookie") {
33
33
  // If the user is not authenticated, there's no auth to retrieve
34
- if (!ctx.cookies.authorization) return;
34
+ if (!ctx.cookies.authentication) return;
35
35
 
36
- return validateCookie(ctx.cookies.authorization);
36
+ return validateCookie(ctx.cookies.authentication);
37
37
  }
38
38
 
39
- throw new Error("Invalid auth type " + type);
39
+ throw new Error(`Invalid auth type "${type}"`);
40
40
  };
41
41
 
42
- export default async function findAuth(ctx) {
42
+ export default async function auth(ctx) {
43
43
  if (!ctx.options.auth) return; // NO AUTH AT ALL; nothing to do here
44
44
  const options = ctx.options.auth;
45
45
 
@@ -47,7 +47,10 @@ export default async function findAuth(ctx) {
47
47
  if (!sessionId) return; // NO SESSION FOUND; no auth
48
48
 
49
49
  const auth = await options.session.get(sessionId);
50
+ // Mmh, which one to do...
50
51
  if (!auth) throw ServerError.AUTH_NO_SESSION();
52
+ // if (!auth) return; // SESSION ALREADY INVALID; no auth
53
+
51
54
  if (!auth.provider) throw ServerError.AUTH_NO_PROVIDER();
52
55
  if (!options.provider.includes(auth.provider)) {
53
56
  const valid = JSON.stringify(options.provider);
package/src/auth/index.js CHANGED
@@ -14,6 +14,13 @@ const middle = async (ctx) => {
14
14
  if (ctx.options.auth) {
15
15
  ctx.app.post("/auth/logout", logout);
16
16
 
17
+ if (ctx.options.auth.provider.includes("github")) {
18
+ if (!env.GITHUB_ID) throw new Error("GITHUB_ID not defined");
19
+ if (!env.GITHUB_SECRET) throw new Error("GITHUB_SECRET not defined");
20
+ ctx.app.get("/auth/login/github", providers.github.login);
21
+ ctx.app.get("/auth/callback/github", providers.github.callback);
22
+ }
23
+
17
24
  if (ctx.options.auth.provider.includes("email")) {
18
25
  ctx.app.post("/auth/register/email", providers.email.register);
19
26
  ctx.app.post("/auth/login/email", providers.email.login);
@@ -4,7 +4,7 @@ import kv from "polystore";
4
4
 
5
5
  import server from "../index.js";
6
6
 
7
- const ID = "REqA2l022l8Q0tuIRtqLOPUy";
7
+ const ID = "REqA2l022l8Q0tuI";
8
8
 
9
9
  describe("auth", () => {
10
10
  it("requires a provider", () => {
@@ -24,14 +24,14 @@ describe("auth", () => {
24
24
  id: ID,
25
25
  type: "token",
26
26
  provider: "wrong",
27
- user: "QypOn5SQApyOPdUpIZsO9u2O",
27
+ user: "QypOn5SQApyOPdUp",
28
28
  email: "abc@test.com",
29
29
  time: "2024-07-01T03:21:40Z",
30
30
  });
31
- const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
31
+ const authorization = "Bearer REqA2l022l8Q0tuI";
32
32
  const req = await api.get("/", { headers: { authorization } });
33
33
  expect(req).not.toSucceed(
34
- 'Invalid provider "wrong", valid ones are: "email"'
34
+ 'Invalid provider "wrong", valid ones are: "email"',
35
35
  );
36
36
  });
37
37
  });
@@ -47,7 +47,7 @@ describe("token", () => {
47
47
  });
48
48
 
49
49
  it("should be Bearer", async () => {
50
- const authorization = "Basic REqA2l022l8Q0tuIRtqLOPUy";
50
+ const authorization = "Basic REqA2l022l8Q0tuI";
51
51
  const req = await api.get("/", { headers: { authorization } });
52
52
  expect(req).not.toSucceed("Invalid Authorization type, 'Basic'");
53
53
  });
@@ -59,7 +59,7 @@ describe("token", () => {
59
59
  });
60
60
 
61
61
  it("cannot get the session", async () => {
62
- const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
62
+ const authorization = "Bearer REqA2l022l8Q0tuI";
63
63
  const req = await api.get("/", { headers: { authorization } });
64
64
  expect(req).not.toSucceed("Invalid session");
65
65
  });
@@ -69,29 +69,29 @@ describe("token", () => {
69
69
  id: ID,
70
70
  type: "token",
71
71
  provider: "email",
72
- user: "QypOn5SQApyOPdUpIZsO9u2O",
72
+ user: "QypOn5SQApyOPdUp",
73
73
  email: "abc@test.com",
74
74
  time: "2024-07-01T03:21:40Z",
75
75
  });
76
- const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
76
+ const authorization = "Bearer REqA2l022l8Q0tuI";
77
77
  const req = await api.get("/", { headers: { authorization } });
78
78
  expect(req).not.toSucceed("Credentials do not correspond to a user");
79
79
  });
80
80
 
81
81
  it("cannot get the user", async () => {
82
- store.set("auth:REqA2l022l8Q0tuIRtqLOPUy", {
83
- id: "REqA2l022l8Q0tuIRtqLOPUy",
82
+ store.set("auth:REqA2l022l8Q0tuI", {
83
+ id: "REqA2l022l8Q0tuI",
84
84
  type: "token",
85
85
  provider: "email",
86
- user: "QypOn5SQApyOPdUpIZsO9u2O",
86
+ user: "QypOn5SQApyOPdUp",
87
87
  email: "abc@test.com",
88
88
  time: "2024-07-01T03:21:40Z",
89
89
  });
90
- store.set("user:abc@test.com", {
91
- id: "QypOn5SQApyOPdUpIZsO9u2O",
90
+ store.set("user:QypOn5SQApyOPdUp", {
91
+ id: "QypOn5SQApyOPdUp",
92
92
  email: "abc@test.com",
93
93
  });
94
- const authorization = "Bearer REqA2l022l8Q0tuIRtqLOPUy";
94
+ const authorization = "Bearer REqA2l022l8Q0tuI";
95
95
  const req = await api.get("/", { headers: { authorization } });
96
96
  expect(req).toSucceed();
97
97
  });
@@ -104,19 +104,19 @@ describe("cookie", () => {
104
104
  .test();
105
105
 
106
106
  it("should have the proper token in email", async () => {
107
- const cookie = "authorization=hello";
107
+ const cookie = "authentication=hello";
108
108
  const req = await api.get("/", { headers: { cookie } });
109
109
  expect(req).not.toSucceed("Invalid Authorization cookie");
110
110
  });
111
111
 
112
112
  it("can get the proper session", async () => {
113
- const cookie = "authorization=REqA2l022l8Q0tuIRtqLOPUy";
113
+ const cookie = "authentication=REqA2l022l8Q0tuI";
114
114
  const req = await api.get("/", { headers: { cookie } });
115
115
  expect(req).not.toSucceed("Invalid session");
116
116
  });
117
117
 
118
118
  it("can get the proper session", async () => {
119
- const cookie = "authorization=REqA2l022l8Q0tuIRtqLOPUy";
119
+ const cookie = "authentication=REqA2l022l8Q0tuI";
120
120
  const req = await api.get("/", { headers: { cookie } });
121
121
  expect(req).not.toSucceed();
122
122
  });
@@ -1,6 +1,5 @@
1
1
  import { createId } from "../../helpers/index.js";
2
2
  import { ServerError, status } from "../../index.js";
3
- import findUser from "../findUser.js";
4
3
  import updateUser from "../updateUser.js";
5
4
 
6
5
  const hash = new Proxy(
@@ -12,7 +11,7 @@ const hash = new Proxy(
12
11
  self,
13
12
  await import("argon2").catch(() => {
14
13
  throw new ServerError.AUTH_ARGON_NEEDED();
15
- })
14
+ }),
16
15
  );
17
16
  if (key === "verify" && !self.verify) {
18
17
  return async (hash, pass) => {
@@ -28,29 +27,29 @@ const hash = new Proxy(
28
27
  }
29
28
  return self[key];
30
29
  },
31
- }
30
+ },
32
31
  );
33
32
 
34
33
  const createSession = async (user, ctx) => {
35
- const { type, session, cleanUser } = ctx.options.auth;
34
+ const { type, session, cleanUser, redirect = "/user" } = ctx.options.auth;
36
35
  user = cleanUser(user);
37
- const token = createId();
36
+ const id = createId();
38
37
  const provider = "email";
39
38
  const time = new Date().toISOString().replace(/\.[0-9]*/, "");
40
39
  ctx.auth = {
41
- id: token,
40
+ id,
42
41
  type,
43
42
  provider,
44
- user: user.id,
43
+ user: user.email,
45
44
  email: user.email,
46
45
  time,
47
46
  };
48
- await session.set(token, ctx.auth, { expires: "1w" });
47
+ await session.set(id, ctx.auth, { expires: "1w" });
49
48
 
50
49
  if (type === "token") {
51
- return status(201).json({ ...user, token });
50
+ return status(201).json({ ...user, token: id });
52
51
  } else if (type === "cookie") {
53
- return status(201).cookies({ authorization: token }).send(user);
52
+ return status(302).cookies({ authentication: id }).redirect(redirect);
54
53
  } else if (type === "jwt") {
55
54
  throw new Error("JWT auth not supported yet");
56
55
  } else if (type === "key") {
@@ -87,10 +86,12 @@ async function register(ctx) {
87
86
  const store = ctx.options.auth.store;
88
87
  if (await store.has(email)) throw ServerError.REGISTER_EMAIL_EXISTS();
89
88
 
89
+ const time = new Date().toISOString().replace(/\.[0-9]*/, "");
90
90
  const user = {
91
- id: createId(),
91
+ id: createId(user.email),
92
92
  email,
93
93
  password: await hash.hash(password),
94
+ time,
94
95
  ...data,
95
96
  };
96
97
  await store.set(email, user);
@@ -123,7 +124,7 @@ async function reset(ctx) {
123
124
  async function password(ctx) {
124
125
  const { previous, updated } = ctx.body;
125
126
 
126
- const fullUser = await findUser(ctx.auth, ctx.options.auth.store);
127
+ const fullUser = await ctx.options.auth.store.get(ctx.auth.user);
127
128
 
128
129
  const isValid = await hash.verify(fullUser.password, previous);
129
130
  if (!isValid) throw ServerError.LOGIN_WRONG_PASSWORD();
@@ -0,0 +1,82 @@
1
+ import createId from "../../helpers/createId.js";
2
+ import { redirect, status } from "../../reply.js";
3
+
4
+ const oauth = async (code) => {
5
+ const fch = async (url, { body, headers = {}, ...rest } = {}) => {
6
+ headers.accept = "application/json";
7
+ headers["content-type"] = "application/json";
8
+ const res = await fetch(url, { ...rest, body, headers });
9
+ if (!res.ok) throw new Error("Invalid request");
10
+ return res.json();
11
+ };
12
+
13
+ const res = await fch("https://github.com/login/oauth/access_token", {
14
+ method: "post",
15
+ body: JSON.stringify({
16
+ client_id: env.GITHUB_ID,
17
+ client_secret: env.GITHUB_SECRET,
18
+ code,
19
+ }),
20
+ });
21
+ return (path) => {
22
+ return fch("https://api.github.com" + path, {
23
+ headers: { Authorization: "Bearer " + res.access_token },
24
+ });
25
+ };
26
+ };
27
+
28
+ const login = (ctx) => {
29
+ return redirect(
30
+ `https://github.com/login/oauth/authorize?client_id=${env.GITHUB_ID}&scope=user:email`,
31
+ );
32
+ };
33
+
34
+ const getUserProfile = async (code) => {
35
+ const api = await oauth(code);
36
+ const [profile, emails] = await Promise.all([
37
+ api("/user"),
38
+ api("/user/emails"),
39
+ ]);
40
+ const email = emails.sort((a) => (a.primary ? -1 : 1))[0]?.email;
41
+ return { ...profile, email };
42
+ };
43
+
44
+ const callback = async (ctx) => {
45
+ const { type, cleanUser, store, session, redirect } = ctx.options.auth;
46
+
47
+ const profile = await getUserProfile(ctx.url.query.code);
48
+
49
+ const auth = {
50
+ id: createId(),
51
+ type,
52
+ provider: "github",
53
+ user: createId(profile.email),
54
+ email: profile.email,
55
+ time: new Date().toISOString().replace(/\.[0-9]*/, ""),
56
+ };
57
+ const user = cleanUser({
58
+ id: profile.id,
59
+ name: profile.name,
60
+ email: profile.email,
61
+ picture: profile.avatar_url,
62
+ location: profile.location,
63
+ created: profile.created_at,
64
+ });
65
+
66
+ await store.set(auth.user, user);
67
+ await session.set(auth.id, auth, { expires: "1w" });
68
+
69
+ if (auth.type === "token") {
70
+ return status(201).json({ ...user, token: auth.id });
71
+ } else if (auth.type === "cookie") {
72
+ return status(302).cookies({ authentication: auth.id }).redirect(redirect);
73
+ } else if (auth.type === "jwt") {
74
+ throw new Error("JWT auth not supported yet");
75
+ } else if (auth.type === "key") {
76
+ throw new Error("Key auth not supported yet");
77
+ } else {
78
+ throw new Error("Unknown auth type");
79
+ }
80
+ };
81
+
82
+ export default { login, callback };
@@ -1,3 +1,4 @@
1
1
  import { default as email } from "./email.js";
2
+ import { default as github } from "./github.js";
2
3
 
3
- export default { email };
4
+ export default { email, github };
package/src/auth/user.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import ServerError from "../ServerError.js";
2
- import findUser from "./findUser.js";
3
2
 
4
3
  export default async function user(ctx) {
5
4
  if (!ctx.auth) return;
6
5
 
7
- const user = await findUser(ctx.auth, ctx.options.auth.store);
6
+ const user = await ctx.options.auth.store.get(ctx.auth.user);
8
7
  if (!user) throw ServerError.AUTH_NO_USER();
9
8
  return ctx.options.auth.cleanUser(user);
10
9
  }
@@ -41,15 +41,15 @@ describe("parseBody", () => {
41
41
  const body = await parseBody(
42
42
  getBody(),
43
43
  "multipart/form-data; boundary=----WebKitFormBoundaryvef1fLxmoUdYZWXp",
44
- { write: (id) => id }
44
+ { write: (id) => id },
45
45
  );
46
46
  expect(body).toMatchObject({
47
47
  hello: "world",
48
48
  test: ["test message 123456", "test message number two"],
49
49
  });
50
50
 
51
- const matchMd = expect.stringMatching(/^\w{24}.md$/);
52
- const matchTxt = expect.stringMatching(/^\w{24}.txt$/);
51
+ const matchMd = expect.stringMatching(/^\w{16}.md$/);
52
+ const matchTxt = expect.stringMatching(/^\w{16}.txt$/);
53
53
  expect(body).toMatchObject({
54
54
  profile: matchMd,
55
55
  gallery: [matchTxt, matchTxt],
@@ -58,10 +58,13 @@ export default function config(options) {
58
58
  }
59
59
  if (!options.auth.cleanUser) {
60
60
  options.auth.cleanUser = (fullUser) => {
61
- const { password, ...user } = fullUser;
61
+ const { password, token, ...user } = fullUser;
62
62
  return user;
63
63
  };
64
64
  }
65
+ if (!options.auth.redirect) {
66
+ options.auth.redirect = "/user";
67
+ }
65
68
  }
66
69
 
67
70
  return options;
@@ -12,12 +12,12 @@ describe("set-cookie", () => {
12
12
  it("sets the right cookie", async () => {
13
13
  const api = app.test();
14
14
  const res = await api.get("/hello");
15
- expect(res.headers["set-cookie"]).toBe("hello=world");
15
+ expect(res.headers["set-cookie"]).toBe("hello=world;Path=/");
16
16
  });
17
17
 
18
18
  it("can set multiple cookies", async () => {
19
19
  const api = app.test();
20
20
  const res = await api.get("/multiple");
21
- expect(res.headers["set-cookie"]).toEqual(["a=b", "c=d"]);
21
+ expect(res.headers["set-cookie"]).toEqual(["a=b;Path=/", "c=d;Path=/"]);
22
22
  });
23
23
  });
@@ -9,7 +9,7 @@ export default function createCookies(cookies) {
9
9
  val = { value: val };
10
10
  }
11
11
  const { value, path, expires } = val;
12
- const pathPart = path ? ";Path=" + path : "";
12
+ const pathPart = ";Path=" + (path || "/");
13
13
  const expiresPart = expires ? ";Expires=" + expires : "";
14
14
  return `${key}=${value || ""}${pathPart}${expiresPart}`;
15
15
  });
@@ -1,17 +1,52 @@
1
- const urlAlphabet =
1
+ const alphabet =
2
2
  "useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict";
3
3
 
4
4
  export let random = (bytes) => crypto.getRandomValues(new Uint8Array(bytes));
5
5
 
6
- export default function createId() {
7
- let size = 24;
6
+ // Credit: https://stackoverflow.com/a/52171480/938236
7
+ const cyrb53 = (str, seed = 0) => {
8
+ if (typeof str !== "string") str = String(str);
9
+ let h1 = 0xdeadbeef ^ seed;
10
+ let h2 = 0x41c6ce57 ^ seed;
11
+ for (let i = 0, ch; i < str.length; i++) {
12
+ ch = str.charCodeAt(i);
13
+ h1 = Math.imul(h1 ^ ch, 2654435761);
14
+ h2 = Math.imul(h2 ^ ch, 1597334677);
15
+ }
16
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
17
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
18
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
19
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
20
+
21
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
22
+ };
23
+
24
+ const hash = (str, size) => {
25
+ let chars = "";
26
+
27
+ let num = cyrb53(str);
28
+ for (let i = 0; i < size; i++) {
29
+ if (num < alphabet.length) num = cyrb53(str, i);
30
+ chars += alphabet[num % alphabet.length];
31
+ num = Math.floor(num / alphabet.length);
32
+ }
33
+ return chars;
34
+ };
35
+
36
+ const randomId = (size = 16) => {
8
37
  let id = "";
9
- let bytes = crypto.getRandomValues(new Uint8Array(size));
38
+ let bytes = random(size);
10
39
  while (size--) {
11
40
  // Using the bitwise AND operator to "cap" the value of
12
41
  // the random byte from 255 to 63, in that way we can make sure
13
42
  // that the value will be a valid index for the "chars" string.
14
- id += urlAlphabet[bytes[size] & 61];
43
+ id += alphabet[bytes[size] & 61];
15
44
  }
16
45
  return id;
46
+ };
47
+
48
+ export default function createId(source) {
49
+ const size = 16;
50
+ if (source) return hash(source, size);
51
+ return randomId(size);
17
52
  }
@@ -19,7 +19,7 @@ export default async function parseResponse(out, ctx) {
19
19
 
20
20
  // A plain string will be converted to either html or plain
21
21
  if (typeof out === "string") {
22
- const type = /\s*\</.test(out) ? "text/html" : "text/plain";
22
+ const type = /^\s*\</.test(out) ? "text/html" : "text/plain";
23
23
  out = new Response(out, { headers: { "content-type": type } });
24
24
  }
25
25
 
package/src/reply.js CHANGED
@@ -60,6 +60,10 @@ Reply.prototype.json = function (body) {
60
60
  }).send(JSON.stringify(body));
61
61
  };
62
62
 
63
+ Reply.prototype.redirect = function (Location) {
64
+ return this.headers({ Location }).status(302).send();
65
+ };
66
+
63
67
  Reply.prototype.file = async function (path) {
64
68
  try {
65
69
  const data = await fs.readFile(path);
@@ -133,4 +137,5 @@ export const cookies = (...args) => new Reply().cookies(...args);
133
137
  export const send = (...args) => new Reply().send(...args);
134
138
  export const json = (...args) => new Reply().json(...args);
135
139
  export const file = (...args) => new Reply().file(...args);
140
+ export const redirect = (...args) => new Reply().redirect(...args);
136
141
  export const view = (...args) => new Reply().view(...args);
@@ -1,5 +0,0 @@
1
- export default async function findUser(auth, store) {
2
- if (auth.provider === "email") {
3
- return await store.get(auth.email);
4
- }
5
- }