@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.
- package/AGENTS.md +155 -0
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +27 -16
- package/PROMPT.md +325 -36
- package/README.md +338 -53
- package/dist/cli.js +248 -17
- package/dist/core/admin-html.d.ts +32 -0
- package/dist/core/admin-html.js +350 -0
- package/dist/core/admin.d.ts +56 -0
- package/dist/core/admin.js +90 -0
- package/dist/core/config.d.ts +10 -3
- package/dist/core/config.js +22 -8
- package/dist/core/db.d.ts +155 -10
- package/dist/core/db.js +449 -69
- package/dist/core/email.d.ts +49 -0
- package/dist/core/email.js +213 -0
- package/dist/core/guard.d.ts +52 -0
- package/dist/core/guard.js +106 -0
- package/dist/core/html.d.ts +43 -0
- package/dist/core/html.js +122 -28
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +95 -0
- package/dist/core/options.js +377 -0
- package/dist/core/org.d.ts +43 -0
- package/dist/core/org.js +65 -0
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/password.d.ts +16 -0
- package/dist/core/password.js +41 -12
- package/dist/core/rate.d.ts +26 -0
- package/dist/core/rate.js +70 -0
- package/dist/core/reset.d.ts +9 -0
- package/dist/core/reset.js +31 -0
- package/dist/core/session.d.ts +11 -1
- package/dist/core/session.js +27 -7
- package/dist/core/signup.d.ts +13 -6
- package/dist/core/signup.js +30 -15
- package/dist/core/tokens.d.ts +29 -0
- package/dist/core/tokens.js +64 -0
- package/dist/core/verify.d.ts +8 -0
- package/dist/core/verify.js +33 -0
- package/dist/core/yaml.d.ts +10 -0
- package/dist/core/yaml.js +85 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +18 -6
- package/dist/next/index.d.ts +10 -3
- package/dist/next/index.js +649 -63
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +22 -10
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +107 -0
- 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
|
|
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
|
|
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
|
-
✓
|
|
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
|
|
54
|
+
Every page and API route is now behind sign-in. To know who is asking:
|
|
53
55
|
|
|
54
56
|
```ts
|
|
55
|
-
import {
|
|
57
|
+
import { getSession } from "@binary1702/auth/next"
|
|
56
58
|
|
|
57
59
|
export async function GET() {
|
|
58
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
213
|
+
**Self sign-up at `/auth/sign-up`**, controlled by one key in `b17_auth.yaml`:
|
|
104
214
|
|
|
105
|
-
|
|
|
215
|
+
| `b17_auth.yaml` | Who can sign up |
|
|
106
216
|
|---|---|
|
|
107
|
-
|
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
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
|
-
|
|
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: ["/((?!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
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. `
|
|
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
|
-
|
|
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
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
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()` |
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|