@binary1702/auth 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +324 -36
  5. package/README.md +279 -51
  6. package/dist/cli.js +232 -17
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +131 -10
  14. package/dist/core/db.js +388 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +11 -1
  32. package/dist/core/session.js +27 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +10 -3
  44. package/dist/next/index.js +444 -52
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +11 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
@@ -5,104 +5,178 @@
5
5
  // export { GET, POST } from "@binary1702/auth/next"
6
6
  //
7
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-up the sign-up page (see core/signup.ts for when)
11
- // POST /auth/sign-up create user, set cookie, redirect
12
- // GET /auth/sign-out clear cookie, redirect to sign-in
13
- // POST /auth/sign-out same (for fetch callers)
14
- // GET /auth/session { user } or { user: null }
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-up the sign-up page (see core/signup.ts for when)
11
+ // POST /auth/sign-up create user, email a verify link, set cookie, redirect
12
+ // GET /auth/sign-out clear cookie, redirect to sign-in
13
+ // POST /auth/sign-out same (for fetch callers)
14
+ // GET /auth/session the session contract (core/org.ts), or 401
15
+ // POST /auth/session switch the active organization { organization: id }
16
+ // GET /auth/verify?token= consume the link, mark the email verified
17
+ // POST /auth/verify/resend email a fresh link (body: email)
18
+ // GET /auth/forgot "send me a reset link" form
19
+ // POST /auth/forgot email a reset link if the address exists; same answer either way
20
+ // GET /auth/reset?token= "choose a new password" form
21
+ // POST /auth/reset consume the token, set the password, sign in
15
22
  //
16
23
  // Use anywhere on the server:
17
- // const user = await requireUser() // { id, email } or throws AuthError(401)
18
- // const user = await getUser() // { id, email } or null
19
- // Both check the database: a deleted user or a revoked session is null.
24
+ // const session = await getSession() // { user, organization, authorization } or null
25
+ // const session = await requireSession() // same, or throws AuthError(401)
26
+ // const session = await requirePermission("invoices.create") // or throws AuthError(403)
27
+ // const user = await getUser() / requireUser() // just the user part
28
+ // All of them run the same check: signature, user still exists with
29
+ // the epoch the token was signed under, email verified when required.
20
30
  // ─────────────────────────────────────────────────────────────
21
31
  import { cookies } from "next/headers.js";
22
32
  import { NextResponse } from "next/server.js";
23
- import { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_UP_PATH, getSecret } from "../core/config.js";
33
+ import { randomBytes } from "node:crypto";
34
+ import { COOKIE_NAME, SIGN_IN_PATH, SIGN_UP_PATH, cookieName, getSecret } from "../core/config.js";
35
+ import { options } from "../core/options.js";
24
36
  import { isCurrent, signSession, verifySession } from "../core/session.js";
25
- import { hashPassword, verifyPassword } from "../core/password.js";
26
- import { countUsers, createUser, findUser, sessionEpoch } from "../core/db.js";
27
- import { signInPage, signUpPage } from "../core/html.js";
28
- import { signupPolicy, validateCredentials } from "../core/signup.js";
37
+ import { hashPassword, needsRehash, verifyPassword } from "../core/password.js";
38
+ import { addMember, adminOrgs, adminUsers, countUsers, createOrg, createUser, db, DuplicateUserError, rehashPassword, findOrg, findUser, markVerified, removeMember, removeOrg, removeUser, revokeSessions, sessionRow, setActiveOrg, setPassword, setPasswordById, } from "../core/db.js";
39
+ import { forgotPage, messagePage, resendForm, resetPage, securityHeaders, setSiteName, signInPage, signUpPage } from "../core/html.js";
40
+ import { adminPage } from "../core/admin-html.js";
41
+ import { ADMIN_PATH, backTo as adminBackTo, isAdmin, isOrgAction, isUserAction, refusedOnSelf, roleChoices } from "../core/admin.js";
42
+ import { emailTransport } from "../core/email.js";
43
+ import { clientIp, rateLimit, retryMessage } from "../core/rate.js";
44
+ import { signupPolicy, validateCredentials, validatePassword } from "../core/signup.js";
45
+ import { isSameOrigin, safeEqual, safeNext } from "../core/guard.js";
46
+ import { consumeToken, issueToken } from "../core/tokens.js";
47
+ import { sendEmail } from "../core/email.js";
48
+ import { VERIFY_TTL_SECONDS, mayAccess, verifyEmail, verifyLink } from "../core/verify.js";
49
+ import { FORGOT_PATH, FORGOT_SENT, RESET_LINK_INVALID, RESET_TTL_SECONDS, resetEmail, resetLink } from "../core/reset.js";
50
+ import { buildSession, hasPermission } from "../core/org.js";
29
51
  export class AuthError extends Error {
30
- status = 401;
31
- constructor(message = "Unauthorized") {
52
+ status;
53
+ constructor(message = "Unauthorized", status = 401) {
32
54
  super(message);
33
55
  this.name = "AuthError";
56
+ this.status = status;
34
57
  }
35
58
  }
36
59
  /**
37
- * The full check, for anything that hands out data: a valid signature,
38
- * AND the user still exists with the epoch the token was signed under.
39
- * One indexed lookup by primary key. (The proxy skips the lookup; see
40
- * core/session.ts for why that is safe.)
60
+ * The one session check, for anything that hands out data: a valid
61
+ * signature, AND the user still exists with the epoch the token was
62
+ * signed under, AND (when required) a verified email. One query, which
63
+ * also brings the active organization and role. (The proxy skips the
64
+ * lookup; see core/session.ts for why that is safe.)
41
65
  */
42
- async function userFromToken(token) {
66
+ async function sessionFromToken(token) {
43
67
  if (!token)
44
68
  return null;
45
69
  const claim = await verifySession(token, getSecret());
46
- if (!claim || !isCurrent(claim, await sessionEpoch(claim.id)))
70
+ if (!claim)
47
71
  return null;
48
- return { id: claim.id, email: claim.email };
72
+ const row = await sessionRow(claim.id);
73
+ const o = options();
74
+ if (!row || !isCurrent(claim, row.epoch) || !mayAccess(row.verified, o.requireVerified))
75
+ return null;
76
+ return buildSession(row, o);
77
+ }
78
+ async function cookieToken() {
79
+ return pickCookie(async (name) => (await cookies()).get(name)?.value);
80
+ }
81
+ /** The current cookie name first, then the pre-0.3.0 one, so upgrading signs nobody out. */
82
+ async function pickCookie(get) {
83
+ return (await get(cookieName(options().production))) ?? (await get(COOKIE_NAME));
84
+ }
85
+ /** The session contract for the current request, or null if signed out, deleted or revoked. */
86
+ export async function getSession() {
87
+ return sessionFromToken(await cookieToken());
88
+ }
89
+ /** The session, or throw AuthError (status 401). */
90
+ export async function requireSession() {
91
+ const session = await getSession();
92
+ if (!session)
93
+ throw new AuthError();
94
+ return session;
95
+ }
96
+ /** The session, or throw AuthError: 401 when signed out, 403 when the role lacks the permission. */
97
+ export async function requirePermission(permission) {
98
+ const session = await requireSession();
99
+ if (!hasPermission(session, permission))
100
+ throw new AuthError("Forbidden", 403);
101
+ return session;
49
102
  }
50
103
  /** Current user from the session cookie, or null if signed out, deleted or revoked. */
51
104
  export async function getUser() {
52
- return userFromToken((await cookies()).get(COOKIE_NAME)?.value);
105
+ return (await getSession())?.user ?? null;
53
106
  }
54
107
  /** Current user, or throw AuthError (status 401). */
55
108
  export async function requireUser() {
56
- const user = await getUser();
57
- if (!user)
58
- throw new AuthError();
59
- return user;
109
+ return (await requireSession()).user;
60
110
  }
61
- /** A 401 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
111
+ /** A 401/403 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
62
112
  export function unauthorized(err) {
63
113
  if (err && !(err instanceof AuthError))
64
114
  throw err;
65
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
115
+ const status = err instanceof AuthError ? err.status : 401;
116
+ return NextResponse.json({ error: status === 403 ? "Forbidden" : "Unauthorized" }, { status });
66
117
  }
67
118
  const cookieOptions = () => ({
68
119
  httpOnly: true,
69
120
  sameSite: "lax",
70
- secure: process.env.NODE_ENV === "production",
121
+ secure: options().production,
71
122
  path: "/",
72
- maxAge: SESSION_DAYS * 24 * 60 * 60,
123
+ maxAge: options().sessionDays * 24 * 60 * 60,
73
124
  });
74
- /** Only allow same-origin relative paths as a post-login destination. */
75
- function safeNext(value) {
76
- if (!value || !value.startsWith("/") || value.startsWith("//"))
77
- return "/";
78
- return value;
125
+ /** An auth page: HTML plus the headers every one of them carries (see securityHeaders) */
126
+ function html(body, status = 200, headers = securityHeaders()) {
127
+ return new NextResponse(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...headers } });
79
128
  }
80
- function html(body, status = 200) {
81
- return new NextResponse(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
129
+ /** Per-user JSON: never cached by a browser or a CDN */
130
+ function sessionJson(session) {
131
+ const res = session
132
+ ? NextResponse.json(session)
133
+ : NextResponse.json({ error: "Unauthorized" }, { status: 401 });
134
+ res.headers.set("cache-control", "no-store");
135
+ return res;
82
136
  }
83
137
  async function readBody(req) {
84
138
  const type = req.headers.get("content-type") ?? "";
85
139
  const get = (b, k) => String((b instanceof FormData ? b.get(k) : b[k]) ?? "");
86
140
  const b = type.includes("application/json")
87
141
  ? (await req.json().catch(() => ({})))
88
- : await req.formData();
142
+ : await req.formData().catch(() => new FormData());
89
143
  return {
90
144
  email: get(b, "email").trim().toLowerCase(),
91
145
  password: get(b, "password"),
92
146
  invite: get(b, "invite").trim(),
93
- next: safeNext(get(b, "next")),
147
+ token: get(b, "token").trim(),
148
+ organization: get(b, "organization").trim(),
149
+ next: safeNext(get(b, "next"), req.url),
94
150
  json: type.includes("application/json"),
95
151
  };
96
152
  }
153
+ /** Every field of a form or JSON body as strings (the admin forms vary per action). */
154
+ async function readFields(req) {
155
+ const type = req.headers.get("content-type") ?? "";
156
+ const out = new Proxy({}, { get: (t, k) => (typeof k === "string" ? (t[k] ?? "") : undefined) });
157
+ if (type.includes("application/json")) {
158
+ const b = (await req.json().catch(() => ({})));
159
+ for (const [k, v] of Object.entries(b))
160
+ out[k] = String(v ?? "");
161
+ }
162
+ else {
163
+ const fd = await req.formData().catch(() => new FormData());
164
+ fd.forEach((v, k) => (out[k] = String(v)));
165
+ }
166
+ return out;
167
+ }
168
+ async function currentSession(req) {
169
+ return sessionFromToken(await pickCookie((name) => req.cookies.get(name)?.value));
170
+ }
97
171
  async function currentUser(req) {
98
- return userFromToken(req.cookies.get(COOKIE_NAME)?.value);
172
+ return (await currentSession(req))?.user ?? null;
99
173
  }
100
174
  /** Sign the user in on a response: cookie + redirect (form) or JSON (fetch). */
101
175
  async function signedIn(req, user, next, json) {
102
- const token = await signSession(user, getSecret());
176
+ const token = await signSession(user, getSecret(), options().sessionDays);
103
177
  const body = { user: { id: user.id, email: user.email } };
104
178
  const res = json ? NextResponse.json(body) : NextResponse.redirect(new URL(next, req.url), 303);
105
- res.cookies.set(COOKIE_NAME, token, cookieOptions());
179
+ res.cookies.set(cookieName(options().production), token, cookieOptions());
106
180
  return res;
107
181
  }
108
182
  /** Back to a form with ?error=<msg>, keeping ?next. */
@@ -113,22 +187,176 @@ function backTo(req, path, error, next) {
113
187
  url.searchParams.set("next", next);
114
188
  return NextResponse.redirect(url, 303);
115
189
  }
190
+ /** The sign-in page in its "check your email" state. */
191
+ function checkYourEmail(req, next, json, user) {
192
+ if (json)
193
+ return NextResponse.json({ user: user ?? null, verify: true }, { status: user ? 200 : 403 });
194
+ const url = new URL(SIGN_IN_PATH, req.url);
195
+ url.searchParams.set("verify", "1");
196
+ if (next !== "/")
197
+ url.searchParams.set("next", next);
198
+ return NextResponse.redirect(url, 303);
199
+ }
200
+ /** 429 with Retry-After: JSON for fetch callers, the form again (with the message) for browsers. */
201
+ function tooMany(d, json, page) {
202
+ const message = retryMessage(d.retryAfter);
203
+ const res = json ? NextResponse.json({ error: message, retryAfter: d.retryAfter }, { status: 429 }) : html(page(message), 429);
204
+ res.headers.set("retry-after", String(d.retryAfter));
205
+ return res;
206
+ }
116
207
  function signOutResponse(req, json) {
117
208
  const res = json
118
209
  ? NextResponse.json({ ok: true })
119
210
  : NextResponse.redirect(new URL(SIGN_IN_PATH, req.url), 303);
211
+ // Clear both names: the current one and the pre-0.3.0 one a browser may still hold
212
+ res.cookies.set(cookieName(options().production), "", { ...cookieOptions(), maxAge: 0 });
120
213
  res.cookies.set(COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0 });
121
214
  return res;
122
215
  }
123
216
  async function policy() {
124
- return signupPolicy(process.env, await countUsers());
217
+ return signupPolicy(options().signup, await countUsers());
218
+ }
219
+ /** Issue a verify token and email the link. Throws if the email cannot be sent. */
220
+ async function sendVerification(req, user, next) {
221
+ const raw = await issueToken(user.id, "verify", VERIFY_TTL_SECONDS, { next });
222
+ await sendEmail(verifyEmail(user.email, verifyLink(req.url, raw, next), siteName(req)));
223
+ }
224
+ /** For subjects and titles: `name` from b17_auth.yaml, else the host serving the request */
225
+ function siteName(req) {
226
+ const name = options().name;
227
+ setSiteName(name);
228
+ return name || new URL(req.url).host;
229
+ }
230
+ // ── admin ─────────────────────────────────────────────────────
231
+ async function renderAdmin(self, nonce, view, notice, error) {
232
+ const o = options();
233
+ const [users, orgs, [tokens]] = await Promise.all([
234
+ adminUsers(),
235
+ adminOrgs(),
236
+ db().query(`SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE used_at IS NULL AND expires_at > now())::int AS live FROM tokens`),
237
+ ]);
238
+ const inUse = orgs.flatMap((org) => org.members.map((m) => m.role));
239
+ return adminPage({
240
+ self,
241
+ view,
242
+ users,
243
+ orgs,
244
+ roles: roleChoices(Object.keys(o.permissions), inUse),
245
+ options: o,
246
+ transport: emailTransport({ ...o.email, production: o.production }),
247
+ tokens: tokens ?? { total: 0, live: 0 },
248
+ notice,
249
+ error,
250
+ nonce,
251
+ });
252
+ }
253
+ /** Back to the admin page with a one-line result */
254
+ function adminResult(req, kind, message, back) {
255
+ const url = new URL(`${ADMIN_PATH}${back ? `?${back}` : ""}`, req.url);
256
+ url.searchParams.set(kind, message);
257
+ return NextResponse.redirect(url, 303);
258
+ }
259
+ /** The admin POSTs: what the CLI does, from a form. Returns a notice or an error message. */
260
+ async function adminAction(route, self, f, sendVerify) {
261
+ const ok = (message) => ({ ok: true, message });
262
+ const fail = (message) => ({ ok: false, message });
263
+ const email = f.email.trim().toLowerCase();
264
+ if (route === "admin/users") {
265
+ const action = f.action;
266
+ if (!isUserAction(action))
267
+ return fail("Unknown action.");
268
+ const refused = refusedOnSelf(action, email, self);
269
+ if (refused)
270
+ return fail(refused);
271
+ if (action === "add") {
272
+ const invalid = validateCredentials(email, f.password, options());
273
+ if (invalid)
274
+ return fail(invalid);
275
+ if (await findUser(email))
276
+ return fail("That email is already registered.");
277
+ try {
278
+ await createUser(email, await hashPassword(f.password), { verified: true });
279
+ }
280
+ catch (err) {
281
+ if (err instanceof DuplicateUserError)
282
+ return fail("That email is already registered.");
283
+ throw err;
284
+ }
285
+ return ok(`Added ${email}.`);
286
+ }
287
+ const user = await findUser(email);
288
+ if (!user)
289
+ return fail(`No user ${email}.`);
290
+ if (action === "set-password") {
291
+ const invalid = validatePassword(f.password);
292
+ if (invalid)
293
+ return fail(invalid);
294
+ await setPassword(email, await hashPassword(f.password));
295
+ return ok(`Password set for ${email}; their other sessions are ended.`);
296
+ }
297
+ if (action === "sign-out") {
298
+ await revokeSessions(email);
299
+ return ok(`Every session of ${email} is ended.`);
300
+ }
301
+ if (action === "verify") {
302
+ await markVerified(user.id);
303
+ return ok(`${email} marked verified.`);
304
+ }
305
+ if (action === "send-verify") {
306
+ if (user.verified_at)
307
+ return fail(`${email} is already verified.`);
308
+ try {
309
+ await sendVerify({ id: user.id, email: user.email });
310
+ }
311
+ catch (err) {
312
+ return fail(`Could not send: ${err instanceof Error ? err.message : String(err)}`);
313
+ }
314
+ return ok(`Verification email sent to ${email}.`);
315
+ }
316
+ await removeUser(email);
317
+ return ok(`Removed ${email}.`);
318
+ }
319
+ if (route === "admin/orgs") {
320
+ const action = f.action;
321
+ if (!isOrgAction(action))
322
+ return fail("Unknown action.");
323
+ if (action === "create") {
324
+ const name = f.name.trim();
325
+ if (!name)
326
+ return fail("An organization needs a name.");
327
+ if (await findOrg(name))
328
+ return fail(`There is already an organization called ${name}.`);
329
+ const org = await createOrg(name);
330
+ return ok(`Created ${org.name}.`);
331
+ }
332
+ const org = await findOrg(f.org);
333
+ if (!org)
334
+ return fail("No such organization.");
335
+ if (action === "remove-org") {
336
+ await removeOrg(org.id);
337
+ return ok(`Removed ${org.name} and its memberships.`);
338
+ }
339
+ const user = await findUser(email);
340
+ if (!user)
341
+ return fail(`No user ${email}. Add them first.`);
342
+ if (action === "remove-member") {
343
+ return (await removeMember(org.id, user.id)) ? ok(`${email} removed from ${org.name}.`) : fail(`${email} is not a member of ${org.name}.`);
344
+ }
345
+ const role = f.role.trim();
346
+ if (!/^[a-z0-9_-]{1,32}$/i.test(role))
347
+ return fail("A role is one word: letters, digits, - or _.");
348
+ await addMember(org.id, user.id, role); // add-member and set-role are the same upsert
349
+ return ok(`${email} is ${role} in ${org.name}.`);
350
+ }
351
+ return fail("Unknown action.");
125
352
  }
126
353
  // ── handlers ──────────────────────────────────────────────────
127
354
  export async function GET(req, ctx) {
128
355
  const { path } = await ctx.params;
129
356
  const route = path.join("/");
130
357
  const q = req.nextUrl.searchParams;
131
- const next = safeNext(q.get("next"));
358
+ const next = safeNext(q.get("next"), req.url);
359
+ siteName(req); // so <title> carries the app name on every page below
132
360
  if (route === "sign-in") {
133
361
  if (await currentUser(req))
134
362
  return NextResponse.redirect(new URL(next, req.url));
@@ -137,7 +365,13 @@ export async function GET(req, ctx) {
137
365
  if (p.reason === "first")
138
366
  return NextResponse.redirect(new URL(SIGN_UP_PATH, req.url));
139
367
  const error = q.get("error") === "1" ? "Wrong email or password." : (q.get("error") ?? undefined);
140
- return html(signInPage({ next, error, canSignUp: p.allowed }));
368
+ const verify = q.get("verify") === "1";
369
+ const notice = verify
370
+ ? "Check your email for a verification link, then sign in."
371
+ : q.get("verified") === "1"
372
+ ? "Email verified. Sign in to continue."
373
+ : undefined;
374
+ return html(signInPage({ next, error, notice, resend: verify, canSignUp: p.allowed }));
141
375
  }
142
376
  if (route === "sign-up") {
143
377
  if (await currentUser(req))
@@ -150,16 +384,97 @@ export async function GET(req, ctx) {
150
384
  if (route === "sign-out")
151
385
  return signOutResponse(req, false);
152
386
  if (route === "session")
153
- return NextResponse.json({ user: await currentUser(req) });
387
+ return sessionJson(await currentSession(req));
388
+ if (route === "admin") {
389
+ const session = await currentSession(req);
390
+ if (!session)
391
+ return NextResponse.redirect(new URL(`${SIGN_IN_PATH}?next=${encodeURIComponent(ADMIN_PATH)}`, req.url));
392
+ if (!isAdmin(session, options().admins)) {
393
+ return html(messagePage({ title: "Not an admin", message: `Add ${session.user.email} under admins: in b17_auth.yaml to open this page.`, link: { href: "/", text: "Back" } }), 403);
394
+ }
395
+ const nonce = randomBytes(16).toString("base64");
396
+ const orgId = q.get("org");
397
+ const tab = q.get("tab");
398
+ const view = orgId ? { tab: "org", orgId } : tab === "users" || tab === "settings" ? { tab } : { tab: "orgs" };
399
+ return html(await renderAdmin(session.user.email, nonce, view, q.get("notice") ?? undefined, q.get("error") ?? undefined), 200, securityHeaders(nonce));
400
+ }
401
+ if (route === "forgot") {
402
+ return html(forgotPage({ sent: q.get("sent") === "1", message: FORGOT_SENT, error: q.get("error") ?? undefined }));
403
+ }
404
+ if (route === "reset") {
405
+ // Show the form without consuming the token: a mail scanner that
406
+ // prefetches the link must not burn it.
407
+ const token = q.get("token") ?? "";
408
+ if (!token)
409
+ return html(messagePage({ title: "Link expired", message: RESET_LINK_INVALID, link: { href: FORGOT_PATH, text: "Request a new link" } }), 400);
410
+ return html(resetPage({ token, error: q.get("error") ?? undefined }));
411
+ }
412
+ if (route === "verify") {
413
+ const token = await consumeToken(q.get("token") ?? "", "verify");
414
+ const user = token ? await markVerified(token.userId) : null;
415
+ if (!user) {
416
+ return html(messagePage({
417
+ title: "Link expired",
418
+ message: "This verification link is invalid, already used, or older than 24 hours.",
419
+ after: resendForm(),
420
+ }), 400);
421
+ }
422
+ const dest = safeNext(typeof token.meta.next === "string" ? token.meta.next : q.get("next"), req.url);
423
+ // Signed in already (sign-up set the cookie)? Carry on. Otherwise sign in.
424
+ if (await currentUser(req))
425
+ return NextResponse.redirect(new URL(dest, req.url), 303);
426
+ const url = new URL(SIGN_IN_PATH, req.url);
427
+ url.searchParams.set("verified", "1");
428
+ if (dest !== "/")
429
+ url.searchParams.set("next", dest);
430
+ return NextResponse.redirect(url, 303);
431
+ }
154
432
  return NextResponse.json({ error: "Not found" }, { status: 404 });
155
433
  }
156
434
  export async function POST(req, ctx) {
157
435
  const { path } = await ctx.params;
158
436
  const route = path.join("/");
437
+ siteName(req); // pages rendered from a POST (a failed reset) carry the app name too
438
+ // Every POST here sets or clears the session cookie, which SameSite
439
+ // does not protect: another site could sign the visitor in as itself.
440
+ if (!isSameOrigin(req.headers)) {
441
+ return NextResponse.json({ error: "Cross-site request refused" }, { status: 403 });
442
+ }
159
443
  if (route === "sign-out")
160
444
  return signOutResponse(req, true);
445
+ if (route === "admin/users" || route === "admin/orgs") {
446
+ const session = await currentSession(req);
447
+ if (!session || !isAdmin(session, options().admins)) {
448
+ return NextResponse.json({ error: session ? "Forbidden" : "Unauthorized" }, { status: session ? 403 : 401 });
449
+ }
450
+ const f = await readFields(req);
451
+ const result = await adminAction(route, session.user.email, f, (user) => sendVerification(req, user, "/"));
452
+ // After removing an organization there is nothing to return to; otherwise, the view the form was on
453
+ const back = f.action === "remove-org" ? "" : adminBackTo(f);
454
+ return adminResult(req, result.ok ? "notice" : "error", result.message, back);
455
+ }
456
+ if (route === "session") {
457
+ // Switch the active organization. The id is the client's wish; the
458
+ // membership check in setActiveOrg() is what makes it true.
459
+ const session = await currentSession(req);
460
+ if (!session)
461
+ return sessionJson(null);
462
+ const { organization, json, next } = await readBody(req);
463
+ if (!(await setActiveOrg(session.user.id, organization))) {
464
+ return NextResponse.json({ error: "Not a member of that organization" }, { status: 403 });
465
+ }
466
+ return json ? sessionJson(await currentSession(req)) : NextResponse.redirect(new URL(next, req.url), 303);
467
+ }
161
468
  if (route === "sign-in") {
162
469
  const { email, password, next, json } = await readBody(req);
470
+ // Count the attempt before checking anything: wrong guesses are the ones that matter
471
+ const byIp = await rateLimit("signin:ip", clientIp(req.headers));
472
+ const byEmail = email ? await rateLimit("signin:email", email) : null;
473
+ const limited = !byIp.allowed ? byIp : byEmail && !byEmail.allowed ? byEmail : null;
474
+ if (limited) {
475
+ const canSignUp = (await policy()).allowed;
476
+ return tooMany(limited, json, (error) => signInPage({ next, error, email, canSignUp }));
477
+ }
163
478
  const row = email && password ? await findUser(email) : null;
164
479
  const ok = row ? await verifyPassword(password, row.password_hash) : false;
165
480
  if (!row || !ok) {
@@ -167,6 +482,11 @@ export async function POST(req, ctx) {
167
482
  return NextResponse.json({ error: "Wrong email or password" }, { status: 401 });
168
483
  return backTo(req, SIGN_IN_PATH, "1", next);
169
484
  }
485
+ if (!mayAccess(row.verified_at !== null, options().requireVerified))
486
+ return checkYourEmail(req, next, json);
487
+ // A row hashed under older, weaker parameters gets upgraded now that we hold the password
488
+ if (needsRehash(row.password_hash))
489
+ await rehashPassword(row.id, await hashPassword(password));
170
490
  return signedIn(req, { id: row.id, email: row.email, epoch: row.epoch }, next, json);
171
491
  }
172
492
  if (route === "sign-up") {
@@ -175,15 +495,87 @@ export async function POST(req, ctx) {
175
495
  const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : backTo(req, SIGN_UP_PATH, msg, next);
176
496
  if (!p.allowed)
177
497
  return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
178
- if (p.inviteRequired && invite !== process.env.AUTH_INVITE_CODE)
498
+ const byIp = await rateLimit("signup:ip", clientIp(req.headers));
499
+ if (!byIp.allowed)
500
+ return tooMany(byIp, json, (error) => signUpPage({ next, error, inviteRequired: p.inviteRequired, first: p.reason === "first" }));
501
+ const o = options();
502
+ if (p.inviteRequired && !safeEqual(invite, o.inviteCode ?? ""))
179
503
  return fail("Wrong invite code.", 403);
180
- const invalid = validateCredentials(email, password, process.env);
504
+ const invalid = validateCredentials(email, password, o);
181
505
  if (invalid)
182
506
  return fail(invalid);
183
507
  if (await findUser(email))
184
508
  return fail("That email is already registered.", 409);
185
- const user = await createUser(email, await hashPassword(password));
509
+ let user;
510
+ try {
511
+ user = await createUser(email, await hashPassword(password));
512
+ }
513
+ catch (err) {
514
+ // Two sign-ups for one email at the same instant: the check above passed for both, the database let one in
515
+ if (err instanceof DuplicateUserError)
516
+ return fail("That email is already registered.", 409);
517
+ throw err;
518
+ }
519
+ const required = o.requireVerified;
520
+ try {
521
+ await sendVerification(req, user, next);
522
+ }
523
+ catch (err) {
524
+ // The account exists either way. Only when verification gates
525
+ // access is a lost email a failure the user must hear about.
526
+ console.error("[auth] could not send the verification email:", err instanceof Error ? err.message : err);
527
+ if (required)
528
+ return fail("Could not send the verification email. Try again later.", 500);
529
+ }
530
+ if (required)
531
+ return checkYourEmail(req, next, json, user);
186
532
  return signedIn(req, user, next, json);
187
533
  }
534
+ if (route === "forgot") {
535
+ const { email, json } = await readBody(req);
536
+ if (email) {
537
+ const d = await rateLimit("forgot:email", email);
538
+ if (!d.allowed)
539
+ return tooMany(d, json, (error) => forgotPage({ error }));
540
+ }
541
+ const row = email ? await findUser(email) : null;
542
+ if (row) {
543
+ const raw = await issueToken(row.id, "reset", RESET_TTL_SECONDS);
544
+ await sendEmail(resetEmail(row.email, resetLink(req.url, raw), siteName(req))).catch((err) => console.error("[auth] could not send the reset email:", err instanceof Error ? err.message : err));
545
+ }
546
+ // Same answer whether or not the address exists
547
+ if (json)
548
+ return NextResponse.json({ ok: true, message: FORGOT_SENT });
549
+ const url = new URL(FORGOT_PATH, req.url);
550
+ url.searchParams.set("sent", "1");
551
+ return NextResponse.redirect(url, 303);
552
+ }
553
+ if (route === "reset") {
554
+ const { token, password, next, json } = await readBody(req);
555
+ const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : html(resetPage({ token, error: msg }), status);
556
+ // Validate before consuming, so a weak password does not burn the link
557
+ const invalid = validatePassword(password);
558
+ if (invalid)
559
+ return fail(invalid);
560
+ const consumed = await consumeToken(token, "reset");
561
+ const user = consumed ? await setPasswordById(consumed.userId, await hashPassword(password)) : null;
562
+ if (!user)
563
+ return fail(RESET_LINK_INVALID);
564
+ return signedIn(req, user, next, json);
565
+ }
566
+ if (route === "verify/resend") {
567
+ const { email, next, json } = await readBody(req);
568
+ if (email) {
569
+ const d = await rateLimit("verify:email", email);
570
+ if (!d.allowed)
571
+ return tooMany(d, json, (error) => signInPage({ next, error, email, resend: true }));
572
+ }
573
+ const row = email ? await findUser(email) : null;
574
+ if (row && row.verified_at === null) {
575
+ await sendVerification(req, { id: row.id, email: row.email }, next).catch((err) => console.error("[auth] could not resend the verification email:", err instanceof Error ? err.message : err));
576
+ }
577
+ // Same answer whether or not the address exists
578
+ return json ? NextResponse.json({ ok: true }) : checkYourEmail(req, next, false);
579
+ }
188
580
  return NextResponse.json({ error: "Not found" }, { status: 404 });
189
581
  }
@@ -1,5 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from "next/server.js";
2
+ import { MATCHER } from "../core/guard.ts";
2
3
  /** Paste this into proxy.ts as `export const config = { matcher: MATCHER }` */
3
- export declare const MATCHER: string[];
4
+ export { MATCHER };
4
5
  export declare function proxy(request: NextRequest): Promise<NextResponse>;
5
6
  export default proxy;