@binary1702/auth 0.2.4 → 0.2.5
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 +4 -2
- package/PROMPT.md +6 -6
- package/README.md +28 -19
- package/dist/cli.js +23 -5
- package/dist/core/db.d.ts +11 -1
- package/dist/core/db.js +44 -4
- package/dist/core/session.d.ts +13 -2
- package/dist/core/session.js +23 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.js +22 -10
- package/dist/next/proxy.js +5 -0
- package/package.json +1 -1
package/INSTALL.md
CHANGED
|
@@ -27,7 +27,7 @@ Every page and API route is now behind sign-in. Get the user anywhere on the ser
|
|
|
27
27
|
```ts
|
|
28
28
|
import { requireUser } from "@binary1702/auth/next"
|
|
29
29
|
|
|
30
|
-
const user = await requireUser() // { id,
|
|
30
|
+
const user = await requireUser() // { id, email } or throws 401
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
## URLs you get
|
|
@@ -44,7 +44,9 @@ From the terminal:
|
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
npx @binary1702/auth add-user
|
|
47
|
-
npx @binary1702/auth set-password
|
|
47
|
+
npx @binary1702/auth set-password # also ends their sessions
|
|
48
|
+
npx @binary1702/auth remove-user
|
|
49
|
+
npx @binary1702/auth sign-out-everywhere
|
|
48
50
|
npx @binary1702/auth list-users
|
|
49
51
|
```
|
|
50
52
|
|
package/PROMPT.md
CHANGED
|
@@ -4,7 +4,7 @@ Copy everything below the line into an AI coding agent running inside the Next.j
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Install `@binary1702/auth` in this project. It adds
|
|
7
|
+
Install `@binary1702/auth` in this project. It adds email + password auth to a Next.js App Router app. Follow these steps exactly and report the result of each.
|
|
8
8
|
|
|
9
9
|
## Before you start
|
|
10
10
|
|
|
@@ -22,7 +22,7 @@ Run, in the project root:
|
|
|
22
22
|
npx @binary1702/auth
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
It is interactive. It asks for the Postgres connection string, then
|
|
25
|
+
It is interactive. It asks for the Postgres connection string, then an email and a password for the first user. If you cannot answer prompts, run it and hand control to me.
|
|
26
26
|
|
|
27
27
|
It does the following, skipping anything already done:
|
|
28
28
|
|
|
@@ -33,7 +33,7 @@ It does the following, skipping anything already done:
|
|
|
33
33
|
✓ app/auth/[...path]/route.ts → export { GET, POST } from "@binary1702/auth/next"
|
|
34
34
|
✓ proxy.ts → guard in front of every route
|
|
35
35
|
✓ auth_users table ready → in the database
|
|
36
|
-
✓ user "<
|
|
36
|
+
✓ user "<email>" created
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
If `proxy.ts` or `middleware.ts` already existed, the CLI does not touch it and prints a snippet instead. Merge the snippet into the existing file: call the package's `proxy` for the paths you want protected and keep the matcher inline. On Next 15 the file is `middleware.ts`, same content.
|
|
@@ -47,8 +47,8 @@ Start the dev server and check with real HTTP requests:
|
|
|
47
47
|
| `GET /` with no cookie | redirect to `/auth/sign-in` |
|
|
48
48
|
| `GET /auth/sign-in` | 200, HTML form |
|
|
49
49
|
| `GET /api/<any>` with no cookie | 401 JSON |
|
|
50
|
-
| `POST /auth/sign-in` JSON `{ "
|
|
51
|
-
| `GET /auth/session` with that cookie | `{ user: { id,
|
|
50
|
+
| `POST /auth/sign-in` JSON `{ "email", "password" }` | 200 `{ user }` and `Set-Cookie: auth_session=…; HttpOnly` |
|
|
51
|
+
| `GET /auth/session` with that cookie | `{ user: { id, email } }` |
|
|
52
52
|
| `GET /auth/sign-out` | cookie cleared, redirect to sign-in |
|
|
53
53
|
|
|
54
54
|
Then open `/auth/sign-in` in the browser, sign in, confirm you land on `/`.
|
|
@@ -60,7 +60,7 @@ Wherever the app needs the current user on the server:
|
|
|
60
60
|
```ts
|
|
61
61
|
import { requireUser } from "@binary1702/auth/next"
|
|
62
62
|
|
|
63
|
-
const user = await requireUser() // { id,
|
|
63
|
+
const user = await requireUser() // { id, email } or throws AuthError (401)
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
`getUser()` returns `null` instead of throwing. Both work in route handlers, server components and server actions.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @binary1702/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Email + password auth for Next.js. Plug and play.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @binary1702/auth
|
|
@@ -33,10 +33,10 @@ You will be asked for one thing: your Postgres connection string (Neon). Everyth
|
|
|
33
33
|
✓ auth_users table ready
|
|
34
34
|
|
|
35
35
|
Create the first user:
|
|
36
|
-
|
|
36
|
+
Email: ben@example.com
|
|
37
37
|
Password:
|
|
38
38
|
Password again:
|
|
39
|
-
✓ user "ben" created
|
|
39
|
+
✓ user "ben@example.com" created
|
|
40
40
|
|
|
41
41
|
Done. Start the dev server and open /auth/sign-in.
|
|
42
42
|
```
|
|
@@ -55,7 +55,7 @@ Every page and API route is now behind sign-in. To know who the user is:
|
|
|
55
55
|
import { requireUser } from "@binary1702/auth/next"
|
|
56
56
|
|
|
57
57
|
export async function GET() {
|
|
58
|
-
const user = await requireUser() // { id,
|
|
58
|
+
const user = await requireUser() // { id, email }
|
|
59
59
|
// ...
|
|
60
60
|
}
|
|
61
61
|
```
|
|
@@ -65,7 +65,7 @@ That is the only line you ever write by hand.
|
|
|
65
65
|
```ts
|
|
66
66
|
import { getUser } from "@binary1702/auth/next"
|
|
67
67
|
|
|
68
|
-
const user = await getUser() // { id,
|
|
68
|
+
const user = await getUser() // { id, email } or null
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
Works in route handlers, server components, and server actions.
|
|
@@ -92,9 +92,11 @@ Two ways in. Pick one or use both.
|
|
|
92
92
|
**From the terminal** (always works):
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
|
-
npx @binary1702/auth add-user
|
|
96
|
-
npx @binary1702/auth add-user gladys
|
|
97
|
-
npx @binary1702/auth set-password ben
|
|
95
|
+
npx @binary1702/auth add-user # prompts for email and password
|
|
96
|
+
npx @binary1702/auth add-user gladys@example.com # email given
|
|
97
|
+
npx @binary1702/auth set-password ben@example.com # also signs them out everywhere
|
|
98
|
+
npx @binary1702/auth remove-user ben@example.com # delete; their sessions end too
|
|
99
|
+
npx @binary1702/auth sign-out-everywhere ben@example.com # end every session, keep the account
|
|
98
100
|
npx @binary1702/auth list-users
|
|
99
101
|
```
|
|
100
102
|
|
|
@@ -105,10 +107,11 @@ npx @binary1702/auth list-users
|
|
|
105
107
|
| nothing set | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
|
|
106
108
|
| `AUTH_SIGNUP=open` | Anyone. The sign-in page gets a "Create one" link. |
|
|
107
109
|
| `AUTH_SIGNUP=open` + `AUTH_INVITE_CODE=secret` | Anyone who knows the code. The form gets an invite field. |
|
|
110
|
+
| `AUTH_EMAIL_DOMAIN=example.com` | Combine with any of the above: only `@example.com` emails can sign up. |
|
|
108
111
|
|
|
109
|
-
Rules on the form:
|
|
112
|
+
Rules on the form: a valid email address, password at least 8. Accounts are created and signed in immediately. Nothing to approve.
|
|
110
113
|
|
|
111
|
-
Programmatic: `POST /auth/sign-up` as JSON `{
|
|
114
|
+
Programmatic: `POST /auth/sign-up` as JSON `{ email, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
|
|
112
115
|
|
|
113
116
|
---
|
|
114
117
|
|
|
@@ -150,6 +153,7 @@ If you already had a `proxy.ts`, the CLI leaves it alone and prints the snippet
|
|
|
150
153
|
Both live in `.env.local`. Copy both to your host (Vercel, etc.) when you deploy.
|
|
151
154
|
|
|
152
155
|
Rotating `AUTH_SECRET` signs everyone out. That is the revoke-all button.
|
|
156
|
+
For one user: `sign-out-everywhere`, `set-password` or `remove-user`.
|
|
153
157
|
|
|
154
158
|
---
|
|
155
159
|
|
|
@@ -158,23 +162,28 @@ Rotating `AUTH_SECRET` signs everyone out. That is the revoke-all button.
|
|
|
158
162
|
Five primitives around one cookie. Nothing else.
|
|
159
163
|
|
|
160
164
|
```
|
|
161
|
-
auth_users (id,
|
|
165
|
+
auth_users (id, email, password_hash, created_at,
|
|
166
|
+
session_epoch) identity one table, owned by the package
|
|
162
167
|
scrypt proof node:crypto, no dependency
|
|
163
|
-
signed JWT in an httpOnly cookie, 7 days
|
|
168
|
+
signed JWT in an httpOnly cookie, 7 days, carries the
|
|
169
|
+
user's session_epoch session revoked when the epoch moves on
|
|
164
170
|
proxy.ts guard verifies the cookie signature only
|
|
171
|
+
getUser() / requireUser() gate signature + one lookup by id
|
|
165
172
|
/auth/sign-in /auth/sign-out /auth/session lifecycle one route file
|
|
166
173
|
```
|
|
167
174
|
|
|
168
175
|
```
|
|
169
176
|
Request
|
|
170
177
|
├─ proxy.ts cookie valid? → continue else → redirect (page) or 401 (api)
|
|
171
|
-
├─ /auth/sign-in POST
|
|
172
|
-
└─ your route requireUser() → reads cookie → { id,
|
|
178
|
+
├─ /auth/sign-in POST email+password → verify hash → set cookie → redirect
|
|
179
|
+
└─ your route requireUser() → reads cookie → user still exists, epoch matches? → { id, email }
|
|
173
180
|
```
|
|
174
181
|
|
|
175
182
|
**Authentication only.** The package tells you `user.id`. What that user may see or edit is your app's job. Keeping that boundary is what keeps this package small enough to never break.
|
|
176
183
|
|
|
177
|
-
**
|
|
184
|
+
**Two gates.** The proxy checks only the token's signature, so it costs nothing per request. `getUser()` / `requireUser()` also look the user up by id and compare the token's `session_epoch` with the database's. Deleting a user, changing their password or `sign-out-everywhere` moves the epoch on (or removes the row), and every older token stops working there. A revoked token still passes the proxy and can load a page, but it gets no user and no data: treat `null` / 401 as "go to `/auth/sign-out`", which clears the cookie and lands on sign-in.
|
|
185
|
+
|
|
186
|
+
Tokens signed before 0.2.5 carry no epoch and count as 0, so upgrading signs nobody out. The `session_epoch` column is added automatically on first start.
|
|
178
187
|
|
|
179
188
|
---
|
|
180
189
|
|
|
@@ -185,8 +194,8 @@ Request
|
|
|
185
194
|
| Export | |
|
|
186
195
|
|---|---|
|
|
187
196
|
| `GET`, `POST` | Route handlers. Mount at `app/auth/[...path]/route.ts`. |
|
|
188
|
-
| `requireUser()` | `Promise<{ id,
|
|
189
|
-
| `getUser()` | `Promise<{ id,
|
|
197
|
+
| `requireUser()` | `Promise<{ id, email }>`. Throws `AuthError` (status 401) if signed out, deleted or revoked. |
|
|
198
|
+
| `getUser()` | `Promise<{ id, email } \| null>`. Null if signed out, deleted or revoked. One database lookup; throws only if the database is unreachable. |
|
|
190
199
|
| `unauthorized(err)` | Turns an `AuthError` into a 401 JSON response. Rethrows anything else. |
|
|
191
200
|
| `AuthError` | The error class. Has `.status = 401`. |
|
|
192
201
|
|
|
@@ -223,7 +232,7 @@ const { user } = await fetch("/auth/session").then((r) => r.json())
|
|
|
223
232
|
await fetch("/auth/sign-in", {
|
|
224
233
|
method: "POST",
|
|
225
234
|
headers: { "content-type": "application/json" },
|
|
226
|
-
body: JSON.stringify({
|
|
235
|
+
body: JSON.stringify({ email, password }),
|
|
227
236
|
}) // 200 { user } or 401 { error }
|
|
228
237
|
|
|
229
238
|
// sign out
|
|
@@ -236,7 +245,7 @@ Or just link to `/auth/sign-out`.
|
|
|
236
245
|
|
|
237
246
|
## Custom sign-in page
|
|
238
247
|
|
|
239
|
-
The built-in page is plain HTML with light and dark mode. If you want your own, make any page with a form that POSTs `
|
|
248
|
+
The built-in page is plain HTML with light and dark mode. If you want your own, make any page with a form that POSTs `email` and `password` to `/auth/sign-in`, and put it anywhere under `/auth/` so the proxy lets it through. Optional hidden field `next` for where to go after.
|
|
240
249
|
|
|
241
250
|
---
|
|
242
251
|
|
package/dist/cli.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
// 7. create the first user
|
|
15
15
|
//
|
|
16
16
|
// Subcommands:
|
|
17
|
-
// add-user [
|
|
18
|
-
// set-password [
|
|
17
|
+
// add-user [email] create another user
|
|
18
|
+
// set-password [email] change a password (signs them out everywhere)
|
|
19
|
+
// remove-user [email] delete a user (signs them out everywhere)
|
|
20
|
+
// sign-out-everywhere [email] end every session of a user
|
|
19
21
|
// list-users
|
|
20
22
|
// ─────────────────────────────────────────────────────────────
|
|
21
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
@@ -246,13 +248,16 @@ async function main() {
|
|
|
246
248
|
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
247
249
|
console.log(` npx ${PKG} set up auth in this project (safe to re-run)
|
|
248
250
|
npx ${PKG} add-user [email] create a user
|
|
249
|
-
npx ${PKG} set-password [email]
|
|
251
|
+
npx ${PKG} set-password [email] also signs them out everywhere
|
|
252
|
+
npx ${PKG} remove-user [email] delete; also signs them out everywhere
|
|
253
|
+
npx ${PKG} sign-out-everywhere [email] end every session of a user
|
|
250
254
|
npx ${PKG} list-users
|
|
251
255
|
`);
|
|
252
256
|
return;
|
|
253
257
|
}
|
|
254
258
|
stepProject();
|
|
255
|
-
|
|
259
|
+
const USER_CMDS = ["add-user", "set-password", "remove-user", "sign-out-everywhere", "list-users"];
|
|
260
|
+
if (USER_CMDS.includes(cmd)) {
|
|
256
261
|
await stepEnv();
|
|
257
262
|
const d = await db();
|
|
258
263
|
if (cmd === "add-user")
|
|
@@ -268,7 +273,20 @@ async function main() {
|
|
|
268
273
|
fail("Password must be at least 8 characters.");
|
|
269
274
|
const { hashPassword } = await import("./core/password.js");
|
|
270
275
|
const ok = await d.setPassword(email, await hashPassword(password));
|
|
271
|
-
ok ? done(`password updated for "${email}"`) : fail(`No user "${email}".`);
|
|
276
|
+
ok ? done(`password updated for "${email}", old sessions ended`) : fail(`No user "${email}".`);
|
|
277
|
+
}
|
|
278
|
+
if (cmd === "remove-user" || cmd === "sign-out-everywhere") {
|
|
279
|
+
const email = (arg ?? (await ask("Email"))).trim().toLowerCase();
|
|
280
|
+
if (cmd === "remove-user") {
|
|
281
|
+
const sure = await ask(`Delete "${email}"? This cannot be undone. Type the email again`);
|
|
282
|
+
if (sure.trim().toLowerCase() !== email)
|
|
283
|
+
fail("Not deleted.");
|
|
284
|
+
(await d.removeUser(email)) ? done(`user "${email}" deleted, sessions ended`) : fail(`No user "${email}".`);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
;
|
|
288
|
+
(await d.revokeSessions(email)) ? done(`every session of "${email}" ended`) : fail(`No user "${email}".`);
|
|
289
|
+
}
|
|
272
290
|
}
|
|
273
291
|
console.log();
|
|
274
292
|
return;
|
package/dist/core/db.d.ts
CHANGED
|
@@ -5,12 +5,22 @@ type Row = {
|
|
|
5
5
|
id: string;
|
|
6
6
|
email: string;
|
|
7
7
|
password_hash: string;
|
|
8
|
+
epoch: number;
|
|
8
9
|
};
|
|
9
10
|
export declare function findUser(email: string): Promise<Row | null>;
|
|
10
|
-
export declare function createUser(email: string, passwordHash: string): Promise<User
|
|
11
|
+
export declare function createUser(email: string, passwordHash: string): Promise<User & {
|
|
12
|
+
epoch: number;
|
|
13
|
+
}>;
|
|
14
|
+
/** The user's current session_epoch, or null if the user is gone */
|
|
15
|
+
export declare function sessionEpoch(id: string): Promise<number | null>;
|
|
16
|
+
/** Revoke every session of this user. False if there is no such user. */
|
|
17
|
+
export declare function revokeSessions(email: string): Promise<boolean>;
|
|
18
|
+
/** Delete the user. Their sessions die with the row. False if none. */
|
|
19
|
+
export declare function removeUser(email: string): Promise<boolean>;
|
|
11
20
|
export declare function countUsers(): Promise<number>;
|
|
12
21
|
export declare function listUsers(): Promise<(User & {
|
|
13
22
|
created_at: string;
|
|
14
23
|
})[]>;
|
|
24
|
+
/** Change a password. Also revokes every session, old password's included. */
|
|
15
25
|
export declare function setPassword(email: string, passwordHash: string): Promise<boolean>;
|
|
16
26
|
export { TABLE };
|
package/dist/core/db.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
2
|
// Storage. One table, owned by this package.
|
|
3
3
|
//
|
|
4
|
-
// auth_users (id, email, password_hash, created_at)
|
|
4
|
+
// auth_users (id, email, password_hash, created_at, session_epoch)
|
|
5
|
+
//
|
|
6
|
+
// session_epoch: every session token carries the value it was signed
|
|
7
|
+
// under, and is only good while they match (see core/session.ts).
|
|
8
|
+
// +1 revokes every session of that user; deleting the row revokes
|
|
9
|
+
// them too, since there is nothing left to match.
|
|
5
10
|
//
|
|
6
11
|
// ensureSchema() is idempotent and memoized per process, so route
|
|
7
12
|
// handlers can call it on every request for free after the first.
|
|
@@ -22,6 +27,10 @@ export function ensureSchema() {
|
|
|
22
27
|
password_hash TEXT NOT NULL,
|
|
23
28
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
24
29
|
)
|
|
30
|
+
`;
|
|
31
|
+
// Added in 0.2.5: installs from before get it on their next start
|
|
32
|
+
await sql() `
|
|
33
|
+
ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS session_epoch INT NOT NULL DEFAULT 0
|
|
25
34
|
`;
|
|
26
35
|
})().catch((err) => {
|
|
27
36
|
_schema = null; // let the next call retry
|
|
@@ -31,7 +40,7 @@ export function ensureSchema() {
|
|
|
31
40
|
export async function findUser(email) {
|
|
32
41
|
await ensureSchema();
|
|
33
42
|
const rows = (await sql() `
|
|
34
|
-
SELECT id, email, password_hash FROM auth_users WHERE email = ${email}
|
|
43
|
+
SELECT id, email, password_hash, session_epoch AS epoch FROM auth_users WHERE email = ${email}
|
|
35
44
|
`);
|
|
36
45
|
return rows[0] ?? null;
|
|
37
46
|
}
|
|
@@ -40,10 +49,38 @@ export async function createUser(email, passwordHash) {
|
|
|
40
49
|
const [row] = (await sql() `
|
|
41
50
|
INSERT INTO auth_users (email, password_hash)
|
|
42
51
|
VALUES (${email}, ${passwordHash})
|
|
43
|
-
RETURNING id, email
|
|
52
|
+
RETURNING id, email, session_epoch AS epoch
|
|
44
53
|
`);
|
|
45
54
|
return row;
|
|
46
55
|
}
|
|
56
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
|
+
/** The user's current session_epoch, or null if the user is gone */
|
|
58
|
+
export async function sessionEpoch(id) {
|
|
59
|
+
// A non-UUID would make Postgres throw on the cast; it can't be a user
|
|
60
|
+
if (!UUID.test(id))
|
|
61
|
+
return null;
|
|
62
|
+
await ensureSchema();
|
|
63
|
+
const rows = (await sql() `
|
|
64
|
+
SELECT session_epoch FROM auth_users WHERE id = ${id}
|
|
65
|
+
`);
|
|
66
|
+
return rows[0]?.session_epoch ?? null;
|
|
67
|
+
}
|
|
68
|
+
/** Revoke every session of this user. False if there is no such user. */
|
|
69
|
+
export async function revokeSessions(email) {
|
|
70
|
+
await ensureSchema();
|
|
71
|
+
const rows = (await sql() `
|
|
72
|
+
UPDATE auth_users SET session_epoch = session_epoch + 1 WHERE email = ${email} RETURNING id
|
|
73
|
+
`);
|
|
74
|
+
return rows.length > 0;
|
|
75
|
+
}
|
|
76
|
+
/** Delete the user. Their sessions die with the row. False if none. */
|
|
77
|
+
export async function removeUser(email) {
|
|
78
|
+
await ensureSchema();
|
|
79
|
+
const rows = (await sql() `
|
|
80
|
+
DELETE FROM auth_users WHERE email = ${email} RETURNING id
|
|
81
|
+
`);
|
|
82
|
+
return rows.length > 0;
|
|
83
|
+
}
|
|
47
84
|
export async function countUsers() {
|
|
48
85
|
await ensureSchema();
|
|
49
86
|
const [row] = (await sql() `SELECT COUNT(*)::int AS n FROM auth_users`);
|
|
@@ -55,10 +92,13 @@ export async function listUsers() {
|
|
|
55
92
|
SELECT id, email, created_at FROM auth_users ORDER BY created_at
|
|
56
93
|
`);
|
|
57
94
|
}
|
|
95
|
+
/** Change a password. Also revokes every session, old password's included. */
|
|
58
96
|
export async function setPassword(email, passwordHash) {
|
|
59
97
|
await ensureSchema();
|
|
60
98
|
const rows = (await sql() `
|
|
61
|
-
UPDATE auth_users
|
|
99
|
+
UPDATE auth_users
|
|
100
|
+
SET password_hash = ${passwordHash}, session_epoch = session_epoch + 1
|
|
101
|
+
WHERE email = ${email} RETURNING id
|
|
62
102
|
`);
|
|
63
103
|
return rows.length > 0;
|
|
64
104
|
}
|
package/dist/core/session.d.ts
CHANGED
|
@@ -2,5 +2,16 @@ export type User = {
|
|
|
2
2
|
id: string;
|
|
3
3
|
email: string;
|
|
4
4
|
};
|
|
5
|
-
|
|
6
|
-
export
|
|
5
|
+
/** What a valid token says: who, and under which epoch it was signed */
|
|
6
|
+
export type Claim = User & {
|
|
7
|
+
epoch: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function signSession(user: User & {
|
|
10
|
+
epoch?: number;
|
|
11
|
+
}, secret: string): Promise<string>;
|
|
12
|
+
export declare function verifySession(token: string, secret: string): Promise<Claim | null>;
|
|
13
|
+
/**
|
|
14
|
+
* The revocation rule. `epoch` is the user's current session_epoch,
|
|
15
|
+
* or null when the user no longer exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isCurrent(claim: Claim, epoch: number | null): boolean;
|
package/dist/core/session.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
2
|
// Session token. A signed JWT (HS256) carried in an httpOnly cookie.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Two gates, two costs:
|
|
5
|
+
// signature verifySession() needs the secret only, never the
|
|
6
|
+
// database. The proxy runs this on every request, free.
|
|
7
|
+
// epoch isCurrent() the token carries the user's
|
|
8
|
+
// session_epoch from when it was signed. getUser() looks
|
|
9
|
+
// the user up and compares. Deleting the user (no row) or
|
|
10
|
+
// bumping the epoch (password change, sign-out-everywhere)
|
|
11
|
+
// makes every older token stop matching: revocation.
|
|
12
|
+
//
|
|
13
|
+
// So a revoked token still passes the proxy and loads a page shell,
|
|
14
|
+
// but getUser()/requireUser() say no, and those guard the data.
|
|
7
15
|
// ─────────────────────────────────────────────────────────────
|
|
8
16
|
import { SignJWT, jwtVerify } from "jose";
|
|
9
17
|
import { SESSION_DAYS } from "./config.js";
|
|
10
18
|
const key = (secret) => new TextEncoder().encode(secret);
|
|
11
19
|
export async function signSession(user, secret) {
|
|
12
|
-
return new SignJWT({ email: user.email })
|
|
20
|
+
return new SignJWT({ email: user.email, ep: user.epoch ?? 0 })
|
|
13
21
|
.setProtectedHeader({ alg: "HS256" })
|
|
14
22
|
.setSubject(user.id)
|
|
15
23
|
.setIssuedAt()
|
|
@@ -21,9 +29,19 @@ export async function verifySession(token, secret) {
|
|
|
21
29
|
const { payload } = await jwtVerify(token, key(secret), { algorithms: ["HS256"] });
|
|
22
30
|
if (typeof payload.sub !== "string" || typeof payload.email !== "string")
|
|
23
31
|
return null;
|
|
24
|
-
|
|
32
|
+
// Tokens from before epochs existed carry no `ep`: they count as 0,
|
|
33
|
+
// so upgrading the package signs nobody out
|
|
34
|
+
const epoch = typeof payload.ep === "number" ? payload.ep : 0;
|
|
35
|
+
return { id: payload.sub, email: payload.email, epoch };
|
|
25
36
|
}
|
|
26
37
|
catch {
|
|
27
38
|
return null;
|
|
28
39
|
}
|
|
29
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The revocation rule. `epoch` is the user's current session_epoch,
|
|
43
|
+
* or null when the user no longer exists.
|
|
44
|
+
*/
|
|
45
|
+
export function isCurrent(claim, epoch) {
|
|
46
|
+
return epoch !== null && claim.epoch === epoch;
|
|
47
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { hashPassword, verifyPassword } from "./core/password.ts";
|
|
2
|
-
export { signSession, verifySession, type User } from "./core/session.ts";
|
|
3
|
-
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.ts";
|
|
2
|
+
export { isCurrent, signSession, verifySession, type Claim, type User } from "./core/session.ts";
|
|
3
|
+
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword, sessionEpoch, revokeSessions, removeUser } from "./core/db.ts";
|
|
4
4
|
export { signInPage, signUpPage } from "./core/html.ts";
|
|
5
5
|
export { signupPolicy, validateCredentials, type SignupPolicy } from "./core/signup.ts";
|
|
6
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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Core primitives. Framework-free. Use these if you are not on Next.js
|
|
2
2
|
// or want to build your own handlers.
|
|
3
3
|
export { hashPassword, verifyPassword } from "./core/password.js";
|
|
4
|
-
export { signSession, verifySession } from "./core/session.js";
|
|
5
|
-
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.js";
|
|
4
|
+
export { isCurrent, signSession, verifySession } from "./core/session.js";
|
|
5
|
+
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword, sessionEpoch, revokeSessions, removeUser } from "./core/db.js";
|
|
6
6
|
export { signInPage, signUpPage } from "./core/html.js";
|
|
7
7
|
export { signupPolicy, validateCredentials } from "./core/signup.js";
|
|
8
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.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare class AuthError extends Error {
|
|
|
5
5
|
status: number;
|
|
6
6
|
constructor(message?: string);
|
|
7
7
|
}
|
|
8
|
-
/** Current user from the session cookie, or null
|
|
8
|
+
/** Current user from the session cookie, or null if signed out, deleted or revoked. */
|
|
9
9
|
export declare function getUser(): Promise<User | null>;
|
|
10
10
|
/** Current user, or throw AuthError (status 401). */
|
|
11
11
|
export declare function requireUser(): Promise<User>;
|
package/dist/next/index.js
CHANGED
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
// Use anywhere on the server:
|
|
17
17
|
// const user = await requireUser() // { id, email } or throws AuthError(401)
|
|
18
18
|
// const user = await getUser() // { id, email } or null
|
|
19
|
+
// Both check the database: a deleted user or a revoked session is null.
|
|
19
20
|
// ─────────────────────────────────────────────────────────────
|
|
20
21
|
import { cookies } from "next/headers.js";
|
|
21
22
|
import { NextResponse } from "next/server.js";
|
|
22
23
|
import { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_UP_PATH, getSecret } from "../core/config.js";
|
|
23
|
-
import { signSession, verifySession } from "../core/session.js";
|
|
24
|
+
import { isCurrent, signSession, verifySession } from "../core/session.js";
|
|
24
25
|
import { hashPassword, verifyPassword } from "../core/password.js";
|
|
25
|
-
import { countUsers, createUser, findUser } from "../core/db.js";
|
|
26
|
+
import { countUsers, createUser, findUser, sessionEpoch } from "../core/db.js";
|
|
26
27
|
import { signInPage, signUpPage } from "../core/html.js";
|
|
27
28
|
import { signupPolicy, validateCredentials } from "../core/signup.js";
|
|
28
29
|
export class AuthError extends Error {
|
|
@@ -32,12 +33,23 @@ export class AuthError extends Error {
|
|
|
32
33
|
this.name = "AuthError";
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The full check, for anything that hands out data: a valid signature,
|
|
38
|
+
* AND the user still exists with the epoch the token was signed under.
|
|
39
|
+
* One indexed lookup by primary key. (The proxy skips the lookup; see
|
|
40
|
+
* core/session.ts for why that is safe.)
|
|
41
|
+
*/
|
|
42
|
+
async function userFromToken(token) {
|
|
38
43
|
if (!token)
|
|
39
44
|
return null;
|
|
40
|
-
|
|
45
|
+
const claim = await verifySession(token, getSecret());
|
|
46
|
+
if (!claim || !isCurrent(claim, await sessionEpoch(claim.id)))
|
|
47
|
+
return null;
|
|
48
|
+
return { id: claim.id, email: claim.email };
|
|
49
|
+
}
|
|
50
|
+
/** Current user from the session cookie, or null if signed out, deleted or revoked. */
|
|
51
|
+
export async function getUser() {
|
|
52
|
+
return userFromToken((await cookies()).get(COOKIE_NAME)?.value);
|
|
41
53
|
}
|
|
42
54
|
/** Current user, or throw AuthError (status 401). */
|
|
43
55
|
export async function requireUser() {
|
|
@@ -83,13 +95,13 @@ async function readBody(req) {
|
|
|
83
95
|
};
|
|
84
96
|
}
|
|
85
97
|
async function currentUser(req) {
|
|
86
|
-
|
|
87
|
-
return token ? verifySession(token, getSecret()) : null;
|
|
98
|
+
return userFromToken(req.cookies.get(COOKIE_NAME)?.value);
|
|
88
99
|
}
|
|
89
100
|
/** Sign the user in on a response: cookie + redirect (form) or JSON (fetch). */
|
|
90
101
|
async function signedIn(req, user, next, json) {
|
|
91
102
|
const token = await signSession(user, getSecret());
|
|
92
|
-
const
|
|
103
|
+
const body = { user: { id: user.id, email: user.email } };
|
|
104
|
+
const res = json ? NextResponse.json(body) : NextResponse.redirect(new URL(next, req.url), 303);
|
|
93
105
|
res.cookies.set(COOKIE_NAME, token, cookieOptions());
|
|
94
106
|
return res;
|
|
95
107
|
}
|
|
@@ -155,7 +167,7 @@ export async function POST(req, ctx) {
|
|
|
155
167
|
return NextResponse.json({ error: "Wrong email or password" }, { status: 401 });
|
|
156
168
|
return backTo(req, SIGN_IN_PATH, "1", next);
|
|
157
169
|
}
|
|
158
|
-
return signedIn(req, { id: row.id, email: row.email }, next, json);
|
|
170
|
+
return signedIn(req, { id: row.id, email: row.email, epoch: row.epoch }, next, json);
|
|
159
171
|
}
|
|
160
172
|
if (route === "sign-up") {
|
|
161
173
|
const { email, password, invite, next, json } = await readBody(req);
|
package/dist/next/proxy.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// no/invalid cookie + /api request → 401 JSON
|
|
7
7
|
// valid cookie → continue
|
|
8
8
|
//
|
|
9
|
+
// A cheap first gate, not the last one: a revoked session (deleted
|
|
10
|
+
// user, changed password) still has a valid signature and passes here.
|
|
11
|
+
// getUser()/requireUser() check the database and refuse it, so no data
|
|
12
|
+
// leaks; the app should treat their null/401 as "go sign in".
|
|
13
|
+
//
|
|
9
14
|
// The CLI writes proxy.ts with an inline `config.matcher`, because
|
|
10
15
|
// Next reads the matcher statically and ignores a re-exported one.
|
|
11
16
|
// This function also guards those paths itself, so a missing matcher
|