@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Email. One function, no dependency: everything goes over fetch.
|
|
3
|
+
//
|
|
4
|
+
// gmail email.gmail.user + clientEmail + privateKey (a service
|
|
5
|
+
// account; Workspace: domain-wide delegation) or
|
|
6
|
+
// clientId + clientSecret + refreshToken (OAuth)
|
|
7
|
+
// resend email.resend.apiKey + email.from
|
|
8
|
+
// console neither, not production: print the message
|
|
9
|
+
// none neither, production: throw at the first send
|
|
10
|
+
//
|
|
11
|
+
// (b17_auth.yaml keys; the AUTH_GMAIL_* / AUTH_RESEND_* env vars map
|
|
12
|
+
// onto them, see core/options.ts.)
|
|
13
|
+
//
|
|
14
|
+
// Gmail is two requests: a token from oauth2.googleapis.com (a signed
|
|
15
|
+
// JWT assertion for a service account, a refresh grant otherwise) and
|
|
16
|
+
// a POST of the raw RFC 2822 message to gmail.googleapis.com. Tokens
|
|
17
|
+
// are cached in-process until a minute before they expire.
|
|
18
|
+
//
|
|
19
|
+
// The decision and the message building are pure; fetch is injected.
|
|
20
|
+
// ─────────────────────────────────────────────────────────────
|
|
21
|
+
import { SignJWT, importPKCS8 } from "jose";
|
|
22
|
+
import { options } from "./options.js";
|
|
23
|
+
export const RESEND_URL = "https://api.resend.com/emails";
|
|
24
|
+
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
25
|
+
export const GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send";
|
|
26
|
+
export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.send";
|
|
27
|
+
export const EMAIL_NOT_CONFIGURED = "Email is not configured. In b17_auth.yaml set email.gmail (user + clientEmail + privateKey) or email.resend (apiKey) + email.from (run: npx @binary1702/auth).";
|
|
28
|
+
function current() {
|
|
29
|
+
const o = options();
|
|
30
|
+
return { ...o.email, production: o.production };
|
|
31
|
+
}
|
|
32
|
+
// ── decision ──────────────────────────────────────────────────
|
|
33
|
+
function gmailConfigured(cfg) {
|
|
34
|
+
const g = cfg.gmail;
|
|
35
|
+
if (!g?.user)
|
|
36
|
+
return false;
|
|
37
|
+
if (g.clientEmail && g.privateKey)
|
|
38
|
+
return true;
|
|
39
|
+
return Boolean(g.clientId && g.clientSecret && g.refreshToken);
|
|
40
|
+
}
|
|
41
|
+
/** Which way a message goes. Gmail wins when both are configured. Pure. */
|
|
42
|
+
export function emailTransport(cfg) {
|
|
43
|
+
if (gmailConfigured(cfg))
|
|
44
|
+
return "gmail";
|
|
45
|
+
if (cfg.resend?.apiKey && cfg.from)
|
|
46
|
+
return "resend";
|
|
47
|
+
return cfg.production ? "none" : "console";
|
|
48
|
+
}
|
|
49
|
+
// ── addresses ─────────────────────────────────────────────────
|
|
50
|
+
const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
|
|
51
|
+
/** RFC 2047 for a header that may hold non-ASCII (a subject with an accent) */
|
|
52
|
+
export function encodeHeader(value) {
|
|
53
|
+
return /^[\x20-\x7e]*$/.test(value) ? value : `=?UTF-8?B?${b64(value)}?=`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A From/To value as the header needs it. `Name <addr>` keeps the name
|
|
57
|
+
* quoted when it holds characters that mean something in a header
|
|
58
|
+
* (parentheses, commas, colons, …) and RFC 2047-encoded when it is not
|
|
59
|
+
* ASCII. A bare address passes through. Pure.
|
|
60
|
+
*/
|
|
61
|
+
export function formatAddress(value) {
|
|
62
|
+
const m = value.trim().match(/^(.*?)\s*<([^<>]+)>$/);
|
|
63
|
+
if (!m)
|
|
64
|
+
return value.trim();
|
|
65
|
+
const addr = m[2].trim();
|
|
66
|
+
let name = m[1].trim().replace(/^"(.*)"$/, "$1");
|
|
67
|
+
if (!name)
|
|
68
|
+
return addr;
|
|
69
|
+
if (!/^[\x20-\x7e]*$/.test(name))
|
|
70
|
+
name = encodeHeader(name);
|
|
71
|
+
else if (/[()<>\[\]:;@\\,."]/.test(name))
|
|
72
|
+
name = `"${name.replace(/(["\\])/g, "\\$1")}"`;
|
|
73
|
+
return `${name} <${addr}>`;
|
|
74
|
+
}
|
|
75
|
+
// ── resend ────────────────────────────────────────────────────
|
|
76
|
+
/** The request Resend expects. Pure. */
|
|
77
|
+
export function resendRequest(msg, cfg) {
|
|
78
|
+
return {
|
|
79
|
+
url: RESEND_URL,
|
|
80
|
+
init: {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { authorization: `Bearer ${cfg.resend?.apiKey}`, "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ from: cfg.from, to: [msg.to], subject: msg.subject, text: msg.text, html: msg.html, reply_to: cfg.replyTo }),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// ── gmail: the message ────────────────────────────────────────
|
|
88
|
+
/** The raw RFC 2822 message Gmail wants, base64url-encoded. Pure. */
|
|
89
|
+
export function gmailRaw(msg, from, replyTo) {
|
|
90
|
+
const crlf = (s) => s.replace(/\r?\n/g, "\r\n");
|
|
91
|
+
const head = [`From: ${formatAddress(from)}`, `To: ${formatAddress(msg.to)}`];
|
|
92
|
+
if (replyTo)
|
|
93
|
+
head.push(`Reply-To: ${formatAddress(replyTo)}`);
|
|
94
|
+
head.push(`Subject: ${encodeHeader(msg.subject)}`, "MIME-Version: 1.0");
|
|
95
|
+
let body;
|
|
96
|
+
if (msg.html) {
|
|
97
|
+
const boundary = `b_${Buffer.from(msg.subject + msg.to).toString("hex").slice(0, 24)}`;
|
|
98
|
+
head.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
99
|
+
body = [
|
|
100
|
+
`--${boundary}`,
|
|
101
|
+
"Content-Type: text/plain; charset=UTF-8",
|
|
102
|
+
"Content-Transfer-Encoding: base64",
|
|
103
|
+
"",
|
|
104
|
+
b64(crlf(msg.text)),
|
|
105
|
+
`--${boundary}`,
|
|
106
|
+
"Content-Type: text/html; charset=UTF-8",
|
|
107
|
+
"Content-Transfer-Encoding: base64",
|
|
108
|
+
"",
|
|
109
|
+
b64(crlf(msg.html)),
|
|
110
|
+
`--${boundary}--`,
|
|
111
|
+
].join("\r\n");
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
head.push("Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: base64");
|
|
115
|
+
body = b64(crlf(msg.text));
|
|
116
|
+
}
|
|
117
|
+
return Buffer.from(head.join("\r\n") + "\r\n\r\n" + body, "utf8").toString("base64url");
|
|
118
|
+
}
|
|
119
|
+
/** The service account's email and PEM key (client_email and private_key in the JSON Google hands out) */
|
|
120
|
+
export function serviceAccount(gmail) {
|
|
121
|
+
if (!gmail.clientEmail || !gmail.privateKey) {
|
|
122
|
+
throw new Error("email.gmail needs clientEmail and privateKey (the service account's client_email and private_key)");
|
|
123
|
+
}
|
|
124
|
+
// .env files tend to keep the key's newlines as the two characters \ n
|
|
125
|
+
const key = gmail.privateKey.replace(/\\n/g, "\n").trim();
|
|
126
|
+
if (!key.includes("-----BEGIN") || !key.includes("PRIVATE KEY-----")) {
|
|
127
|
+
throw new Error("email.gmail.privateKey does not look like a PEM private key (-----BEGIN PRIVATE KEY----- …)");
|
|
128
|
+
}
|
|
129
|
+
return { email: gmail.clientEmail, key };
|
|
130
|
+
}
|
|
131
|
+
/** The signed assertion for a service account acting as the mailbox user (domain-wide delegation). */
|
|
132
|
+
export async function serviceAccountAssertion(gmail, now = new Date()) {
|
|
133
|
+
const sa = serviceAccount(gmail);
|
|
134
|
+
const iat = Math.floor(now.getTime() / 1000);
|
|
135
|
+
return new SignJWT({ scope: GMAIL_SCOPE })
|
|
136
|
+
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
|
|
137
|
+
.setIssuer(sa.email)
|
|
138
|
+
.setSubject(gmail.user)
|
|
139
|
+
.setAudience(GOOGLE_TOKEN_URL)
|
|
140
|
+
.setIssuedAt(iat)
|
|
141
|
+
.setExpirationTime(iat + 3600)
|
|
142
|
+
.sign(await importPKCS8(sa.key, "RS256"));
|
|
143
|
+
}
|
|
144
|
+
/** The token request body, either grant. */
|
|
145
|
+
export async function gmailTokenRequest(gmail) {
|
|
146
|
+
if (gmail.clientEmail && gmail.privateKey) {
|
|
147
|
+
return new URLSearchParams({
|
|
148
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
149
|
+
assertion: await serviceAccountAssertion(gmail),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return new URLSearchParams({
|
|
153
|
+
grant_type: "refresh_token",
|
|
154
|
+
client_id: gmail.clientId,
|
|
155
|
+
client_secret: gmail.clientSecret,
|
|
156
|
+
refresh_token: gmail.refreshToken,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const tokens = new Map();
|
|
160
|
+
async function gmailToken(gmail, fetchFn) {
|
|
161
|
+
const cached = tokens.get(gmail.user);
|
|
162
|
+
if (cached && cached.expiresAt - Date.now() > 60_000)
|
|
163
|
+
return cached.token;
|
|
164
|
+
const res = await fetchFn(GOOGLE_TOKEN_URL, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
167
|
+
body: await gmailTokenRequest(gmail),
|
|
168
|
+
});
|
|
169
|
+
const data = (await res.json().catch(() => ({})));
|
|
170
|
+
if (!res.ok || !data.access_token) {
|
|
171
|
+
throw new Error(`Google would not issue a Gmail token (${res.status}): ${data.error ?? ""} ${data.error_description ?? ""}`.trim());
|
|
172
|
+
}
|
|
173
|
+
tokens.set(gmail.user, { token: data.access_token, expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000 });
|
|
174
|
+
return data.access_token;
|
|
175
|
+
}
|
|
176
|
+
/** Forget cached tokens (tests, or after rotating credentials). */
|
|
177
|
+
export function resetGmailTokens() {
|
|
178
|
+
tokens.clear();
|
|
179
|
+
}
|
|
180
|
+
// ── send ──────────────────────────────────────────────────────
|
|
181
|
+
export async function sendEmail(msg, cfg = current(), fetchFn = fetch, log = console.log) {
|
|
182
|
+
switch (emailTransport(cfg)) {
|
|
183
|
+
case "none":
|
|
184
|
+
throw new Error(EMAIL_NOT_CONFIGURED);
|
|
185
|
+
case "console":
|
|
186
|
+
log(["", `[auth] email to ${msg.to}`, `[auth] subject: ${msg.subject}`, msg.text.replace(/^/gm, "[auth] "), ""].join("\n"));
|
|
187
|
+
return;
|
|
188
|
+
case "resend": {
|
|
189
|
+
const { url, init } = resendRequest(msg, cfg);
|
|
190
|
+
const res = await fetchFn(url, init);
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
const detail = await res.text().catch(() => "");
|
|
193
|
+
throw new Error(`Resend refused the email (${res.status}): ${detail.slice(0, 300)}`);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case "gmail": {
|
|
198
|
+
const gmail = cfg.gmail;
|
|
199
|
+
const token = await gmailToken(gmail, fetchFn);
|
|
200
|
+
const res = await fetchFn(GMAIL_SEND_URL, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
203
|
+
body: JSON.stringify({ raw: gmailRaw(msg, cfg.from || gmail.user, cfg.replyTo) }),
|
|
204
|
+
});
|
|
205
|
+
if (!res.ok) {
|
|
206
|
+
if (res.status === 401)
|
|
207
|
+
tokens.delete(gmail.user); // stale token: next send fetches a new one
|
|
208
|
+
const detail = await res.text().catch(() => "");
|
|
209
|
+
throw new Error(`Gmail refused the email (${res.status}): ${detail.slice(0, 300)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A post-login destination, reduced to a same-origin path.
|
|
3
|
+
* Resolves the value the way the browser will (`/\evil.com` and
|
|
4
|
+
* `/\t/evil.com` both become `https://evil.com/`) and keeps it only
|
|
5
|
+
* if it stays on `base`'s origin.
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeNext(value: string | null | undefined, base: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The proxy runs on everything except Next's build output; this
|
|
10
|
+
* function decides the rest. Kept in sync with the CLI's proxy.ts.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MATCHER: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Does a configured pattern cover this path?
|
|
15
|
+
* /pricing that path only
|
|
16
|
+
* /blog/* /blog, /blog/ and anything below (never /blogs)
|
|
17
|
+
*/
|
|
18
|
+
export declare function matchesPattern(pathname: string, pattern: string): boolean;
|
|
19
|
+
export type AccessRules = {
|
|
20
|
+
public?: readonly string[];
|
|
21
|
+
protected?: readonly string[];
|
|
22
|
+
/** The app's own sign-in / sign-up pages (b17_auth.yaml `pages:`): exact paths, always open */
|
|
23
|
+
pages?: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Paths reachable without a session. The auth pages (the package's under
|
|
27
|
+
* /auth/, and the app's own named under `pages:`) and Next internals
|
|
28
|
+
* always are. Then one of two modes from b17_auth.yaml:
|
|
29
|
+
*
|
|
30
|
+
* protected: /app/* /api/* only these need a session; everything else is open
|
|
31
|
+
* public: / /pricing everything needs a session except these, static
|
|
32
|
+
* files by extension, and never anything under /api/
|
|
33
|
+
* (so /api/export.csv stays guarded unless listed)
|
|
34
|
+
*
|
|
35
|
+
* Neither set: the second mode with an empty list, i.e. everything guarded.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isPublic(pathname: string, rules?: AccessRules | readonly string[]): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Equality that takes the same time whatever the inputs, for the invite
|
|
40
|
+
* code. Hashing both sides first means a length difference leaks nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function safeEqual(a: string, b: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* True unless the request provably came from another site.
|
|
45
|
+
* Sec-Fetch-Site sent by every modern browser: same-origin or none
|
|
46
|
+
* (typed URL, bookmark) pass, anything else fails
|
|
47
|
+
* Origin older browsers: must match the host served
|
|
48
|
+
* neither not a browser (curl, server code): passes
|
|
49
|
+
*/
|
|
50
|
+
export declare function isSameOrigin(headers: {
|
|
51
|
+
get(name: string): string | null;
|
|
52
|
+
}): boolean;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Request guards. Pure functions over strings and headers, so the
|
|
3
|
+
// security rules can be tested and read without Next or a database.
|
|
4
|
+
//
|
|
5
|
+
// safeNext() where to go after sign-in: same-origin path, or "/"
|
|
6
|
+
// isPublic() which paths the proxy lets through without a cookie
|
|
7
|
+
// isSameOrigin() refuse cross-site POSTs (login CSRF)
|
|
8
|
+
// safeEqual() constant-time string compare (invite code)
|
|
9
|
+
// ─────────────────────────────────────────────────────────────
|
|
10
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
11
|
+
/**
|
|
12
|
+
* A post-login destination, reduced to a same-origin path.
|
|
13
|
+
* Resolves the value the way the browser will (`/\evil.com` and
|
|
14
|
+
* `/\t/evil.com` both become `https://evil.com/`) and keeps it only
|
|
15
|
+
* if it stays on `base`'s origin.
|
|
16
|
+
*/
|
|
17
|
+
export function safeNext(value, base) {
|
|
18
|
+
if (!value)
|
|
19
|
+
return "/";
|
|
20
|
+
try {
|
|
21
|
+
const origin = new URL(base).origin;
|
|
22
|
+
const url = new URL(value, origin);
|
|
23
|
+
if (url.origin !== origin)
|
|
24
|
+
return "/";
|
|
25
|
+
return url.pathname + url.search + url.hash;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "/";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The proxy runs on everything except Next's build output; this
|
|
33
|
+
* function decides the rest. Kept in sync with the CLI's proxy.ts.
|
|
34
|
+
*/
|
|
35
|
+
export const MATCHER = ["/((?!_next/static|_next/image|favicon.ico).*)"];
|
|
36
|
+
const STATIC_FILE = /\.(?:png|jpe?g|gif|svg|ico|webp|avif|css|js|map|woff2?|ttf|otf|txt|xml|webmanifest)$/i;
|
|
37
|
+
/**
|
|
38
|
+
* Does a configured pattern cover this path?
|
|
39
|
+
* /pricing that path only
|
|
40
|
+
* /blog/* /blog, /blog/ and anything below (never /blogs)
|
|
41
|
+
*/
|
|
42
|
+
export function matchesPattern(pathname, pattern) {
|
|
43
|
+
if (pattern.endsWith("/*")) {
|
|
44
|
+
const base = pattern.slice(0, -2) || "/";
|
|
45
|
+
return pathname === base || pathname === base + "/" || pathname.startsWith(base === "/" ? "/" : base + "/");
|
|
46
|
+
}
|
|
47
|
+
return pathname === pattern;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Paths reachable without a session. The auth pages (the package's under
|
|
51
|
+
* /auth/, and the app's own named under `pages:`) and Next internals
|
|
52
|
+
* always are. Then one of two modes from b17_auth.yaml:
|
|
53
|
+
*
|
|
54
|
+
* protected: /app/* /api/* only these need a session; everything else is open
|
|
55
|
+
* public: / /pricing everything needs a session except these, static
|
|
56
|
+
* files by extension, and never anything under /api/
|
|
57
|
+
* (so /api/export.csv stays guarded unless listed)
|
|
58
|
+
*
|
|
59
|
+
* Neither set: the second mode with an empty list, i.e. everything guarded.
|
|
60
|
+
*/
|
|
61
|
+
export function isPublic(pathname, rules = {}) {
|
|
62
|
+
const r = Array.isArray(rules) ? { public: rules } : rules;
|
|
63
|
+
if (/^\/auth(\/|$)/.test(pathname))
|
|
64
|
+
return true;
|
|
65
|
+
if (r.pages?.includes(pathname))
|
|
66
|
+
return true;
|
|
67
|
+
if (pathname.startsWith("/_next/"))
|
|
68
|
+
return true;
|
|
69
|
+
if (r.protected?.length)
|
|
70
|
+
return !r.protected.some((p) => matchesPattern(pathname, p));
|
|
71
|
+
if (r.public?.some((p) => matchesPattern(pathname, p)))
|
|
72
|
+
return true;
|
|
73
|
+
if (pathname.startsWith("/api/"))
|
|
74
|
+
return false;
|
|
75
|
+
return STATIC_FILE.test(pathname);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Equality that takes the same time whatever the inputs, for the invite
|
|
79
|
+
* code. Hashing both sides first means a length difference leaks nothing.
|
|
80
|
+
*/
|
|
81
|
+
export function safeEqual(a, b) {
|
|
82
|
+
const h = (s) => createHash("sha256").update(s, "utf8").digest();
|
|
83
|
+
return timingSafeEqual(h(a), h(b));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* True unless the request provably came from another site.
|
|
87
|
+
* Sec-Fetch-Site sent by every modern browser: same-origin or none
|
|
88
|
+
* (typed URL, bookmark) pass, anything else fails
|
|
89
|
+
* Origin older browsers: must match the host served
|
|
90
|
+
* neither not a browser (curl, server code): passes
|
|
91
|
+
*/
|
|
92
|
+
export function isSameOrigin(headers) {
|
|
93
|
+
const site = headers.get("sec-fetch-site");
|
|
94
|
+
if (site)
|
|
95
|
+
return site === "same-origin" || site === "none";
|
|
96
|
+
const origin = headers.get("origin");
|
|
97
|
+
if (!origin)
|
|
98
|
+
return true;
|
|
99
|
+
const host = (headers.get("x-forwarded-host") ?? headers.get("host"))?.split(",")[0].trim();
|
|
100
|
+
try {
|
|
101
|
+
return new URL(origin).host === host;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/core/html.d.ts
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
|
+
import { type Pages } from "./pages.ts";
|
|
2
|
+
export declare const escape: (s: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* Headers every auth page is served with. Pure.
|
|
5
|
+
* no-store a sign-in form or an admin page is never cached, by a browser or a CDN
|
|
6
|
+
* X-Frame-Options never shown inside another site's frame (clickjacking)
|
|
7
|
+
* CSP no scripts at all unless the page brings its own nonce; styles and the
|
|
8
|
+
* data: favicon are inline; forms post only to this origin
|
|
9
|
+
*/
|
|
10
|
+
export declare function securityHeaders(scriptNonce?: string): Record<string, string>;
|
|
11
|
+
export declare function setSiteName(name: string | undefined): void;
|
|
12
|
+
export declare function setPages(p: Pages | undefined): void;
|
|
13
|
+
/** "Didn't get the email?" A second form, POSTing to /auth/verify/resend. */
|
|
14
|
+
export declare function resendForm(email?: string): string;
|
|
1
15
|
export declare function signInPage(opts?: {
|
|
2
16
|
error?: string;
|
|
17
|
+
notice?: string;
|
|
3
18
|
next?: string;
|
|
4
19
|
title?: string;
|
|
5
20
|
canSignUp?: boolean;
|
|
21
|
+
resend?: boolean;
|
|
22
|
+
email?: string;
|
|
23
|
+
}): string;
|
|
24
|
+
/** Ask for the email to send a reset link to. With `sent`, show the (always identical) confirmation instead. */
|
|
25
|
+
export declare function forgotPage(opts?: {
|
|
26
|
+
sent?: boolean;
|
|
27
|
+
error?: string;
|
|
28
|
+
message?: string;
|
|
29
|
+
}): string;
|
|
30
|
+
/**
|
|
31
|
+
* The form behind an emailed link: a new password, the token hidden. A
|
|
32
|
+
* reset link, or (`invite`) the welcome link an added user gets: same
|
|
33
|
+
* form, posted to /auth/invite, worded for a first password.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resetPage(opts: {
|
|
36
|
+
token: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
invite?: boolean;
|
|
6
39
|
}): string;
|
|
7
40
|
export declare function signUpPage(opts?: {
|
|
8
41
|
error?: string;
|
|
@@ -11,3 +44,13 @@ export declare function signUpPage(opts?: {
|
|
|
11
44
|
inviteRequired?: boolean;
|
|
12
45
|
first?: boolean;
|
|
13
46
|
}): string;
|
|
47
|
+
/** A page with no form: a result, an error, a "check your email". */
|
|
48
|
+
export declare function messagePage(opts: {
|
|
49
|
+
title: string;
|
|
50
|
+
message: string;
|
|
51
|
+
link?: {
|
|
52
|
+
href: string;
|
|
53
|
+
text: string;
|
|
54
|
+
};
|
|
55
|
+
after?: string;
|
|
56
|
+
}): string;
|
package/dist/core/html.js
CHANGED
|
@@ -1,75 +1,169 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// The
|
|
3
|
-
//
|
|
2
|
+
// The auth pages. Plain HTML served by the route handler, so the
|
|
3
|
+
// consumer never builds a form or a page. Every untrusted value goes
|
|
4
|
+
// through escape().
|
|
4
5
|
// ─────────────────────────────────────────────────────────────
|
|
5
|
-
import {
|
|
6
|
-
|
|
6
|
+
import { pagePath } from "./pages.js";
|
|
7
|
+
import { VERIFY_PATH } from "./verify.js";
|
|
8
|
+
import { FORGOT_PATH, RESET_PATH } from "./reset.js";
|
|
9
|
+
import { INVITE_PATH } from "./invite.js";
|
|
10
|
+
import { PASSWORD_MAX, PASSWORD_MIN } from "./signup.js";
|
|
11
|
+
export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
12
|
+
/**
|
|
13
|
+
* Headers every auth page is served with. Pure.
|
|
14
|
+
* no-store a sign-in form or an admin page is never cached, by a browser or a CDN
|
|
15
|
+
* X-Frame-Options never shown inside another site's frame (clickjacking)
|
|
16
|
+
* CSP no scripts at all unless the page brings its own nonce; styles and the
|
|
17
|
+
* data: favicon are inline; forms post only to this origin
|
|
18
|
+
*/
|
|
19
|
+
export function securityHeaders(scriptNonce) {
|
|
20
|
+
const script = scriptNonce ? `script-src 'nonce-${scriptNonce}'` : "script-src 'none'";
|
|
21
|
+
return {
|
|
22
|
+
"cache-control": "no-store",
|
|
23
|
+
"x-frame-options": "DENY",
|
|
24
|
+
"content-security-policy": `default-src 'none'; ${script}; style-src 'unsafe-inline'; img-src data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
|
|
25
|
+
"referrer-policy": "same-origin",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
7
28
|
const STYLE = `
|
|
8
|
-
:root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #ddd; --muted: #666; --err: #c0392b; }
|
|
9
|
-
@media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --line: #333; --muted: #999; --err: #ff6b6b; } }
|
|
29
|
+
:root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #ddd; --muted: #666; --err: #c0392b; --ok: #1e8449; }
|
|
30
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --line: #333; --muted: #999; --err: #ff6b6b; --ok: #58d68d; } }
|
|
10
31
|
* { box-sizing: border-box; }
|
|
11
32
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg);
|
|
12
33
|
font: 15px/1.4 system-ui, -apple-system, sans-serif; }
|
|
13
|
-
|
|
34
|
+
main { width: 100%; max-width: 320px; padding: 16px; display: grid; gap: 12px; }
|
|
35
|
+
form { display: grid; gap: 12px; }
|
|
14
36
|
h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
|
|
37
|
+
p { margin: 0; }
|
|
15
38
|
label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
|
|
16
39
|
input { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
|
|
17
40
|
input:focus { outline: 2px solid var(--fg); outline-offset: 1px; }
|
|
18
41
|
button { font: inherit; font-weight: 500; padding: 9px; border: 0; border-radius: 6px; background: var(--fg); color: var(--bg); cursor: pointer; }
|
|
19
|
-
.error {
|
|
20
|
-
.
|
|
42
|
+
.error { font-size: 13px; color: var(--err); }
|
|
43
|
+
.notice { font-size: 13px; color: var(--ok); }
|
|
44
|
+
.alt { margin-top: 4px; font-size: 13px; color: var(--muted); text-align: center; }
|
|
21
45
|
.alt a { color: var(--fg); }
|
|
46
|
+
.resend { margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
|
|
47
|
+
.resend button { background: none; color: var(--fg); border: 1px solid var(--line); }
|
|
22
48
|
`;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
49
|
+
/** The app name for <title>, set by the handlers from b17_auth.yaml `name` */
|
|
50
|
+
let siteName = "";
|
|
51
|
+
export function setSiteName(name) {
|
|
52
|
+
siteName = name ?? "";
|
|
53
|
+
}
|
|
54
|
+
/** Where links to sign-in / sign-up point: the app's own pages when b17_auth.yaml names them */
|
|
55
|
+
let pages = {};
|
|
56
|
+
export function setPages(p) {
|
|
57
|
+
pages = p ?? {};
|
|
58
|
+
}
|
|
59
|
+
const SIGN_IN_PATH = () => pagePath(pages, "signIn");
|
|
60
|
+
const SIGN_UP_PATH = () => pagePath(pages, "signUp");
|
|
61
|
+
function shell(title, body) {
|
|
27
62
|
return `<!doctype html>
|
|
28
63
|
<html lang="en">
|
|
29
64
|
<head>
|
|
30
65
|
<meta charset="utf-8">
|
|
31
66
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
-
<title>${title}</title>
|
|
67
|
+
<title>${escape(siteName ? `${title} · ${siteName}` : title)}</title>
|
|
33
68
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔒</text></svg>">
|
|
34
69
|
<style>${STYLE}</style>
|
|
35
70
|
</head>
|
|
36
71
|
<body>
|
|
37
|
-
<
|
|
72
|
+
<main>
|
|
73
|
+
${body}
|
|
74
|
+
</main>
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|
|
78
|
+
function page(opts) {
|
|
79
|
+
const title = escape(opts.title);
|
|
80
|
+
const next = escape(opts.next ?? "/");
|
|
81
|
+
const error = opts.error ? ` <p class="error">${escape(opts.error)}</p>\n` : "";
|
|
82
|
+
const notice = opts.notice ? ` <p class="notice">${escape(opts.notice)}</p>\n` : "";
|
|
83
|
+
return shell(opts.title, `<form method="post" action="${opts.action}">
|
|
38
84
|
<h1>${title}</h1>
|
|
39
|
-
|
|
40
|
-
${opts.
|
|
41
|
-
<input type="hidden" name="next" value="${next}">
|
|
85
|
+
${error}${notice}${opts.fields}
|
|
86
|
+
<input type="hidden" name="next" value="${next}">${opts.hidden ?? ""}
|
|
42
87
|
<button type="submit">${escape(opts.button)}</button>
|
|
43
88
|
${opts.alt ?? ""}
|
|
44
89
|
</form>
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
${opts.after ?? ""}`);
|
|
91
|
+
}
|
|
92
|
+
const nextQuery = (next) => (next && next !== "/" ? `?next=${encodeURIComponent(next)}` : "");
|
|
93
|
+
/** "Didn't get the email?" A second form, POSTing to /auth/verify/resend. */
|
|
94
|
+
export function resendForm(email) {
|
|
95
|
+
return `<form method="post" action="${VERIFY_PATH}/resend" class="resend">
|
|
96
|
+
<label>Did not get the email? <input name="email" type="email" autocomplete="email" value="${escape(email ?? "")}" required></label>
|
|
97
|
+
<button type="submit">Send the link again</button>
|
|
98
|
+
</form>`;
|
|
47
99
|
}
|
|
48
100
|
export function signInPage(opts = {}) {
|
|
49
|
-
const next = opts.next && opts.next !== "/" ? `?next=${encodeURIComponent(opts.next)}` : "";
|
|
50
101
|
return page({
|
|
51
102
|
title: opts.title ?? "Sign in",
|
|
52
|
-
action: SIGN_IN_PATH,
|
|
103
|
+
action: SIGN_IN_PATH(),
|
|
53
104
|
error: opts.error,
|
|
105
|
+
notice: opts.notice,
|
|
54
106
|
next: opts.next,
|
|
55
107
|
button: "Sign in",
|
|
56
108
|
fields: `
|
|
57
|
-
<label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
|
|
109
|
+
<label>Email <input name="email" type="email" autocomplete="email" autofocus required value="${escape(opts.email ?? "")}"></label>
|
|
58
110
|
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>`,
|
|
59
|
-
alt:
|
|
111
|
+
alt: `<p class="alt"><a href="${FORGOT_PATH}">Forgot password?</a>${opts.canSignUp ? ` · No account? <a href="${SIGN_UP_PATH()}${nextQuery(opts.next)}">Create one</a>` : ""}</p>`,
|
|
112
|
+
after: opts.resend ? resendForm(opts.email) : undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Ask for the email to send a reset link to. With `sent`, show the (always identical) confirmation instead. */
|
|
116
|
+
export function forgotPage(opts = {}) {
|
|
117
|
+
if (opts.sent) {
|
|
118
|
+
return messagePage({
|
|
119
|
+
title: "Check your email",
|
|
120
|
+
message: opts.message ?? "",
|
|
121
|
+
link: { href: SIGN_IN_PATH(), text: "Back to sign in" },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return page({
|
|
125
|
+
title: "Reset your password",
|
|
126
|
+
action: FORGOT_PATH,
|
|
127
|
+
error: opts.error,
|
|
128
|
+
button: "Send reset link",
|
|
129
|
+
fields: `
|
|
130
|
+
<label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>`,
|
|
131
|
+
alt: `<p class="alt"><a href="${SIGN_IN_PATH()}">Back to sign in</a></p>`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The form behind an emailed link: a new password, the token hidden. A
|
|
136
|
+
* reset link, or (`invite`) the welcome link an added user gets: same
|
|
137
|
+
* form, posted to /auth/invite, worded for a first password.
|
|
138
|
+
*/
|
|
139
|
+
export function resetPage(opts) {
|
|
140
|
+
return page({
|
|
141
|
+
title: opts.invite ? `Welcome${siteName ? ` to ${siteName}` : ""}` : "Choose a new password",
|
|
142
|
+
action: opts.invite ? INVITE_PATH : RESET_PATH,
|
|
143
|
+
error: opts.error,
|
|
144
|
+
notice: opts.invite ? "Choose a password to finish setting up your account." : undefined,
|
|
145
|
+
button: opts.invite ? "Set password and sign in" : "Set password",
|
|
146
|
+
hidden: `\n <input type="hidden" name="token" value="${escape(opts.token)}">`,
|
|
147
|
+
fields: `
|
|
148
|
+
<label>${opts.invite ? "Password" : "New password"} <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" autofocus required></label>`,
|
|
149
|
+
alt: opts.invite ? `<p class="alt">Already set it? <a href="${SIGN_IN_PATH()}">Sign in</a></p>` : `<p class="alt"><a href="${FORGOT_PATH}">Request a new link</a></p>`,
|
|
60
150
|
});
|
|
61
151
|
}
|
|
62
152
|
export function signUpPage(opts = {}) {
|
|
63
|
-
const next = opts.next && opts.next !== "/" ? `?next=${encodeURIComponent(opts.next)}` : "";
|
|
64
153
|
return page({
|
|
65
154
|
title: opts.title ?? (opts.first ? "Create the first account" : "Create account"),
|
|
66
|
-
action: SIGN_UP_PATH,
|
|
155
|
+
action: SIGN_UP_PATH(),
|
|
67
156
|
error: opts.error,
|
|
68
157
|
next: opts.next,
|
|
69
158
|
button: "Create account",
|
|
70
159
|
fields: `
|
|
71
160
|
<label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
|
|
72
|
-
<label>Password <input name="password" type="password" autocomplete="new-password" minlength="
|
|
73
|
-
alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH}${next}">Sign in</a></p>`,
|
|
161
|
+
<label>Password <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" required></label>${opts.inviteRequired ? `\n <label>Invite code <input name="invite" autocomplete="off" required></label>` : ""}`,
|
|
162
|
+
alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH()}${nextQuery(opts.next)}">Sign in</a></p>`,
|
|
74
163
|
});
|
|
75
164
|
}
|
|
165
|
+
/** A page with no form: a result, an error, a "check your email". */
|
|
166
|
+
export function messagePage(opts) {
|
|
167
|
+
const link = opts.link ? `\n<p class="alt"><a href="${escape(opts.link.href)}">${escape(opts.link.text)}</a></p>` : "";
|
|
168
|
+
return shell(opts.title, `<h1>${escape(opts.title)}</h1>\n<p>${escape(opts.message)}</p>${link}\n${opts.after ?? ""}`);
|
|
169
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EmailMessage } from "./email.ts";
|
|
2
|
+
export declare const INVITE_PATH = "/auth/invite";
|
|
3
|
+
export declare const INVITE_TTL_SECONDS: number;
|
|
4
|
+
export declare const INVITE_LINK_INVALID = "This invitation link is invalid, already used, or older than seven days. Ask for a new one.";
|
|
5
|
+
/** The absolute link that goes in the email */
|
|
6
|
+
export declare function inviteLink(base: string, rawToken: string): string;
|
|
7
|
+
export declare function inviteEmail(to: string, link: string, site: string): EmailMessage;
|