@madauth/server 0.1.0 → 0.2.0-beta.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.
package/README.md CHANGED
@@ -1,27 +1,35 @@
1
1
  # @madauth/server
2
2
 
3
- The server of [madAuth](https://github.com/inouiw/madAuth), a self-hostable alternative to Cognito / Auth0: Google and e-mail & password sign-in with madAuth sessions. It runs in Docker, on AWS Lambda, on Azure Functions or inside your own Node server.
3
+ The server of [madAuth](https://github.com/inouiw/madAuth), a self-hostable alternative to Cognito / Auth0: Google and e-mail & password sign-in with madAuth sessions. It runs with Node, in Docker, on AWS Lambda, on Azure Functions or inside your own Node server.
4
4
 
5
5
  The browser side is [`@madauth/web`](https://www.npmjs.com/package/@madauth/web).
6
6
 
7
7
  ## Run it
8
8
 
9
- With Docker:
9
+ With Node.js 22.13 or newer:
10
10
 
11
11
  ```bash
12
- docker run --rm -p 8787:8787 --env-file .env -v madauth-data:/data ghcr.io/inouiw/madauth-server
12
+ npm install @madauth/server
13
13
  ```
14
14
 
15
- Or with Node.js 22.13 or newer:
15
+ ```bash
16
+ npx @madauth/server init
17
+ ```
16
18
 
17
19
  ```bash
18
- npx @madauth/server generate-key
20
+ npx @madauth/server start --env-file .env
19
21
  ```
20
22
 
23
+ `init` asks a few questions and writes the configuration to `.env`, with a new signing key, then prints the next steps. `start` runs the server on port 8787. See [Getting started](https://github.com/inouiw/madAuth/blob/main/docs/getting-started.md).
24
+
25
+ With Docker:
26
+
21
27
  ```bash
22
- npm install @madauth/server
28
+ docker run --rm -p 8787:8787 --env-file .env -v madauth-data:/data ghcr.io/inouiw/madauth-server
23
29
  ```
24
30
 
31
+ Or inside your own Node server:
32
+
25
33
  ```ts
26
34
  import { createApp, loadConfig } from '@madauth/server';
27
35
 
@@ -38,12 +46,14 @@ The package also contains self-contained bundles for AWS Lambda (`dist/standalon
38
46
  | `@madauth/server/verify` | verify a madAuth session in your own backend |
39
47
  | `@madauth/server/webhook` | verify webhook calls in your receiver |
40
48
  | `@madauth/server/sqlite` | the SQLite store |
49
+ | `@madauth/server/dynamodb` | the Amazon DynamoDB store |
41
50
  | `@madauth/server/node`, `/lambda`, `/azure` | hosting entry points |
42
51
  | `@madauth/server/testing` | a contract test suite for custom store adapters |
43
52
 
44
53
  ## Documentation
45
54
 
46
- See [Running the madAuth server](https://github.com/inouiw/madAuth/blob/main/docs/server.md) for the configuration, Google Cloud Console setup, webhooks, hosting and custom store adapters.
55
+ - [Getting started](https://github.com/inouiw/madAuth/blob/main/docs/getting-started.md): set up madAuth on your machine, step by step.
56
+ - [Running the madAuth server](https://github.com/inouiw/madAuth/blob/main/docs/server.md): the configuration, Google Cloud Console setup, webhooks, hosting and custom store adapters.
47
57
 
48
58
  ## License
49
59
 
package/dist/bin.js CHANGED
@@ -1,46 +1,105 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- createTablesSql
4
- } from "./chunk-3WIWWKRI.js";
3
+ createTablesSql,
4
+ upgradeTablesSql
5
+ } from "./chunk-CNL562LZ.js";
5
6
  import {
6
- ConfigError,
7
+ madauthSchema
8
+ } from "./chunk-XNBWZZHT.js";
9
+ import {
10
+ Roles,
7
11
  Users,
8
12
  checkPasswordPolicy,
9
13
  hashPassword,
10
14
  isValidEmail,
15
+ loadConfig,
16
+ loadStore,
11
17
  loadUserStoreConfig,
12
- normalizeEmail
13
- } from "./chunk-V2XKGETK.js";
18
+ normalizeEmail,
19
+ parseRoles
20
+ } from "./chunk-5CKEMC6M.js";
14
21
  import {
15
22
  generateSigningKey
16
23
  } from "./chunk-JDQLSL36.js";
17
24
  import {
25
+ DEV_WEBHOOK_RECEIVER_URL,
18
26
  generateWebhookSecret
19
- } from "./chunk-AVZE6FFR.js";
27
+ } from "./chunk-6EJDVZCD.js";
20
28
 
21
29
  // src/cli.ts
30
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
22
31
  import { createInterface } from "readline";
23
- var usage = `Usage: npx @madauth/server <command>
32
+ import { parseArgs, parseEnv } from "util";
33
+ var PORT = 8787;
34
+ var usage = `Usage: npx @madauth/server <command> [--env-file <path>]
24
35
 
25
36
  Commands:
37
+ init Ask a few questions, write the configuration to .env and print the next steps.
38
+ An answer given as an option is not asked for: --app-url <url>,
39
+ --google-client-id <id>, --google-client-secret <secret>, --password or
40
+ --no-password, --database <url>, --webhook-url <url>. --yes takes the
41
+ default for the rest. --out <path> writes another file, --force overwrites.
42
+ start Start the server on PORT (default ${PORT})
26
43
  generate-key Print a new private ES256 key to use as MADAUTH_SIGNING_KEY
27
44
  generate-webhook-secret Print a new secret to use as WEBHOOK_SECRET (madAuth and your receiver)
28
45
  create-user <email> Create a user with a password (asks for it), already verified.
29
46
  Uses DATABASE_URL and PASSWORD_MIN_LENGTH from the environment.
30
- schema [--dialect <name>] Print the SQL that creates madAuth's tables for a custom store adapter.
31
- Dialects: postgres (default), mysql, sqlite`;
47
+ set-roles <email> [role...] Set the roles of an e-mail address, e.g. to make the first admin:
48
+ set-roles you@example.com admin. Without roles, removes them all.
49
+ Uses DATABASE_URL from the environment.
50
+ get-roles <email> Print the roles of an e-mail address.
51
+ schema [--dialect <name>] [--from <version>]
52
+ Print the SQL that creates madAuth's tables for a custom store adapter.
53
+ Dialects: postgres (default), mysql, sqlite. With --from, print only the
54
+ changes since that schema version, to upgrade existing tables.
55
+
56
+ Options:
57
+ --env-file <path> Read environment variables from this file, for any command. A variable
58
+ that is already set in the environment wins.`;
59
+ var piped;
60
+ async function readPipedLine() {
61
+ if (!piped) {
62
+ const reader = createInterface({ input: process.stdin, terminal: false });
63
+ const input = piped = { reader, lines: reader[Symbol.asyncIterator](), ended: false };
64
+ reader.once("close", () => input.ended = true);
65
+ }
66
+ if (!piped.ended) piped.reader.resume();
67
+ const next = await piped.lines.next();
68
+ if (!piped.ended) piped.reader.pause();
69
+ return next.done ? "" : next.value;
70
+ }
32
71
  var terminalIo = {
72
+ async ask(question, defaultValue = "") {
73
+ const prompt = `${question}${defaultValue ? ` [${defaultValue}]` : ""}: `;
74
+ if (!process.stdin.isTTY) {
75
+ process.stdout.write(prompt);
76
+ const line = await readPipedLine();
77
+ process.stdout.write("\n");
78
+ return line.trim() || defaultValue;
79
+ }
80
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
81
+ return new Promise((resolve, reject) => {
82
+ let answered = false;
83
+ rl.on("SIGINT", () => rl.close());
84
+ rl.on("close", () => {
85
+ if (answered) return;
86
+ process.stdout.write("\n");
87
+ reject(new Error("Cancelled."));
88
+ });
89
+ rl.question(prompt, (answer) => {
90
+ answered = true;
91
+ rl.close();
92
+ resolve(answer.trim() || defaultValue);
93
+ });
94
+ });
95
+ },
33
96
  async askSecret(question) {
34
97
  process.stdout.write(question);
35
98
  const stdin = process.stdin;
36
99
  if (!stdin.isTTY) {
37
- const rl = createInterface({ input: stdin, terminal: false });
38
- for await (const line of rl) {
39
- rl.close();
40
- process.stdout.write("\n");
41
- return line;
42
- }
43
- return "";
100
+ const line = await readPipedLine();
101
+ process.stdout.write("\n");
102
+ return line;
44
103
  }
45
104
  stdin.setRawMode(true);
46
105
  stdin.setEncoding("utf8");
@@ -72,9 +131,61 @@ var terminalIo = {
72
131
  });
73
132
  }
74
133
  };
134
+ function parseCliArgs(args) {
135
+ return parseArgs({
136
+ args,
137
+ options: {
138
+ "env-file": { type: "string" },
139
+ dialect: { type: "string" },
140
+ // schema: print only the changes since this schema version.
141
+ from: { type: "string" },
142
+ // init: an answer for each question, then where and how to write the file.
143
+ "app-url": { type: "string" },
144
+ "google-client-id": { type: "string" },
145
+ "google-client-secret": { type: "string" },
146
+ password: { type: "boolean" },
147
+ database: { type: "string" },
148
+ "webhook-url": { type: "string" },
149
+ yes: { type: "boolean" },
150
+ out: { type: "string" },
151
+ force: { type: "boolean" }
152
+ },
153
+ allowPositionals: true,
154
+ // --no-password
155
+ allowNegative: true
156
+ });
157
+ }
75
158
  async function runCli(args, io = terminalIo, env = process.env) {
76
- const [command, ...rest] = args;
159
+ let parsed;
77
160
  try {
161
+ parsed = parseCliArgs(args);
162
+ } catch (e) {
163
+ return { output: `${e.message}
164
+
165
+ ${usage}`, exitCode: 1 };
166
+ }
167
+ const { values: options, positionals } = parsed;
168
+ const [command, ...rest] = positionals;
169
+ const maxArguments = command === "set-roles" ? Infinity : command === "create-user" || command === "get-roles" ? 1 : 0;
170
+ if (rest.length > maxArguments) {
171
+ return { output: `Unexpected argument: ${rest.at(-1)}
172
+
173
+ ${usage}`, exitCode: 1 };
174
+ }
175
+ try {
176
+ const envFile = options["env-file"];
177
+ if (envFile !== void 0) {
178
+ if (!existsSync(envFile)) return { output: `--env-file: ${envFile} does not exist.`, exitCode: 1 };
179
+ env = withEnvFile(envFile, env);
180
+ }
181
+ if (command === "init") {
182
+ return await init(options, io);
183
+ }
184
+ if (command === "start") {
185
+ const { start } = await import("./entry/node.js");
186
+ await start({ env });
187
+ return { output: "", exitCode: 0 };
188
+ }
78
189
  if (command === "generate-key") {
79
190
  return { output: JSON.stringify(await generateSigningKey()), exitCode: 0 };
80
191
  }
@@ -82,19 +193,170 @@ async function runCli(args, io = terminalIo, env = process.env) {
82
193
  return { output: generateWebhookSecret(), exitCode: 0 };
83
194
  }
84
195
  if (command === "schema") {
85
- const i = rest.indexOf("--dialect");
86
- const dialect = i >= 0 ? rest[i + 1] : "postgres";
196
+ const dialect = options.dialect ?? "postgres";
87
197
  if (!["postgres", "mysql", "sqlite"].includes(dialect)) return { output: usage, exitCode: 1 };
88
- return { output: createTablesSql(dialect), exitCode: 0 };
198
+ if (options.from === void 0) return { output: createTablesSql(dialect), exitCode: 0 };
199
+ const from = Number(options.from);
200
+ if (!Number.isInteger(from) || from < 1 || from > madauthSchema.version) return { output: usage, exitCode: 1 };
201
+ return { output: upgradeTablesSql(dialect, from) || "-- The tables are up to date.", exitCode: 0 };
89
202
  }
90
203
  if (command === "create-user") {
91
204
  return await createUser(rest[0], io, env);
92
205
  }
206
+ if (command === "set-roles" || command === "get-roles") {
207
+ return await roles(command, rest[0], rest.slice(1), env);
208
+ }
93
209
  } catch (e) {
94
- return { output: e instanceof ConfigError ? e.message : String(e), exitCode: 1 };
210
+ return { output: e instanceof Error ? e.message : String(e), exitCode: 1 };
95
211
  }
96
212
  return { output: usage, exitCode: command ? 1 : 0 };
97
213
  }
214
+ function withEnvFile(path, env) {
215
+ const merged = parseEnv(readFileSync(path, "utf8"));
216
+ for (const [name, value] of Object.entries(env)) {
217
+ if (value !== void 0) merged[name] = value;
218
+ }
219
+ return merged;
220
+ }
221
+ function httpOrigin(value) {
222
+ const url = URL.canParse(value) ? new URL(value) : void 0;
223
+ return url && (url.protocol === "http:" || url.protocol === "https:") ? url.origin : void 0;
224
+ }
225
+ async function init(options, io) {
226
+ const fail = (output2) => ({ output: output2, exitCode: 1 });
227
+ const file = options.out ?? ".env";
228
+ if (existsSync(file) && !options.force) {
229
+ return fail(`${file} already exists. Use --force to overwrite it, or --out <path> to write another file.`);
230
+ }
231
+ const answer = async (given, question, defaultValue = "") => (given ?? (options.yes ? defaultValue : await io.ask(question, defaultValue))).trim();
232
+ const appUrl = await answer(options["app-url"], "App URL, the address your users open", "http://localhost:5173");
233
+ const origin = httpOrigin(appUrl);
234
+ if (!origin) return fail(`The app URL must be an http(s) URL, e.g. https://app.example.com, but is "${appUrl}".`);
235
+ const clientId = await answer(options["google-client-id"], "Google client ID (empty: no Google sign-in)");
236
+ const askClientSecret = () => options.yes ? "" : io.askSecret("Google client secret (empty: no redirect flow): ");
237
+ if (!clientId && options["google-client-secret"] !== void 0) {
238
+ return fail("--google-client-secret needs a Google client ID.");
239
+ }
240
+ const clientSecret = clientId ? (options["google-client-secret"] ?? await askClientSecret()).trim() : "";
241
+ const password = options.password ?? /^y/i.test(await answer(void 0, "E-mail & password sign-in? (yes/no)", "yes"));
242
+ if (!password && (options.database !== void 0 || options["webhook-url"] !== void 0)) {
243
+ return fail("--database and --webhook-url are for e-mail & password sign-in, which is turned off.");
244
+ }
245
+ if (!clientId && !password) {
246
+ return fail("No sign-in method is chosen. Give a Google client ID, turn on e-mail & password sign-in, or both.");
247
+ }
248
+ const database = password ? await answer(options.database, "Database for the users", "sqlite:./madauth.db") : "";
249
+ if (password && !/^(sqlite|dynamodb):./.test(database)) {
250
+ return fail(`The database must be sqlite:<path> or dynamodb:<table> but is "${database}".`);
251
+ }
252
+ const webhookUrl = password ? await answer(options["webhook-url"], "Webhook URL, where madAuth hands over its e-mails", "http://localhost:8790/webhook") : "";
253
+ const variable = (name, value, comment) => `# ${comment}
254
+ ${name}=${value}
255
+ `;
256
+ const content = [
257
+ "# Configuration of the madAuth server, written by npx @madauth/server init. It holds secrets: never commit it.\n",
258
+ "# All settings: https://github.com/inouiw/madAuth/blob/main/docs/server.md#configuration\n\n",
259
+ variable(
260
+ "MADAUTH_ISSUER",
261
+ origin,
262
+ "Public address of the madAuth server: the origin of your app, which proxies /auth and /.well-known to it."
263
+ ),
264
+ // In single quotes, as JSON is full of double quotes. Node's parser removes them; Docker's --env-file
265
+ // keeps them, and the server removes them then.
266
+ variable(
267
+ "MADAUTH_SIGNING_KEY",
268
+ `'${JSON.stringify(await generateSigningKey())}'`,
269
+ "Private key that signs the sessions. Keep it secret, and use the same one on every instance."
270
+ ),
271
+ variable("ALLOWED_ORIGINS", origin, "Origins of your apps, comma-separated. Only these may call the server."),
272
+ clientId && variable("GOOGLE_CLIENT_ID", clientId, "Google sign-in: your OAuth client ID, of the type Web application."),
273
+ clientSecret && variable("GOOGLE_CLIENT_SECRET", clientSecret, "Client secret of the same client. It enables the redirect flow."),
274
+ password && variable("DATABASE_URL", database, "E-mail & password sign-in: where the users are stored."),
275
+ password && variable("WEBHOOK_URL", webhookUrl, "Your webhook receiver. madAuth hands its e-mails to it."),
276
+ password && variable("WEBHOOK_SECRET", generateWebhookSecret(), "Signs every webhook call. Your receiver needs the same one."),
277
+ password && variable(
278
+ "WEBHOOK_EVENTS",
279
+ "email.verify,email.reset,email.already_registered",
280
+ "The types your webhook receiver handles; only these are sent. Add e.g. signup.before or user.created."
281
+ ),
282
+ variable("PORT", String(PORT), "Port the server listens on.")
283
+ ].filter(Boolean).join("");
284
+ await loadConfig({ ...parseEnv(content), DATABASE_URL: void 0 }, password ? { store: {} } : {});
285
+ if (options.force) rmSync(file, { force: true });
286
+ writeFileSync(file, content, { mode: 384, flag: "wx" });
287
+ const google = clientSecret ? "GoogleRedirect" : clientId ? "GoogleFedcm" : void 0;
288
+ return { output: `Wrote ${file}.
289
+
290
+ Next steps:
291
+
292
+ ${nextSteps(file, origin, google, password)}`, exitCode: 0 };
293
+ }
294
+ function nextSteps(file, origin, google, password) {
295
+ const server = `http://localhost:${PORT}`;
296
+ const envFile = /[^\w./-]/.test(file) ? `'${file.replaceAll("'", `'\\''`)}'` : file;
297
+ const providers = [google, password && "Password"].filter(Boolean);
298
+ const googleOrigins = /* @__PURE__ */ new Set([origin, ...new URL(origin).hostname === "localhost" ? ["http://localhost"] : []]);
299
+ const steps = [
300
+ ["Start the madAuth server:", "", ` npx @madauth/server start --env-file ${envFile}`],
301
+ password && [
302
+ "madAuth hands its e-mails to your webhook receiver. For development, start the development receiver,",
303
+ `which prints them, with the same WEBHOOK_URL and WEBHOOK_SECRET as in ${file}:`,
304
+ "",
305
+ ` ${DEV_WEBHOOK_RECEIVER_URL}`
306
+ ],
307
+ [
308
+ "Proxy /auth and /.well-known from your app to the server, so that its cookies are first-party.",
309
+ "With Vite, in vite.config.ts:",
310
+ "",
311
+ " server: {",
312
+ " proxy: {",
313
+ ` '/auth': '${server}',`,
314
+ ` '/.well-known': '${server}',`,
315
+ " },",
316
+ " },"
317
+ ],
318
+ [
319
+ "Sign users in from your app (npm install @madauth/web):",
320
+ "",
321
+ ` import { Madauth, ${providers.join(", ")} } from '@madauth/web';`,
322
+ "",
323
+ ` Madauth.initialize({ providers: [${providers.map((provider) => `new ${provider}()`).join(", ")}] });`,
324
+ " Madauth.onAuthStateChanged((user) => console.log(user)); // the user, or null",
325
+ " signInButton.onclick = () => Madauth.signIn();"
326
+ ],
327
+ google && [
328
+ "In the Google Cloud Console (https://console.cloud.google.com/apis/credentials), add to your OAuth client:",
329
+ "",
330
+ ` Authorized JavaScript origins: ${[...googleOrigins].join(" and ")}`,
331
+ ...google === "GoogleRedirect" ? [` Authorized redirect URIs: ${origin}/auth/google/callback`] : []
332
+ ],
333
+ [
334
+ `Never commit ${file}: it holds the signing key and your secrets. Production needs an https app URL`,
335
+ "and a signing key of its own: run init again for it."
336
+ ]
337
+ ].filter((step) => !!step);
338
+ return steps.map((lines, i) => `${i + 1}. ${lines.map((line, j) => j && line ? ` ${line}` : line).join("\n")}`).join("\n\n");
339
+ }
340
+ async function roles(command, email, names, env) {
341
+ if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
342
+
343
+ ${usage}`, exitCode: 1 };
344
+ const store = await loadStore(env);
345
+ const address = normalizeEmail(email);
346
+ const assignments = new Roles(store);
347
+ if (command === "set-roles") {
348
+ const parsed = parseRoles(names);
349
+ if (!parsed) {
350
+ return {
351
+ output: 'Role names consist of lower-case letters, digits, "-" and "_", and start with a letter, e.g. admin.',
352
+ exitCode: 1
353
+ };
354
+ }
355
+ await assignments.set(address, parsed, null);
356
+ }
357
+ const current = await assignments.get(address);
358
+ return { output: current.length ? `${address}: ${current.join(" ")}` : `${address} has no roles.`, exitCode: 0 };
359
+ }
98
360
  async function createUser(email, io, env) {
99
361
  if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
100
362
 
@@ -117,5 +379,5 @@ ${usage}`, exitCode: 1 };
117
379
 
118
380
  // src/bin.ts
119
381
  var { output, exitCode } = await runCli(process.argv.slice(2));
120
- (exitCode ? console.error : console.log)(output);
382
+ if (output) (exitCode ? console.error : console.log)(output);
121
383
  process.exitCode = exitCode;