@binary1702/auth 0.1.1 → 0.2.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/INSTALL.md +11 -0
- package/PROMPT.md +1 -1
- package/README.md +17 -2
- package/dist/cli.js +18 -15
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +1 -0
- package/dist/core/db.d.ts +4 -4
- package/dist/core/db.js +11 -11
- package/dist/core/html.d.ts +8 -0
- package/dist/core/html.js +50 -19
- package/dist/core/session.d.ts +1 -1
- package/dist/core/session.js +3 -3
- package/dist/core/signup.d.ts +15 -0
- package/dist/core/signup.js +26 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/next/index.js +86 -44
- package/package.json +2 -2
package/INSTALL.md
CHANGED
|
@@ -40,8 +40,19 @@ const user = await requireUser() // { id, username } or throws 401
|
|
|
40
40
|
|
|
41
41
|
## More users
|
|
42
42
|
|
|
43
|
+
From the terminal:
|
|
44
|
+
|
|
43
45
|
```bash
|
|
44
46
|
npx @binary1702/auth add-user
|
|
45
47
|
npx @binary1702/auth set-password
|
|
46
48
|
npx @binary1702/auth list-users
|
|
47
49
|
```
|
|
50
|
+
|
|
51
|
+
Or let people register themselves at `/auth/sign-up`. Add to `.env.local`:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
AUTH_SIGNUP=open # anyone can create an account
|
|
55
|
+
AUTH_INVITE_CODE=secret # optional: the form then asks for this code
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Without `AUTH_SIGNUP`, sign-up is closed. The one exception is a fresh install with zero users, where `/auth/sign-in` takes you to create the first account.
|
package/PROMPT.md
CHANGED
|
@@ -67,7 +67,7 @@ const user = await requireUser() // { id, username } or throws AuthError (401)
|
|
|
67
67
|
|
|
68
68
|
For sign-out, link to `/auth/sign-out`. For client code, `fetch("/auth/session")`.
|
|
69
69
|
|
|
70
|
-
Do not build a sign-up page. Users
|
|
70
|
+
Do not build a sign-up page. One exists at `/auth/sign-up`. It is closed by default (except for the very first user on an empty install). To let people register, add `AUTH_SIGNUP=open` to `.env.local`, optionally with `AUTH_INVITE_CODE=<code>` to require a code on the form. Users can also be created with `npx @binary1702/auth add-user`.
|
|
71
71
|
|
|
72
72
|
## Deploy
|
|
73
73
|
|
package/README.md
CHANGED
|
@@ -77,6 +77,7 @@ Works in route handlers, server components, and server actions.
|
|
|
77
77
|
| URL | What it does |
|
|
78
78
|
|---|---|
|
|
79
79
|
| `/auth/sign-in` | The sign-in page. Served by the package, no React, no styling work. |
|
|
80
|
+
| `/auth/sign-up` | Self registration. Closed unless `AUTH_SIGNUP=open`. See [Users](#users). |
|
|
80
81
|
| `/auth/sign-out` | Clears the session and sends you back to sign-in. Link to it. |
|
|
81
82
|
| `/auth/session` | `{ user }` or `{ user: null }` as JSON, for client code. |
|
|
82
83
|
|
|
@@ -84,9 +85,11 @@ Everything else on the site requires a session. Unauthenticated page requests re
|
|
|
84
85
|
|
|
85
86
|
---
|
|
86
87
|
|
|
87
|
-
##
|
|
88
|
+
## Users
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
Two ways in. Pick one or use both.
|
|
91
|
+
|
|
92
|
+
**From the terminal** (always works):
|
|
90
93
|
|
|
91
94
|
```bash
|
|
92
95
|
npx @binary1702/auth add-user # prompts for username and password
|
|
@@ -95,6 +98,18 @@ npx @binary1702/auth set-password ben
|
|
|
95
98
|
npx @binary1702/auth list-users
|
|
96
99
|
```
|
|
97
100
|
|
|
101
|
+
**Self sign-up at `/auth/sign-up`**, controlled by one env var:
|
|
102
|
+
|
|
103
|
+
| `.env.local` | Who can sign up |
|
|
104
|
+
|---|---|
|
|
105
|
+
| nothing set | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
|
|
106
|
+
| `AUTH_SIGNUP=open` | Anyone. The sign-in page gets a "Create one" link. |
|
|
107
|
+
| `AUTH_SIGNUP=open` + `AUTH_INVITE_CODE=secret` | Anyone who knows the code. The form gets an invite field. |
|
|
108
|
+
|
|
109
|
+
Rules on the form: username 2 to 32 characters (letters, numbers, `. _ -`), password at least 8. Accounts are created and signed in immediately. Nothing to approve.
|
|
110
|
+
|
|
111
|
+
Programmatic: `POST /auth/sign-up` as JSON `{ username, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
|
|
112
|
+
|
|
98
113
|
---
|
|
99
114
|
|
|
100
115
|
## The two files it wrote
|
package/dist/cli.js
CHANGED
|
@@ -197,22 +197,22 @@ async function stepSchema() {
|
|
|
197
197
|
await ensureSchema().catch((e) => fail(`Could not reach the database: ${e.message}`));
|
|
198
198
|
done("auth_users table ready");
|
|
199
199
|
}
|
|
200
|
-
async function createUserInteractive(
|
|
200
|
+
async function createUserInteractive(presetEmail) {
|
|
201
201
|
const { createUser, findUser } = await db();
|
|
202
202
|
const { hashPassword } = await import("./core/password.js");
|
|
203
|
-
const
|
|
204
|
-
if (!
|
|
205
|
-
fail("
|
|
206
|
-
if (await findUser(
|
|
207
|
-
fail(`User "${
|
|
203
|
+
const email = (presetEmail ?? (await ask("Email"))).trim().toLowerCase();
|
|
204
|
+
if (!email)
|
|
205
|
+
fail("Email is required.");
|
|
206
|
+
if (await findUser(email))
|
|
207
|
+
fail(`User "${email}" already exists.`);
|
|
208
208
|
const password = await ask("Password", { hidden: true });
|
|
209
209
|
if (password.length < 8)
|
|
210
210
|
fail("Password must be at least 8 characters.");
|
|
211
211
|
const again = await ask("Password again", { hidden: true });
|
|
212
212
|
if (password !== again)
|
|
213
213
|
fail("Passwords do not match.");
|
|
214
|
-
await createUser(
|
|
215
|
-
done(`user "${
|
|
214
|
+
await createUser(email, await hashPassword(password));
|
|
215
|
+
done(`user "${email}" created`);
|
|
216
216
|
}
|
|
217
217
|
async function stepFirstUser() {
|
|
218
218
|
const { countUsers } = await db();
|
|
@@ -231,9 +231,12 @@ ${c.bold("Done.")} Start the dev server and open ${c.bold("/auth/sign-in")}.
|
|
|
231
231
|
Get the user in any route or server component:
|
|
232
232
|
|
|
233
233
|
import { requireUser } from "${PKG}/next"
|
|
234
|
-
const user = await requireUser() // { id,
|
|
234
|
+
const user = await requireUser() // { id, email }
|
|
235
235
|
|
|
236
236
|
Add users later: npx ${PKG} add-user
|
|
237
|
+
Or let people register themselves at /auth/sign-up:
|
|
238
|
+
AUTH_SIGNUP=open in .env.local (anyone)
|
|
239
|
+
AUTH_INVITE_CODE=<code> optional, the form then needs it
|
|
237
240
|
`);
|
|
238
241
|
}
|
|
239
242
|
// ── main ──────────────────────────────────────────────────────
|
|
@@ -242,8 +245,8 @@ async function main() {
|
|
|
242
245
|
console.log(`\n${c.bold(PKG)}\n`);
|
|
243
246
|
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
244
247
|
console.log(` npx ${PKG} set up auth in this project (safe to re-run)
|
|
245
|
-
npx ${PKG} add-user [
|
|
246
|
-
npx ${PKG} set-password [
|
|
248
|
+
npx ${PKG} add-user [email] create a user
|
|
249
|
+
npx ${PKG} set-password [email]
|
|
247
250
|
npx ${PKG} list-users
|
|
248
251
|
`);
|
|
249
252
|
return;
|
|
@@ -256,16 +259,16 @@ async function main() {
|
|
|
256
259
|
await createUserInteractive(arg);
|
|
257
260
|
if (cmd === "list-users") {
|
|
258
261
|
for (const u of await d.listUsers())
|
|
259
|
-
console.log(` ${u.
|
|
262
|
+
console.log(` ${u.email} ${c.dim(u.id)}`);
|
|
260
263
|
}
|
|
261
264
|
if (cmd === "set-password") {
|
|
262
|
-
const
|
|
265
|
+
const email = (arg ?? (await ask("Email"))).trim().toLowerCase();
|
|
263
266
|
const password = await ask("New password", { hidden: true });
|
|
264
267
|
if (password.length < 8)
|
|
265
268
|
fail("Password must be at least 8 characters.");
|
|
266
269
|
const { hashPassword } = await import("./core/password.js");
|
|
267
|
-
const ok = await d.setPassword(
|
|
268
|
-
ok ? done(`password updated for "${
|
|
270
|
+
const ok = await d.setPassword(email, await hashPassword(password));
|
|
271
|
+
ok ? done(`password updated for "${email}"`) : fail(`No user "${email}".`);
|
|
269
272
|
}
|
|
270
273
|
console.log();
|
|
271
274
|
return;
|
package/dist/core/config.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const COOKIE_NAME = "auth_session";
|
|
|
2
2
|
export declare const SESSION_DAYS = 7;
|
|
3
3
|
export declare const SIGN_IN_PATH = "/auth/sign-in";
|
|
4
4
|
export declare const SIGN_OUT_PATH = "/auth/sign-out";
|
|
5
|
+
export declare const SIGN_UP_PATH = "/auth/sign-up";
|
|
5
6
|
export declare const TABLE = "auth_users";
|
|
6
7
|
export declare function getSecret(): string;
|
|
7
8
|
export declare function getDatabaseUrl(): string;
|
package/dist/core/config.js
CHANGED
|
@@ -8,6 +8,7 @@ export const COOKIE_NAME = "auth_session";
|
|
|
8
8
|
export const SESSION_DAYS = 7;
|
|
9
9
|
export const SIGN_IN_PATH = "/auth/sign-in";
|
|
10
10
|
export const SIGN_OUT_PATH = "/auth/sign-out";
|
|
11
|
+
export const SIGN_UP_PATH = "/auth/sign-up";
|
|
11
12
|
export const TABLE = "auth_users";
|
|
12
13
|
export function getSecret() {
|
|
13
14
|
const secret = process.env.AUTH_SECRET;
|
package/dist/core/db.d.ts
CHANGED
|
@@ -3,14 +3,14 @@ import type { User } from "./session.ts";
|
|
|
3
3
|
export declare function ensureSchema(): Promise<void>;
|
|
4
4
|
type Row = {
|
|
5
5
|
id: string;
|
|
6
|
-
|
|
6
|
+
email: string;
|
|
7
7
|
password_hash: string;
|
|
8
8
|
};
|
|
9
|
-
export declare function findUser(
|
|
10
|
-
export declare function createUser(
|
|
9
|
+
export declare function findUser(email: string): Promise<Row | null>;
|
|
10
|
+
export declare function createUser(email: string, passwordHash: string): Promise<User>;
|
|
11
11
|
export declare function countUsers(): Promise<number>;
|
|
12
12
|
export declare function listUsers(): Promise<(User & {
|
|
13
13
|
created_at: string;
|
|
14
14
|
})[]>;
|
|
15
|
-
export declare function setPassword(
|
|
15
|
+
export declare function setPassword(email: string, passwordHash: string): Promise<boolean>;
|
|
16
16
|
export { TABLE };
|
package/dist/core/db.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
2
|
// Storage. One table, owned by this package.
|
|
3
3
|
//
|
|
4
|
-
// auth_users (id,
|
|
4
|
+
// auth_users (id, email, password_hash, created_at)
|
|
5
5
|
//
|
|
6
6
|
// ensureSchema() is idempotent and memoized per process, so route
|
|
7
7
|
// handlers can call it on every request for free after the first.
|
|
@@ -18,7 +18,7 @@ export function ensureSchema() {
|
|
|
18
18
|
await sql() `
|
|
19
19
|
CREATE TABLE IF NOT EXISTS auth_users (
|
|
20
20
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
21
|
-
|
|
21
|
+
email TEXT NOT NULL UNIQUE,
|
|
22
22
|
password_hash TEXT NOT NULL,
|
|
23
23
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
24
24
|
)
|
|
@@ -28,19 +28,19 @@ export function ensureSchema() {
|
|
|
28
28
|
throw err;
|
|
29
29
|
}));
|
|
30
30
|
}
|
|
31
|
-
export async function findUser(
|
|
31
|
+
export async function findUser(email) {
|
|
32
32
|
await ensureSchema();
|
|
33
33
|
const rows = (await sql() `
|
|
34
|
-
SELECT id,
|
|
34
|
+
SELECT id, email, password_hash FROM auth_users WHERE email = ${email}
|
|
35
35
|
`);
|
|
36
36
|
return rows[0] ?? null;
|
|
37
37
|
}
|
|
38
|
-
export async function createUser(
|
|
38
|
+
export async function createUser(email, passwordHash) {
|
|
39
39
|
await ensureSchema();
|
|
40
40
|
const [row] = (await sql() `
|
|
41
|
-
INSERT INTO auth_users (
|
|
42
|
-
VALUES (${
|
|
43
|
-
RETURNING id,
|
|
41
|
+
INSERT INTO auth_users (email, password_hash)
|
|
42
|
+
VALUES (${email}, ${passwordHash})
|
|
43
|
+
RETURNING id, email
|
|
44
44
|
`);
|
|
45
45
|
return row;
|
|
46
46
|
}
|
|
@@ -52,13 +52,13 @@ export async function countUsers() {
|
|
|
52
52
|
export async function listUsers() {
|
|
53
53
|
await ensureSchema();
|
|
54
54
|
return (await sql() `
|
|
55
|
-
SELECT id,
|
|
55
|
+
SELECT id, email, created_at FROM auth_users ORDER BY created_at
|
|
56
56
|
`);
|
|
57
57
|
}
|
|
58
|
-
export async function setPassword(
|
|
58
|
+
export async function setPassword(email, passwordHash) {
|
|
59
59
|
await ensureSchema();
|
|
60
60
|
const rows = (await sql() `
|
|
61
|
-
UPDATE auth_users SET password_hash = ${passwordHash} WHERE
|
|
61
|
+
UPDATE auth_users SET password_hash = ${passwordHash} WHERE email = ${email} RETURNING id
|
|
62
62
|
`);
|
|
63
63
|
return rows.length > 0;
|
|
64
64
|
}
|
package/dist/core/html.d.ts
CHANGED
|
@@ -2,4 +2,12 @@ export declare function signInPage(opts?: {
|
|
|
2
2
|
error?: string;
|
|
3
3
|
next?: string;
|
|
4
4
|
title?: string;
|
|
5
|
+
canSignUp?: boolean;
|
|
6
|
+
}): string;
|
|
7
|
+
export declare function signUpPage(opts?: {
|
|
8
|
+
error?: string;
|
|
9
|
+
next?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
inviteRequired?: boolean;
|
|
12
|
+
first?: boolean;
|
|
5
13
|
}): string;
|
package/dist/core/html.js
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// The sign-in
|
|
3
|
-
// so the consumer never builds a form or a page.
|
|
2
|
+
// The sign-in and sign-up pages. Plain HTML served by the route
|
|
3
|
+
// handler, so the consumer never builds a form or a page.
|
|
4
4
|
// ─────────────────────────────────────────────────────────────
|
|
5
|
-
import { SIGN_IN_PATH } from "./config.js";
|
|
5
|
+
import { SIGN_IN_PATH, SIGN_UP_PATH } from "./config.js";
|
|
6
6
|
const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
|
-
|
|
8
|
-
const title = escape(opts.title ?? "Sign in");
|
|
9
|
-
const next = escape(opts.next ?? "/");
|
|
10
|
-
const error = opts.error ? `<p class="error">${escape(opts.error)}</p>` : "";
|
|
11
|
-
return `<!doctype html>
|
|
12
|
-
<html lang="en">
|
|
13
|
-
<head>
|
|
14
|
-
<meta charset="utf-8">
|
|
15
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
-
<title>${title}</title>
|
|
17
|
-
<style>
|
|
7
|
+
const STYLE = `
|
|
18
8
|
:root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #ddd; --muted: #666; --err: #c0392b; }
|
|
19
9
|
@media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --line: #333; --muted: #999; --err: #ff6b6b; } }
|
|
20
10
|
* { box-sizing: border-box; }
|
|
@@ -27,17 +17,58 @@ export function signInPage(opts = {}) {
|
|
|
27
17
|
input:focus { outline: 2px solid var(--fg); outline-offset: 1px; }
|
|
28
18
|
button { font: inherit; font-weight: 500; padding: 9px; border: 0; border-radius: 6px; background: var(--fg); color: var(--bg); cursor: pointer; }
|
|
29
19
|
.error { margin: 0; font-size: 13px; color: var(--err); }
|
|
30
|
-
|
|
20
|
+
.alt { margin: 4px 0 0; font-size: 13px; color: var(--muted); text-align: center; }
|
|
21
|
+
.alt a { color: var(--fg); }
|
|
22
|
+
`;
|
|
23
|
+
function page(opts) {
|
|
24
|
+
const title = escape(opts.title);
|
|
25
|
+
const next = escape(opts.next ?? "/");
|
|
26
|
+
const error = opts.error ? `<p class="error">${escape(opts.error)}</p>` : "";
|
|
27
|
+
return `<!doctype html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<title>${title}</title>
|
|
33
|
+
<style>${STYLE}</style>
|
|
31
34
|
</head>
|
|
32
35
|
<body>
|
|
33
|
-
<form method="post" action="${
|
|
36
|
+
<form method="post" action="${opts.action}">
|
|
34
37
|
<h1>${title}</h1>
|
|
35
38
|
${error}
|
|
36
|
-
|
|
37
|
-
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
|
39
|
+
${opts.fields}
|
|
38
40
|
<input type="hidden" name="next" value="${next}">
|
|
39
|
-
<button type="submit"
|
|
41
|
+
<button type="submit">${escape(opts.button)}</button>
|
|
42
|
+
${opts.alt ?? ""}
|
|
40
43
|
</form>
|
|
41
44
|
</body>
|
|
42
45
|
</html>`;
|
|
43
46
|
}
|
|
47
|
+
export function signInPage(opts = {}) {
|
|
48
|
+
const next = opts.next && opts.next !== "/" ? `?next=${encodeURIComponent(opts.next)}` : "";
|
|
49
|
+
return page({
|
|
50
|
+
title: opts.title ?? "Sign in",
|
|
51
|
+
action: SIGN_IN_PATH,
|
|
52
|
+
error: opts.error,
|
|
53
|
+
next: opts.next,
|
|
54
|
+
button: "Sign in",
|
|
55
|
+
fields: `
|
|
56
|
+
<label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
|
|
57
|
+
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>`,
|
|
58
|
+
alt: opts.canSignUp ? `<p class="alt">No account? <a href="${SIGN_UP_PATH}${next}">Create one</a></p>` : undefined,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function signUpPage(opts = {}) {
|
|
62
|
+
const next = opts.next && opts.next !== "/" ? `?next=${encodeURIComponent(opts.next)}` : "";
|
|
63
|
+
return page({
|
|
64
|
+
title: opts.title ?? (opts.first ? "Create the first account" : "Create account"),
|
|
65
|
+
action: SIGN_UP_PATH,
|
|
66
|
+
error: opts.error,
|
|
67
|
+
next: opts.next,
|
|
68
|
+
button: "Create account",
|
|
69
|
+
fields: `
|
|
70
|
+
<label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
|
|
71
|
+
<label>Password <input name="password" type="password" autocomplete="new-password" minlength="8" required></label>${opts.inviteRequired ? `\n <label>Invite code <input name="invite" autocomplete="off" required></label>` : ""}`,
|
|
72
|
+
alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH}${next}">Sign in</a></p>`,
|
|
73
|
+
});
|
|
74
|
+
}
|
package/dist/core/session.d.ts
CHANGED
package/dist/core/session.js
CHANGED
|
@@ -9,7 +9,7 @@ import { SignJWT, jwtVerify } from "jose";
|
|
|
9
9
|
import { SESSION_DAYS } from "./config.js";
|
|
10
10
|
const key = (secret) => new TextEncoder().encode(secret);
|
|
11
11
|
export async function signSession(user, secret) {
|
|
12
|
-
return new SignJWT({
|
|
12
|
+
return new SignJWT({ email: user.email })
|
|
13
13
|
.setProtectedHeader({ alg: "HS256" })
|
|
14
14
|
.setSubject(user.id)
|
|
15
15
|
.setIssuedAt()
|
|
@@ -19,9 +19,9 @@ export async function signSession(user, secret) {
|
|
|
19
19
|
export async function verifySession(token, secret) {
|
|
20
20
|
try {
|
|
21
21
|
const { payload } = await jwtVerify(token, key(secret), { algorithms: ["HS256"] });
|
|
22
|
-
if (typeof payload.sub !== "string" || typeof payload.
|
|
22
|
+
if (typeof payload.sub !== "string" || typeof payload.email !== "string")
|
|
23
23
|
return null;
|
|
24
|
-
return { id: payload.sub,
|
|
24
|
+
return { id: payload.sub, email: payload.email };
|
|
25
25
|
}
|
|
26
26
|
catch {
|
|
27
27
|
return null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SignupPolicy = {
|
|
2
|
+
/** Can anyone reach the sign-up page right now? */
|
|
3
|
+
allowed: boolean;
|
|
4
|
+
/** Is an invite code required on the form? */
|
|
5
|
+
inviteRequired: boolean;
|
|
6
|
+
/** Why it is allowed. "first" skips the invite code: there is nobody to invite you. */
|
|
7
|
+
reason: "open" | "first" | "closed";
|
|
8
|
+
};
|
|
9
|
+
export declare function signupPolicy(env: {
|
|
10
|
+
AUTH_SIGNUP?: string;
|
|
11
|
+
AUTH_INVITE_CODE?: string;
|
|
12
|
+
}, userCount: number): SignupPolicy;
|
|
13
|
+
export declare const EMAIL_RE: RegExp;
|
|
14
|
+
/** Returns an error message, or null when the pair is acceptable. */
|
|
15
|
+
export declare function validateCredentials(email: string, password: string): string | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Sign-up policy. One pure function, so it can be tested and read.
|
|
3
|
+
//
|
|
4
|
+
// AUTH_SIGNUP=open anyone may register at /auth/sign-up
|
|
5
|
+
// AUTH_INVITE_CODE=<code> with "open": the form also needs this code
|
|
6
|
+
// (unset) closed, EXCEPT when there are no users yet,
|
|
7
|
+
// so a fresh install can create its first
|
|
8
|
+
// account in the browser instead of the CLI
|
|
9
|
+
// ─────────────────────────────────────────────────────────────
|
|
10
|
+
export function signupPolicy(env, userCount) {
|
|
11
|
+
if (userCount === 0)
|
|
12
|
+
return { allowed: true, inviteRequired: false, reason: "first" };
|
|
13
|
+
if (env.AUTH_SIGNUP === "open") {
|
|
14
|
+
return { allowed: true, inviteRequired: Boolean(env.AUTH_INVITE_CODE), reason: "open" };
|
|
15
|
+
}
|
|
16
|
+
return { allowed: false, inviteRequired: false, reason: "closed" };
|
|
17
|
+
}
|
|
18
|
+
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
/** Returns an error message, or null when the pair is acceptable. */
|
|
20
|
+
export function validateCredentials(email, password) {
|
|
21
|
+
if (!EMAIL_RE.test(email))
|
|
22
|
+
return "Please enter a valid email address.";
|
|
23
|
+
if (password.length < 8)
|
|
24
|
+
return "Password must be at least 8 characters.";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { hashPassword, verifyPassword } from "./core/password.ts";
|
|
2
2
|
export { signSession, verifySession, type User } from "./core/session.ts";
|
|
3
3
|
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.ts";
|
|
4
|
-
export { signInPage } from "./core/html.ts";
|
|
5
|
-
export {
|
|
4
|
+
export { signInPage, signUpPage } from "./core/html.ts";
|
|
5
|
+
export { signupPolicy, validateCredentials, type SignupPolicy } from "./core/signup.ts";
|
|
6
|
+
export { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, SIGN_UP_PATH, getSecret, getDatabaseUrl } from "./core/config.ts";
|
package/dist/index.js
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
export { hashPassword, verifyPassword } from "./core/password.js";
|
|
4
4
|
export { signSession, verifySession } from "./core/session.js";
|
|
5
5
|
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.js";
|
|
6
|
-
export { signInPage } from "./core/html.js";
|
|
7
|
-
export {
|
|
6
|
+
export { signInPage, signUpPage } from "./core/html.js";
|
|
7
|
+
export { signupPolicy, validateCredentials } from "./core/signup.js";
|
|
8
|
+
export { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, SIGN_UP_PATH, getSecret, getDatabaseUrl } from "./core/config.js";
|
package/dist/next/index.js
CHANGED
|
@@ -7,21 +7,24 @@
|
|
|
7
7
|
// Serves:
|
|
8
8
|
// GET /auth/sign-in the sign-in page
|
|
9
9
|
// POST /auth/sign-in verify, set cookie, redirect to ?next or /
|
|
10
|
+
// GET /auth/sign-up the sign-up page (see core/signup.ts for when)
|
|
11
|
+
// POST /auth/sign-up create user, set cookie, redirect
|
|
10
12
|
// GET /auth/sign-out clear cookie, redirect to sign-in
|
|
11
13
|
// POST /auth/sign-out same (for fetch callers)
|
|
12
14
|
// GET /auth/session { user } or { user: null }
|
|
13
15
|
//
|
|
14
16
|
// Use anywhere on the server:
|
|
15
|
-
// const user = await requireUser() // { id,
|
|
16
|
-
// const user = await getUser() // { id,
|
|
17
|
+
// const user = await requireUser() // { id, email } or throws AuthError(401)
|
|
18
|
+
// const user = await getUser() // { id, email } or null
|
|
17
19
|
// ─────────────────────────────────────────────────────────────
|
|
18
20
|
import { cookies } from "next/headers.js";
|
|
19
21
|
import { NextResponse } from "next/server.js";
|
|
20
|
-
import { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, getSecret } from "../core/config.js";
|
|
22
|
+
import { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_UP_PATH, getSecret } from "../core/config.js";
|
|
21
23
|
import { signSession, verifySession } from "../core/session.js";
|
|
22
|
-
import { verifyPassword } from "../core/password.js";
|
|
23
|
-
import { findUser } from "../core/db.js";
|
|
24
|
-
import { signInPage } from "../core/html.js";
|
|
24
|
+
import { hashPassword, verifyPassword } from "../core/password.js";
|
|
25
|
+
import { countUsers, createUser, findUser } from "../core/db.js";
|
|
26
|
+
import { signInPage, signUpPage } from "../core/html.js";
|
|
27
|
+
import { signupPolicy, validateCredentials } from "../core/signup.js";
|
|
25
28
|
export class AuthError extends Error {
|
|
26
29
|
status = 401;
|
|
27
30
|
constructor(message = "Unauthorized") {
|
|
@@ -67,18 +70,37 @@ function html(body, status = 200) {
|
|
|
67
70
|
}
|
|
68
71
|
async function readBody(req) {
|
|
69
72
|
const type = req.headers.get("content-type") ?? "";
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const f = await req.formData();
|
|
73
|
+
const get = (b, k) => String((b instanceof FormData ? b.get(k) : b[k]) ?? "");
|
|
74
|
+
const b = type.includes("application/json")
|
|
75
|
+
? (await req.json().catch(() => ({})))
|
|
76
|
+
: await req.formData();
|
|
75
77
|
return {
|
|
76
|
-
|
|
77
|
-
password:
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
email: get(b, "email").trim().toLowerCase(),
|
|
79
|
+
password: get(b, "password"),
|
|
80
|
+
invite: get(b, "invite").trim(),
|
|
81
|
+
next: safeNext(get(b, "next")),
|
|
82
|
+
json: type.includes("application/json"),
|
|
80
83
|
};
|
|
81
84
|
}
|
|
85
|
+
async function currentUser(req) {
|
|
86
|
+
const token = req.cookies.get(COOKIE_NAME)?.value;
|
|
87
|
+
return token ? verifySession(token, getSecret()) : null;
|
|
88
|
+
}
|
|
89
|
+
/** Sign the user in on a response: cookie + redirect (form) or JSON (fetch). */
|
|
90
|
+
async function signedIn(req, user, next, json) {
|
|
91
|
+
const token = await signSession(user, getSecret());
|
|
92
|
+
const res = json ? NextResponse.json({ user }) : NextResponse.redirect(new URL(next, req.url), 303);
|
|
93
|
+
res.cookies.set(COOKIE_NAME, token, cookieOptions());
|
|
94
|
+
return res;
|
|
95
|
+
}
|
|
96
|
+
/** Back to a form with ?error=<msg>, keeping ?next. */
|
|
97
|
+
function backTo(req, path, error, next) {
|
|
98
|
+
const url = new URL(path, req.url);
|
|
99
|
+
url.searchParams.set("error", error);
|
|
100
|
+
if (next !== "/")
|
|
101
|
+
url.searchParams.set("next", next);
|
|
102
|
+
return NextResponse.redirect(url, 303);
|
|
103
|
+
}
|
|
82
104
|
function signOutResponse(req, json) {
|
|
83
105
|
const res = json
|
|
84
106
|
? NextResponse.json({ ok: true })
|
|
@@ -86,26 +108,37 @@ function signOutResponse(req, json) {
|
|
|
86
108
|
res.cookies.set(COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0 });
|
|
87
109
|
return res;
|
|
88
110
|
}
|
|
111
|
+
async function policy() {
|
|
112
|
+
return signupPolicy(process.env, await countUsers());
|
|
113
|
+
}
|
|
89
114
|
// ── handlers ──────────────────────────────────────────────────
|
|
90
115
|
export async function GET(req, ctx) {
|
|
91
116
|
const { path } = await ctx.params;
|
|
92
117
|
const route = path.join("/");
|
|
118
|
+
const q = req.nextUrl.searchParams;
|
|
119
|
+
const next = safeNext(q.get("next"));
|
|
93
120
|
if (route === "sign-in") {
|
|
94
|
-
|
|
95
|
-
const existing = req.cookies.get(COOKIE_NAME)?.value;
|
|
96
|
-
if (existing && (await verifySession(existing, getSecret()))) {
|
|
121
|
+
if (await currentUser(req))
|
|
97
122
|
return NextResponse.redirect(new URL(next, req.url));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
const p = await policy();
|
|
124
|
+
// A fresh install has nobody to sign in: go straight to creating the first account.
|
|
125
|
+
if (p.reason === "first")
|
|
126
|
+
return NextResponse.redirect(new URL(SIGN_UP_PATH, req.url));
|
|
127
|
+
const error = q.get("error") === "1" ? "Wrong email or password." : (q.get("error") ?? undefined);
|
|
128
|
+
return html(signInPage({ next, error, canSignUp: p.allowed }));
|
|
129
|
+
}
|
|
130
|
+
if (route === "sign-up") {
|
|
131
|
+
if (await currentUser(req))
|
|
132
|
+
return NextResponse.redirect(new URL(next, req.url));
|
|
133
|
+
const p = await policy();
|
|
134
|
+
if (!p.allowed)
|
|
135
|
+
return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
|
|
136
|
+
return html(signUpPage({ next, error: q.get("error") ?? undefined, inviteRequired: p.inviteRequired, first: p.reason === "first" }));
|
|
101
137
|
}
|
|
102
138
|
if (route === "sign-out")
|
|
103
139
|
return signOutResponse(req, false);
|
|
104
|
-
if (route === "session")
|
|
105
|
-
|
|
106
|
-
const user = token ? await verifySession(token, getSecret()) : null;
|
|
107
|
-
return NextResponse.json({ user });
|
|
108
|
-
}
|
|
140
|
+
if (route === "session")
|
|
141
|
+
return NextResponse.json({ user: await currentUser(req) });
|
|
109
142
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
110
143
|
}
|
|
111
144
|
export async function POST(req, ctx) {
|
|
@@ -113,23 +146,32 @@ export async function POST(req, ctx) {
|
|
|
113
146
|
const route = path.join("/");
|
|
114
147
|
if (route === "sign-out")
|
|
115
148
|
return signOutResponse(req, true);
|
|
116
|
-
if (route
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (next !== "/")
|
|
127
|
-
url.searchParams.set("next", next);
|
|
128
|
-
return NextResponse.redirect(url, 303);
|
|
149
|
+
if (route === "sign-in") {
|
|
150
|
+
const { email, password, next, json } = await readBody(req);
|
|
151
|
+
const row = email && password ? await findUser(email) : null;
|
|
152
|
+
const ok = row ? await verifyPassword(password, row.password_hash) : false;
|
|
153
|
+
if (!row || !ok) {
|
|
154
|
+
if (json)
|
|
155
|
+
return NextResponse.json({ error: "Wrong email or password" }, { status: 401 });
|
|
156
|
+
return backTo(req, SIGN_IN_PATH, "1", next);
|
|
157
|
+
}
|
|
158
|
+
return signedIn(req, { id: row.id, email: row.email }, next, json);
|
|
129
159
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
160
|
+
if (route === "sign-up") {
|
|
161
|
+
const { email, password, invite, next, json } = await readBody(req);
|
|
162
|
+
const p = await policy();
|
|
163
|
+
const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : backTo(req, SIGN_UP_PATH, msg, next);
|
|
164
|
+
if (!p.allowed)
|
|
165
|
+
return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
|
|
166
|
+
if (p.inviteRequired && invite !== process.env.AUTH_INVITE_CODE)
|
|
167
|
+
return fail("Wrong invite code.", 403);
|
|
168
|
+
const invalid = validateCredentials(email, password);
|
|
169
|
+
if (invalid)
|
|
170
|
+
return fail(invalid);
|
|
171
|
+
if (await findUser(email))
|
|
172
|
+
return fail("That email is already registered.", 409);
|
|
173
|
+
const user = await createUser(email, await hashPassword(password));
|
|
174
|
+
return signedIn(req, user, next, json);
|
|
175
|
+
}
|
|
176
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
135
177
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@binary1702/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Email + password auth for Next.js. One command: npx @binary1702/auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|