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