@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.
- package/AGENTS.md +155 -0
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +27 -16
- package/PROMPT.md +325 -36
- package/README.md +338 -53
- package/dist/cli.js +248 -17
- package/dist/core/admin-html.d.ts +32 -0
- package/dist/core/admin-html.js +350 -0
- package/dist/core/admin.d.ts +56 -0
- package/dist/core/admin.js +90 -0
- package/dist/core/config.d.ts +10 -3
- package/dist/core/config.js +22 -8
- package/dist/core/db.d.ts +155 -10
- package/dist/core/db.js +449 -69
- package/dist/core/email.d.ts +49 -0
- package/dist/core/email.js +213 -0
- package/dist/core/guard.d.ts +52 -0
- package/dist/core/guard.js +106 -0
- package/dist/core/html.d.ts +43 -0
- package/dist/core/html.js +122 -28
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +95 -0
- package/dist/core/options.js +377 -0
- package/dist/core/org.d.ts +43 -0
- package/dist/core/org.js +65 -0
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/password.d.ts +16 -0
- package/dist/core/password.js +41 -12
- package/dist/core/rate.d.ts +26 -0
- package/dist/core/rate.js +70 -0
- package/dist/core/reset.d.ts +9 -0
- package/dist/core/reset.js +31 -0
- package/dist/core/session.d.ts +11 -1
- package/dist/core/session.js +27 -7
- package/dist/core/signup.d.ts +13 -6
- package/dist/core/signup.js +30 -15
- package/dist/core/tokens.d.ts +29 -0
- package/dist/core/tokens.js +64 -0
- package/dist/core/verify.d.ts +8 -0
- package/dist/core/verify.js +33 -0
- package/dist/core/yaml.d.ts +10 -0
- package/dist/core/yaml.js +85 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +18 -6
- package/dist/next/index.d.ts +10 -3
- package/dist/next/index.js +649 -63
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +22 -10
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +107 -0
- package/package.json +18 -4
package/dist/next/index.js
CHANGED
|
@@ -5,104 +5,206 @@
|
|
|
5
5
|
// export { GET, POST } from "@binary1702/auth/next"
|
|
6
6
|
//
|
|
7
7
|
// Serves:
|
|
8
|
-
// GET /auth/sign-in
|
|
9
|
-
// POST /auth/sign-in
|
|
10
|
-
// GET /auth/sign-up
|
|
11
|
-
// POST /auth/sign-up
|
|
12
|
-
// GET /auth/sign-out
|
|
13
|
-
// POST /auth/sign-out
|
|
14
|
-
// GET /auth/session
|
|
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
|
|
18
|
-
// const
|
|
19
|
-
//
|
|
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 {
|
|
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,
|
|
27
|
-
import { signInPage, signUpPage } from "../core/html.js";
|
|
28
|
-
import {
|
|
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
|
|
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
|
|
38
|
-
* AND the user still exists with the epoch the token was
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
75
|
+
async function sessionFromToken(token) {
|
|
43
76
|
if (!token)
|
|
44
77
|
return null;
|
|
45
78
|
const claim = await verifySession(token, getSecret());
|
|
46
|
-
if (!claim
|
|
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
|
|
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
|
|
128
|
+
return (await getSession())?.user ?? null;
|
|
53
129
|
}
|
|
54
130
|
/** Current user, or throw AuthError (status 401). */
|
|
55
131
|
export async function requireUser() {
|
|
56
|
-
|
|
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
|
-
|
|
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:
|
|
147
|
+
secure: options().production,
|
|
71
148
|
path: "/",
|
|
72
|
-
maxAge:
|
|
149
|
+
maxAge: options().sessionDays * 24 * 60 * 60,
|
|
73
150
|
});
|
|
74
|
-
/**
|
|
75
|
-
function
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|