@binary1702/auth 0.0.0-stage → 0.1.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/INSTALL.md +47 -0
- package/README.md +241 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +285 -0
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.js +25 -0
- package/dist/core/db.d.ts +16 -0
- package/dist/core/db.js +65 -0
- package/dist/core/html.d.ts +5 -0
- package/dist/core/html.js +43 -0
- package/dist/core/password.d.ts +2 -0
- package/dist/core/password.js +32 -0
- package/dist/core/session.d.ts +6 -0
- package/dist/core/session.js +29 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +7 -0
- package/dist/next/index.d.ts +20 -0
- package/dist/next/index.js +135 -0
- package/dist/next/proxy.d.ts +5 -0
- package/dist/next/proxy.js +35 -0
- package/package.json +64 -4
package/INSTALL.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Install
|
|
2
|
+
|
|
3
|
+
Inside a Next.js project (App Router):
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @binary1702/auth
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That is the whole install. It asks for your Postgres connection string once, then does everything else:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
✓ @binary1702/auth installed
|
|
13
|
+
✓ DATABASE_URL → .env.local
|
|
14
|
+
✓ AUTH_SECRET generated → .env.local
|
|
15
|
+
✓ app/auth/[...path]/route.ts
|
|
16
|
+
✓ proxy.ts
|
|
17
|
+
✓ auth_users table ready
|
|
18
|
+
✓ user "ben" created
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Safe to run again. It only does what is left.
|
|
22
|
+
|
|
23
|
+
## Use it
|
|
24
|
+
|
|
25
|
+
Every page and API route is now behind sign-in. Get the user anywhere on the server:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { requireUser } from "@binary1702/auth/next"
|
|
29
|
+
|
|
30
|
+
const user = await requireUser() // { id, username } or throws 401
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## URLs you get
|
|
34
|
+
|
|
35
|
+
| URL | |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `/auth/sign-in` | sign-in page |
|
|
38
|
+
| `/auth/sign-out` | clears the session |
|
|
39
|
+
| `/auth/session` | `{ user }` as JSON, for client code |
|
|
40
|
+
|
|
41
|
+
## More users
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx @binary1702/auth add-user
|
|
45
|
+
npx @binary1702/auth set-password
|
|
46
|
+
npx @binary1702/auth list-users
|
|
47
|
+
```
|
package/README.md
CHANGED
|
@@ -1,3 +1,242 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @binary1702/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Username + password auth for Next.js. Plug and play.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @binary1702/auth
|
|
7
|
+
```
|
|
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.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick start (new project)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx create-next-app my-app
|
|
17
|
+
cd my-app
|
|
18
|
+
npx @binary1702/auth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You will be asked for one thing: your Postgres connection string (Neon). Everything else is automatic.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
@binary1702/auth
|
|
25
|
+
|
|
26
|
+
✓ @binary1702/auth installed
|
|
27
|
+
✓ DATABASE_URL → .env.local
|
|
28
|
+
✓ AUTH_SECRET generated → .env.local
|
|
29
|
+
✓ app/auth/[...path]/route.ts
|
|
30
|
+
✓ proxy.ts
|
|
31
|
+
✓ auth_users table ready
|
|
32
|
+
|
|
33
|
+
Create the first user:
|
|
34
|
+
Username: ben
|
|
35
|
+
Password:
|
|
36
|
+
Password again:
|
|
37
|
+
✓ user "ben" created
|
|
38
|
+
|
|
39
|
+
Done. Start the dev server and open /auth/sign-in.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run `npm run dev`, open the site, you are redirected to the sign-in page. Sign in. Done.
|
|
43
|
+
|
|
44
|
+
**Safe to re-run.** Each step is skipped if it is already done. Running it again on a finished project just offers to add another user.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Use it in your code
|
|
49
|
+
|
|
50
|
+
Every page and API route is now behind sign-in. To know who the user is:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { requireUser } from "@binary1702/auth/next"
|
|
54
|
+
|
|
55
|
+
export async function GET() {
|
|
56
|
+
const user = await requireUser() // { id, username }
|
|
57
|
+
// ...
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That is the only line you ever write by hand.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { getUser } from "@binary1702/auth/next"
|
|
65
|
+
|
|
66
|
+
const user = await getUser() // { id, username } or null, never throws
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Works in route handlers, server components, and server actions.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## What you get
|
|
74
|
+
|
|
75
|
+
| URL | What it does |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `/auth/sign-in` | The sign-in page. Served by the package, no React, no styling work. |
|
|
78
|
+
| `/auth/sign-out` | Clears the session and sends you back to sign-in. Link to it. |
|
|
79
|
+
| `/auth/session` | `{ user }` or `{ user: null }` as JSON, for client code. |
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Managing users
|
|
86
|
+
|
|
87
|
+
There is no sign-up page. Users are created from the terminal.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx @binary1702/auth add-user # prompts for username and password
|
|
91
|
+
npx @binary1702/auth add-user gladys # username given
|
|
92
|
+
npx @binary1702/auth set-password ben
|
|
93
|
+
npx @binary1702/auth list-users
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## The two files it wrote
|
|
99
|
+
|
|
100
|
+
You do not need to touch these. Here is what they are, for when you are curious.
|
|
101
|
+
|
|
102
|
+
**`app/auth/[...path]/route.ts`** mounts the handlers.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
export { GET, POST } from "@binary1702/auth/next"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**`proxy.ts`** guards every request. (On Next 15 this file is called `middleware.ts`. Same content.)
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { proxy } from "@binary1702/auth/next/proxy"
|
|
112
|
+
|
|
113
|
+
export default proxy
|
|
114
|
+
|
|
115
|
+
export const config = {
|
|
116
|
+
matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The matcher is inline because Next reads it statically at build time and ignores a re-exported one.
|
|
121
|
+
|
|
122
|
+
If you already had a `proxy.ts`, the CLI leaves it alone and prints the snippet for you to merge.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Env vars
|
|
127
|
+
|
|
128
|
+
| Var | What | Who sets it |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `DATABASE_URL` | Postgres connection string | You, once, when the CLI asks |
|
|
131
|
+
| `AUTH_SECRET` | Signs the session cookie | The CLI generates it |
|
|
132
|
+
|
|
133
|
+
Both live in `.env.local`. Copy both to your host (Vercel, etc.) when you deploy.
|
|
134
|
+
|
|
135
|
+
Rotating `AUTH_SECRET` signs everyone out. That is the revoke-all button.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## How it works
|
|
140
|
+
|
|
141
|
+
Five primitives around one cookie. Nothing else.
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
auth_users (id, username, password_hash, created_at) identity one table, owned by the package
|
|
145
|
+
scrypt proof node:crypto, no dependency
|
|
146
|
+
signed JWT in an httpOnly cookie, 7 days session stateless, zero DB reads per request
|
|
147
|
+
proxy.ts guard verifies the cookie signature only
|
|
148
|
+
/auth/sign-in /auth/sign-out /auth/session lifecycle one route file
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
Request
|
|
153
|
+
├─ proxy.ts cookie valid? → continue else → redirect (page) or 401 (api)
|
|
154
|
+
├─ /auth/sign-in POST username+password → verify hash → set cookie → redirect
|
|
155
|
+
└─ your route requireUser() → reads cookie → { id, username }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**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.
|
|
159
|
+
|
|
160
|
+
**Trade-off you should know.** The session is a signed token, not a database row. That is why the proxy costs nothing per request. The cost is that one session cannot be revoked before it expires. Change `AUTH_SECRET` to revoke all of them.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## API reference
|
|
165
|
+
|
|
166
|
+
### `@binary1702/auth/next`
|
|
167
|
+
|
|
168
|
+
| Export | |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `GET`, `POST` | Route handlers. Mount at `app/auth/[...path]/route.ts`. |
|
|
171
|
+
| `requireUser()` | `Promise<{ id, username }>`. Throws `AuthError` (status 401) if signed out. |
|
|
172
|
+
| `getUser()` | `Promise<{ id, username } \| null>`. Never throws. |
|
|
173
|
+
| `unauthorized(err)` | Turns an `AuthError` into a 401 JSON response. Rethrows anything else. |
|
|
174
|
+
| `AuthError` | The error class. Has `.status = 401`. |
|
|
175
|
+
|
|
176
|
+
### `@binary1702/auth/next/proxy`
|
|
177
|
+
|
|
178
|
+
| Export | |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `proxy` | The guard function. Default export too. |
|
|
181
|
+
| `MATCHER` | The matcher array, for reference. Next needs it written inline in your file. |
|
|
182
|
+
|
|
183
|
+
### `@binary1702/auth` (framework-free core)
|
|
184
|
+
|
|
185
|
+
For building your own handlers or using the primitives outside Next.
|
|
186
|
+
|
|
187
|
+
| Export | |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `hashPassword(pw)`, `verifyPassword(pw, hash)` | scrypt |
|
|
190
|
+
| `signSession(user, secret)`, `verifySession(token, secret)` | JWT HS256 |
|
|
191
|
+
| `ensureSchema()`, `findUser()`, `createUser()`, `countUsers()`, `listUsers()`, `setPassword()` | Database |
|
|
192
|
+
| `signInPage({ error?, next?, title? })` | The HTML form as a string |
|
|
193
|
+
| `COOKIE_NAME`, `SESSION_DAYS`, `SIGN_IN_PATH`, `SIGN_OUT_PATH` | Constants |
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Client-side usage
|
|
198
|
+
|
|
199
|
+
No client library. Plain HTTP.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// who am I
|
|
203
|
+
const { user } = await fetch("/auth/session").then((r) => r.json())
|
|
204
|
+
|
|
205
|
+
// sign in from a custom form
|
|
206
|
+
await fetch("/auth/sign-in", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "content-type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ username, password }),
|
|
210
|
+
}) // 200 { user } or 401 { error }
|
|
211
|
+
|
|
212
|
+
// sign out
|
|
213
|
+
await fetch("/auth/sign-out", { method: "POST" })
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Or just link to `/auth/sign-out`.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Custom sign-in page
|
|
221
|
+
|
|
222
|
+
The built-in page is plain HTML with light and dark mode. If you want your own, make any page with a form that POSTs `username` and `password` to `/auth/sign-in`, and put it anywhere under `/auth/` so the proxy lets it through. Optional hidden field `next` for where to go after.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Requirements
|
|
227
|
+
|
|
228
|
+
- Next.js 15 or 16, App Router
|
|
229
|
+
- Node 20+
|
|
230
|
+
- A Postgres database (built for Neon, works with any Postgres that the Neon serverless driver can reach)
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
npm install
|
|
238
|
+
npm test # core primitives, no database needed
|
|
239
|
+
npm run build # tsc → dist/
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Release: `npm version <patch|minor|major> && npm publish && git push --follow-tags`.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// npx @binary1702/auth
|
|
4
|
+
//
|
|
5
|
+
// One command. Each step is skipped if already done, so it is
|
|
6
|
+
// safe to run again at any time.
|
|
7
|
+
//
|
|
8
|
+
// 1. install the package into the project
|
|
9
|
+
// 2. DATABASE_URL → prompt, write .env.local
|
|
10
|
+
// 3. AUTH_SECRET → generate, write .env.local
|
|
11
|
+
// 4. app/auth/[...path]/route.ts
|
|
12
|
+
// 5. proxy.ts
|
|
13
|
+
// 6. create auth_users table
|
|
14
|
+
// 7. create the first user
|
|
15
|
+
//
|
|
16
|
+
// Subcommands:
|
|
17
|
+
// add-user [name] create another user
|
|
18
|
+
// set-password [name] change a password
|
|
19
|
+
// list-users
|
|
20
|
+
// ─────────────────────────────────────────────────────────────
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { createInterface } from "node:readline";
|
|
26
|
+
import { stdin, stdout } from "node:process";
|
|
27
|
+
const PKG = "@binary1702/auth";
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
// ── output ────────────────────────────────────────────────────
|
|
30
|
+
const c = {
|
|
31
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
32
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
33
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
34
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
35
|
+
};
|
|
36
|
+
const done = (msg) => console.log(` ${c.green("✓")} ${msg}`);
|
|
37
|
+
const skip = (msg) => console.log(` ${c.dim("·")} ${c.dim(msg)}`);
|
|
38
|
+
const fail = (msg) => {
|
|
39
|
+
console.error(`\n ${c.red("✗")} ${msg}\n`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
};
|
|
42
|
+
// ── prompts ───────────────────────────────────────────────────
|
|
43
|
+
// One readline for the whole run, with every incoming line queued.
|
|
44
|
+
// readline emits all buffered lines at once (piped stdin, pasted text),
|
|
45
|
+
// so a question registered after that burst would otherwise see nothing.
|
|
46
|
+
let _rl = null;
|
|
47
|
+
let muted = false;
|
|
48
|
+
let inputClosed = false;
|
|
49
|
+
const lines = [];
|
|
50
|
+
const waiters = [];
|
|
51
|
+
function rl() {
|
|
52
|
+
if (_rl)
|
|
53
|
+
return _rl;
|
|
54
|
+
_rl = createInterface({ input: stdin, output: stdout, terminal: stdin.isTTY ?? false });
|
|
55
|
+
// Echo nothing while a password is being typed (terminal mode only).
|
|
56
|
+
const anyRl = _rl;
|
|
57
|
+
anyRl._writeToOutput = (s) => {
|
|
58
|
+
if (!muted)
|
|
59
|
+
stdout.write(s);
|
|
60
|
+
};
|
|
61
|
+
_rl.on("line", (line) => {
|
|
62
|
+
const w = waiters.shift();
|
|
63
|
+
w ? w(line) : lines.push(line);
|
|
64
|
+
});
|
|
65
|
+
_rl.on("close", () => {
|
|
66
|
+
inputClosed = true;
|
|
67
|
+
while (waiters.length)
|
|
68
|
+
waiters.shift()(null);
|
|
69
|
+
});
|
|
70
|
+
return _rl;
|
|
71
|
+
}
|
|
72
|
+
function closePrompts() {
|
|
73
|
+
_rl?.close();
|
|
74
|
+
_rl = null;
|
|
75
|
+
}
|
|
76
|
+
async function ask(question, opts = {}) {
|
|
77
|
+
rl();
|
|
78
|
+
const suffix = opts.default ? c.dim(` (${opts.default})`) : "";
|
|
79
|
+
stdout.write(` ${question}${suffix}: `);
|
|
80
|
+
if (opts.hidden)
|
|
81
|
+
muted = true;
|
|
82
|
+
const line = lines.length > 0
|
|
83
|
+
? lines.shift()
|
|
84
|
+
: inputClosed
|
|
85
|
+
? null
|
|
86
|
+
: await new Promise((res) => waiters.push(res));
|
|
87
|
+
if (opts.hidden) {
|
|
88
|
+
muted = false;
|
|
89
|
+
stdout.write("\n");
|
|
90
|
+
}
|
|
91
|
+
if (line === null)
|
|
92
|
+
return fail("Input ended before all prompts were answered.");
|
|
93
|
+
return opts.hidden ? line : line.trim() || opts.default || "";
|
|
94
|
+
}
|
|
95
|
+
// ── env file ──────────────────────────────────────────────────
|
|
96
|
+
const ENV_FILE = join(cwd, ".env.local");
|
|
97
|
+
function readEnv() {
|
|
98
|
+
const out = {};
|
|
99
|
+
if (!existsSync(ENV_FILE))
|
|
100
|
+
return out;
|
|
101
|
+
for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) {
|
|
102
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
103
|
+
if (m)
|
|
104
|
+
out[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function writeEnv(key, value) {
|
|
109
|
+
const prefix = existsSync(ENV_FILE) && !readFileSync(ENV_FILE, "utf8").endsWith("\n") && readFileSync(ENV_FILE, "utf8").length > 0 ? "\n" : "";
|
|
110
|
+
appendFileSync(ENV_FILE, `${prefix}${key}=${value}\n`);
|
|
111
|
+
process.env[key] = value;
|
|
112
|
+
}
|
|
113
|
+
// ── files the consumer needs ──────────────────────────────────
|
|
114
|
+
const ROUTE_FILE = `export { GET, POST } from "${PKG}/next"\n`;
|
|
115
|
+
const PROXY_FILE = `import { proxy } from "${PKG}/next/proxy"
|
|
116
|
+
|
|
117
|
+
export default proxy
|
|
118
|
+
|
|
119
|
+
// Everything except the auth pages, Next internals, and static files.
|
|
120
|
+
export const config = {
|
|
121
|
+
matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"],
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
function writeIfMissing(rel, content) {
|
|
125
|
+
const abs = join(cwd, rel);
|
|
126
|
+
if (existsSync(abs)) {
|
|
127
|
+
skip(`${rel} exists`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
131
|
+
writeFileSync(abs, content);
|
|
132
|
+
done(rel);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// ── steps ─────────────────────────────────────────────────────
|
|
136
|
+
function stepProject() {
|
|
137
|
+
if (!existsSync(join(cwd, "package.json"))) {
|
|
138
|
+
fail(`No package.json here. Run this inside your Next.js project:\n npx create-next-app my-app && cd my-app && npx ${PKG}`);
|
|
139
|
+
}
|
|
140
|
+
if (existsSync(join(cwd, "src", "app")))
|
|
141
|
+
return { appDir: "src/app", src: true };
|
|
142
|
+
if (existsSync(join(cwd, "app")))
|
|
143
|
+
return { appDir: "app", src: false };
|
|
144
|
+
return fail(`No app/ directory. ${PKG} needs the Next.js App Router.`);
|
|
145
|
+
}
|
|
146
|
+
function stepInstall() {
|
|
147
|
+
if (existsSync(join(cwd, "node_modules", ...PKG.split("/")))) {
|
|
148
|
+
skip(`${PKG} installed`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
console.log(` ${c.dim(`npm install ${PKG}`)}`);
|
|
152
|
+
const r = spawnSync("npm", ["install", PKG], { cwd, stdio: "inherit" });
|
|
153
|
+
if (r.status !== 0)
|
|
154
|
+
fail(`npm install ${PKG} failed`);
|
|
155
|
+
done(`${PKG} installed`);
|
|
156
|
+
}
|
|
157
|
+
async function stepEnv() {
|
|
158
|
+
const env = readEnv();
|
|
159
|
+
if (env.DATABASE_URL) {
|
|
160
|
+
process.env.DATABASE_URL = env.DATABASE_URL;
|
|
161
|
+
skip("DATABASE_URL set");
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const guess = process.env.DATABASE_URL ?? env.NEON_DATABASE_URL ?? process.env.NEON_DATABASE_URL;
|
|
165
|
+
const url = await ask("Postgres connection string (DATABASE_URL)", { default: guess });
|
|
166
|
+
if (!url.startsWith("postgres"))
|
|
167
|
+
fail("That does not look like a postgres:// URL.");
|
|
168
|
+
writeEnv("DATABASE_URL", url);
|
|
169
|
+
done("DATABASE_URL → .env.local");
|
|
170
|
+
}
|
|
171
|
+
if (env.AUTH_SECRET) {
|
|
172
|
+
process.env.AUTH_SECRET = env.AUTH_SECRET;
|
|
173
|
+
skip("AUTH_SECRET set");
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
writeEnv("AUTH_SECRET", randomBytes(32).toString("base64url"));
|
|
177
|
+
done("AUTH_SECRET generated → .env.local");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function stepFiles(appDir, src) {
|
|
181
|
+
writeIfMissing(`${appDir}/auth/[...path]/route.ts`, ROUTE_FILE);
|
|
182
|
+
const proxyPath = src ? "src/proxy.ts" : "proxy.ts";
|
|
183
|
+
const legacy = [src ? "src/middleware.ts" : "middleware.ts", proxyPath].filter((p) => existsSync(join(cwd, p)));
|
|
184
|
+
if (legacy.length) {
|
|
185
|
+
skip(`${legacy[0]} exists, not touched. Add to it:`);
|
|
186
|
+
console.log(c.dim(PROXY_FILE.replace(/^/gm, " ")));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
writeIfMissing(proxyPath, PROXY_FILE);
|
|
190
|
+
}
|
|
191
|
+
async function db() {
|
|
192
|
+
// Imported lazily: needs DATABASE_URL in process.env first.
|
|
193
|
+
return import("./core/db.js");
|
|
194
|
+
}
|
|
195
|
+
async function stepSchema() {
|
|
196
|
+
const { ensureSchema } = await db();
|
|
197
|
+
await ensureSchema().catch((e) => fail(`Could not reach the database: ${e.message}`));
|
|
198
|
+
done("auth_users table ready");
|
|
199
|
+
}
|
|
200
|
+
async function createUserInteractive(presetName) {
|
|
201
|
+
const { createUser, findUser } = await db();
|
|
202
|
+
const { hashPassword } = await import("./core/password.js");
|
|
203
|
+
const username = presetName ?? (await ask("Username"));
|
|
204
|
+
if (!username)
|
|
205
|
+
fail("Username is required.");
|
|
206
|
+
if (await findUser(username))
|
|
207
|
+
fail(`User "${username}" already exists.`);
|
|
208
|
+
const password = await ask("Password", { hidden: true });
|
|
209
|
+
if (password.length < 8)
|
|
210
|
+
fail("Password must be at least 8 characters.");
|
|
211
|
+
const again = await ask("Password again", { hidden: true });
|
|
212
|
+
if (password !== again)
|
|
213
|
+
fail("Passwords do not match.");
|
|
214
|
+
await createUser(username, await hashPassword(password));
|
|
215
|
+
done(`user "${username}" created`);
|
|
216
|
+
}
|
|
217
|
+
async function stepFirstUser() {
|
|
218
|
+
const { countUsers } = await db();
|
|
219
|
+
const n = await countUsers();
|
|
220
|
+
if (n > 0) {
|
|
221
|
+
skip(`${n} user${n === 1 ? "" : "s"} exist`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
console.log("\n Create the first user:");
|
|
225
|
+
await createUserInteractive();
|
|
226
|
+
}
|
|
227
|
+
function finish() {
|
|
228
|
+
console.log(`
|
|
229
|
+
${c.bold("Done.")} Start the dev server and open ${c.bold("/auth/sign-in")}.
|
|
230
|
+
|
|
231
|
+
Get the user in any route or server component:
|
|
232
|
+
|
|
233
|
+
import { requireUser } from "${PKG}/next"
|
|
234
|
+
const user = await requireUser() // { id, username }
|
|
235
|
+
|
|
236
|
+
Add users later: npx ${PKG} add-user
|
|
237
|
+
`);
|
|
238
|
+
}
|
|
239
|
+
// ── main ──────────────────────────────────────────────────────
|
|
240
|
+
async function main() {
|
|
241
|
+
const [cmd, arg] = process.argv.slice(2);
|
|
242
|
+
console.log(`\n${c.bold(PKG)}\n`);
|
|
243
|
+
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
244
|
+
console.log(` npx ${PKG} set up auth in this project (safe to re-run)
|
|
245
|
+
npx ${PKG} add-user [name] create a user
|
|
246
|
+
npx ${PKG} set-password [name]
|
|
247
|
+
npx ${PKG} list-users
|
|
248
|
+
`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
stepProject();
|
|
252
|
+
if (cmd === "add-user" || cmd === "set-password" || cmd === "list-users") {
|
|
253
|
+
await stepEnv();
|
|
254
|
+
const d = await db();
|
|
255
|
+
if (cmd === "add-user")
|
|
256
|
+
await createUserInteractive(arg);
|
|
257
|
+
if (cmd === "list-users") {
|
|
258
|
+
for (const u of await d.listUsers())
|
|
259
|
+
console.log(` ${u.username} ${c.dim(u.id)}`);
|
|
260
|
+
}
|
|
261
|
+
if (cmd === "set-password") {
|
|
262
|
+
const username = arg ?? (await ask("Username"));
|
|
263
|
+
const password = await ask("New password", { hidden: true });
|
|
264
|
+
if (password.length < 8)
|
|
265
|
+
fail("Password must be at least 8 characters.");
|
|
266
|
+
const { hashPassword } = await import("./core/password.js");
|
|
267
|
+
const ok = await d.setPassword(username, await hashPassword(password));
|
|
268
|
+
ok ? done(`password updated for "${username}"`) : fail(`No user "${username}".`);
|
|
269
|
+
}
|
|
270
|
+
console.log();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (cmd)
|
|
274
|
+
fail(`Unknown command "${cmd}". Try: npx ${PKG} help`);
|
|
275
|
+
const { appDir, src } = stepProject();
|
|
276
|
+
stepInstall();
|
|
277
|
+
await stepEnv();
|
|
278
|
+
stepFiles(appDir, src);
|
|
279
|
+
await stepSchema();
|
|
280
|
+
await stepFirstUser();
|
|
281
|
+
finish();
|
|
282
|
+
}
|
|
283
|
+
main()
|
|
284
|
+
.catch((e) => fail(e instanceof Error ? e.message : String(e)))
|
|
285
|
+
.finally(closePrompts);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const COOKIE_NAME = "auth_session";
|
|
2
|
+
export declare const SESSION_DAYS = 7;
|
|
3
|
+
export declare const SIGN_IN_PATH = "/auth/sign-in";
|
|
4
|
+
export declare const SIGN_OUT_PATH = "/auth/sign-out";
|
|
5
|
+
export declare const TABLE = "auth_users";
|
|
6
|
+
export declare function getSecret(): string;
|
|
7
|
+
export declare function getDatabaseUrl(): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Configuration. Two env vars, nothing else.
|
|
3
|
+
//
|
|
4
|
+
// DATABASE_URL Postgres connection string (Neon)
|
|
5
|
+
// AUTH_SECRET signs the session cookie; generated by the CLI
|
|
6
|
+
// ─────────────────────────────────────────────────────────────
|
|
7
|
+
export const COOKIE_NAME = "auth_session";
|
|
8
|
+
export const SESSION_DAYS = 7;
|
|
9
|
+
export const SIGN_IN_PATH = "/auth/sign-in";
|
|
10
|
+
export const SIGN_OUT_PATH = "/auth/sign-out";
|
|
11
|
+
export const TABLE = "auth_users";
|
|
12
|
+
export function getSecret() {
|
|
13
|
+
const secret = process.env.AUTH_SECRET;
|
|
14
|
+
if (!secret || secret.length < 16) {
|
|
15
|
+
throw new Error("AUTH_SECRET is missing. Run: npx @binary1702/auth");
|
|
16
|
+
}
|
|
17
|
+
return secret;
|
|
18
|
+
}
|
|
19
|
+
export function getDatabaseUrl() {
|
|
20
|
+
const url = process.env.DATABASE_URL;
|
|
21
|
+
if (!url) {
|
|
22
|
+
throw new Error("DATABASE_URL is missing. Run: npx @binary1702/auth");
|
|
23
|
+
}
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TABLE } from "./config.ts";
|
|
2
|
+
import type { User } from "./session.ts";
|
|
3
|
+
export declare function ensureSchema(): Promise<void>;
|
|
4
|
+
type Row = {
|
|
5
|
+
id: string;
|
|
6
|
+
username: string;
|
|
7
|
+
password_hash: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function findUser(username: string): Promise<Row | null>;
|
|
10
|
+
export declare function createUser(username: string, passwordHash: string): Promise<User>;
|
|
11
|
+
export declare function countUsers(): Promise<number>;
|
|
12
|
+
export declare function listUsers(): Promise<(User & {
|
|
13
|
+
created_at: string;
|
|
14
|
+
})[]>;
|
|
15
|
+
export declare function setPassword(username: string, passwordHash: string): Promise<boolean>;
|
|
16
|
+
export { TABLE };
|
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Storage. One table, owned by this package.
|
|
3
|
+
//
|
|
4
|
+
// auth_users (id, username, password_hash, created_at)
|
|
5
|
+
//
|
|
6
|
+
// ensureSchema() is idempotent and memoized per process, so route
|
|
7
|
+
// handlers can call it on every request for free after the first.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────
|
|
9
|
+
import { neon } from "@neondatabase/serverless";
|
|
10
|
+
import { getDatabaseUrl, TABLE } from "./config.js";
|
|
11
|
+
let _sql = null;
|
|
12
|
+
function sql() {
|
|
13
|
+
return (_sql ??= neon(getDatabaseUrl()));
|
|
14
|
+
}
|
|
15
|
+
let _schema = null;
|
|
16
|
+
export function ensureSchema() {
|
|
17
|
+
return (_schema ??= (async () => {
|
|
18
|
+
await sql() `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS auth_users (
|
|
20
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
21
|
+
username TEXT NOT NULL UNIQUE,
|
|
22
|
+
password_hash TEXT NOT NULL,
|
|
23
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
24
|
+
)
|
|
25
|
+
`;
|
|
26
|
+
})().catch((err) => {
|
|
27
|
+
_schema = null; // let the next call retry
|
|
28
|
+
throw err;
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
export async function findUser(username) {
|
|
32
|
+
await ensureSchema();
|
|
33
|
+
const rows = (await sql() `
|
|
34
|
+
SELECT id, username, password_hash FROM auth_users WHERE username = ${username}
|
|
35
|
+
`);
|
|
36
|
+
return rows[0] ?? null;
|
|
37
|
+
}
|
|
38
|
+
export async function createUser(username, passwordHash) {
|
|
39
|
+
await ensureSchema();
|
|
40
|
+
const [row] = (await sql() `
|
|
41
|
+
INSERT INTO auth_users (username, password_hash)
|
|
42
|
+
VALUES (${username}, ${passwordHash})
|
|
43
|
+
RETURNING id, username
|
|
44
|
+
`);
|
|
45
|
+
return row;
|
|
46
|
+
}
|
|
47
|
+
export async function countUsers() {
|
|
48
|
+
await ensureSchema();
|
|
49
|
+
const [row] = (await sql() `SELECT COUNT(*)::int AS n FROM auth_users`);
|
|
50
|
+
return row.n;
|
|
51
|
+
}
|
|
52
|
+
export async function listUsers() {
|
|
53
|
+
await ensureSchema();
|
|
54
|
+
return (await sql() `
|
|
55
|
+
SELECT id, username, created_at FROM auth_users ORDER BY created_at
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
export async function setPassword(username, passwordHash) {
|
|
59
|
+
await ensureSchema();
|
|
60
|
+
const rows = (await sql() `
|
|
61
|
+
UPDATE auth_users SET password_hash = ${passwordHash} WHERE username = ${username} RETURNING id
|
|
62
|
+
`);
|
|
63
|
+
return rows.length > 0;
|
|
64
|
+
}
|
|
65
|
+
export { TABLE };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// The sign-in page. Plain HTML served by the route handler,
|
|
3
|
+
// so the consumer never builds a form or a page.
|
|
4
|
+
// ─────────────────────────────────────────────────────────────
|
|
5
|
+
import { SIGN_IN_PATH } from "./config.js";
|
|
6
|
+
const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
|
+
export function signInPage(opts = {}) {
|
|
8
|
+
const title = escape(opts.title ?? "Sign in");
|
|
9
|
+
const next = escape(opts.next ?? "/");
|
|
10
|
+
const error = opts.error ? `<p class="error">${escape(opts.error)}</p>` : "";
|
|
11
|
+
return `<!doctype html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="utf-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
+
<title>${title}</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #ddd; --muted: #666; --err: #c0392b; }
|
|
19
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --line: #333; --muted: #999; --err: #ff6b6b; } }
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg);
|
|
22
|
+
font: 15px/1.4 system-ui, -apple-system, sans-serif; }
|
|
23
|
+
form { width: 100%; max-width: 320px; padding: 16px; display: grid; gap: 12px; }
|
|
24
|
+
h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
|
|
25
|
+
label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
|
|
26
|
+
input { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
|
|
27
|
+
input:focus { outline: 2px solid var(--fg); outline-offset: 1px; }
|
|
28
|
+
button { font: inherit; font-weight: 500; padding: 9px; border: 0; border-radius: 6px; background: var(--fg); color: var(--bg); cursor: pointer; }
|
|
29
|
+
.error { margin: 0; font-size: 13px; color: var(--err); }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<form method="post" action="${SIGN_IN_PATH}">
|
|
34
|
+
<h1>${title}</h1>
|
|
35
|
+
${error}
|
|
36
|
+
<label>Username <input name="username" autocomplete="username" autofocus required></label>
|
|
37
|
+
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
|
38
|
+
<input type="hidden" name="next" value="${next}">
|
|
39
|
+
<button type="submit">Sign in</button>
|
|
40
|
+
</form>
|
|
41
|
+
</body>
|
|
42
|
+
</html>`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Password hashing. scrypt from node:crypto, no dependency.
|
|
3
|
+
//
|
|
4
|
+
// Stored format: scrypt$N$r$p$<salt b64url>$<hash b64url>
|
|
5
|
+
// Parameters live in the string, so they can be raised later
|
|
6
|
+
// without invalidating existing rows.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────
|
|
8
|
+
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const scrypt = promisify(scryptCb);
|
|
11
|
+
const N = 16384;
|
|
12
|
+
const R = 8;
|
|
13
|
+
const P = 1;
|
|
14
|
+
const KEYLEN = 64;
|
|
15
|
+
function run(password, salt, n, r, p) {
|
|
16
|
+
return scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 256 * n * r });
|
|
17
|
+
}
|
|
18
|
+
export async function hashPassword(password) {
|
|
19
|
+
const salt = randomBytes(16);
|
|
20
|
+
const hash = await run(password, salt, N, R, P);
|
|
21
|
+
return ["scrypt", N, R, P, salt.toString("base64url"), hash.toString("base64url")].join("$");
|
|
22
|
+
}
|
|
23
|
+
export async function verifyPassword(password, stored) {
|
|
24
|
+
const parts = stored.split("$");
|
|
25
|
+
if (parts.length !== 6 || parts[0] !== "scrypt")
|
|
26
|
+
return false;
|
|
27
|
+
const [, n, r, p, saltB64, hashB64] = parts;
|
|
28
|
+
const salt = Buffer.from(saltB64, "base64url");
|
|
29
|
+
const expected = Buffer.from(hashB64, "base64url");
|
|
30
|
+
const actual = await run(password, salt, Number(n), Number(r), Number(p));
|
|
31
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Session token. A signed JWT (HS256) carried in an httpOnly cookie.
|
|
3
|
+
//
|
|
4
|
+
// Stateless: verifying needs the secret only, never the database.
|
|
5
|
+
// That is what lets the proxy run on every request at zero cost.
|
|
6
|
+
// Trade-off: a token cannot be revoked before it expires (7 days).
|
|
7
|
+
// ─────────────────────────────────────────────────────────────
|
|
8
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
9
|
+
import { SESSION_DAYS } from "./config.js";
|
|
10
|
+
const key = (secret) => new TextEncoder().encode(secret);
|
|
11
|
+
export async function signSession(user, secret) {
|
|
12
|
+
return new SignJWT({ username: user.username })
|
|
13
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
14
|
+
.setSubject(user.id)
|
|
15
|
+
.setIssuedAt()
|
|
16
|
+
.setExpirationTime(`${SESSION_DAYS}d`)
|
|
17
|
+
.sign(key(secret));
|
|
18
|
+
}
|
|
19
|
+
export async function verifySession(token, secret) {
|
|
20
|
+
try {
|
|
21
|
+
const { payload } = await jwtVerify(token, key(secret), { algorithms: ["HS256"] });
|
|
22
|
+
if (typeof payload.sub !== "string" || typeof payload.username !== "string")
|
|
23
|
+
return null;
|
|
24
|
+
return { id: payload.sub, username: payload.username };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { hashPassword, verifyPassword } from "./core/password.ts";
|
|
2
|
+
export { signSession, verifySession, type User } from "./core/session.ts";
|
|
3
|
+
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.ts";
|
|
4
|
+
export { signInPage } from "./core/html.ts";
|
|
5
|
+
export { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, getSecret, getDatabaseUrl } from "./core/config.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Core primitives. Framework-free. Use these if you are not on Next.js
|
|
2
|
+
// or want to build your own handlers.
|
|
3
|
+
export { hashPassword, verifyPassword } from "./core/password.js";
|
|
4
|
+
export { signSession, verifySession } from "./core/session.js";
|
|
5
|
+
export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.js";
|
|
6
|
+
export { signInPage } from "./core/html.js";
|
|
7
|
+
export { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, getSecret, getDatabaseUrl } from "./core/config.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server.js";
|
|
2
|
+
import { type User } from "../core/session.ts";
|
|
3
|
+
export type { User };
|
|
4
|
+
export declare class AuthError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
constructor(message?: string);
|
|
7
|
+
}
|
|
8
|
+
/** Current user from the session cookie, or null. Never touches the database. */
|
|
9
|
+
export declare function getUser(): Promise<User | null>;
|
|
10
|
+
/** Current user, or throw AuthError (status 401). */
|
|
11
|
+
export declare function requireUser(): Promise<User>;
|
|
12
|
+
/** A 401 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
|
|
13
|
+
export declare function unauthorized(err?: unknown): NextResponse;
|
|
14
|
+
type Ctx = {
|
|
15
|
+
params: Promise<{
|
|
16
|
+
path: string[];
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
export declare function GET(req: NextRequest, ctx: Ctx): Promise<NextResponse>;
|
|
20
|
+
export declare function POST(req: NextRequest, ctx: Ctx): Promise<NextResponse>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Next.js route handlers + helpers.
|
|
3
|
+
//
|
|
4
|
+
// Mount once: app/auth/[...path]/route.ts
|
|
5
|
+
// export { GET, POST } from "@binary1702/auth/next"
|
|
6
|
+
//
|
|
7
|
+
// Serves:
|
|
8
|
+
// GET /auth/sign-in the sign-in page
|
|
9
|
+
// POST /auth/sign-in verify, set cookie, redirect to ?next or /
|
|
10
|
+
// GET /auth/sign-out clear cookie, redirect to sign-in
|
|
11
|
+
// POST /auth/sign-out same (for fetch callers)
|
|
12
|
+
// GET /auth/session { user } or { user: null }
|
|
13
|
+
//
|
|
14
|
+
// Use anywhere on the server:
|
|
15
|
+
// const user = await requireUser() // { id, username } or throws AuthError(401)
|
|
16
|
+
// const user = await getUser() // { id, username } or null
|
|
17
|
+
// ─────────────────────────────────────────────────────────────
|
|
18
|
+
import { cookies } from "next/headers.js";
|
|
19
|
+
import { NextResponse } from "next/server.js";
|
|
20
|
+
import { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, getSecret } from "../core/config.js";
|
|
21
|
+
import { signSession, verifySession } from "../core/session.js";
|
|
22
|
+
import { verifyPassword } from "../core/password.js";
|
|
23
|
+
import { findUser } from "../core/db.js";
|
|
24
|
+
import { signInPage } from "../core/html.js";
|
|
25
|
+
export class AuthError extends Error {
|
|
26
|
+
status = 401;
|
|
27
|
+
constructor(message = "Unauthorized") {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "AuthError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Current user from the session cookie, or null. Never touches the database. */
|
|
33
|
+
export async function getUser() {
|
|
34
|
+
const token = (await cookies()).get(COOKIE_NAME)?.value;
|
|
35
|
+
if (!token)
|
|
36
|
+
return null;
|
|
37
|
+
return verifySession(token, getSecret());
|
|
38
|
+
}
|
|
39
|
+
/** Current user, or throw AuthError (status 401). */
|
|
40
|
+
export async function requireUser() {
|
|
41
|
+
const user = await getUser();
|
|
42
|
+
if (!user)
|
|
43
|
+
throw new AuthError();
|
|
44
|
+
return user;
|
|
45
|
+
}
|
|
46
|
+
/** A 401 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
|
|
47
|
+
export function unauthorized(err) {
|
|
48
|
+
if (err && !(err instanceof AuthError))
|
|
49
|
+
throw err;
|
|
50
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
51
|
+
}
|
|
52
|
+
const cookieOptions = () => ({
|
|
53
|
+
httpOnly: true,
|
|
54
|
+
sameSite: "lax",
|
|
55
|
+
secure: process.env.NODE_ENV === "production",
|
|
56
|
+
path: "/",
|
|
57
|
+
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
|
58
|
+
});
|
|
59
|
+
/** Only allow same-origin relative paths as a post-login destination. */
|
|
60
|
+
function safeNext(value) {
|
|
61
|
+
if (!value || !value.startsWith("/") || value.startsWith("//"))
|
|
62
|
+
return "/";
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function html(body, status = 200) {
|
|
66
|
+
return new NextResponse(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
|
|
67
|
+
}
|
|
68
|
+
async function readBody(req) {
|
|
69
|
+
const type = req.headers.get("content-type") ?? "";
|
|
70
|
+
if (type.includes("application/json")) {
|
|
71
|
+
const b = (await req.json().catch(() => ({})));
|
|
72
|
+
return { username: String(b.username ?? ""), password: String(b.password ?? ""), next: safeNext(String(b.next ?? "")), json: true };
|
|
73
|
+
}
|
|
74
|
+
const f = await req.formData();
|
|
75
|
+
return {
|
|
76
|
+
username: String(f.get("username") ?? ""),
|
|
77
|
+
password: String(f.get("password") ?? ""),
|
|
78
|
+
next: safeNext(String(f.get("next") ?? "")),
|
|
79
|
+
json: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function signOutResponse(req, json) {
|
|
83
|
+
const res = json
|
|
84
|
+
? NextResponse.json({ ok: true })
|
|
85
|
+
: NextResponse.redirect(new URL(SIGN_IN_PATH, req.url), 303);
|
|
86
|
+
res.cookies.set(COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0 });
|
|
87
|
+
return res;
|
|
88
|
+
}
|
|
89
|
+
// ── handlers ──────────────────────────────────────────────────
|
|
90
|
+
export async function GET(req, ctx) {
|
|
91
|
+
const { path } = await ctx.params;
|
|
92
|
+
const route = path.join("/");
|
|
93
|
+
if (route === "sign-in") {
|
|
94
|
+
const next = safeNext(req.nextUrl.searchParams.get("next"));
|
|
95
|
+
const existing = req.cookies.get(COOKIE_NAME)?.value;
|
|
96
|
+
if (existing && (await verifySession(existing, getSecret()))) {
|
|
97
|
+
return NextResponse.redirect(new URL(next, req.url));
|
|
98
|
+
}
|
|
99
|
+
const error = req.nextUrl.searchParams.get("error") ? "Wrong username or password." : undefined;
|
|
100
|
+
return html(signInPage({ next, error }));
|
|
101
|
+
}
|
|
102
|
+
if (route === "sign-out")
|
|
103
|
+
return signOutResponse(req, false);
|
|
104
|
+
if (route === "session") {
|
|
105
|
+
const token = req.cookies.get(COOKIE_NAME)?.value;
|
|
106
|
+
const user = token ? await verifySession(token, getSecret()) : null;
|
|
107
|
+
return NextResponse.json({ user });
|
|
108
|
+
}
|
|
109
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
110
|
+
}
|
|
111
|
+
export async function POST(req, ctx) {
|
|
112
|
+
const { path } = await ctx.params;
|
|
113
|
+
const route = path.join("/");
|
|
114
|
+
if (route === "sign-out")
|
|
115
|
+
return signOutResponse(req, true);
|
|
116
|
+
if (route !== "sign-in")
|
|
117
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
118
|
+
const { username, password, next, json } = await readBody(req);
|
|
119
|
+
const row = username && password ? await findUser(username.trim()) : null;
|
|
120
|
+
const ok = row ? await verifyPassword(password, row.password_hash) : false;
|
|
121
|
+
if (!row || !ok) {
|
|
122
|
+
if (json)
|
|
123
|
+
return NextResponse.json({ error: "Wrong username or password" }, { status: 401 });
|
|
124
|
+
const url = new URL(SIGN_IN_PATH, req.url);
|
|
125
|
+
url.searchParams.set("error", "1");
|
|
126
|
+
if (next !== "/")
|
|
127
|
+
url.searchParams.set("next", next);
|
|
128
|
+
return NextResponse.redirect(url, 303);
|
|
129
|
+
}
|
|
130
|
+
const user = { id: row.id, username: row.username };
|
|
131
|
+
const token = await signSession(user, getSecret());
|
|
132
|
+
const res = json ? NextResponse.json({ user }) : NextResponse.redirect(new URL(next, req.url), 303);
|
|
133
|
+
res.cookies.set(COOKIE_NAME, token, cookieOptions());
|
|
134
|
+
return res;
|
|
135
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server.js";
|
|
2
|
+
/** Paste this into proxy.ts as `export const config = { matcher: MATCHER }` */
|
|
3
|
+
export declare const MATCHER: string[];
|
|
4
|
+
export declare function proxy(request: NextRequest): Promise<NextResponse>;
|
|
5
|
+
export default proxy;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Proxy (Next 16) / middleware (Next 15). Runs before every route.
|
|
3
|
+
//
|
|
4
|
+
// Verifies the cookie signature only. No database, no network.
|
|
5
|
+
// no/invalid cookie + page request → 302 to /auth/sign-in?next=<path>
|
|
6
|
+
// no/invalid cookie + /api request → 401 JSON
|
|
7
|
+
// valid cookie → continue
|
|
8
|
+
//
|
|
9
|
+
// The CLI writes proxy.ts with an inline `config.matcher`, because
|
|
10
|
+
// Next reads the matcher statically and ignores a re-exported one.
|
|
11
|
+
// This function also guards those paths itself, so a missing matcher
|
|
12
|
+
// degrades to "slightly slower", never to "static assets blocked".
|
|
13
|
+
// ─────────────────────────────────────────────────────────────
|
|
14
|
+
import { NextResponse } from "next/server.js";
|
|
15
|
+
import { COOKIE_NAME, SIGN_IN_PATH, getSecret } from "../core/config.js";
|
|
16
|
+
import { verifySession } from "../core/session.js";
|
|
17
|
+
/** Paste this into proxy.ts as `export const config = { matcher: MATCHER }` */
|
|
18
|
+
export const MATCHER = ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"];
|
|
19
|
+
const PUBLIC = /^\/(auth(\/|$)|_next\/|favicon\.ico$)|\.[a-z0-9]+$/i;
|
|
20
|
+
export async function proxy(request) {
|
|
21
|
+
const { pathname, search } = request.nextUrl;
|
|
22
|
+
if (PUBLIC.test(pathname))
|
|
23
|
+
return NextResponse.next();
|
|
24
|
+
const token = request.cookies.get(COOKIE_NAME)?.value;
|
|
25
|
+
if (token && (await verifySession(token, getSecret())))
|
|
26
|
+
return NextResponse.next();
|
|
27
|
+
if (pathname.startsWith("/api/")) {
|
|
28
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
29
|
+
}
|
|
30
|
+
const url = new URL(SIGN_IN_PATH, request.url);
|
|
31
|
+
if (pathname !== "/")
|
|
32
|
+
url.searchParams.set("next", pathname + search);
|
|
33
|
+
return NextResponse.redirect(url);
|
|
34
|
+
}
|
|
35
|
+
export default proxy;
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@binary1702/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Username + password auth for Next.js. One command: npx @binary1702/auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/binary1702/auth.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/binary1702/auth#readme",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"bin": {
|
|
13
|
+
"binary1702-auth": "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./next": {
|
|
23
|
+
"types": "./dist/next/index.d.ts",
|
|
24
|
+
"import": "./dist/next/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./next/proxy": {
|
|
27
|
+
"types": "./dist/next/proxy.d.ts",
|
|
28
|
+
"import": "./dist/next/proxy.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"INSTALL.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"prepublishOnly": "npm run build",
|
|
38
|
+
"test": "node --test tests/*.test.ts"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@neondatabase/serverless": "^1.1.0",
|
|
42
|
+
"jose": "^6.2.5"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"next": ">=15.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^20",
|
|
49
|
+
"next": "^16.3.2",
|
|
50
|
+
"typescript": "^5.9.3"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"auth",
|
|
57
|
+
"authentication",
|
|
58
|
+
"nextjs",
|
|
59
|
+
"password",
|
|
60
|
+
"neon",
|
|
61
|
+
"postgres"
|
|
62
|
+
],
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|