@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.
- package/AGENTS.md +29 -13
- package/CHANGELOG.md +18 -0
- package/PROMPT.md +4 -3
- package/README.md +78 -21
- package/dist/cli.js +22 -6
- package/dist/core/admin-html.d.ts +8 -2
- package/dist/core/admin-html.js +133 -35
- package/dist/core/admin.d.ts +35 -5
- package/dist/core/admin.js +49 -7
- package/dist/core/config.d.ts +2 -2
- package/dist/core/config.js +2 -2
- package/dist/core/db.d.ts +25 -1
- package/dist/core/db.js +62 -1
- package/dist/core/guard.d.ts +4 -1
- package/dist/core/guard.js +4 -1
- package/dist/core/html.d.ts +8 -1
- package/dist/core/html.js +26 -13
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +24 -5
- package/dist/core/options.js +89 -22
- package/dist/core/org.d.ts +14 -5
- package/dist/core/org.js +27 -17
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/signup.d.ts +4 -0
- package/dist/core/signup.js +7 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/next/index.js +250 -56
- package/dist/next/proxy.js +14 -6
- package/dist/react/index.d.ts +55 -1
- package/dist/react/index.js +54 -2
- package/package.json +3 -2
package/dist/next/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
227
|
-
setSiteName(name);
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
399
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
|
448
|
-
return NextResponse.json({ error:
|
|
449
|
-
}
|
|
636
|
+
if (!session)
|
|
637
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
450
638
|
const f = await readFields(req);
|
|
451
|
-
|
|
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") {
|
package/dist/next/proxy.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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);
|