@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.
- package/AGENTS.md +139 -0
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +30 -17
- package/PROMPT.md +325 -37
- package/README.md +293 -56
- package/dist/cli.js +255 -22
- package/dist/core/admin-html.d.ts +26 -0
- package/dist/core/admin-html.js +252 -0
- package/dist/core/admin.d.ts +26 -0
- package/dist/core/admin.js +48 -0
- package/dist/core/config.d.ts +8 -1
- package/dist/core/config.js +20 -6
- package/dist/core/db.d.ts +138 -7
- package/dist/core/db.js +406 -47
- package/dist/core/email.d.ts +49 -0
- package/dist/core/email.js +213 -0
- package/dist/core/guard.d.ts +49 -0
- package/dist/core/guard.js +103 -0
- package/dist/core/html.d.ts +36 -0
- package/dist/core/html.js +106 -25
- package/dist/core/options.d.ts +76 -0
- package/dist/core/options.js +310 -0
- package/dist/core/org.d.ts +34 -0
- package/dist/core/org.js +55 -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 +23 -2
- package/dist/core/session.js +45 -7
- package/dist/core/signup.d.ts +9 -6
- package/dist/core/signup.js +26 -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 +17 -6
- package/dist/index.js +17 -6
- package/dist/next/index.d.ts +11 -4
- package/dist/next/index.js +455 -51
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +16 -7
- package/dist/react/index.d.ts +18 -0
- package/dist/react/index.js +55 -0
- package/package.json +16 -3
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# @binary1702/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Email + password auth for Next.js. Plug and play.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @binary1702/auth
|
|
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,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
|
-
✓
|
|
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
|
|
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
|
|
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". |
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
npx @binary1702/auth add-user gladys
|
|
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
|
|
208
|
+
**Self sign-up at `/auth/sign-up`**, controlled by one key in `b17_auth.yaml`:
|
|
102
209
|
|
|
103
|
-
|
|
|
210
|
+
| `b17_auth.yaml` | Who can sign up |
|
|
104
211
|
|---|---|
|
|
105
|
-
|
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
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
|
-
|
|
217
|
+
(`AUTH_SIGNUP=open`, `AUTH_INVITE_CODE` and `AUTH_EMAIL_DOMAIN` as env vars still work and override the file.)
|
|
110
218
|
|
|
111
|
-
|
|
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: ["/((?!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/
|
|
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
|
|
172
|
-
|
|
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
|
|
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
|
-
**
|
|
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
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
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()` |
|
|
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
|
|
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({
|
|
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 `
|
|
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
|
|