@binary1702/auth 0.2.5 → 0.4.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 (53) hide show
  1. package/AGENTS.md +155 -0
  2. package/CHANGELOG.md +49 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +325 -36
  5. package/README.md +338 -53
  6. package/dist/cli.js +248 -17
  7. package/dist/core/admin-html.d.ts +32 -0
  8. package/dist/core/admin-html.js +350 -0
  9. package/dist/core/admin.d.ts +56 -0
  10. package/dist/core/admin.js +90 -0
  11. package/dist/core/config.d.ts +10 -3
  12. package/dist/core/config.js +22 -8
  13. package/dist/core/db.d.ts +155 -10
  14. package/dist/core/db.js +449 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +52 -0
  18. package/dist/core/guard.js +106 -0
  19. package/dist/core/html.d.ts +43 -0
  20. package/dist/core/html.js +122 -28
  21. package/dist/core/invite.d.ts +7 -0
  22. package/dist/core/invite.js +30 -0
  23. package/dist/core/options.d.ts +95 -0
  24. package/dist/core/options.js +377 -0
  25. package/dist/core/org.d.ts +43 -0
  26. package/dist/core/org.js +65 -0
  27. package/dist/core/pages.d.ts +34 -0
  28. package/dist/core/pages.js +73 -0
  29. package/dist/core/password.d.ts +16 -0
  30. package/dist/core/password.js +41 -12
  31. package/dist/core/rate.d.ts +26 -0
  32. package/dist/core/rate.js +70 -0
  33. package/dist/core/reset.d.ts +9 -0
  34. package/dist/core/reset.js +31 -0
  35. package/dist/core/session.d.ts +11 -1
  36. package/dist/core/session.js +27 -7
  37. package/dist/core/signup.d.ts +13 -6
  38. package/dist/core/signup.js +30 -15
  39. package/dist/core/tokens.d.ts +29 -0
  40. package/dist/core/tokens.js +64 -0
  41. package/dist/core/verify.d.ts +8 -0
  42. package/dist/core/verify.js +33 -0
  43. package/dist/core/yaml.d.ts +10 -0
  44. package/dist/core/yaml.js +85 -0
  45. package/dist/index.d.ts +18 -6
  46. package/dist/index.js +18 -6
  47. package/dist/next/index.d.ts +10 -3
  48. package/dist/next/index.js +649 -63
  49. package/dist/next/proxy.d.ts +2 -1
  50. package/dist/next/proxy.js +22 -10
  51. package/dist/react/index.d.ts +72 -0
  52. package/dist/react/index.js +107 -0
  53. package/package.json +18 -4
package/README.md CHANGED
@@ -6,9 +6,9 @@ Email + password auth for Next.js. Plug and play.
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,9 +28,11 @@ 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
38
  Email: ben@example.com
@@ -49,39 +51,147 @@ 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, email }
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". With `pages.signIn` in `b17_auth.yaml`, redirects to [your own page](#your-own-sign-in-page). |
78
+ | `/auth/sign-up` | The registration form. Closed unless `signup: open` (or `invite`) in `b17_auth.yaml`. See [Users](#users). Same `pages.signUp` override. |
79
+ | `/auth/sign-out` | Not a page: clears the cookie and redirects to sign-in, or to `?next=<same-origin path>` (`/auth/sign-out?next=/` lands on the public front). 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
+ | `/auth/invite?token=…` | The link an added user gets (admin page, "Add user" with no password): choose a password, which verifies the address, and you are signed in. Works once, seven days. |
85
+
86
+ **API (JSON, for code)**
87
+
88
+ | Call | Does | Answers |
89
+ |---|---|---|
90
+ | `GET /auth/session` | the [session contract](#the-session-contract) | `200 { user, organization, authorization }` or `401` |
91
+ | `POST /auth/session` | switch the active organization `{ organization: "<id>" }` | the new contract, or `403` if not a member |
92
+ | `GET /auth/policy` | what a sign-in / sign-up page needs before it renders | `200 { signup: { allowed, inviteRequired, reason }, pages, name }` |
93
+ | `POST /auth/sign-in` | `{ email, password, next? }` | `200 { user, next }` + cookie, `401`, or `403 { verify: true }` |
94
+ | `POST /auth/sign-up` | `{ email, password, invite?, next? }` | `200 { user, next }` + cookie, or `400 / 403 / 404 / 409 { error }` |
95
+ | `POST /auth/sign-out` | clears the cookie | `200 { ok: true }` |
96
+ | `POST /auth/verify/resend` | `{ email }`: emails a fresh verification link | `200 { ok: true }`, always |
97
+ | `POST /auth/forgot` | `{ email }`: emails a reset link (1 h) if the address exists | `200 { ok: true }`, always |
98
+ | `POST /auth/reset` | `{ token, password }` | `200 { user }` + cookie, or `400 { error }` |
99
+
100
+ 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`.
101
+
102
+ 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.
103
+
104
+ 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.
105
+
106
+ ---
107
+
108
+ ## The session contract
109
+
110
+ 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.
111
+
112
+ ```json
113
+ {
114
+ "user": { "id": "4f1c…", "email": "ben@binary1702.com" },
115
+ "organization": { "id": "9a2e…", "name": "Binary1702" },
116
+ "authorization": { "role": "admin", "permissions": ["invoices.view", "invoices.create"] }
117
+ }
118
+ ```
119
+
120
+ ```
121
+ user who: from the cookie, checked against the database on every call
122
+ organization where: the user's active organization, or null if they are in none
123
+ authorization what: the role of their membership, and the permissions that role maps to
124
+ ```
125
+
126
+ 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.
127
+
128
+ **Server** (route handlers, server components, server actions):
64
129
 
65
130
  ```ts
66
- import { getUser } from "@binary1702/auth/next"
131
+ import { getSession, requireSession, requirePermission, unauthorized } from "@binary1702/auth/next"
132
+
133
+ const session = await getSession() // contract or null
134
+ const session = await requireSession() // contract or throws AuthError(401)
135
+ const session = await requirePermission("invoices.create") // contract or throws AuthError(401 | 403)
136
+
137
+ export async function POST() {
138
+ try {
139
+ const { user, organization } = await requirePermission("invoices.create")
140
+ // ...
141
+ } catch (e) {
142
+ return unauthorized(e) // 401 or 403 JSON for AuthError, rethrows anything else
143
+ }
144
+ }
145
+ ```
146
+
147
+ `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.
148
+
149
+ **React** (rendering only; the server still checks every protected operation):
67
150
 
68
- const user = await getUser() // { id, email } or null
151
+ ```tsx
152
+ // app/layout.tsx (server): pass the session down, no extra request
153
+ import { getSession } from "@binary1702/auth/next"
154
+ import { AuthProvider } from "@binary1702/auth/react"
155
+
156
+ export default async function Layout({ children }) {
157
+ return <AuthProvider session={await getSession()}>{children}</AuthProvider>
158
+ }
159
+
160
+ // any client component
161
+ import { useAuth } from "@binary1702/auth/react"
162
+
163
+ const { user, organization, authorization } = useAuth()
69
164
  ```
70
165
 
71
- Works in route handlers, server components, and server actions.
166
+ Without a `session` prop, `<AuthProvider>` fetches `GET /auth/session` once on mount; `useAuth()` also gives `loading` and `refresh()`.
72
167
 
73
- ---
168
+ **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
169
 
75
- ## What you get
170
+ ```bash
171
+ npx @binary1702/auth add-org "Binary1702"
172
+ npx @binary1702/auth add-member ben@binary1702.com Binary1702 admin # role defaults to "member"
173
+ npx @binary1702/auth remove-member ben@binary1702.com Binary1702
174
+ npx @binary1702/auth list-orgs
175
+ ```
76
176
 
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. |
177
+ **The admin page**, `/auth/admin`, does the same as those commands from the browser: add a user two ways, **Register** (email + password, ready and verified) or **Invite** (email only: a seven-day link to choose a password, which verifies the address and signs them in), set a password, sign someone out everywhere, mark an email verified or send the Invite link again, remove a user; create an organization, add a member with a role, change a role, remove a member or an organization. And it decides *what a role means*: the **Roles** tab is a grid, one row per permission the app declares, one checkbox per role, a Save button per role. Each organization's page carries the same grid for its own rule. Who may open it:
83
178
 
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.
179
+ ```yaml
180
+ admins: ben@binary1702.com ops@binary1702.com
181
+ ```
182
+
183
+ An admin cannot remove or sign out the account they are using. An organization's **owner** may open their own organization on the page (`/auth/admin?org=<id>`) and manage its roles and members, nothing else.
184
+
185
+ **Permissions** have three parts, each in one place:
186
+
187
+ ```
188
+ b17_auth.yaml permissions: what exists id: label, the rows of the grid. The app's vocabulary.
189
+ roles: the start role → permissions, written into the database once
190
+ roles table /auth/admin what's allowed per role, for every organization or for one; most specific wins
191
+ session JSON authorization the result { role, permissions }, read by the app
192
+ ```
193
+
194
+ Nothing saved for a role means it may do nothing. The `owner` role always holds everything and cannot be edited; so does any email under `admins:`. A saved role grants exactly its ticked boxes: a permission the app declares later starts off until someone ticks it. `"*"` means everything and `"invoices.*"` everything under that prefix, in the yaml's starting lists and in `hasPermission()`.
85
195
 
86
196
  ---
87
197
 
@@ -100,16 +210,18 @@ npx @binary1702/auth sign-out-everywhere ben@example.com # end every sess
100
210
  npx @binary1702/auth list-users
101
211
  ```
102
212
 
103
- **Self sign-up at `/auth/sign-up`**, controlled by one env var:
213
+ **Self sign-up at `/auth/sign-up`**, controlled by one key in `b17_auth.yaml`:
104
214
 
105
- | `.env.local` | Who can sign up |
215
+ | `b17_auth.yaml` | Who can sign up |
106
216
  |---|---|
107
- | nothing set | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
108
- | `AUTH_SIGNUP=open` | Anyone. The sign-in page gets a "Create one" link. |
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. |
217
+ | `signup: closed` (the default) | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
218
+ | `signup: open` | Anyone. The sign-in page gets a "Create one" link. |
219
+ | `signup: invite` + `inviteCode: ${AUTH_INVITE_CODE}` | Anyone who knows the code (kept in `.env.local`). The form gets an invite field. |
220
+ | `emailDomain: example.com` | Combine with either: only `@example.com` emails can sign up. |
111
221
 
112
- Rules on the form: a valid email address, password at least 8. Accounts are created and signed in immediately. Nothing to approve.
222
+ (`AUTH_SIGNUP=open`, `AUTH_INVITE_CODE` and `AUTH_EMAIL_DOMAIN` as env vars still work and override the file.)
223
+
224
+ 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.
113
225
 
114
226
  Programmatic: `POST /auth/sign-up` as JSON `{ email, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
115
227
 
@@ -133,24 +245,119 @@ import { proxy } from "@binary1702/auth/next/proxy"
133
245
  export default proxy
134
246
 
135
247
  export const config = {
136
- matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
248
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
137
249
  }
138
250
  ```
139
251
 
140
- The matcher is inline because Next reads it statically at build time and ignores a re-exported one.
252
+ 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/`.
253
+
254
+ **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.
255
+
256
+ 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.
141
257
 
142
258
  If you already had a `proxy.ts`, the CLI leaves it alone and prints the snippet for you to merge.
143
259
 
144
260
  ---
145
261
 
262
+ ## b17_auth.yaml
263
+
264
+ 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.
265
+
266
+ ```yaml
267
+ name: Binary1702 # page titles, email subjects
268
+ signup: open # open | closed | invite
269
+ protected: /app/* /api/* # only these need sign-in; everything else is open. * = anything below
270
+
271
+ database: ${DATABASE_URL}
272
+ secret: ${AUTH_SECRET}
273
+
274
+ email:
275
+ from: NoReply (Binary1702) <no-reply@binary1702.com>
276
+ replyTo: support@binary1702.com # optional
277
+ gmail: # or resend: { apiKey: ${AUTH_RESEND_API_KEY} }
278
+ user: ben@binary1702.com
279
+ clientEmail: ${AUTH_GMAIL_CLIENT_EMAIL}
280
+ privateKey: ${AUTH_GMAIL_PRIVATE_KEY}
281
+
282
+ permissions: # what the app gates: id: label (the rows on /auth/admin › Roles)
283
+ invoices.read: See invoices
284
+ invoices.create: Create invoices
285
+ reports.read: See reports
286
+
287
+ roles: # the starting point, written into the database once. all = everything, x.* = a prefix
288
+ owner: all # (then /auth/admin › Roles decides; owner always has everything)
289
+ admin: all
290
+ member: invoices.* reports.read
291
+ ```
292
+
293
+ | Key | Default | |
294
+ |---|---|---|
295
+ | `name` | the request's host | shown in `<title>` and email subjects |
296
+ | `signup` | `closed` | `invite` also needs `inviteCode: ${AUTH_INVITE_CODE}` |
297
+ | `emailDomain` | any | only these addresses may sign up |
298
+ | `requireVerified` | `false` | no session until the verification link is clicked |
299
+ | `sessionDays` | `7` | cookie lifetime |
300
+ | `protected` | none: everything guarded | only these paths need sign-in; the rest is open. `/x/*` covers `/x` and everything below |
301
+ | `public` | none | the reverse: everything needs sign-in except these. Use one or the other. |
302
+ | `admins` | none | emails that may open `/auth/admin` |
303
+ | `pages` | the built-in pages | `signIn: /sign-in` and/or `signUp: /sign-up`: your own pages, see [Your own sign-in page](#your-own-sign-in-page) |
304
+ | `home` | `/` | the signed-in home, e.g. `/app`: where sign-in lands when nothing says otherwise, the admin page's "app" link, and where a signed-in visitor opening `/` is sent (so `/` can be the public site) |
305
+ | `email` | console in dev | see [Email](#email) |
306
+ | `permissions` | none | what the app gates, `id: label`; see [The session contract](#the-session-contract) |
307
+ | `roles` | `owner: all` | role → permissions, the starting point written into the database once; after that `/auth/admin › Roles` decides |
308
+
309
+ 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.
310
+
311
+ **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.
312
+
313
+ **`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.
314
+
315
+ ---
316
+
146
317
  ## Env vars
147
318
 
148
319
  | Var | What | Who sets it |
149
320
  |---|---|---|
150
321
  | `DATABASE_URL` | Postgres connection string | You, once, when the CLI asks |
151
322
  | `AUTH_SECRET` | Signs the session cookie | The CLI generates it |
323
+ | `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. |
324
+ | `AUTH_RESEND_API_KEY` + `AUTH_EMAIL_FROM` | Or send through [Resend](https://resend.com) | Same prompt |
325
+ | `AUTH_EMAIL_FROM` | With Gmail: optional display name, `Binary1702 <verify@binary1702.com>` | You, optional |
326
+ | `AUTH_REQUIRE_VERIFIED` | `true`: no session until the email is verified. Default off. | You, optional |
327
+ | `AUTH_ROLES` (or the older `AUTH_PERMISSIONS`) | JSON map of role → permissions, the starting point written into the database once (same as `roles:`) | You, optional |
328
+ | `AUTH_SIGNUP`, `AUTH_INVITE_CODE`, `AUTH_EMAIL_DOMAIN` | Who may sign up. See [Users](#users). | You, optional |
329
+ | `AUTH_PROTECTED` / `AUTH_PUBLIC` | Which paths need sign-in, space-separated. Same as the yaml keys. | You, optional |
330
+ | `AUTH_ADMINS` | Emails that may open `/auth/admin` | You, optional |
331
+ | `AUTH_EMAIL_REPLY_TO` | Reply-To on outgoing email | You, optional |
332
+ | `AUTH_GMAIL_CLIENT_ID` + `AUTH_GMAIL_CLIENT_SECRET` + `AUTH_GMAIL_REFRESH_TOKEN` | Gmail through a personal account instead of a service account | You, optional |
333
+ | `AUTH_CONFIG` | Path to the settings file when it is not `b17_auth.yaml` in the working directory | You, rarely |
334
+
335
+ 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.
336
+
337
+ ---
338
+
339
+ ## Email
340
+
341
+ 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:
342
+
343
+ ```
344
+ AUTH_GMAIL_USER + AUTH_GMAIL_CLIENT_EMAIL + AUTH_GMAIL_PRIVATE_KEY → Gmail API, as that mailbox
345
+ AUTH_RESEND_API_KEY + AUTH_EMAIL_FROM → Resend
346
+ neither, in development → printed to the dev server's console (the links work)
347
+ neither, in production → the first send throws; sign-in and sign-up keep working
348
+ ```
349
+
350
+ **Gmail API with a Google Workspace mailbox** (for example `verify@binary1702.com`):
152
351
 
153
- Both live in `.env.local`. Copy both to your host (Vercel, etc.) when you deploy.
352
+ 1. In Google Cloud: create a project, enable the **Gmail API**, create a **service account**, and download its JSON key.
353
+ 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`.
354
+ 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`.
355
+
356
+ 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.
357
+
358
+ **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.
359
+
360
+ **Resend**: an API key and a From address on a domain you have verified in Resend.
154
361
 
155
362
  Rotating `AUTH_SECRET` signs everyone out. That is the revoke-all button.
156
363
  For one user: `sign-out-everywhere`, `set-password` or `remove-user`.
@@ -159,31 +366,45 @@ For one user: `sign-out-everywhere`, `set-password` or `remove-user`.
159
366
 
160
367
  ## How it works
161
368
 
162
- Five primitives around one cookie. Nothing else.
369
+ 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.
163
370
 
164
371
  ```
165
- auth_users (id, email, password_hash, created_at,
166
- session_epoch) identity one table, owned by the package
167
- scrypt proof node:crypto, no dependency
168
- signed JWT in an httpOnly cookie, 7 days, carries the
169
- user's session_epoch session revoked when the epoch moves on
170
- proxy.ts guard verifies the cookie signature only
171
- getUser() / requireUser() gate signature + one lookup by id
172
- /auth/sign-in /auth/sign-out /auth/session lifecycle one route file
372
+ users (id, email, password_hash, session_epoch,
373
+ email_verified_at, active_org_id) identity owned by the package
374
+ orgs, members (org_id, user_id, role) where + role a user may be in several; one is active
375
+ tokens (user_id, kind, token_hash, expires_at,
376
+ used_at) links verify / reset; only the hash is stored
377
+ rate_limits (bucket, count, reset_at) brakes sign-in 20/IP and 10/email per 15 min, sign-up
378
+ 10/IP/h, reset and resend emails 3/h → 429
379
+ auth_migrations schema numbered steps, applied once on first start
380
+ scrypt proof node:crypto, no dependency
381
+ signed JWT in an httpOnly cookie, carries the user's
382
+ session_epoch session says WHO; revoked when the epoch moves on
383
+ proxy.ts guard signature only, plus protected: / public: paths
384
+ getSession() and friends gate signature + one query → the contract
385
+ b17_auth.yaml settings name, sign-up, public, email, permissions
386
+ sendEmail() email Gmail API or Resend, over fetch
387
+ /auth/* lifecycle one route file
173
388
  ```
174
389
 
175
390
  ```
176
391
  Request
177
- ├─ proxy.ts cookie valid? → continue else → redirect (page) or 401 (api)
392
+ ├─ proxy.ts open path, or cookie valid? → continue else → redirect (page) or 401 (api)
178
393
  ├─ /auth/sign-in POST email+password → verify hash → set cookie → redirect
179
- └─ your route requireUser() → reads cookie → user still exists, epoch matches? → { id, email }
394
+ ├─ /auth/sign-up create user → email a verify link → set cookie (or wait for the link)
395
+ ├─ /auth/forgot email a reset link → /auth/reset sets the password, bumps the epoch, signs in
396
+ └─ your route getSession() → cookie → user exists, epoch matches, verified if required?
397
+ → + active org + role → permissions from the roles table (set on /auth/admin)
398
+ → { user, organization, authorization }
180
399
  ```
181
400
 
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.
401
+ **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.
183
402
 
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.
403
+ **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.
185
404
 
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.
405
+ **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.
406
+
407
+ **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.
187
408
 
188
409
  ---
189
410
 
@@ -194,10 +415,25 @@ Tokens signed before 0.2.5 carry no epoch and count as 0, so upgrading signs nob
194
415
  | Export | |
195
416
  |---|---|
196
417
  | `GET`, `POST` | Route handlers. Mount at `app/auth/[...path]/route.ts`. |
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. |
199
- | `unauthorized(err)` | Turns an `AuthError` into a 401 JSON response. Rethrows anything else. |
200
- | `AuthError` | The error class. Has `.status = 401`. |
418
+ | `getSession()` | `Promise<Session \| null>`. The [contract](#the-session-contract). Null if signed out, deleted, revoked or (when required) unverified. One database query. |
419
+ | `requireSession()` | Same, or throws `AuthError` (401). |
420
+ | `requirePermission(p)` | Same, or throws `AuthError` (401 signed out, 403 role lacks `p`). |
421
+ | `getUser()` / `requireUser()` | Just the `user` part: `{ id, email }`. |
422
+ | `unauthorized(err)` | Turns an `AuthError` into a 401/403 JSON response. Rethrows anything else. |
423
+ | `AuthError` | The error class. Has `.status` (401 or 403). |
424
+ | `Session`, `Organization`, `Authorization`, `User` | The types. |
425
+
426
+ ### `@binary1702/auth/react`
427
+
428
+ | Export | |
429
+ |---|---|
430
+ | `AuthProvider` | `<AuthProvider session={…}>` with the server's `getSession()` result, or without a prop to fetch once on mount. |
431
+ | `useAuth()` | `{ user, organization, authorization, loading, refresh }`. Nulls and an empty permission list when signed out. |
432
+ | `fetchSession()` | `GET /auth/session` from the browser: `Session \| null`. |
433
+ | `signIn({ email, password, next? })` | `POST /auth/sign-in` as JSON. `{ ok: true, user, next }` or `{ ok: false, status, error, verify, retryAfter? }`. For [your own sign-in page](#your-own-sign-in-page). |
434
+ | `signUp({ email, password, invite?, next? })` | `POST /auth/sign-up`. Same result shape; `verify: true` with `ok` means "check your email". |
435
+ | `signOut()` | `POST /auth/sign-out`. |
436
+ | `fetchPolicy()` | `GET /auth/policy`: `{ signup: { allowed, inviteRequired, reason }, pages, name }`. |
201
437
 
202
438
  ### `@binary1702/auth/next/proxy`
203
439
 
@@ -214,7 +450,12 @@ For building your own handlers or using the primitives outside Next.
214
450
  |---|---|
215
451
  | `hashPassword(pw)`, `verifyPassword(pw, hash)` | scrypt |
216
452
  | `signSession(user, secret)`, `verifySession(token, secret)` | JWT HS256 |
217
- | `ensureSchema()`, `findUser()`, `createUser()`, `countUsers()`, `listUsers()`, `setPassword()` | Database |
453
+ | `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. |
454
+ | `createOrg()`, `findOrg()`, `listOrgs()`, `addMember()`, `removeMember()`, `setActiveOrg()` | Organizations |
455
+ | `buildSession()`, `hasPermission()`, `permissionsFor()` | The session contract, pure |
456
+ | `storedRoles()`, `setRolePermissions()`, `resetRole()`, `seedRoles()`, `memberRole()` | The roles table: what each role may do, per scope |
457
+ | `issueToken()`, `consumeToken()` | One-time tokens (`verify`, `reset`): only the SHA-256 is stored |
458
+ | `sendEmail()` | Resend over `fetch`, console in development |
218
459
  | `signInPage({ error?, next?, title? })` | The HTML form as a string |
219
460
  | `COOKIE_NAME`, `SESSION_DAYS`, `SIGN_IN_PATH`, `SIGN_OUT_PATH` | Constants |
220
461
 
@@ -225,8 +466,9 @@ For building your own handlers or using the primitives outside Next.
225
466
  No client library. Plain HTTP.
226
467
 
227
468
  ```ts
228
- // who am I
229
- const { user } = await fetch("/auth/session").then((r) => r.json())
469
+ // who am I, where am I, what may I do (401 when signed out)
470
+ const res = await fetch("/auth/session")
471
+ const { user, organization, authorization } = res.ok ? await res.json() : {}
230
472
 
231
473
  // sign in from a custom form
232
474
  await fetch("/auth/sign-in", {
@@ -243,9 +485,52 @@ Or just link to `/auth/sign-out`.
243
485
 
244
486
  ---
245
487
 
246
- ## Custom sign-in page
488
+ ## Your own sign-in page
489
+
490
+ The built-in pages are plain HTML with light and dark mode. To draw them in your app's design, name your pages in `b17_auth.yaml`:
491
+
492
+ ```yaml
493
+ pages:
494
+ signIn: /sign-in
495
+ signUp: /sign-up # either, or both
496
+ ```
497
+
498
+ That moves the *page*, not the protocol. The package keeps deciding what is valid, who may sign up, where to go next and when to refuse; your page only renders.
499
+
500
+ ```
501
+ pages.signIn set → the proxy, sign-out, a verified email, "no account yet" all land on /sign-in
502
+ GET /auth/sign-in?next=… redirects to /sign-in?next=… (old links keep working)
503
+ /sign-in is always open, in both protected: and public: modes
504
+ POST /auth/sign-in unchanged: that is what your page calls
505
+ ```
506
+
507
+ Your page is an ordinary route in the app (`app/sign-in/page.tsx`), built with the functions from `@binary1702/auth/react`:
508
+
509
+ ```tsx
510
+ "use client"
511
+ import { fetchPolicy, signIn, signUp } from "@binary1702/auth/react"
512
+
513
+ const r = await signIn({ email, password, next }) // POST /auth/sign-in as JSON
514
+ if (r.ok) location.assign(r.next) // next: checked by the server, same origin only
515
+ else setError(r.error) // 401 wrong password, 429 too many tries, 403 verify your email
516
+
517
+ const { signup, pages } = await fetchPolicy() // GET /auth/policy
518
+ // signup.allowed may anyone sign up right now? (sign-up page: show "closed" otherwise)
519
+ // signup.inviteRequired ask for the invite code
520
+ // signup.reason "first": no users yet, this is the first account (sign-in page: send to pages.signUp)
521
+ ```
522
+
523
+ What arrives on your page in the query, because the package redirected there:
524
+
525
+ | Query | Means | Where from |
526
+ |---|---|---|
527
+ | `?next=/app/x` | where to go after sign-in; pass it to `signIn()` as is | the proxy |
528
+ | `?error=1` | wrong email or password | a form POST to `/auth/sign-in` that failed (JSON callers get `401` instead) |
529
+ | `?error=<text>` | the message | a form POST to `/auth/sign-up` that failed |
530
+ | `?verify=1` | account created, email not verified yet | sign-up with `requireVerified` |
531
+ | `?verified=1` | the verification link worked; sign in now | `/auth/verify` |
247
532
 
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.
533
+ A form that POSTs `email`, `password` (and `next`, `invite`) to `/auth/sign-in` or `/auth/sign-up` works too; it gets redirects instead of JSON. Either way the request must come from your own site: cross-site POSTs get `403`. Forgot-password and reset stay the package's pages under `/auth/`; link to `/auth/forgot`.
249
534
 
250
535
  ---
251
536