@binary1702/auth 0.2.5 → 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 +27 -16
- package/PROMPT.md +324 -36
- package/README.md +279 -51
- package/dist/cli.js +232 -17
- 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 +131 -10
- package/dist/core/db.js +388 -69
- 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 +11 -1
- package/dist/core/session.js +27 -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 +10 -3
- package/dist/next/index.js +444 -52
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +11 -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,104 +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
|
|
19
|
-
//
|
|
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.
|
|
20
30
|
// ─────────────────────────────────────────────────────────────
|
|
21
31
|
import { cookies } from "next/headers.js";
|
|
22
32
|
import { NextResponse } from "next/server.js";
|
|
23
|
-
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";
|
|
24
36
|
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 {
|
|
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";
|
|
29
51
|
export class AuthError extends Error {
|
|
30
|
-
status
|
|
31
|
-
constructor(message = "Unauthorized") {
|
|
52
|
+
status;
|
|
53
|
+
constructor(message = "Unauthorized", status = 401) {
|
|
32
54
|
super(message);
|
|
33
55
|
this.name = "AuthError";
|
|
56
|
+
this.status = status;
|
|
34
57
|
}
|
|
35
58
|
}
|
|
36
59
|
/**
|
|
37
|
-
* The
|
|
38
|
-
* AND the user still exists with the epoch the token was
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.)
|
|
41
65
|
*/
|
|
42
|
-
async function
|
|
66
|
+
async function sessionFromToken(token) {
|
|
43
67
|
if (!token)
|
|
44
68
|
return null;
|
|
45
69
|
const claim = await verifySession(token, getSecret());
|
|
46
|
-
if (!claim
|
|
70
|
+
if (!claim)
|
|
47
71
|
return null;
|
|
48
|
-
|
|
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;
|
|
49
102
|
}
|
|
50
103
|
/** Current user from the session cookie, or null if signed out, deleted or revoked. */
|
|
51
104
|
export async function getUser() {
|
|
52
|
-
return
|
|
105
|
+
return (await getSession())?.user ?? null;
|
|
53
106
|
}
|
|
54
107
|
/** Current user, or throw AuthError (status 401). */
|
|
55
108
|
export async function requireUser() {
|
|
56
|
-
|
|
57
|
-
if (!user)
|
|
58
|
-
throw new AuthError();
|
|
59
|
-
return user;
|
|
109
|
+
return (await requireSession()).user;
|
|
60
110
|
}
|
|
61
|
-
/** 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) }` */
|
|
62
112
|
export function unauthorized(err) {
|
|
63
113
|
if (err && !(err instanceof AuthError))
|
|
64
114
|
throw err;
|
|
65
|
-
|
|
115
|
+
const status = err instanceof AuthError ? err.status : 401;
|
|
116
|
+
return NextResponse.json({ error: status === 403 ? "Forbidden" : "Unauthorized" }, { status });
|
|
66
117
|
}
|
|
67
118
|
const cookieOptions = () => ({
|
|
68
119
|
httpOnly: true,
|
|
69
120
|
sameSite: "lax",
|
|
70
|
-
secure:
|
|
121
|
+
secure: options().production,
|
|
71
122
|
path: "/",
|
|
72
|
-
maxAge:
|
|
123
|
+
maxAge: options().sessionDays * 24 * 60 * 60,
|
|
73
124
|
});
|
|
74
|
-
/**
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
return "/";
|
|
78
|
-
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 } });
|
|
79
128
|
}
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
136
|
}
|
|
83
137
|
async function readBody(req) {
|
|
84
138
|
const type = req.headers.get("content-type") ?? "";
|
|
85
139
|
const get = (b, k) => String((b instanceof FormData ? b.get(k) : b[k]) ?? "");
|
|
86
140
|
const b = type.includes("application/json")
|
|
87
141
|
? (await req.json().catch(() => ({})))
|
|
88
|
-
: await req.formData();
|
|
142
|
+
: await req.formData().catch(() => new FormData());
|
|
89
143
|
return {
|
|
90
144
|
email: get(b, "email").trim().toLowerCase(),
|
|
91
145
|
password: get(b, "password"),
|
|
92
146
|
invite: get(b, "invite").trim(),
|
|
93
|
-
|
|
147
|
+
token: get(b, "token").trim(),
|
|
148
|
+
organization: get(b, "organization").trim(),
|
|
149
|
+
next: safeNext(get(b, "next"), req.url),
|
|
94
150
|
json: type.includes("application/json"),
|
|
95
151
|
};
|
|
96
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
|
+
}
|
|
97
171
|
async function currentUser(req) {
|
|
98
|
-
return
|
|
172
|
+
return (await currentSession(req))?.user ?? null;
|
|
99
173
|
}
|
|
100
174
|
/** Sign the user in on a response: cookie + redirect (form) or JSON (fetch). */
|
|
101
175
|
async function signedIn(req, user, next, json) {
|
|
102
|
-
const token = await signSession(user, getSecret());
|
|
176
|
+
const token = await signSession(user, getSecret(), options().sessionDays);
|
|
103
177
|
const body = { user: { id: user.id, email: user.email } };
|
|
104
178
|
const res = json ? NextResponse.json(body) : NextResponse.redirect(new URL(next, req.url), 303);
|
|
105
|
-
res.cookies.set(
|
|
179
|
+
res.cookies.set(cookieName(options().production), token, cookieOptions());
|
|
106
180
|
return res;
|
|
107
181
|
}
|
|
108
182
|
/** Back to a form with ?error=<msg>, keeping ?next. */
|
|
@@ -113,22 +187,176 @@ function backTo(req, path, error, next) {
|
|
|
113
187
|
url.searchParams.set("next", next);
|
|
114
188
|
return NextResponse.redirect(url, 303);
|
|
115
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
|
+
}
|
|
116
207
|
function signOutResponse(req, json) {
|
|
117
208
|
const res = json
|
|
118
209
|
? NextResponse.json({ ok: true })
|
|
119
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 });
|
|
120
213
|
res.cookies.set(COOKIE_NAME, "", { ...cookieOptions(), maxAge: 0 });
|
|
121
214
|
return res;
|
|
122
215
|
}
|
|
123
216
|
async function policy() {
|
|
124
|
-
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.");
|
|
125
352
|
}
|
|
126
353
|
// ── handlers ──────────────────────────────────────────────────
|
|
127
354
|
export async function GET(req, ctx) {
|
|
128
355
|
const { path } = await ctx.params;
|
|
129
356
|
const route = path.join("/");
|
|
130
357
|
const q = req.nextUrl.searchParams;
|
|
131
|
-
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
|
|
132
360
|
if (route === "sign-in") {
|
|
133
361
|
if (await currentUser(req))
|
|
134
362
|
return NextResponse.redirect(new URL(next, req.url));
|
|
@@ -137,7 +365,13 @@ export async function GET(req, ctx) {
|
|
|
137
365
|
if (p.reason === "first")
|
|
138
366
|
return NextResponse.redirect(new URL(SIGN_UP_PATH, req.url));
|
|
139
367
|
const error = q.get("error") === "1" ? "Wrong email or password." : (q.get("error") ?? undefined);
|
|
140
|
-
|
|
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 }));
|
|
141
375
|
}
|
|
142
376
|
if (route === "sign-up") {
|
|
143
377
|
if (await currentUser(req))
|
|
@@ -150,16 +384,97 @@ export async function GET(req, ctx) {
|
|
|
150
384
|
if (route === "sign-out")
|
|
151
385
|
return signOutResponse(req, false);
|
|
152
386
|
if (route === "session")
|
|
153
|
-
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
|
+
}
|
|
154
432
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
155
433
|
}
|
|
156
434
|
export async function POST(req, ctx) {
|
|
157
435
|
const { path } = await ctx.params;
|
|
158
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
|
+
}
|
|
159
443
|
if (route === "sign-out")
|
|
160
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
|
+
}
|
|
161
468
|
if (route === "sign-in") {
|
|
162
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
|
+
}
|
|
163
478
|
const row = email && password ? await findUser(email) : null;
|
|
164
479
|
const ok = row ? await verifyPassword(password, row.password_hash) : false;
|
|
165
480
|
if (!row || !ok) {
|
|
@@ -167,6 +482,11 @@ export async function POST(req, ctx) {
|
|
|
167
482
|
return NextResponse.json({ error: "Wrong email or password" }, { status: 401 });
|
|
168
483
|
return backTo(req, SIGN_IN_PATH, "1", next);
|
|
169
484
|
}
|
|
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));
|
|
170
490
|
return signedIn(req, { id: row.id, email: row.email, epoch: row.epoch }, next, json);
|
|
171
491
|
}
|
|
172
492
|
if (route === "sign-up") {
|
|
@@ -175,15 +495,87 @@ export async function POST(req, ctx) {
|
|
|
175
495
|
const fail = (msg, status = 400) => json ? NextResponse.json({ error: msg }, { status }) : backTo(req, SIGN_UP_PATH, msg, next);
|
|
176
496
|
if (!p.allowed)
|
|
177
497
|
return NextResponse.json({ error: "Sign-up is closed" }, { status: 404 });
|
|
178
|
-
|
|
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 ?? ""))
|
|
179
503
|
return fail("Wrong invite code.", 403);
|
|
180
|
-
const invalid = validateCredentials(email, password,
|
|
504
|
+
const invalid = validateCredentials(email, password, o);
|
|
181
505
|
if (invalid)
|
|
182
506
|
return fail(invalid);
|
|
183
507
|
if (await findUser(email))
|
|
184
508
|
return fail("That email is already registered.", 409);
|
|
185
|
-
|
|
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);
|
|
186
532
|
return signedIn(req, user, next, json);
|
|
187
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);
|
|
564
|
+
return signedIn(req, user, next, json);
|
|
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
|
+
}
|
|
188
580
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
189
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;
|