@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 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, username } or throws 401
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 username + password auth to a Next.js App Router app. Follow these steps exactly and report the result of each.
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 a username and a password for the first user. If you cannot answer prompts, run it and hand control to me.
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 "<name>" created
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 `{ "username", "password" }` | 200 `{ user }` and `Set-Cookie: auth_session=…; HttpOnly` |
51
- | `GET /auth/session` with that cookie | `{ user: { id, username } }` |
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, username } or throws AuthError (401)
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
- Username + password auth for Next.js. Plug and play.
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
- Username: ben
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, username }
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, username } or null, never throws
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 # prompts for username and password
96
- npx @binary1702/auth add-user gladys # username given
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: username 2 to 32 characters (letters, numbers, `. _ -`), password at least 8. Accounts are created and signed in immediately. Nothing to approve.
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 `{ username, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
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, username, password_hash, created_at) identity one table, owned by the package
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 session stateless, zero DB reads per request
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 username+password → verify hash → set cookie → redirect
172
- └─ your route requireUser() → reads cookie → { id, username }
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
- **Trade-off you should know.** The session is a signed token, not a database row. That is why the proxy costs nothing per request. The cost is that one session cannot be revoked before it expires. Change `AUTH_SECRET` to revoke all of them.
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, username }>`. Throws `AuthError` (status 401) if signed out. |
189
- | `getUser()` | `Promise<{ id, username } \| null>`. Never throws. |
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({ username, password }),
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 `username` 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.
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 [name] create another user
18
- // set-password [name] change a 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
- if (cmd === "add-user" || cmd === "set-password" || cmd === "list-users") {
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 SET password_hash = ${passwordHash} WHERE email = ${email} RETURNING id
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
  }
@@ -2,5 +2,16 @@ export type User = {
2
2
  id: string;
3
3
  email: string;
4
4
  };
5
- export declare function signSession(user: User, secret: string): Promise<string>;
6
- export declare function verifySession(token: string, secret: string): Promise<User | null>;
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;
@@ -1,15 +1,23 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
2
  // Session token. A signed JWT (HS256) carried in an httpOnly cookie.
3
3
  //
4
- // Stateless: verifying needs the secret only, never the database.
5
- // That is what lets the proxy run on every request at zero cost.
6
- // Trade-off: a token cannot be revoked before it expires (7 days).
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
- return { id: payload.sub, email: payload.email };
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";
@@ -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. Never touches the database. */
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>;
@@ -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
- /** Current user from the session cookie, or null. Never touches the database. */
36
- export async function getUser() {
37
- const token = (await cookies()).get(COOKIE_NAME)?.value;
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
- return verifySession(token, getSecret());
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
- const token = req.cookies.get(COOKIE_NAME)?.value;
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 res = json ? NextResponse.json({ user }) : NextResponse.redirect(new URL(next, req.url), 303);
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);
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@binary1702/auth",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Email + password auth for Next.js. One command: npx @binary1702/auth",
5
5
  "type": "module",
6
6
  "repository": {