@binary1702/auth 0.3.0 → 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.
@@ -9,16 +9,23 @@
9
9
  // POST /auth/sign-in verify, set cookie, redirect to ?next or /
10
10
  // GET /auth/sign-up the sign-up page (see core/signup.ts for when)
11
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)
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
14
  // GET /auth/session the session contract (core/org.ts), or 401
15
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.
16
21
  // GET /auth/verify?token= consume the link, mark the email verified
17
22
  // POST /auth/verify/resend email a fresh link (body: email)
18
23
  // GET /auth/forgot "send me a reset link" form
19
24
  // POST /auth/forgot email a reset link if the address exists; same answer either way
20
25
  // GET /auth/reset?token= "choose a new password" form
21
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
22
29
  //
23
30
  // Use anywhere on the server:
24
31
  // const session = await getSession() // { user, organization, authorization } or null
@@ -31,23 +38,25 @@
31
38
  import { cookies } from "next/headers.js";
32
39
  import { NextResponse } from "next/server.js";
33
40
  import { randomBytes } from "node:crypto";
34
- import { COOKIE_NAME, SIGN_IN_PATH, SIGN_UP_PATH, cookieName, getSecret } from "../core/config.js";
41
+ import { COOKIE_NAME, cookieName, getSecret } from "../core/config.js";
35
42
  import { options } from "../core/options.js";
43
+ import { landing, movedTo, pageOverride, pagePath } from "../core/pages.js";
36
44
  import { isCurrent, signSession, verifySession } from "../core/session.js";
37
45
  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";
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";
40
48
  import { adminPage } from "../core/admin-html.js";
41
- import { ADMIN_PATH, backTo as adminBackTo, isAdmin, isOrgAction, isUserAction, refusedOnSelf, roleChoices } from "../core/admin.js";
49
+ import { ADMIN_PATH, ROLE_NAME, backTo as adminBackTo, grantedFromFields, isAdmin, isOrgAction, isRoleAction, isUserAction, mayEditRoles, refusedOnSelf, roleChoices } from "../core/admin.js";
42
50
  import { emailTransport } from "../core/email.js";
43
51
  import { clientIp, rateLimit, retryMessage } from "../core/rate.js";
44
- import { signupPolicy, validateCredentials, validatePassword } from "../core/signup.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";
45
54
  import { isSameOrigin, safeEqual, safeNext } from "../core/guard.js";
46
55
  import { consumeToken, issueToken } from "../core/tokens.js";
47
56
  import { sendEmail } from "../core/email.js";
48
57
  import { VERIFY_TTL_SECONDS, mayAccess, verifyEmail, verifyLink } from "../core/verify.js";
49
58
  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";
59
+ import { OWNER, buildSession, hasPermission } from "../core/org.js";
51
60
  export class AuthError extends Error {
52
61
  status;
53
62
  constructor(message = "Unauthorized", status = 401) {
@@ -69,12 +78,26 @@ async function sessionFromToken(token) {
69
78
  const claim = await verifySession(token, getSecret());
70
79
  if (!claim)
71
80
  return null;
81
+ await seeded();
72
82
  const row = await sessionRow(claim.id);
73
83
  const o = options();
74
84
  if (!row || !isCurrent(claim, row.epoch) || !mayAccess(row.verified, o.requireVerified))
75
85
  return null;
76
86
  return buildSession(row, o);
77
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
+ }
78
101
  async function cookieToken() {
79
102
  return pickCookie(async (name) => (await cookies()).get(name)?.value);
80
103
  }
@@ -115,6 +138,9 @@ export function unauthorized(err) {
115
138
  const status = err instanceof AuthError ? err.status : 401;
116
139
  return NextResponse.json({ error: status === 403 ? "Forbidden" : "Unauthorized" }, { status });
117
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");
118
144
  const cookieOptions = () => ({
119
145
  httpOnly: true,
120
146
  sameSite: "lax",
@@ -146,7 +172,8 @@ async function readBody(req) {
146
172
  invite: get(b, "invite").trim(),
147
173
  token: get(b, "token").trim(),
148
174
  organization: get(b, "organization").trim(),
149
- next: safeNext(get(b, "next"), req.url),
175
+ // Same-origin only; nothing (or the root) means the app's home
176
+ next: landing(safeNext(get(b, "next"), req.url), options().home),
150
177
  json: type.includes("application/json"),
151
178
  };
152
179
  }
@@ -174,7 +201,8 @@ async function currentUser(req) {
174
201
  /** Sign the user in on a response: cookie + redirect (form) or JSON (fetch). */
175
202
  async function signedIn(req, user, next, json) {
176
203
  const token = await signSession(user, getSecret(), options().sessionDays);
177
- const body = { user: { id: user.id, email: user.email } };
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 };
178
206
  const res = json ? NextResponse.json(body) : NextResponse.redirect(new URL(next, req.url), 303);
179
207
  res.cookies.set(cookieName(options().production), token, cookieOptions());
180
208
  return res;
@@ -191,7 +219,7 @@ function backTo(req, path, error, next) {
191
219
  function checkYourEmail(req, next, json, user) {
192
220
  if (json)
193
221
  return NextResponse.json({ user: user ?? null, verify: true }, { status: user ? 200 : 403 });
194
- const url = new URL(SIGN_IN_PATH, req.url);
222
+ const url = new URL(SIGN_IN_PATH(), req.url);
195
223
  url.searchParams.set("verify", "1");
196
224
  if (next !== "/")
197
225
  url.searchParams.set("next", next);
@@ -204,10 +232,12 @@ function tooMany(d, json, page) {
204
232
  res.headers.set("retry-after", String(d.retryAfter));
205
233
  return res;
206
234
  }
207
- function signOutResponse(req, json) {
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();
208
238
  const res = json
209
239
  ? NextResponse.json({ ok: true })
210
- : NextResponse.redirect(new URL(SIGN_IN_PATH, req.url), 303);
240
+ : NextResponse.redirect(new URL(to === "/" && !next ? SIGN_IN_PATH() : to, req.url), 303);
211
241
  // Clear both names: the current one and the pre-0.3.0 one a browser may still hold
212
242
  res.cookies.set(cookieName(options().production), "", { ...cookieOptions(), maxAge: 0 });
213
243
  res.cookies.set(COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0 });
@@ -216,32 +246,65 @@ function signOutResponse(req, json) {
216
246
  async function policy() {
217
247
  return signupPolicy(options().signup, await countUsers());
218
248
  }
219
- /** Issue a verify token and email the link. Throws if the email cannot be sent. */
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. */
220
250
  async function sendVerification(req, user, next) {
221
251
  const raw = await issueToken(user.id, "verify", VERIFY_TTL_SECONDS, { next });
222
- await sendEmail(verifyEmail(user.email, verifyLink(req.url, raw, next), siteName(req)));
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;
223
262
  }
224
- /** For subjects and titles: `name` from b17_auth.yaml, else the host serving the request */
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. */
225
277
  function siteName(req) {
226
- const name = options().name;
227
- setSiteName(name);
228
- return name || new URL(req.url).host;
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;
229
287
  }
230
288
  // ── admin ─────────────────────────────────────────────────────
231
- async function renderAdmin(self, nonce, view, notice, error) {
289
+ async function renderAdmin(self, nonce, view, limited, notice, error, link) {
232
290
  const o = options();
233
- const [users, orgs, [tokens]] = await Promise.all([
291
+ await seeded();
292
+ const [users, orgs, stored, [tokens]] = await Promise.all([
234
293
  adminUsers(),
235
294
  adminOrgs(),
295
+ storedRoles(),
236
296
  db().query(`SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE used_at IS NULL AND expires_at > now())::int AS live FROM tokens`),
237
297
  ]);
238
- const inUse = orgs.flatMap((org) => org.members.map((m) => m.role));
298
+ const inUse = [...orgs.flatMap((org) => org.members.map((m) => m.role)), ...stored.map((s) => s.name)];
239
299
  return adminPage({
240
300
  self,
241
301
  view,
242
302
  users,
243
303
  orgs,
244
- roles: roleChoices(Object.keys(o.permissions), inUse),
304
+ stored,
305
+ roles: roleChoices(Object.keys(o.roles), inUse),
306
+ limited,
307
+ link,
245
308
  options: o,
246
309
  transport: emailTransport({ ...o.email, production: o.production }),
247
310
  tokens: tokens ?? { total: 0, live: 0 },
@@ -251,13 +314,22 @@ async function renderAdmin(self, nonce, view, notice, error) {
251
314
  });
252
315
  }
253
316
  /** Back to the admin page with a one-line result */
254
- function adminResult(req, kind, message, back) {
317
+ function adminResult(req, kind, message, back, link) {
255
318
  const url = new URL(`${ADMIN_PATH}${back ? `?${back}` : ""}`, req.url);
256
319
  url.searchParams.set(kind, message);
320
+ if (link)
321
+ url.searchParams.set("link", link);
257
322
  return NextResponse.redirect(url, 303);
258
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
+ }
259
331
  /** 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) {
332
+ async function adminAction(route, self, f, sendVerify, invite) {
261
333
  const ok = (message) => ({ ok: true, message });
262
334
  const fail = (message) => ({ ok: false, message });
263
335
  const email = f.email.trim().toLowerCase();
@@ -269,24 +341,53 @@ async function adminAction(route, self, f, sendVerify) {
269
341
  if (refused)
270
342
  return fail(refused);
271
343
  if (action === "add") {
272
- const invalid = validateCredentials(email, f.password, options());
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());
273
353
  if (invalid)
274
354
  return fail(invalid);
275
355
  if (await findUser(email))
276
356
  return fail("That email is already registered.");
357
+ let created;
277
358
  try {
278
- await createUser(email, await hashPassword(f.password), { verified: true });
359
+ created = await createUser(email, await hashPassword(byInvite ? randomBytes(32).toString("base64url") : f.password), { verified: !byInvite });
279
360
  }
280
361
  catch (err) {
281
362
  if (err instanceof DuplicateUserError)
282
363
  return fail("That email is already registered.");
283
364
  throw err;
284
365
  }
285
- return ok(`Added ${email}.`);
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}` };
286
377
  }
287
378
  const user = await findUser(email);
288
379
  if (!user)
289
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
+ }
290
391
  if (action === "set-password") {
291
392
  const invalid = validatePassword(f.password);
292
393
  if (invalid)
@@ -305,13 +406,15 @@ async function adminAction(route, self, f, sendVerify) {
305
406
  if (action === "send-verify") {
306
407
  if (user.verified_at)
307
408
  return fail(`${email} is already verified.`);
409
+ let link;
308
410
  try {
309
- await sendVerify({ id: user.id, email: user.email });
411
+ link = await sendVerify({ id: user.id, email: user.email });
310
412
  }
311
413
  catch (err) {
312
414
  return fail(`Could not send: ${err instanceof Error ? err.message : String(err)}`);
313
415
  }
314
- return ok(`Verification email sent to ${email}.`);
416
+ // Without a transport, development prints the email to the terminal: say so instead of claiming a send
417
+ return emailOutcome("verification", email, link);
315
418
  }
316
419
  await removeUser(email);
317
420
  return ok(`Removed ${email}.`);
@@ -343,13 +446,69 @@ async function adminAction(route, self, f, sendVerify) {
343
446
  return (await removeMember(org.id, user.id)) ? ok(`${email} removed from ${org.name}.`) : fail(`${email} is not a member of ${org.name}.`);
344
447
  }
345
448
  const role = f.role.trim();
346
- if (!/^[a-z0-9_-]{1,32}$/i.test(role))
449
+ if (!ROLE_NAME.test(role))
347
450
  return fail("A role is one word: letters, digits, - or _.");
348
451
  await addMember(org.id, user.id, role); // add-member and set-role are the same upsert
349
452
  return ok(`${email} is ${role} in ${org.name}.`);
350
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
+ }
351
489
  return fail("Unknown action.");
352
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));
511
+ }
353
512
  // ── handlers ──────────────────────────────────────────────────
354
513
  export async function GET(req, ctx) {
355
514
  const { path } = await ctx.params;
@@ -359,11 +518,15 @@ export async function GET(req, ctx) {
359
518
  siteName(req); // so <title> carries the app name on every page below
360
519
  if (route === "sign-in") {
361
520
  if (await currentUser(req))
362
- 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;
363
526
  const p = await policy();
364
527
  // A fresh install has nobody to sign in: go straight to creating the first account.
365
528
  if (p.reason === "first")
366
- return NextResponse.redirect(new URL(SIGN_UP_PATH, req.url));
529
+ return NextResponse.redirect(new URL(SIGN_UP_PATH(), req.url));
367
530
  const error = q.get("error") === "1" ? "Wrong email or password." : (q.get("error") ?? undefined);
368
531
  const verify = q.get("verify") === "1";
369
532
  const notice = verify
@@ -375,28 +538,45 @@ export async function GET(req, ctx) {
375
538
  }
376
539
  if (route === "sign-up") {
377
540
  if (await currentUser(req))
378
- 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;
379
545
  const p = await policy();
380
546
  if (!p.allowed)
381
547
  return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
382
548
  return html(signUpPage({ next, error: q.get("error") ?? undefined, inviteRequired: p.inviteRequired, first: p.reason === "first" }));
383
549
  }
384
550
  if (route === "sign-out")
385
- return signOutResponse(req, false);
551
+ return signOutResponse(req, false, q.get("next"));
386
552
  if (route === "session")
387
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
+ }
388
561
  if (route === "admin") {
389
562
  const session = await currentSession(req);
390
563
  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
- }
564
+ return NextResponse.redirect(new URL(`${SIGN_IN_PATH()}?next=${encodeURIComponent(ADMIN_PATH)}`, req.url));
395
565
  const nonce = randomBytes(16).toString("base64");
396
566
  const orgId = q.get("org");
397
567
  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));
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));
400
580
  }
401
581
  if (route === "forgot") {
402
582
  return html(forgotPage({ sent: q.get("sent") === "1", message: FORGOT_SENT, error: q.get("error") ?? undefined }));
@@ -409,6 +589,13 @@ export async function GET(req, ctx) {
409
589
  return html(messagePage({ title: "Link expired", message: RESET_LINK_INVALID, link: { href: FORGOT_PATH, text: "Request a new link" } }), 400);
410
590
  return html(resetPage({ token, error: q.get("error") ?? undefined }));
411
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
+ }
412
599
  if (route === "verify") {
413
600
  const token = await consumeToken(q.get("token") ?? "", "verify");
414
601
  const user = token ? await markVerified(token.userId) : null;
@@ -423,7 +610,7 @@ export async function GET(req, ctx) {
423
610
  // Signed in already (sign-up set the cookie)? Carry on. Otherwise sign in.
424
611
  if (await currentUser(req))
425
612
  return NextResponse.redirect(new URL(dest, req.url), 303);
426
- const url = new URL(SIGN_IN_PATH, req.url);
613
+ const url = new URL(SIGN_IN_PATH(), req.url);
427
614
  url.searchParams.set("verified", "1");
428
615
  if (dest !== "/")
429
616
  url.searchParams.set("next", dest);
@@ -440,18 +627,22 @@ export async function POST(req, ctx) {
440
627
  if (!isSameOrigin(req.headers)) {
441
628
  return NextResponse.json({ error: "Cross-site request refused" }, { status: 403 });
442
629
  }
443
- if (route === "sign-out")
444
- return signOutResponse(req, true);
445
- if (route === "admin/users" || route === "admin/orgs") {
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") {
446
635
  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
- }
636
+ if (!session)
637
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
450
638
  const f = await readFields(req);
451
- const result = await adminAction(route, session.user.email, f, (user) => sendVerification(req, user, "/"));
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));
452
643
  // After removing an organization there is nothing to return to; otherwise, the view the form was on
453
644
  const back = f.action === "remove-org" ? "" : adminBackTo(f);
454
- return adminResult(req, result.ok ? "notice" : "error", result.message, back);
645
+ return adminResult(req, result.ok ? "notice" : "error", result.message, back, result.link);
455
646
  }
456
647
  if (route === "session") {
457
648
  // Switch the active organization. The id is the client's wish; the
@@ -480,7 +671,7 @@ export async function POST(req, ctx) {
480
671
  if (!row || !ok) {
481
672
  if (json)
482
673
  return NextResponse.json({ error: "Wrong email or password" }, { status: 401 });
483
- return backTo(req, SIGN_IN_PATH, "1", next);
674
+ return backTo(req, SIGN_IN_PATH(), "1", next);
484
675
  }
485
676
  if (!mayAccess(row.verified_at !== null, options().requireVerified))
486
677
  return checkYourEmail(req, next, json);
@@ -492,7 +683,7 @@ export async function POST(req, ctx) {
492
683
  if (route === "sign-up") {
493
684
  const { email, password, invite, next, json } = await readBody(req);
494
685
  const p = await policy();
495
- 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);
496
687
  if (!p.allowed)
497
688
  return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
498
689
  const byIp = await rateLimit("signup:ip", clientIp(req.headers));
@@ -550,17 +741,20 @@ export async function POST(req, ctx) {
550
741
  url.searchParams.set("sent", "1");
551
742
  return NextResponse.redirect(url, 303);
552
743
  }
553
- if (route === "reset") {
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";
554
748
  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);
749
+ const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : html(resetPage({ token, invite, error: msg }), status);
556
750
  // Validate before consuming, so a weak password does not burn the link
557
751
  const invalid = validatePassword(password);
558
752
  if (invalid)
559
753
  return fail(invalid);
560
- const consumed = await consumeToken(token, "reset");
754
+ const consumed = await consumeToken(token, invite ? "invite" : "reset");
561
755
  const user = consumed ? await setPasswordById(consumed.userId, await hashPassword(password)) : null;
562
756
  if (!user)
563
- return fail(RESET_LINK_INVALID);
757
+ return fail(invite ? INVITE_LINK_INVALID : RESET_LINK_INVALID);
564
758
  return signedIn(req, user, next, json);
565
759
  }
566
760
  if (route === "verify/resend") {
@@ -2,10 +2,13 @@
2
2
  // Proxy (Next 16) / middleware (Next 15). Runs before every route.
3
3
  //
4
4
  // Verifies the cookie signature only. No database, no network.
5
- // no/invalid cookie + page request → 302 to /auth/sign-in?next=<path>
5
+ // no/invalid cookie + page request → 302 to the sign-in page?next=<path>
6
6
  // no/invalid cookie + /api request → 401 JSON
7
7
  // valid cookie → continue
8
8
  //
9
+ // The sign-in page is /auth/sign-in, or the app's own page when
10
+ // b17_auth.yaml names one under `pages:` (core/pages.ts).
11
+ //
9
12
  // A cheap first gate, not the last one: a revoked session (deleted
10
13
  // user, changed password) still has a valid signature and passes here.
11
14
  // getUser()/requireUser() check the database and refuse it, so no data
@@ -17,26 +20,31 @@
17
20
  // makes every other decision, so the rule lives in one tested place.
18
21
  // ─────────────────────────────────────────────────────────────
19
22
  import { NextResponse } from "next/server.js";
20
- import { COOKIE_NAME, SIGN_IN_PATH, cookieName, getSecret } from "../core/config.js";
23
+ import { COOKIE_NAME, cookieName, getSecret } from "../core/config.js";
21
24
  import { verifySession } from "../core/session.js";
22
25
  import { MATCHER, isPublic } from "../core/guard.js";
23
26
  import { options } from "../core/options.js";
27
+ import { appPages, pagePath, sendsHome } from "../core/pages.js";
24
28
  /** Paste this into proxy.ts as `export const config = { matcher: MATCHER }` */
25
29
  export { MATCHER };
26
30
  export async function proxy(request) {
27
31
  const { pathname, search } = request.nextUrl;
28
32
  // `protected:` or `public:` from b17_auth.yaml, on top of the built-in rules (auth pages, Next internals)
29
33
  const o = options();
30
- if (isPublic(pathname, { public: o.public, protected: o.protected }))
31
- return NextResponse.next();
32
34
  // The current cookie name, then the pre-0.3.0 one: upgrading signs nobody out
33
35
  const token = request.cookies.get(cookieName(o.production))?.value ?? request.cookies.get(COOKIE_NAME)?.value;
34
- if (token && (await verifySession(token, getSecret())))
36
+ const signedIn = async () => !!token && !!(await verifySession(token, getSecret()));
37
+ // With `home:` set, the root is the public site and the app lives elsewhere: a signed-in visitor goes there
38
+ if (sendsHome(pathname, o.home) && (await signedIn()))
39
+ return NextResponse.redirect(new URL(o.home, request.url));
40
+ if (isPublic(pathname, { public: o.public, protected: o.protected, pages: appPages(o.pages) }))
41
+ return NextResponse.next();
42
+ if (await signedIn())
35
43
  return NextResponse.next();
36
44
  if (pathname.startsWith("/api/")) {
37
45
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
38
46
  }
39
- const url = new URL(SIGN_IN_PATH, request.url);
47
+ const url = new URL(pagePath(o.pages, "signIn"), request.url);
40
48
  if (pathname !== "/")
41
49
  url.searchParams.set("next", pathname + search);
42
50
  return NextResponse.redirect(url);