@binary1702/auth 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/AGENTS.md +155 -0
  2. package/CHANGELOG.md +49 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +325 -36
  5. package/README.md +338 -53
  6. package/dist/cli.js +248 -17
  7. package/dist/core/admin-html.d.ts +32 -0
  8. package/dist/core/admin-html.js +350 -0
  9. package/dist/core/admin.d.ts +56 -0
  10. package/dist/core/admin.js +90 -0
  11. package/dist/core/config.d.ts +10 -3
  12. package/dist/core/config.js +22 -8
  13. package/dist/core/db.d.ts +155 -10
  14. package/dist/core/db.js +449 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +52 -0
  18. package/dist/core/guard.js +106 -0
  19. package/dist/core/html.d.ts +43 -0
  20. package/dist/core/html.js +122 -28
  21. package/dist/core/invite.d.ts +7 -0
  22. package/dist/core/invite.js +30 -0
  23. package/dist/core/options.d.ts +95 -0
  24. package/dist/core/options.js +377 -0
  25. package/dist/core/org.d.ts +43 -0
  26. package/dist/core/org.js +65 -0
  27. package/dist/core/pages.d.ts +34 -0
  28. package/dist/core/pages.js +73 -0
  29. package/dist/core/password.d.ts +16 -0
  30. package/dist/core/password.js +41 -12
  31. package/dist/core/rate.d.ts +26 -0
  32. package/dist/core/rate.js +70 -0
  33. package/dist/core/reset.d.ts +9 -0
  34. package/dist/core/reset.js +31 -0
  35. package/dist/core/session.d.ts +11 -1
  36. package/dist/core/session.js +27 -7
  37. package/dist/core/signup.d.ts +13 -6
  38. package/dist/core/signup.js +30 -15
  39. package/dist/core/tokens.d.ts +29 -0
  40. package/dist/core/tokens.js +64 -0
  41. package/dist/core/verify.d.ts +8 -0
  42. package/dist/core/verify.js +33 -0
  43. package/dist/core/yaml.d.ts +10 -0
  44. package/dist/core/yaml.js +85 -0
  45. package/dist/index.d.ts +18 -6
  46. package/dist/index.js +18 -6
  47. package/dist/next/index.d.ts +10 -3
  48. package/dist/next/index.js +649 -63
  49. package/dist/next/proxy.d.ts +2 -1
  50. package/dist/next/proxy.js +22 -10
  51. package/dist/react/index.d.ts +72 -0
  52. package/dist/react/index.js +107 -0
  53. package/package.json +18 -4
@@ -5,104 +5,206 @@
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 ?next (same origin) or the sign-in page
13
+ // POST /auth/sign-out same (JSON 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/policy { signup, pages, name } for an app's own sign-in / sign-up page
17
+ //
18
+ // With `pages:` in b17_auth.yaml the app serves sign-in / sign-up itself:
19
+ // GET /auth/sign-in and /auth/sign-up redirect there (core/pages.ts),
20
+ // every redirect above lands there, and the POSTs stay the protocol.
21
+ // GET /auth/verify?token= consume the link, mark the email verified
22
+ // POST /auth/verify/resend email a fresh link (body: email)
23
+ // GET /auth/forgot "send me a reset link" form
24
+ // POST /auth/forgot email a reset link if the address exists; same answer either way
25
+ // GET /auth/reset?token= "choose a new password" form
26
+ // POST /auth/reset consume the token, set the password, sign in
27
+ // GET /auth/invite?token= the welcome form an added user gets (choose a password)
28
+ // POST /auth/invite consume the invite token, set the password (verified), sign in
15
29
  //
16
30
  // 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.
31
+ // const session = await getSession() // { user, organization, authorization } or null
32
+ // const session = await requireSession() // same, or throws AuthError(401)
33
+ // const session = await requirePermission("invoices.create") // or throws AuthError(403)
34
+ // const user = await getUser() / requireUser() // just the user part
35
+ // All of them run the same check: signature, user still exists with
36
+ // the epoch the token was signed under, email verified when required.
20
37
  // ─────────────────────────────────────────────────────────────
21
38
  import { cookies } from "next/headers.js";
22
39
  import { NextResponse } from "next/server.js";
23
- import { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_UP_PATH, getSecret } from "../core/config.js";
40
+ import { randomBytes } from "node:crypto";
41
+ import { COOKIE_NAME, cookieName, getSecret } from "../core/config.js";
42
+ import { options } from "../core/options.js";
43
+ import { landing, movedTo, pageOverride, pagePath } from "../core/pages.js";
24
44
  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";
45
+ import { hashPassword, needsRehash, verifyPassword } from "../core/password.js";
46
+ import { addMember, adminOrgs, adminUsers, countUsers, createOrg, createUser, db, DuplicateUserError, rehashPassword, findOrg, findUser, markVerified, memberRole, removeMember, removeOrg, removeUser, resetRole, seedRoles, revokeSessions, sessionRow, setActiveOrg, setPassword, setPasswordById, setRolePermissions, storedRoles, } from "../core/db.js";
47
+ import { forgotPage, messagePage, resendForm, resetPage, securityHeaders, setPages, setSiteName, signInPage, signUpPage } from "../core/html.js";
48
+ import { adminPage } from "../core/admin-html.js";
49
+ import { ADMIN_PATH, ROLE_NAME, backTo as adminBackTo, grantedFromFields, isAdmin, isOrgAction, isRoleAction, isUserAction, mayEditRoles, refusedOnSelf, roleChoices } from "../core/admin.js";
50
+ import { emailTransport } from "../core/email.js";
51
+ import { clientIp, rateLimit, retryMessage } from "../core/rate.js";
52
+ import { signupPolicy, validateCredentials, validateEmail, validatePassword } from "../core/signup.js";
53
+ import { INVITE_LINK_INVALID, INVITE_TTL_SECONDS, inviteEmail, inviteLink } from "../core/invite.js";
54
+ import { isSameOrigin, safeEqual, safeNext } from "../core/guard.js";
55
+ import { consumeToken, issueToken } from "../core/tokens.js";
56
+ import { sendEmail } from "../core/email.js";
57
+ import { VERIFY_TTL_SECONDS, mayAccess, verifyEmail, verifyLink } from "../core/verify.js";
58
+ import { FORGOT_PATH, FORGOT_SENT, RESET_LINK_INVALID, RESET_TTL_SECONDS, resetEmail, resetLink } from "../core/reset.js";
59
+ import { OWNER, buildSession, hasPermission } from "../core/org.js";
29
60
  export class AuthError extends Error {
30
- status = 401;
31
- constructor(message = "Unauthorized") {
61
+ status;
62
+ constructor(message = "Unauthorized", status = 401) {
32
63
  super(message);
33
64
  this.name = "AuthError";
65
+ this.status = status;
34
66
  }
35
67
  }
36
68
  /**
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.)
69
+ * The one session check, for anything that hands out data: a valid
70
+ * signature, AND the user still exists with the epoch the token was
71
+ * signed under, AND (when required) a verified email. One query, which
72
+ * also brings the active organization and role. (The proxy skips the
73
+ * lookup; see core/session.ts for why that is safe.)
41
74
  */
42
- async function userFromToken(token) {
75
+ async function sessionFromToken(token) {
43
76
  if (!token)
44
77
  return null;
45
78
  const claim = await verifySession(token, getSecret());
46
- if (!claim || !isCurrent(claim, await sessionEpoch(claim.id)))
79
+ if (!claim)
80
+ return null;
81
+ await seeded();
82
+ const row = await sessionRow(claim.id);
83
+ const o = options();
84
+ if (!row || !isCurrent(claim, row.epoch) || !mayAccess(row.verified, o.requireVerified))
47
85
  return null;
48
- return { id: claim.id, email: claim.email };
86
+ return buildSession(row, o);
87
+ }
88
+ /**
89
+ * The starting point for roles, written once per process (and then once
90
+ * per database: INSERT … DO NOTHING). The CLI does the same at install;
91
+ * this covers a database the CLI never saw, such as production's.
92
+ */
93
+ let seeding = null;
94
+ function seeded() {
95
+ seeding ??= seedRoles(options().roles).catch((err) => {
96
+ seeding = null; // retry on the next request
97
+ throw err;
98
+ });
99
+ return seeding;
100
+ }
101
+ async function cookieToken() {
102
+ return pickCookie(async (name) => (await cookies()).get(name)?.value);
103
+ }
104
+ /** The current cookie name first, then the pre-0.3.0 one, so upgrading signs nobody out. */
105
+ async function pickCookie(get) {
106
+ return (await get(cookieName(options().production))) ?? (await get(COOKIE_NAME));
107
+ }
108
+ /** The session contract for the current request, or null if signed out, deleted or revoked. */
109
+ export async function getSession() {
110
+ return sessionFromToken(await cookieToken());
111
+ }
112
+ /** The session, or throw AuthError (status 401). */
113
+ export async function requireSession() {
114
+ const session = await getSession();
115
+ if (!session)
116
+ throw new AuthError();
117
+ return session;
118
+ }
119
+ /** The session, or throw AuthError: 401 when signed out, 403 when the role lacks the permission. */
120
+ export async function requirePermission(permission) {
121
+ const session = await requireSession();
122
+ if (!hasPermission(session, permission))
123
+ throw new AuthError("Forbidden", 403);
124
+ return session;
49
125
  }
50
126
  /** Current user from the session cookie, or null if signed out, deleted or revoked. */
51
127
  export async function getUser() {
52
- return userFromToken((await cookies()).get(COOKIE_NAME)?.value);
128
+ return (await getSession())?.user ?? null;
53
129
  }
54
130
  /** Current user, or throw AuthError (status 401). */
55
131
  export async function requireUser() {
56
- const user = await getUser();
57
- if (!user)
58
- throw new AuthError();
59
- return user;
132
+ return (await requireSession()).user;
60
133
  }
61
- /** A 401 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
134
+ /** A 401/403 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
62
135
  export function unauthorized(err) {
63
136
  if (err && !(err instanceof AuthError))
64
137
  throw err;
65
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
138
+ const status = err instanceof AuthError ? err.status : 401;
139
+ return NextResponse.json({ error: status === 403 ? "Forbidden" : "Unauthorized" }, { status });
66
140
  }
141
+ /** The sign-in / sign-up pages: the app's own when b17_auth.yaml names them (core/pages.ts) */
142
+ const SIGN_IN_PATH = () => pagePath(options().pages, "signIn");
143
+ const SIGN_UP_PATH = () => pagePath(options().pages, "signUp");
67
144
  const cookieOptions = () => ({
68
145
  httpOnly: true,
69
146
  sameSite: "lax",
70
- secure: process.env.NODE_ENV === "production",
147
+ secure: options().production,
71
148
  path: "/",
72
- maxAge: SESSION_DAYS * 24 * 60 * 60,
149
+ maxAge: options().sessionDays * 24 * 60 * 60,
73
150
  });
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;
151
+ /** An auth page: HTML plus the headers every one of them carries (see securityHeaders) */
152
+ function html(body, status = 200, headers = securityHeaders()) {
153
+ return new NextResponse(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...headers } });
79
154
  }
80
- function html(body, status = 200) {
81
- return new NextResponse(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
155
+ /** Per-user JSON: never cached by a browser or a CDN */
156
+ function sessionJson(session) {
157
+ const res = session
158
+ ? NextResponse.json(session)
159
+ : NextResponse.json({ error: "Unauthorized" }, { status: 401 });
160
+ res.headers.set("cache-control", "no-store");
161
+ return res;
82
162
  }
83
163
  async function readBody(req) {
84
164
  const type = req.headers.get("content-type") ?? "";
85
165
  const get = (b, k) => String((b instanceof FormData ? b.get(k) : b[k]) ?? "");
86
166
  const b = type.includes("application/json")
87
167
  ? (await req.json().catch(() => ({})))
88
- : await req.formData();
168
+ : await req.formData().catch(() => new FormData());
89
169
  return {
90
170
  email: get(b, "email").trim().toLowerCase(),
91
171
  password: get(b, "password"),
92
172
  invite: get(b, "invite").trim(),
93
- next: safeNext(get(b, "next")),
173
+ token: get(b, "token").trim(),
174
+ organization: get(b, "organization").trim(),
175
+ // Same-origin only; nothing (or the root) means the app's home
176
+ next: landing(safeNext(get(b, "next"), req.url), options().home),
94
177
  json: type.includes("application/json"),
95
178
  };
96
179
  }
180
+ /** Every field of a form or JSON body as strings (the admin forms vary per action). */
181
+ async function readFields(req) {
182
+ const type = req.headers.get("content-type") ?? "";
183
+ const out = new Proxy({}, { get: (t, k) => (typeof k === "string" ? (t[k] ?? "") : undefined) });
184
+ if (type.includes("application/json")) {
185
+ const b = (await req.json().catch(() => ({})));
186
+ for (const [k, v] of Object.entries(b))
187
+ out[k] = String(v ?? "");
188
+ }
189
+ else {
190
+ const fd = await req.formData().catch(() => new FormData());
191
+ fd.forEach((v, k) => (out[k] = String(v)));
192
+ }
193
+ return out;
194
+ }
195
+ async function currentSession(req) {
196
+ return sessionFromToken(await pickCookie((name) => req.cookies.get(name)?.value));
197
+ }
97
198
  async function currentUser(req) {
98
- return userFromToken(req.cookies.get(COOKIE_NAME)?.value);
199
+ return (await currentSession(req))?.user ?? null;
99
200
  }
100
201
  /** Sign the user in on a response: cookie + redirect (form) or JSON (fetch). */
101
202
  async function signedIn(req, user, next, json) {
102
- const token = await signSession(user, getSecret());
103
- const body = { user: { id: user.id, email: user.email } };
203
+ const token = await signSession(user, getSecret(), options().sessionDays);
204
+ // `next` is already reduced to a same-origin path: a fetch caller can follow it as is
205
+ const body = { user: { id: user.id, email: user.email }, next };
104
206
  const res = json ? NextResponse.json(body) : NextResponse.redirect(new URL(next, req.url), 303);
105
- res.cookies.set(COOKIE_NAME, token, cookieOptions());
207
+ res.cookies.set(cookieName(options().production), token, cookieOptions());
106
208
  return res;
107
209
  }
108
210
  /** Back to a form with ?error=<msg>, keeping ?next. */
@@ -113,77 +215,561 @@ function backTo(req, path, error, next) {
113
215
  url.searchParams.set("next", next);
114
216
  return NextResponse.redirect(url, 303);
115
217
  }
116
- function signOutResponse(req, json) {
218
+ /** The sign-in page in its "check your email" state. */
219
+ function checkYourEmail(req, next, json, user) {
220
+ if (json)
221
+ return NextResponse.json({ user: user ?? null, verify: true }, { status: user ? 200 : 403 });
222
+ const url = new URL(SIGN_IN_PATH(), req.url);
223
+ url.searchParams.set("verify", "1");
224
+ if (next !== "/")
225
+ url.searchParams.set("next", next);
226
+ return NextResponse.redirect(url, 303);
227
+ }
228
+ /** 429 with Retry-After: JSON for fetch callers, the form again (with the message) for browsers. */
229
+ function tooMany(d, json, page) {
230
+ const message = retryMessage(d.retryAfter);
231
+ const res = json ? NextResponse.json({ error: message, retryAfter: d.retryAfter }, { status: 429 }) : html(page(message), 429);
232
+ res.headers.set("retry-after", String(d.retryAfter));
233
+ return res;
234
+ }
235
+ /** Clear the cookie, then land on `next` (same origin; the app's public front, say) or the sign-in page */
236
+ function signOutResponse(req, json, next) {
237
+ const to = next ? safeNext(next, req.url) : SIGN_IN_PATH();
117
238
  const res = json
118
239
  ? NextResponse.json({ ok: true })
119
- : NextResponse.redirect(new URL(SIGN_IN_PATH, req.url), 303);
240
+ : NextResponse.redirect(new URL(to === "/" && !next ? SIGN_IN_PATH() : to, req.url), 303);
241
+ // Clear both names: the current one and the pre-0.3.0 one a browser may still hold
242
+ res.cookies.set(cookieName(options().production), "", { ...cookieOptions(), maxAge: 0 });
120
243
  res.cookies.set(COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0 });
121
244
  return res;
122
245
  }
123
246
  async function policy() {
124
- return signupPolicy(process.env, await countUsers());
247
+ return signupPolicy(options().signup, await countUsers());
248
+ }
249
+ /** Issue a verify token and email the link. Throws if the email cannot be sent. Returns the link, for the admin page to show. */
250
+ async function sendVerification(req, user, next) {
251
+ const raw = await issueToken(user.id, "verify", VERIFY_TTL_SECONDS, { next });
252
+ const link = verifyLink(req.url, raw, next);
253
+ await sendEmail(verifyEmail(user.email, link, siteName(req)));
254
+ return link;
255
+ }
256
+ /** Issue an invite token and email the welcome link: choose a password, verified, signed in. Throws if the email cannot be sent. Returns the link, for the admin page to show. */
257
+ async function sendInvite(req, user) {
258
+ const raw = await issueToken(user.id, "invite", INVITE_TTL_SECONDS);
259
+ const link = inviteLink(req.url, raw);
260
+ await sendEmail(inviteEmail(user.email, link, siteName(req)));
261
+ return link;
262
+ }
263
+ /**
264
+ * What to tell the admin after an email went out, honestly: in development
265
+ * without a transport, nothing was sent. The link itself comes along either
266
+ * way, so the admin can hand it over directly (it still works once).
267
+ */
268
+ function emailOutcome(what, email, link) {
269
+ const o = options();
270
+ const transport = emailTransport({ ...o.email, production: o.production });
271
+ if (transport === "console") {
272
+ return { ok: false, message: `Nothing was sent: email is not set up (add email: to b17_auth.yaml). The ${what} link for ${email} is below; it works once.`, link };
273
+ }
274
+ return { ok: true, message: `${what[0].toUpperCase()}${what.slice(1)} link emailed to ${email} via ${transport}. The same link, in case it does not arrive:`, link };
275
+ }
276
+ /** For subjects and titles: `name` from b17_auth.yaml, else the host serving the request. Also tells the pages where sign-in / sign-up live. */
277
+ function siteName(req) {
278
+ const o = options();
279
+ setSiteName(o.name);
280
+ setPages(o.pages);
281
+ return o.name || new URL(req.url).host;
282
+ }
283
+ /** GET /auth/sign-in or /auth/sign-up when the app serves that page itself: same query, the app's path */
284
+ function movedPage(req, key) {
285
+ const custom = pageOverride(options().pages, key);
286
+ return custom ? NextResponse.redirect(new URL(movedTo(custom, req.nextUrl.search), req.url)) : null;
287
+ }
288
+ // ── admin ─────────────────────────────────────────────────────
289
+ async function renderAdmin(self, nonce, view, limited, notice, error, link) {
290
+ const o = options();
291
+ await seeded();
292
+ const [users, orgs, stored, [tokens]] = await Promise.all([
293
+ adminUsers(),
294
+ adminOrgs(),
295
+ storedRoles(),
296
+ db().query(`SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE used_at IS NULL AND expires_at > now())::int AS live FROM tokens`),
297
+ ]);
298
+ const inUse = [...orgs.flatMap((org) => org.members.map((m) => m.role)), ...stored.map((s) => s.name)];
299
+ return adminPage({
300
+ self,
301
+ view,
302
+ users,
303
+ orgs,
304
+ stored,
305
+ roles: roleChoices(Object.keys(o.roles), inUse),
306
+ limited,
307
+ link,
308
+ options: o,
309
+ transport: emailTransport({ ...o.email, production: o.production }),
310
+ tokens: tokens ?? { total: 0, live: 0 },
311
+ notice,
312
+ error,
313
+ nonce,
314
+ });
315
+ }
316
+ /** Back to the admin page with a one-line result */
317
+ function adminResult(req, kind, message, back, link) {
318
+ const url = new URL(`${ADMIN_PATH}${back ? `?${back}` : ""}`, req.url);
319
+ url.searchParams.set(kind, message);
320
+ if (link)
321
+ url.searchParams.set("link", link);
322
+ return NextResponse.redirect(url, 303);
323
+ }
324
+ /** A link the admin page may show: one of this site's own /auth/ links, nothing else (the query is not trusted). */
325
+ function shownLink(req, value) {
326
+ if (!value)
327
+ return undefined;
328
+ const path = safeNext(value, req.url);
329
+ return path.startsWith("/auth/") ? new URL(path, req.url).toString() : undefined;
330
+ }
331
+ /** The admin POSTs: what the CLI does, from a form. Returns a notice or an error message. */
332
+ async function adminAction(route, self, f, sendVerify, invite) {
333
+ const ok = (message) => ({ ok: true, message });
334
+ const fail = (message) => ({ ok: false, message });
335
+ const email = f.email.trim().toLowerCase();
336
+ if (route === "admin/users") {
337
+ const action = f.action;
338
+ if (!isUserAction(action))
339
+ return fail("Unknown action.");
340
+ const refused = refusedOnSelf(action, email, self);
341
+ if (refused)
342
+ return fail(refused);
343
+ if (action === "add") {
344
+ // Two flows, named by the button. Register: email + password, the account is
345
+ // ready and verified (the admin vouches for it). Invite: email only; the account
346
+ // gets a password nobody knows, and the person gets one link to choose theirs,
347
+ // which also verifies the address. A JSON caller without `mode` is read from the
348
+ // password: present means register.
349
+ const byInvite = f.mode ? f.mode === "invite" : !f.password;
350
+ if (!byInvite && !f.password)
351
+ return fail("Register needs a password. To send a link instead, use Invite.");
352
+ const invalid = byInvite ? validateEmail(email, options()) : validateCredentials(email, f.password, options());
353
+ if (invalid)
354
+ return fail(invalid);
355
+ if (await findUser(email))
356
+ return fail("That email is already registered.");
357
+ let created;
358
+ try {
359
+ created = await createUser(email, await hashPassword(byInvite ? randomBytes(32).toString("base64url") : f.password), { verified: !byInvite });
360
+ }
361
+ catch (err) {
362
+ if (err instanceof DuplicateUserError)
363
+ return fail("That email is already registered.");
364
+ throw err;
365
+ }
366
+ if (!byInvite)
367
+ return ok(`Registered ${email}: ready and verified.`);
368
+ let link;
369
+ try {
370
+ link = await invite({ id: created.id, email: created.email });
371
+ }
372
+ catch (err) {
373
+ return fail(`Invited ${email}, but could not send the link: ${err instanceof Error ? err.message : String(err)}. Use "invite" to try again.`);
374
+ }
375
+ const outcome = emailOutcome("invitation", email, link);
376
+ return { ...outcome, message: `Invited ${email}. ${outcome.message}` };
377
+ }
378
+ const user = await findUser(email);
379
+ if (!user)
380
+ return fail(`No user ${email}.`);
381
+ if (action === "invite") {
382
+ let link;
383
+ try {
384
+ link = await invite({ id: user.id, email: user.email });
385
+ }
386
+ catch (err) {
387
+ return fail(`Could not send: ${err instanceof Error ? err.message : String(err)}`);
388
+ }
389
+ return emailOutcome("invitation", email, link);
390
+ }
391
+ if (action === "set-password") {
392
+ const invalid = validatePassword(f.password);
393
+ if (invalid)
394
+ return fail(invalid);
395
+ await setPassword(email, await hashPassword(f.password));
396
+ return ok(`Password set for ${email}; their other sessions are ended.`);
397
+ }
398
+ if (action === "sign-out") {
399
+ await revokeSessions(email);
400
+ return ok(`Every session of ${email} is ended.`);
401
+ }
402
+ if (action === "verify") {
403
+ await markVerified(user.id);
404
+ return ok(`${email} marked verified.`);
405
+ }
406
+ if (action === "send-verify") {
407
+ if (user.verified_at)
408
+ return fail(`${email} is already verified.`);
409
+ let link;
410
+ try {
411
+ link = await sendVerify({ id: user.id, email: user.email });
412
+ }
413
+ catch (err) {
414
+ return fail(`Could not send: ${err instanceof Error ? err.message : String(err)}`);
415
+ }
416
+ // Without a transport, development prints the email to the terminal: say so instead of claiming a send
417
+ return emailOutcome("verification", email, link);
418
+ }
419
+ await removeUser(email);
420
+ return ok(`Removed ${email}.`);
421
+ }
422
+ if (route === "admin/orgs") {
423
+ const action = f.action;
424
+ if (!isOrgAction(action))
425
+ return fail("Unknown action.");
426
+ if (action === "create") {
427
+ const name = f.name.trim();
428
+ if (!name)
429
+ return fail("An organization needs a name.");
430
+ if (await findOrg(name))
431
+ return fail(`There is already an organization called ${name}.`);
432
+ const org = await createOrg(name);
433
+ return ok(`Created ${org.name}.`);
434
+ }
435
+ const org = await findOrg(f.org);
436
+ if (!org)
437
+ return fail("No such organization.");
438
+ if (action === "remove-org") {
439
+ await removeOrg(org.id);
440
+ return ok(`Removed ${org.name} and its memberships.`);
441
+ }
442
+ const user = await findUser(email);
443
+ if (!user)
444
+ return fail(`No user ${email}. Add them first.`);
445
+ if (action === "remove-member") {
446
+ return (await removeMember(org.id, user.id)) ? ok(`${email} removed from ${org.name}.`) : fail(`${email} is not a member of ${org.name}.`);
447
+ }
448
+ const role = f.role.trim();
449
+ if (!ROLE_NAME.test(role))
450
+ return fail("A role is one word: letters, digits, - or _.");
451
+ await addMember(org.id, user.id, role); // add-member and set-role are the same upsert
452
+ return ok(`${email} is ${role} in ${org.name}.`);
453
+ }
454
+ if (route === "admin/roles") {
455
+ const action = f.action;
456
+ if (!isRoleAction(action))
457
+ return fail("Unknown action.");
458
+ const role = f.role.trim();
459
+ if (!ROLE_NAME.test(role))
460
+ return fail("A role is one word: letters, digits, - or _.");
461
+ if (role === OWNER)
462
+ return fail("The owner role always holds everything.");
463
+ // scope: "" = every organization; an organization's id = that one only
464
+ const scope = roleScope(f);
465
+ if (scope && !(await findOrg(scope)))
466
+ return fail("No such organization.");
467
+ if (action === "follow") {
468
+ if (!scope)
469
+ return fail("Only an organization's own rule can be dropped.");
470
+ return (await resetRole(scope, role)) ? ok(`${role} follows Roles again here.`) : fail(`${role} had no rule of its own here.`);
471
+ }
472
+ if (action === "defaults") {
473
+ if (scope)
474
+ return fail("Defaults apply to Roles, not to one organization.");
475
+ const defaults = options().roles[role];
476
+ if (!defaults)
477
+ return fail(`${role} has no default in b17_auth.yaml.`);
478
+ await setRolePermissions("", role, defaults);
479
+ return ok(`${role} is back to its default: ${defaults.join(", ") || "nothing"}.`);
480
+ }
481
+ const catalogue = Object.keys(options().permissions);
482
+ if (!catalogue.length)
483
+ return fail("Declare permissions in b17_auth.yaml first.");
484
+ // Only ids the app declares can be granted, whatever the form says
485
+ const granted = grantedFromFields(f, catalogue);
486
+ await setRolePermissions(scope, role, granted);
487
+ return ok(`${role} may now${scope ? " here" : ""}: ${granted.length ? granted.join(", ") : "nothing"}.`);
488
+ }
489
+ return fail("Unknown action.");
490
+ }
491
+ /** The organization a roles form is about, or "" for every organization. Never taken from the form as is. */
492
+ function roleScope(f) {
493
+ const scope = f.scope || f.org || "";
494
+ return /^[0-9a-f-]{36}$/i.test(scope) ? scope : "";
495
+ }
496
+ /**
497
+ * Who may run an admin POST. A system admin: anything. An organization's
498
+ * owner: its roles grid and its members (never the organization's removal,
499
+ * nothing about users or other organizations).
500
+ */
501
+ async function mayPost(session, route, f) {
502
+ const admin = isAdmin(session, options().admins);
503
+ if (admin)
504
+ return true;
505
+ const orgId = route === "admin/roles" ? roleScope(f) : route === "admin/orgs" ? (/^[0-9a-f-]{36}$/i.test(f.org) ? f.org : "") : "";
506
+ if (!orgId)
507
+ return false;
508
+ if (route === "admin/orgs" && !["add-member", "set-role", "remove-member"].includes(f.action))
509
+ return false;
510
+ return mayEditRoles(false, orgId, await memberRole(session.user.id, orgId));
125
511
  }
126
512
  // ── handlers ──────────────────────────────────────────────────
127
513
  export async function GET(req, ctx) {
128
514
  const { path } = await ctx.params;
129
515
  const route = path.join("/");
130
516
  const q = req.nextUrl.searchParams;
131
- const next = safeNext(q.get("next"));
517
+ const next = safeNext(q.get("next"), req.url);
518
+ siteName(req); // so <title> carries the app name on every page below
132
519
  if (route === "sign-in") {
133
520
  if (await currentUser(req))
134
- return NextResponse.redirect(new URL(next, req.url));
521
+ return NextResponse.redirect(new URL(landing(next, options().home), req.url));
522
+ // The app draws this page itself: hand over, query and all
523
+ const moved = movedPage(req, "signIn");
524
+ if (moved)
525
+ return moved;
135
526
  const p = await policy();
136
527
  // A fresh install has nobody to sign in: go straight to creating the first account.
137
528
  if (p.reason === "first")
138
- return NextResponse.redirect(new URL(SIGN_UP_PATH, req.url));
529
+ return NextResponse.redirect(new URL(SIGN_UP_PATH(), req.url));
139
530
  const error = q.get("error") === "1" ? "Wrong email or password." : (q.get("error") ?? undefined);
140
- return html(signInPage({ next, error, canSignUp: p.allowed }));
531
+ const verify = q.get("verify") === "1";
532
+ const notice = verify
533
+ ? "Check your email for a verification link, then sign in."
534
+ : q.get("verified") === "1"
535
+ ? "Email verified. Sign in to continue."
536
+ : undefined;
537
+ return html(signInPage({ next, error, notice, resend: verify, canSignUp: p.allowed }));
141
538
  }
142
539
  if (route === "sign-up") {
143
540
  if (await currentUser(req))
144
- return NextResponse.redirect(new URL(next, req.url));
541
+ return NextResponse.redirect(new URL(landing(next, options().home), req.url));
542
+ const moved = movedPage(req, "signUp");
543
+ if (moved)
544
+ return moved;
145
545
  const p = await policy();
146
546
  if (!p.allowed)
147
547
  return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
148
548
  return html(signUpPage({ next, error: q.get("error") ?? undefined, inviteRequired: p.inviteRequired, first: p.reason === "first" }));
149
549
  }
150
550
  if (route === "sign-out")
151
- return signOutResponse(req, false);
551
+ return signOutResponse(req, false, q.get("next"));
152
552
  if (route === "session")
153
- return NextResponse.json({ user: await currentUser(req) });
553
+ return sessionJson(await currentSession(req));
554
+ if (route === "policy") {
555
+ // What an app's own sign-in / sign-up page needs before it renders. Public, never cached.
556
+ const o = options();
557
+ const res = NextResponse.json({ signup: await policy(), pages: { signIn: SIGN_IN_PATH(), signUp: SIGN_UP_PATH() }, home: landing(null, o.home), name: o.name });
558
+ res.headers.set("cache-control", "no-store");
559
+ return res;
560
+ }
561
+ if (route === "admin") {
562
+ const session = await currentSession(req);
563
+ if (!session)
564
+ return NextResponse.redirect(new URL(`${SIGN_IN_PATH()}?next=${encodeURIComponent(ADMIN_PATH)}`, req.url));
565
+ const nonce = randomBytes(16).toString("base64");
566
+ const orgId = q.get("org");
567
+ const tab = q.get("tab");
568
+ const admin = isAdmin(session, options().admins);
569
+ if (!admin) {
570
+ // An organization's owner manages that organization: its roles and members, nothing else
571
+ const own = orgId && /^[0-9a-f-]{36}$/i.test(orgId) ? await memberRole(session.user.id, orgId) : null;
572
+ if (!orgId || !mayEditRoles(false, orgId, own)) {
573
+ const back = landing(null, options().home);
574
+ return html(messagePage({ title: "Not an admin", message: `This page is for emails under admins: in b17_auth.yaml, or an organization's owner (open your organization from the app).`, link: { href: back, text: "Back" } }), 403);
575
+ }
576
+ return html(await renderAdmin(session.user.email, nonce, { tab: "org", orgId }, true, q.get("notice") ?? undefined, q.get("error") ?? undefined, shownLink(req, q.get("link"))), 200, securityHeaders(nonce));
577
+ }
578
+ const view = orgId ? { tab: "org", orgId } : tab === "users" || tab === "settings" || tab === "roles" ? { tab } : { tab: "orgs" };
579
+ return html(await renderAdmin(session.user.email, nonce, view, false, q.get("notice") ?? undefined, q.get("error") ?? undefined, shownLink(req, q.get("link"))), 200, securityHeaders(nonce));
580
+ }
581
+ if (route === "forgot") {
582
+ return html(forgotPage({ sent: q.get("sent") === "1", message: FORGOT_SENT, error: q.get("error") ?? undefined }));
583
+ }
584
+ if (route === "reset") {
585
+ // Show the form without consuming the token: a mail scanner that
586
+ // prefetches the link must not burn it.
587
+ const token = q.get("token") ?? "";
588
+ if (!token)
589
+ return html(messagePage({ title: "Link expired", message: RESET_LINK_INVALID, link: { href: FORGOT_PATH, text: "Request a new link" } }), 400);
590
+ return html(resetPage({ token, error: q.get("error") ?? undefined }));
591
+ }
592
+ if (route === "invite") {
593
+ // The welcome link an added user gets: same rule, the form consumes nothing
594
+ const token = q.get("token") ?? "";
595
+ if (!token)
596
+ return html(messagePage({ title: "Link expired", message: INVITE_LINK_INVALID, link: { href: SIGN_IN_PATH(), text: "Sign in" } }), 400);
597
+ return html(resetPage({ token, invite: true, error: q.get("error") ?? undefined }));
598
+ }
599
+ if (route === "verify") {
600
+ const token = await consumeToken(q.get("token") ?? "", "verify");
601
+ const user = token ? await markVerified(token.userId) : null;
602
+ if (!user) {
603
+ return html(messagePage({
604
+ title: "Link expired",
605
+ message: "This verification link is invalid, already used, or older than 24 hours.",
606
+ after: resendForm(),
607
+ }), 400);
608
+ }
609
+ const dest = safeNext(typeof token.meta.next === "string" ? token.meta.next : q.get("next"), req.url);
610
+ // Signed in already (sign-up set the cookie)? Carry on. Otherwise sign in.
611
+ if (await currentUser(req))
612
+ return NextResponse.redirect(new URL(dest, req.url), 303);
613
+ const url = new URL(SIGN_IN_PATH(), req.url);
614
+ url.searchParams.set("verified", "1");
615
+ if (dest !== "/")
616
+ url.searchParams.set("next", dest);
617
+ return NextResponse.redirect(url, 303);
618
+ }
154
619
  return NextResponse.json({ error: "Not found" }, { status: 404 });
155
620
  }
156
621
  export async function POST(req, ctx) {
157
622
  const { path } = await ctx.params;
158
623
  const route = path.join("/");
159
- if (route === "sign-out")
160
- return signOutResponse(req, true);
624
+ siteName(req); // pages rendered from a POST (a failed reset) carry the app name too
625
+ // Every POST here sets or clears the session cookie, which SameSite
626
+ // does not protect: another site could sign the visitor in as itself.
627
+ if (!isSameOrigin(req.headers)) {
628
+ return NextResponse.json({ error: "Cross-site request refused" }, { status: 403 });
629
+ }
630
+ if (route === "sign-out") {
631
+ const { json } = await readBody(req);
632
+ return signOutResponse(req, json, req.nextUrl.searchParams.get("next"));
633
+ }
634
+ if (route === "admin/users" || route === "admin/orgs" || route === "admin/roles") {
635
+ const session = await currentSession(req);
636
+ if (!session)
637
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
638
+ const f = await readFields(req);
639
+ // Checked on the server, per organization: the form's claims mean nothing
640
+ if (!(await mayPost(session, route, f)))
641
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
642
+ const result = await adminAction(route, session.user.email, f, (user) => sendVerification(req, user, "/"), (user) => sendInvite(req, user));
643
+ // After removing an organization there is nothing to return to; otherwise, the view the form was on
644
+ const back = f.action === "remove-org" ? "" : adminBackTo(f);
645
+ return adminResult(req, result.ok ? "notice" : "error", result.message, back, result.link);
646
+ }
647
+ if (route === "session") {
648
+ // Switch the active organization. The id is the client's wish; the
649
+ // membership check in setActiveOrg() is what makes it true.
650
+ const session = await currentSession(req);
651
+ if (!session)
652
+ return sessionJson(null);
653
+ const { organization, json, next } = await readBody(req);
654
+ if (!(await setActiveOrg(session.user.id, organization))) {
655
+ return NextResponse.json({ error: "Not a member of that organization" }, { status: 403 });
656
+ }
657
+ return json ? sessionJson(await currentSession(req)) : NextResponse.redirect(new URL(next, req.url), 303);
658
+ }
161
659
  if (route === "sign-in") {
162
660
  const { email, password, next, json } = await readBody(req);
661
+ // Count the attempt before checking anything: wrong guesses are the ones that matter
662
+ const byIp = await rateLimit("signin:ip", clientIp(req.headers));
663
+ const byEmail = email ? await rateLimit("signin:email", email) : null;
664
+ const limited = !byIp.allowed ? byIp : byEmail && !byEmail.allowed ? byEmail : null;
665
+ if (limited) {
666
+ const canSignUp = (await policy()).allowed;
667
+ return tooMany(limited, json, (error) => signInPage({ next, error, email, canSignUp }));
668
+ }
163
669
  const row = email && password ? await findUser(email) : null;
164
670
  const ok = row ? await verifyPassword(password, row.password_hash) : false;
165
671
  if (!row || !ok) {
166
672
  if (json)
167
673
  return NextResponse.json({ error: "Wrong email or password" }, { status: 401 });
168
- return backTo(req, SIGN_IN_PATH, "1", next);
674
+ return backTo(req, SIGN_IN_PATH(), "1", next);
169
675
  }
676
+ if (!mayAccess(row.verified_at !== null, options().requireVerified))
677
+ return checkYourEmail(req, next, json);
678
+ // A row hashed under older, weaker parameters gets upgraded now that we hold the password
679
+ if (needsRehash(row.password_hash))
680
+ await rehashPassword(row.id, await hashPassword(password));
170
681
  return signedIn(req, { id: row.id, email: row.email, epoch: row.epoch }, next, json);
171
682
  }
172
683
  if (route === "sign-up") {
173
684
  const { email, password, invite, next, json } = await readBody(req);
174
685
  const p = await policy();
175
- const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : backTo(req, SIGN_UP_PATH, msg, next);
686
+ const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : backTo(req, SIGN_UP_PATH(), msg, next);
176
687
  if (!p.allowed)
177
688
  return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
178
- if (p.inviteRequired && invite !== process.env.AUTH_INVITE_CODE)
689
+ const byIp = await rateLimit("signup:ip", clientIp(req.headers));
690
+ if (!byIp.allowed)
691
+ return tooMany(byIp, json, (error) => signUpPage({ next, error, inviteRequired: p.inviteRequired, first: p.reason === "first" }));
692
+ const o = options();
693
+ if (p.inviteRequired && !safeEqual(invite, o.inviteCode ?? ""))
179
694
  return fail("Wrong invite code.", 403);
180
- const invalid = validateCredentials(email, password, process.env);
695
+ const invalid = validateCredentials(email, password, o);
181
696
  if (invalid)
182
697
  return fail(invalid);
183
698
  if (await findUser(email))
184
699
  return fail("That email is already registered.", 409);
185
- const user = await createUser(email, await hashPassword(password));
700
+ let user;
701
+ try {
702
+ user = await createUser(email, await hashPassword(password));
703
+ }
704
+ catch (err) {
705
+ // Two sign-ups for one email at the same instant: the check above passed for both, the database let one in
706
+ if (err instanceof DuplicateUserError)
707
+ return fail("That email is already registered.", 409);
708
+ throw err;
709
+ }
710
+ const required = o.requireVerified;
711
+ try {
712
+ await sendVerification(req, user, next);
713
+ }
714
+ catch (err) {
715
+ // The account exists either way. Only when verification gates
716
+ // access is a lost email a failure the user must hear about.
717
+ console.error("[auth] could not send the verification email:", err instanceof Error ? err.message : err);
718
+ if (required)
719
+ return fail("Could not send the verification email. Try again later.", 500);
720
+ }
721
+ if (required)
722
+ return checkYourEmail(req, next, json, user);
186
723
  return signedIn(req, user, next, json);
187
724
  }
725
+ if (route === "forgot") {
726
+ const { email, json } = await readBody(req);
727
+ if (email) {
728
+ const d = await rateLimit("forgot:email", email);
729
+ if (!d.allowed)
730
+ return tooMany(d, json, (error) => forgotPage({ error }));
731
+ }
732
+ const row = email ? await findUser(email) : null;
733
+ if (row) {
734
+ const raw = await issueToken(row.id, "reset", RESET_TTL_SECONDS);
735
+ 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));
736
+ }
737
+ // Same answer whether or not the address exists
738
+ if (json)
739
+ return NextResponse.json({ ok: true, message: FORGOT_SENT });
740
+ const url = new URL(FORGOT_PATH, req.url);
741
+ url.searchParams.set("sent", "1");
742
+ return NextResponse.redirect(url, 303);
743
+ }
744
+ if (route === "reset" || route === "invite") {
745
+ // One flow, two kinds of token: a reset link (1 h) or an invitation (7 days).
746
+ // Either way: consume, set the password (marks verified, ends old sessions), sign in.
747
+ const invite = route === "invite";
748
+ const { token, password, next, json } = await readBody(req);
749
+ const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : html(resetPage({ token, invite, error: msg }), status);
750
+ // Validate before consuming, so a weak password does not burn the link
751
+ const invalid = validatePassword(password);
752
+ if (invalid)
753
+ return fail(invalid);
754
+ const consumed = await consumeToken(token, invite ? "invite" : "reset");
755
+ const user = consumed ? await setPasswordById(consumed.userId, await hashPassword(password)) : null;
756
+ if (!user)
757
+ return fail(invite ? INVITE_LINK_INVALID : RESET_LINK_INVALID);
758
+ return signedIn(req, user, next, json);
759
+ }
760
+ if (route === "verify/resend") {
761
+ const { email, next, json } = await readBody(req);
762
+ if (email) {
763
+ const d = await rateLimit("verify:email", email);
764
+ if (!d.allowed)
765
+ return tooMany(d, json, (error) => signInPage({ next, error, email, resend: true }));
766
+ }
767
+ const row = email ? await findUser(email) : null;
768
+ if (row && row.verified_at === null) {
769
+ 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));
770
+ }
771
+ // Same answer whether or not the address exists
772
+ return json ? NextResponse.json({ ok: true }) : checkYourEmail(req, next, false);
773
+ }
188
774
  return NextResponse.json({ error: "Not found" }, { status: 404 });
189
775
  }