@binary1702/auth 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +30 -17
  4. package/PROMPT.md +325 -37
  5. package/README.md +293 -56
  6. package/dist/cli.js +255 -22
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +138 -7
  14. package/dist/core/db.js +406 -47
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +23 -2
  32. package/dist/core/session.js +45 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +11 -4
  44. package/dist/next/index.js +455 -51
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +16 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
package/README.md CHANGED
@@ -1,14 +1,14 @@
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
7
7
  ```
8
8
 
9
- One command. It installs itself, writes two env vars, writes two files, creates the table, creates your first user. Then you move on.
9
+ One command. It installs itself, writes the secrets to `.env.local`, writes two files and a settings file, creates the tables, creates your first user. Then you move on.
10
10
 
11
- Letting an AI agent do the install? Paste [PROMPT.md](./PROMPT.md) into it. Same steps, written for an agent, with the checks to run and what to report back. [INSTALL.md](./INSTALL.md) is the short human version.
11
+ Letting an AI agent do the install? Paste [PROMPT.md](./PROMPT.md) into it. Same steps, written for an agent, with the checks to run and what to report back. [INSTALL.md](./INSTALL.md) is the short human version. An agent working *inside* an app that already has it reads [AGENTS.md](./AGENTS.md).
12
12
 
13
13
  ---
14
14
 
@@ -20,7 +20,7 @@ cd my-app
20
20
  npx @binary1702/auth
21
21
  ```
22
22
 
23
- You will be asked for one thing: your Postgres connection string (Neon). Everything else is automatic.
23
+ You will be asked for your Postgres connection string (Neon) and, optionally, how to send email (Enter to skip). Everything else is automatic.
24
24
 
25
25
  ```
26
26
  @binary1702/auth
@@ -28,15 +28,17 @@ You will be asked for one thing: your Postgres connection string (Neon). Everyth
28
28
  ✓ @binary1702/auth installed
29
29
  ✓ DATABASE_URL → .env.local
30
30
  ✓ AUTH_SECRET generated → .env.local
31
+ · email not configured: dev prints emails to the console
31
32
  ✓ app/auth/[...path]/route.ts
32
33
  ✓ proxy.ts
33
- ✓ auth_users table ready
34
+ ✓ b17_auth.yaml written: app name, sign-up policy, email, permissions
35
+ ✓ database schema ready (4 migrations)
34
36
 
35
37
  Create the first user:
36
- Username: ben
38
+ Email: ben@example.com
37
39
  Password:
38
40
  Password again:
39
- ✓ user "ben" created
41
+ ✓ user "ben@example.com" created
40
42
 
41
43
  Done. Start the dev server and open /auth/sign-in.
42
44
  ```
@@ -49,39 +51,142 @@ Run `npm run dev`, open the site, you are redirected to the sign-in page. Sign i
49
51
 
50
52
  ## Use it in your code
51
53
 
52
- Every page and API route is now behind sign-in. To know who the user is:
54
+ Every page and API route is now behind sign-in. To know who is asking:
53
55
 
54
56
  ```ts
55
- import { requireUser } from "@binary1702/auth/next"
57
+ import { getSession } from "@binary1702/auth/next"
56
58
 
57
59
  export async function GET() {
58
- const user = await requireUser() // { id, username }
60
+ const session = await getSession() // { user, organization, authorization } or null
59
61
  // ...
60
62
  }
61
63
  ```
62
64
 
63
- That is the only line you ever write by hand.
65
+ That is the only line you ever write by hand. `requireSession()` throws 401 instead of returning null, `requirePermission("invoices.create")` throws 403 when the role lacks it, and `getUser()` / `requireUser()` give just `{ id, email }`. All work in route handlers, server components, and server actions; `useAuth()` from `@binary1702/auth/react` gives the same object to client components. See [The session contract](#the-session-contract).
66
+
67
+ ---
68
+
69
+ ## What you get
70
+
71
+ Two kinds of URL under `/auth/`. **Pages** are HTML you open in a browser; they carry forms, and the forms POST back to the same path. **API** calls are JSON for code.
72
+
73
+ **Pages (open in a browser)**
74
+
75
+ | URL | What you see |
76
+ |---|---|
77
+ | `/auth/sign-in` | The sign-in form. `?verify=1` shows "check your email" with a resend button; `?verified=1` shows "email verified". |
78
+ | `/auth/sign-up` | The registration form. Closed unless `signup: open` (or `invite`) in `b17_auth.yaml`. See [Users](#users). |
79
+ | `/auth/sign-out` | Not a page: clears the cookie and redirects to sign-in. Link to it. |
80
+ | `/auth/verify?token=…` | The link in the verification email. Marks the email verified and redirects. |
81
+ | `/auth/forgot` | "Send me a reset link". Always answers the same, whether or not the address exists. |
82
+ | `/auth/admin` | Users, organizations, roles: what the CLI does, as a page. Only for emails under `admins:` in `b17_auth.yaml`. |
83
+ | `/auth/reset?token=…` | The link in the reset email. New password, then you are signed in; every older session of yours is ended. |
84
+
85
+ **API (JSON, for code)**
86
+
87
+ | Call | Does | Answers |
88
+ |---|---|---|
89
+ | `GET /auth/session` | the [session contract](#the-session-contract) | `200 { user, organization, authorization }` or `401` |
90
+ | `POST /auth/session` | switch the active organization `{ organization: "<id>" }` | the new contract, or `403` if not a member |
91
+ | `POST /auth/sign-in` | `{ email, password }` | `200 { user }` + cookie, `401`, or `403 { verify: true }` |
92
+ | `POST /auth/sign-up` | `{ email, password, invite? }` | `200 { user }` + cookie, or `400 / 403 / 404 / 409 { error }` |
93
+ | `POST /auth/sign-out` | clears the cookie | `200 { ok: true }` |
94
+ | `POST /auth/verify/resend` | `{ email }`: emails a fresh verification link | `200 { ok: true }`, always |
95
+ | `POST /auth/forgot` | `{ email }`: emails a reset link (1 h) if the address exists | `200 { ok: true }`, always |
96
+ | `POST /auth/reset` | `{ token, password }` | `200 { user }` + cookie, or `400 { error }` |
97
+
98
+ The same POST paths accept form posts too (that is what the pages do); a form post gets a redirect instead of JSON. Every POST refuses cross-site requests with `403`.
99
+
100
+ Guessing is rate limited per fixed window, in the database so it holds across instances: sign-in 20 per IP and 10 per email every 15 minutes, sign-up 10 per IP an hour, reset and verification emails 3 per address an hour. Over the limit answers `429` with a `Retry-After` header (and the message on the form). The IP is the first `x-forwarded-for` entry, else `x-real-ip`, else "unknown", so behind a proxy that sets neither, every visitor shares one bucket.
101
+
102
+ Everything else on the site requires a session. Unauthenticated page requests redirect to `/auth/sign-in?next=<where they were going>`. Unauthenticated `/api/*` requests get a `401` JSON response.
103
+
104
+ ---
105
+
106
+ ## The session contract
107
+
108
+ One endpoint, one shape, three keys. The same object comes from `GET /auth/session` in the browser, `getSession()` on the server, and `useAuth()` in React.
109
+
110
+ ```json
111
+ {
112
+ "user": { "id": "4f1c…", "email": "ben@binary1702.com" },
113
+ "organization": { "id": "9a2e…", "name": "Binary1702" },
114
+ "authorization": { "role": "admin", "permissions": ["invoices.view", "invoices.create"] }
115
+ }
116
+ ```
117
+
118
+ ```
119
+ user who: from the cookie, checked against the database on every call
120
+ organization where: the user's active organization, or null if they are in none
121
+ authorization what: the role of their membership, and the permissions that role maps to
122
+ ```
123
+
124
+ Signed out, revoked, or unverified while `AUTH_REQUIRE_VERIFIED` is on: the endpoint answers `401`, the helpers return `null`. The JSON is sent with `Cache-Control: no-store` and never contains the token.
125
+
126
+ **Server** (route handlers, server components, server actions):
64
127
 
65
128
  ```ts
66
- import { getUser } from "@binary1702/auth/next"
129
+ import { getSession, requireSession, requirePermission, unauthorized } from "@binary1702/auth/next"
130
+
131
+ const session = await getSession() // contract or null
132
+ const session = await requireSession() // contract or throws AuthError(401)
133
+ const session = await requirePermission("invoices.create") // contract or throws AuthError(401 | 403)
134
+
135
+ export async function POST() {
136
+ try {
137
+ const { user, organization } = await requirePermission("invoices.create")
138
+ // ...
139
+ } catch (e) {
140
+ return unauthorized(e) // 401 or 403 JSON for AuthError, rethrows anything else
141
+ }
142
+ }
143
+ ```
144
+
145
+ `getUser()` / `requireUser()` still exist and return just the `user` part. All of them run the same check, so there is one place where "is this session good" is decided.
146
+
147
+ **React** (rendering only; the server still checks every protected operation):
148
+
149
+ ```tsx
150
+ // app/layout.tsx (server): pass the session down, no extra request
151
+ import { getSession } from "@binary1702/auth/next"
152
+ import { AuthProvider } from "@binary1702/auth/react"
67
153
 
68
- const user = await getUser() // { id, username } or null, never throws
154
+ export default async function Layout({ children }) {
155
+ return <AuthProvider session={await getSession()}>{children}</AuthProvider>
156
+ }
157
+
158
+ // any client component
159
+ import { useAuth } from "@binary1702/auth/react"
160
+
161
+ const { user, organization, authorization } = useAuth()
69
162
  ```
70
163
 
71
- Works in route handlers, server components, and server actions.
164
+ Without a `session` prop, `<AuthProvider>` fetches `GET /auth/session` once on mount; `useAuth()` also gives `loading` and `refresh()`.
72
165
 
73
- ---
166
+ **Organizations and roles** live in two tables the package owns, `orgs` and `members (org_id, user_id, role)`. A user can be in several; the session carries the *active* one: the one they last switched to, else the earliest membership. Switching is `POST /auth/session { organization }`, and the server checks the membership, so a client can never pick an organization it is not in.
74
167
 
75
- ## What you get
168
+ ```bash
169
+ npx @binary1702/auth add-org "Binary1702"
170
+ npx @binary1702/auth add-member ben@binary1702.com Binary1702 admin # role defaults to "member"
171
+ npx @binary1702/auth remove-member ben@binary1702.com Binary1702
172
+ npx @binary1702/auth list-orgs
173
+ ```
76
174
 
77
- | URL | What it does |
78
- |---|---|
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). |
81
- | `/auth/sign-out` | Clears the session and sends you back to sign-in. Link to it. |
82
- | `/auth/session` | `{ user }` or `{ user: null }` as JSON, for client code. |
175
+ **The admin page**, `/auth/admin`, does the same as those commands from the browser: add a user, set a password, sign someone out everywhere, mark an email verified, remove a user; create an organization, add a member with a role, change a role, remove a member or an organization. Beside each membership it shows the permissions that role resolves to, so the two layers are visible side by side: the membership row says *who is what, where*; `b17_auth.yaml` says *what a role means*. The page never edits the yaml. Who may open it:
83
176
 
84
- Everything else on the site requires a session. Unauthenticated page requests redirect to `/auth/sign-in?next=<where they were going>`. Unauthenticated `/api/*` requests get a `401` JSON response.
177
+ ```yaml
178
+ admins: ben@binary1702.com ops@binary1702.com
179
+ ```
180
+
181
+ An admin cannot remove or sign out the account they are using.
182
+
183
+ **Permissions** are the app's vocabulary, so they come from one env var: a JSON map from role to permissions. `"*"` means everything, `"invoices.*"` everything under that prefix.
184
+
185
+ ```
186
+ AUTH_PERMISSIONS={"owner":["*"],"admin":["invoices.*"],"member":["invoices.view"]}
187
+ ```
188
+
189
+ A role missing from the map has no permissions. An invalid value throws at the first session check, so a typo cannot silently lock everyone out.
85
190
 
86
191
  ---
87
192
 
@@ -92,23 +197,28 @@ Two ways in. Pick one or use both.
92
197
  **From the terminal** (always works):
93
198
 
94
199
  ```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
200
+ npx @binary1702/auth add-user # prompts for email and password
201
+ npx @binary1702/auth add-user gladys@example.com # email given
202
+ npx @binary1702/auth set-password ben@example.com # also signs them out everywhere
203
+ npx @binary1702/auth remove-user ben@example.com # delete; their sessions end too
204
+ npx @binary1702/auth sign-out-everywhere ben@example.com # end every session, keep the account
98
205
  npx @binary1702/auth list-users
99
206
  ```
100
207
 
101
- **Self sign-up at `/auth/sign-up`**, controlled by one env var:
208
+ **Self sign-up at `/auth/sign-up`**, controlled by one key in `b17_auth.yaml`:
102
209
 
103
- | `.env.local` | Who can sign up |
210
+ | `b17_auth.yaml` | Who can sign up |
104
211
  |---|---|
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. |
212
+ | `signup: closed` (the default) | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
213
+ | `signup: open` | Anyone. The sign-in page gets a "Create one" link. |
214
+ | `signup: invite` + `inviteCode: ${AUTH_INVITE_CODE}` | Anyone who knows the code (kept in `.env.local`). The form gets an invite field. |
215
+ | `emailDomain: example.com` | Combine with either: only `@example.com` emails can sign up. |
108
216
 
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.
217
+ (`AUTH_SIGNUP=open`, `AUTH_INVITE_CODE` and `AUTH_EMAIL_DOMAIN` as env vars still work and override the file.)
110
218
 
111
- Programmatic: `POST /auth/sign-up` as JSON `{ username, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
219
+ Rules on the form: a valid email address, password 8 to 256 characters. Accounts are created and signed in immediately, and a verification link is emailed; with `requireVerified: true` the session waits for that link. Nothing to approve.
220
+
221
+ Programmatic: `POST /auth/sign-up` as JSON `{ email, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
112
222
 
113
223
  ---
114
224
 
@@ -130,51 +240,162 @@ import { proxy } from "@binary1702/auth/next/proxy"
130
240
  export default proxy
131
241
 
132
242
  export const config = {
133
- matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
243
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
134
244
  }
135
245
  ```
136
246
 
137
- The matcher is inline because Next reads it statically at build time and ignores a re-exported one.
247
+ The matcher is inline because Next reads it statically at build time and ignores a re-exported one. It only skips Next's build output; the proxy itself lets through `/auth/*` and static files by extension (`.png`, `.css`, `.svg`, …), never anything under `/api/`.
248
+
249
+ **Upgrading from 0.2.5 or earlier:** replace the matcher in your `proxy.ts` with the one above. The old one skipped any path starting with `auth` (`/authors`) and any path with a dot (`/api/export.csv`), so those were not guarded. Re-running `npx @binary1702/auth` detects the old matcher and tells you.
250
+
251
+ Sign-in, sign-up and sign-out POSTs from another website are refused with `403` (checked via the browser's `Sec-Fetch-Site` / `Origin` headers), so no other site can sign your visitors in or out.
138
252
 
139
253
  If you already had a `proxy.ts`, the CLI leaves it alone and prints the snippet for you to merge.
140
254
 
141
255
  ---
142
256
 
257
+ ## b17_auth.yaml
258
+
259
+ One file in the project root for everything the app decides. The CLI writes it on install; you edit it. Secrets never go in it: they stay in `.env.local` and appear here as `${NAME}` references.
260
+
261
+ ```yaml
262
+ name: Binary1702 # page titles, email subjects
263
+ signup: open # open | closed | invite
264
+ protected: /app/* /api/* # only these need sign-in; everything else is open. * = anything below
265
+
266
+ database: ${DATABASE_URL}
267
+ secret: ${AUTH_SECRET}
268
+
269
+ email:
270
+ from: NoReply (Binary1702) <no-reply@binary1702.com>
271
+ replyTo: support@binary1702.com # optional
272
+ gmail: # or resend: { apiKey: ${AUTH_RESEND_API_KEY} }
273
+ user: ben@binary1702.com
274
+ clientEmail: ${AUTH_GMAIL_CLIENT_EMAIL}
275
+ privateKey: ${AUTH_GMAIL_PRIVATE_KEY}
276
+
277
+ permissions: # role: what it may do. all = everything, x.* = a prefix
278
+ owner: all
279
+ admin: all
280
+ member: invoices.* reports.read
281
+
282
+ organizations: # overrides by organization name
283
+ Acme:
284
+ member: invoices.read
285
+ ```
286
+
287
+ | Key | Default | |
288
+ |---|---|---|
289
+ | `name` | the request's host | shown in `<title>` and email subjects |
290
+ | `signup` | `closed` | `invite` also needs `inviteCode: ${AUTH_INVITE_CODE}` |
291
+ | `emailDomain` | any | only these addresses may sign up |
292
+ | `requireVerified` | `false` | no session until the verification link is clicked |
293
+ | `sessionDays` | `7` | cookie lifetime |
294
+ | `protected` | none: everything guarded | only these paths need sign-in; the rest is open. `/x/*` covers `/x` and everything below |
295
+ | `public` | none | the reverse: everything needs sign-in except these. Use one or the other. |
296
+ | `admins` | none | emails that may open `/auth/admin` |
297
+ | `email` | console in dev | see [Email](#email) |
298
+ | `permissions`, `organizations` | none | see [The session contract](#the-session-contract) |
299
+
300
+ The format is a small subset of YAML: `key: value`, sections by indentation, `#` comments. No arrays: a list is words separated by spaces. Mistakes are reported with the line number at startup.
301
+
302
+ **How it resolves.** Built-in defaults, then the file, then env vars on top. `${NAME}` is replaced from the environment when the file is read; a missing variable is an error that names the key. The keys `database`, `secret`, `inviteCode`, `privateKey`, `clientSecret`, `refreshToken` and `apiKey` must be references: the CLI refuses a literal there unless the file is git-ignored. Without a file, the env vars below work exactly as they always did, so an existing install changes nothing.
303
+
304
+ **`protected` and the proxy.** The matcher in `proxy.ts` is plumbing that says "run the guard on everything"; it never changes. The guard reads `protected:` (or `public:`) from this file and decides per request. With neither key, everything needs sign-in, as before. Next 16 runs the proxy in Node, so this just works. On Next 15, add `runtime: "nodejs"` to the `config` export in `middleware.ts` (and `experimental.nodeMiddleware: true` in `next.config`), since the edge runtime cannot read files.
305
+
306
+ ---
307
+
143
308
  ## Env vars
144
309
 
145
310
  | Var | What | Who sets it |
146
311
  |---|---|---|
147
312
  | `DATABASE_URL` | Postgres connection string | You, once, when the CLI asks |
148
313
  | `AUTH_SECRET` | Signs the session cookie | The CLI generates it |
314
+ | `AUTH_GMAIL_USER` + `AUTH_GMAIL_CLIENT_EMAIL` + `AUTH_GMAIL_PRIVATE_KEY` | Send email through the Gmail API as that mailbox. See [Email](#email). | You, when the CLI asks. Skippable. |
315
+ | `AUTH_RESEND_API_KEY` + `AUTH_EMAIL_FROM` | Or send through [Resend](https://resend.com) | Same prompt |
316
+ | `AUTH_EMAIL_FROM` | With Gmail: optional display name, `Binary1702 <verify@binary1702.com>` | You, optional |
317
+ | `AUTH_REQUIRE_VERIFIED` | `true`: no session until the email is verified. Default off. | You, optional |
318
+ | `AUTH_PERMISSIONS` | JSON map of role → permissions for the [session contract](#the-session-contract) | You, optional |
319
+ | `AUTH_SIGNUP`, `AUTH_INVITE_CODE`, `AUTH_EMAIL_DOMAIN` | Who may sign up. See [Users](#users). | You, optional |
320
+ | `AUTH_PROTECTED` / `AUTH_PUBLIC` | Which paths need sign-in, space-separated. Same as the yaml keys. | You, optional |
321
+ | `AUTH_ADMINS` | Emails that may open `/auth/admin` | You, optional |
322
+ | `AUTH_EMAIL_REPLY_TO` | Reply-To on outgoing email | You, optional |
323
+ | `AUTH_GMAIL_CLIENT_ID` + `AUTH_GMAIL_CLIENT_SECRET` + `AUTH_GMAIL_REFRESH_TOKEN` | Gmail through a personal account instead of a service account | You, optional |
324
+ | `AUTH_CONFIG` | Path to the settings file when it is not `b17_auth.yaml` in the working directory | You, rarely |
325
+
326
+ All live in `.env.local`. Copy them to your host (Vercel, etc.) when you deploy. With a `b17_auth.yaml`, only the secrets need to be env vars; the rest is in the file, and an env var set anyway overrides the file's value for that key.
327
+
328
+ ---
329
+
330
+ ## Email
331
+
332
+ Verification links and password resets go out as email. One function sends them, over `fetch`, with no mail library. Which way it goes is decided by what is set:
333
+
334
+ ```
335
+ AUTH_GMAIL_USER + AUTH_GMAIL_CLIENT_EMAIL + AUTH_GMAIL_PRIVATE_KEY → Gmail API, as that mailbox
336
+ AUTH_RESEND_API_KEY + AUTH_EMAIL_FROM → Resend
337
+ neither, in development → printed to the dev server's console (the links work)
338
+ neither, in production → the first send throws; sign-in and sign-up keep working
339
+ ```
149
340
 
150
- Both live in `.env.local`. Copy both to your host (Vercel, etc.) when you deploy.
341
+ **Gmail API with a Google Workspace mailbox** (for example `verify@binary1702.com`):
342
+
343
+ 1. In Google Cloud: create a project, enable the **Gmail API**, create a **service account**, and download its JSON key.
344
+ 2. In Google Admin → Security → API controls → **Domain-wide delegation**: add the service account's client id with the scope `https://www.googleapis.com/auth/gmail.send`.
345
+ 3. Run `npx @binary1702/auth`, answer `gmail`, give the mailbox and the path to the JSON key. The CLI writes `AUTH_GMAIL_USER`, `AUTH_GMAIL_CLIENT_EMAIL` and `AUTH_GMAIL_PRIVATE_KEY` (the PEM on one line, newlines as `\n`) to `.env.local`.
346
+
347
+ No password or app password is involved: the package signs a short-lived assertion with the service account's key, trades it for a one-hour token at Google's token endpoint, caches the token in memory, and POSTs each message to the Gmail send endpoint. Sent mail shows up in that mailbox's Sent folder.
348
+
349
+ **Gmail API with a personal Gmail account**: set `AUTH_GMAIL_USER`, `AUTH_GMAIL_CLIENT_ID`, `AUTH_GMAIL_CLIENT_SECRET` and `AUTH_GMAIL_REFRESH_TOKEN` (an OAuth client with the `gmail.send` scope, consented once). Same send path.
350
+
351
+ **Resend**: an API key and a From address on a domain you have verified in Resend.
151
352
 
152
353
  Rotating `AUTH_SECRET` signs everyone out. That is the revoke-all button.
354
+ For one user: `sign-out-everywhere`, `set-password` or `remove-user`.
153
355
 
154
356
  ---
155
357
 
156
358
  ## How it works
157
359
 
158
- Five primitives around one cookie. Nothing else.
360
+ A few primitives around one cookie. Each is one file under `src/core/`, pure where it can be, with the Next handlers only wiring them.
159
361
 
160
362
  ```
161
- auth_users (id, username, password_hash, created_at) identity one table, owned by the package
162
- scrypt proof node:crypto, no dependency
163
- signed JWT in an httpOnly cookie, 7 days session stateless, zero DB reads per request
164
- proxy.ts guard verifies the cookie signature only
165
- /auth/sign-in /auth/sign-out /auth/session lifecycle one route file
363
+ users (id, email, password_hash, session_epoch,
364
+ email_verified_at, active_org_id) identity owned by the package
365
+ orgs, members (org_id, user_id, role) where + role a user may be in several; one is active
366
+ tokens (user_id, kind, token_hash, expires_at,
367
+ used_at) links verify / reset; only the hash is stored
368
+ rate_limits (bucket, count, reset_at) brakes sign-in 20/IP and 10/email per 15 min, sign-up
369
+ 10/IP/h, reset and resend emails 3/h → 429
370
+ auth_migrations schema numbered steps, applied once on first start
371
+ scrypt proof node:crypto, no dependency
372
+ signed JWT in an httpOnly cookie, carries the user's
373
+ session_epoch session says WHO; revoked when the epoch moves on
374
+ proxy.ts guard signature only, plus protected: / public: paths
375
+ getSession() and friends gate signature + one query → the contract
376
+ b17_auth.yaml settings name, sign-up, public, email, permissions
377
+ sendEmail() email Gmail API or Resend, over fetch
378
+ /auth/* lifecycle one route file
166
379
  ```
167
380
 
168
381
  ```
169
382
  Request
170
- ├─ 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 }
383
+ ├─ proxy.ts open path, or cookie valid? → continue else → redirect (page) or 401 (api)
384
+ ├─ /auth/sign-in POST email+password → verify hash → set cookie → redirect
385
+ ├─ /auth/sign-up create user → email a verify link → set cookie (or wait for the link)
386
+ ├─ /auth/forgot email a reset link → /auth/reset sets the password, bumps the epoch, signs in
387
+ └─ your route getSession() → cookie → user exists, epoch matches, verified if required?
388
+ → + active org + role → permissions from the yaml
389
+ → { user, organization, authorization }
173
390
  ```
174
391
 
175
- **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.
392
+ **Authentication, plus who-is-where.** The package tells you the user, their active organization and the role's permissions. What rows that role may touch is your app's query. Keeping that boundary is what keeps this package small enough to never break.
393
+
394
+ **Two gates.** The proxy checks only the token's signature, so it costs nothing per request. `getSession()` also looks the user up and compares the token's `session_epoch` with the database's. Deleting a user, changing their password (including by reset link) 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 session and no data: treat `null` / 401 as "go to `/auth/sign-out`", which clears the cookie and lands on sign-in.
176
395
 
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.
396
+ **Hardening.** Every auth page is served with `Cache-Control: no-store`, `X-Frame-Options: DENY`, a `Content-Security-Policy` that allows no scripts (the admin page gets a per-request nonce for its one) and forms posting only to this origin. In production the cookie is `__Host-auth_session`: the browser refuses it unless Secure, path `/`, no domain, so a subdomain or a plain-http page cannot plant one. Passwords are scrypt with N=2^17 (about a third of a second per hash); rows hashed under older parameters are re-hashed on the next successful sign-in. The token carries `iss` and `aud`, so a token minted by another app that shares the secret is refused. The invite code is compared in constant time. Two sign-ups racing on one email: the database decides, the loser gets 409. Emails are unique case-insensitively.
397
+
398
+ **Upgrading from 0.2.x:** nothing to do by hand. The cookie name changes in production but the old one is still read and cleared, tokens from before the 0.3.0 cutoff are accepted without the new claims, and old password hashes upgrade themselves at sign-in. Migrations run on first start, the new columns default to "not verified" and "no organization", tokens signed before keep working, and without a `b17_auth.yaml` the env vars behave as before. Run `npx @binary1702/auth` once to get the settings file written.
178
399
 
179
400
  ---
180
401
 
@@ -185,10 +406,21 @@ Request
185
406
  | Export | |
186
407
  |---|---|
187
408
  | `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. |
190
- | `unauthorized(err)` | Turns an `AuthError` into a 401 JSON response. Rethrows anything else. |
191
- | `AuthError` | The error class. Has `.status = 401`. |
409
+ | `getSession()` | `Promise<Session \| null>`. The [contract](#the-session-contract). Null if signed out, deleted, revoked or (when required) unverified. One database query. |
410
+ | `requireSession()` | Same, or throws `AuthError` (401). |
411
+ | `requirePermission(p)` | Same, or throws `AuthError` (401 signed out, 403 role lacks `p`). |
412
+ | `getUser()` / `requireUser()` | Just the `user` part: `{ id, email }`. |
413
+ | `unauthorized(err)` | Turns an `AuthError` into a 401/403 JSON response. Rethrows anything else. |
414
+ | `AuthError` | The error class. Has `.status` (401 or 403). |
415
+ | `Session`, `Organization`, `Authorization`, `User` | The types. |
416
+
417
+ ### `@binary1702/auth/react`
418
+
419
+ | Export | |
420
+ |---|---|
421
+ | `AuthProvider` | `<AuthProvider session={…}>` with the server's `getSession()` result, or without a prop to fetch once on mount. |
422
+ | `useAuth()` | `{ user, organization, authorization, loading, refresh }`. Nulls and an empty permission list when signed out. |
423
+ | `fetchSession()` | `GET /auth/session` from the browser: `Session \| null`. |
192
424
 
193
425
  ### `@binary1702/auth/next/proxy`
194
426
 
@@ -205,7 +437,11 @@ For building your own handlers or using the primitives outside Next.
205
437
  |---|---|
206
438
  | `hashPassword(pw)`, `verifyPassword(pw, hash)` | scrypt |
207
439
  | `signSession(user, secret)`, `verifySession(token, secret)` | JWT HS256 |
208
- | `ensureSchema()`, `findUser()`, `createUser()`, `countUsers()`, `listUsers()`, `setPassword()` | Database |
440
+ | `ensureSchema()`, `findUser()`, `createUser()`, `countUsers()`, `listUsers()`, `setPassword()`, `sessionRow()` | Users. Every function takes an optional `Db` as its last argument, so handler logic can run against a fake. |
441
+ | `createOrg()`, `findOrg()`, `listOrgs()`, `addMember()`, `removeMember()`, `setActiveOrg()` | Organizations |
442
+ | `buildSession()`, `hasPermission()`, `permissionsFor()` | The session contract, pure |
443
+ | `issueToken()`, `consumeToken()` | One-time tokens (`verify`, `reset`): only the SHA-256 is stored |
444
+ | `sendEmail()` | Resend over `fetch`, console in development |
209
445
  | `signInPage({ error?, next?, title? })` | The HTML form as a string |
210
446
  | `COOKIE_NAME`, `SESSION_DAYS`, `SIGN_IN_PATH`, `SIGN_OUT_PATH` | Constants |
211
447
 
@@ -216,14 +452,15 @@ For building your own handlers or using the primitives outside Next.
216
452
  No client library. Plain HTTP.
217
453
 
218
454
  ```ts
219
- // who am I
220
- const { user } = await fetch("/auth/session").then((r) => r.json())
455
+ // who am I, where am I, what may I do (401 when signed out)
456
+ const res = await fetch("/auth/session")
457
+ const { user, organization, authorization } = res.ok ? await res.json() : {}
221
458
 
222
459
  // sign in from a custom form
223
460
  await fetch("/auth/sign-in", {
224
461
  method: "POST",
225
462
  headers: { "content-type": "application/json" },
226
- body: JSON.stringify({ username, password }),
463
+ body: JSON.stringify({ email, password }),
227
464
  }) // 200 { user } or 401 { error }
228
465
 
229
466
  // sign out
@@ -236,7 +473,7 @@ Or just link to `/auth/sign-out`.
236
473
 
237
474
  ## Custom sign-in page
238
475
 
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.
476
+ 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
477
 
241
478
  ---
242
479