toiljs 0.0.39 → 0.0.41
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/CHANGELOG.md +18 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +11 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/emails.d.ts +29 -0
- package/build/compiler/emails.js +205 -0
- package/build/compiler/generate.js +12 -1
- package/build/compiler/index.js +13 -5
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/envelope.d.ts +1 -0
- package/build/devserver/host.d.ts +1 -0
- package/build/devserver/host.js +40 -1
- package/build/devserver/index.js +3 -0
- package/build/devserver/module.js +3 -0
- package/build/devserver/ratelimit.d.ts +8 -0
- package/build/devserver/ratelimit.js +64 -0
- package/docs/README.md +6 -0
- package/docs/email.md +273 -0
- package/docs/ratelimit.md +95 -0
- package/package.json +3 -2
- package/server/globals/email.ts +188 -0
- package/server/globals/ratelimit.ts +89 -0
- package/server/globals/twofactor.ts +212 -0
- package/server/runtime/rest/RouteContext.ts +22 -0
- package/src/cli/create.ts +11 -0
- package/src/compiler/emails.ts +311 -0
- package/src/compiler/generate.ts +12 -1
- package/src/compiler/index.ts +21 -6
- package/src/devserver/envelope.ts +3 -0
- package/src/devserver/host.ts +66 -1
- package/src/devserver/index.ts +7 -0
- package/src/devserver/module.ts +3 -0
- package/src/devserver/ratelimit.ts +116 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// EmailService + EmailTemplate + the `EmailStatus` enum: the guest surface for
|
|
2
|
+
// the per-tenant outbound email primitive, available as a no-import global (the
|
|
3
|
+
// toilscript `--lib` mechanism, like `AuthService` and `RateLimitService`). A
|
|
4
|
+
// handler calls `EmailService.send(...)` for a one-off, or defines a reusable
|
|
5
|
+
// `EmailTemplate` (with `{{placeholder}}` interpolation, plain text and/or HTML)
|
|
6
|
+
// and calls `template.send(to, vars)`.
|
|
7
|
+
//
|
|
8
|
+
// The host hands the message to the off-core mailer and SUSPENDS the call until
|
|
9
|
+
// the provider responds (on the async edge executor) without blocking the
|
|
10
|
+
// worker. `purpose` is a short tag ("verify", "reset", ...) the host folds into
|
|
11
|
+
// its dedup + per-recipient abuse keys; it is never logged raw. The recipient is
|
|
12
|
+
// validated at the host boundary (no header injection / multiple addresses) and
|
|
13
|
+
// the send is capped per tenant.
|
|
14
|
+
//
|
|
15
|
+
// Backed by the `email_send` host import (toil-backend `email_send_import.rs`,
|
|
16
|
+
// and the toiljs dev-server mock). A tenant that never sends email never imports
|
|
17
|
+
// `email_send`, so AssemblyScript tree-shakes it; a module that does must be
|
|
18
|
+
// deployed to an edge built with the `email` feature.
|
|
19
|
+
|
|
20
|
+
// Host import: submit one email and resolve to its status code. `reqPtr`/`reqLen`
|
|
21
|
+
// is the length-prefixed request blob (header below). Suspends the wasm call
|
|
22
|
+
// until the off-core send completes.
|
|
23
|
+
// @ts-ignore: decorator
|
|
24
|
+
@external('env', 'email_send')
|
|
25
|
+
declare function __toilEmailSend(reqPtr: usize, reqLen: i32): i32;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The result of a send. Kept in sync with `EmailStatus` in toil-backend
|
|
29
|
+
* `email.rs` (`#[repr(i32)]`). `Sent` and `Deduped` are success; the rest say
|
|
30
|
+
* why it was not delivered and whether a retry could help.
|
|
31
|
+
*/
|
|
32
|
+
export enum EmailStatus {
|
|
33
|
+
/** Accepted by the provider. */
|
|
34
|
+
Sent = 0,
|
|
35
|
+
/** This host has no `[email]` capability (or it is disabled / not on this path). */
|
|
36
|
+
Disabled = 1,
|
|
37
|
+
/** The tenant's per-minute budget is exhausted. Retriable later. */
|
|
38
|
+
Budget = 2,
|
|
39
|
+
/** The per-recipient hourly cap was hit. Terminal for this recipient/window. */
|
|
40
|
+
RecipientCapped = 3,
|
|
41
|
+
/** An identical recent (recipient, purpose) send was collapsed. Treat as sent. */
|
|
42
|
+
Deduped = 4,
|
|
43
|
+
/** The mailer was saturated / a queue was full. Retriable; back off. */
|
|
44
|
+
TryLater = 5,
|
|
45
|
+
/** The recipient failed host-side validation (CRLF, multiple addresses, malformed). */
|
|
46
|
+
BadRecipient = 6,
|
|
47
|
+
/** The provider rejected the send, or transport failed after retries. Terminal. */
|
|
48
|
+
ProviderError = 7,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export namespace EmailService {
|
|
52
|
+
/** Header: u16 to_len | u16 subject_len | u16 purpose_len | u32 body_len | u32 html_len. */
|
|
53
|
+
const HEADER_LEN: i32 = 14;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send one email to `to` and return its {@link EmailStatus}. Suspends until
|
|
57
|
+
* the off-core mailer reports a result.
|
|
58
|
+
*
|
|
59
|
+
* `body` is the plain-text body; pass a non-empty `html` to send an HTML
|
|
60
|
+
* message (then `body` is the plain-text alternative — set both for the best
|
|
61
|
+
* deliverability, or leave `body` empty for HTML-only). `purpose` is a short,
|
|
62
|
+
* non-PII tag used for host-side dedup/abuse keying.
|
|
63
|
+
*/
|
|
64
|
+
export function send(
|
|
65
|
+
to: string,
|
|
66
|
+
subject: string,
|
|
67
|
+
body: string,
|
|
68
|
+
purpose: string = 'tx',
|
|
69
|
+
html: string = '',
|
|
70
|
+
): EmailStatus {
|
|
71
|
+
const toB = Uint8Array.wrap(String.UTF8.encode(to));
|
|
72
|
+
const subjB = Uint8Array.wrap(String.UTF8.encode(subject));
|
|
73
|
+
const purpB = Uint8Array.wrap(String.UTF8.encode(purpose));
|
|
74
|
+
const bodyB = Uint8Array.wrap(String.UTF8.encode(body));
|
|
75
|
+
const htmlB = Uint8Array.wrap(String.UTF8.encode(html));
|
|
76
|
+
|
|
77
|
+
const total =
|
|
78
|
+
HEADER_LEN + toB.length + subjB.length + purpB.length + bodyB.length + htmlB.length;
|
|
79
|
+
const buf = new Uint8Array(total);
|
|
80
|
+
const base = buf.dataStart;
|
|
81
|
+
|
|
82
|
+
// Little-endian header (wasm stores are LE), then the five payloads in
|
|
83
|
+
// the order the host parser expects: to, subject, purpose, body, html.
|
|
84
|
+
store<u16>(base, <u16>toB.length, 0);
|
|
85
|
+
store<u16>(base, <u16>subjB.length, 2);
|
|
86
|
+
store<u16>(base, <u16>purpB.length, 4);
|
|
87
|
+
store<u32>(base, <u32>bodyB.length, 6);
|
|
88
|
+
store<u32>(base, <u32>htmlB.length, 10);
|
|
89
|
+
|
|
90
|
+
let off = base + HEADER_LEN;
|
|
91
|
+
memory.copy(off, toB.dataStart, toB.length);
|
|
92
|
+
off += toB.length;
|
|
93
|
+
memory.copy(off, subjB.dataStart, subjB.length);
|
|
94
|
+
off += subjB.length;
|
|
95
|
+
memory.copy(off, purpB.dataStart, purpB.length);
|
|
96
|
+
off += purpB.length;
|
|
97
|
+
memory.copy(off, bodyB.dataStart, bodyB.length);
|
|
98
|
+
off += bodyB.length;
|
|
99
|
+
memory.copy(off, htmlB.dataStart, htmlB.length);
|
|
100
|
+
|
|
101
|
+
return <EmailStatus>__toilEmailSend(base, total);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One template rendered against a variable map: the concrete parts to send. */
|
|
106
|
+
export class RenderedEmail {
|
|
107
|
+
subject: string;
|
|
108
|
+
body: string;
|
|
109
|
+
html: string;
|
|
110
|
+
constructor(subject: string, body: string, html: string) {
|
|
111
|
+
this.subject = subject;
|
|
112
|
+
this.body = body;
|
|
113
|
+
this.html = html;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A reusable email template with `{{placeholder}}` interpolation, defined once
|
|
119
|
+
* and sent many times with different variables:
|
|
120
|
+
*
|
|
121
|
+
* const welcome = new EmailTemplate(
|
|
122
|
+
* 'Welcome, {{name}}!',
|
|
123
|
+
* 'Hi {{name}}, your code is {{code}}.',
|
|
124
|
+
* '<h1>Welcome, {{name}}</h1><p>Your code is <b>{{code}}</b>.</p>',
|
|
125
|
+
* );
|
|
126
|
+
* const vars = new Map<string,string>();
|
|
127
|
+
* vars.set('name', 'Alice'); vars.set('code', '123456');
|
|
128
|
+
* welcome.send('alice@example.com', vars, 'welcome');
|
|
129
|
+
*
|
|
130
|
+
* `{{ key }}` (with surrounding spaces) is accepted; an unknown placeholder
|
|
131
|
+
* renders to the empty string. `html` is optional — omit it for a plain-text
|
|
132
|
+
* template.
|
|
133
|
+
*/
|
|
134
|
+
export class EmailTemplate {
|
|
135
|
+
private subjectTpl: string;
|
|
136
|
+
private bodyTpl: string;
|
|
137
|
+
private htmlTpl: string;
|
|
138
|
+
|
|
139
|
+
constructor(subject: string, body: string, html: string = '') {
|
|
140
|
+
this.subjectTpl = subject;
|
|
141
|
+
this.bodyTpl = body;
|
|
142
|
+
this.htmlTpl = html;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Render the template against `vars` without sending (preview / testing). */
|
|
146
|
+
render(vars: Map<string, string>): RenderedEmail {
|
|
147
|
+
return new RenderedEmail(
|
|
148
|
+
interpolate(this.subjectTpl, vars),
|
|
149
|
+
interpolate(this.bodyTpl, vars),
|
|
150
|
+
this.htmlTpl.length > 0 ? interpolate(this.htmlTpl, vars) : '',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Render and send to `to`. Returns the send's {@link EmailStatus}. */
|
|
155
|
+
send(to: string, vars: Map<string, string>, purpose: string = 'tx'): EmailStatus {
|
|
156
|
+
const r = this.render(vars);
|
|
157
|
+
return EmailService.send(to, r.subject, r.body, purpose, r.html);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Substitute every `{{key}}` in `pattern` with `vars.get(key)`. Surrounding
|
|
163
|
+
* whitespace in the placeholder is ignored (`{{ name }}` == `{{name}}`); an
|
|
164
|
+
* unknown key renders to the empty string; an unterminated `{{` is emitted
|
|
165
|
+
* literally. Module-private (not part of the `--lib` global surface).
|
|
166
|
+
*/
|
|
167
|
+
function interpolate(pattern: string, vars: Map<string, string>): string {
|
|
168
|
+
let out = '';
|
|
169
|
+
let i = 0;
|
|
170
|
+
const n = pattern.length;
|
|
171
|
+
while (i < n) {
|
|
172
|
+
const open = pattern.indexOf('{{', i);
|
|
173
|
+
if (open < 0) {
|
|
174
|
+
out += pattern.substring(i);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
out += pattern.substring(i, open);
|
|
178
|
+
const close = pattern.indexOf('}}', open + 2);
|
|
179
|
+
if (close < 0) {
|
|
180
|
+
out += pattern.substring(open); // unterminated -> literal
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
const key = pattern.substring(open + 2, close).trim();
|
|
184
|
+
if (vars.has(key)) out += vars.get(key);
|
|
185
|
+
i = close + 2;
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// RateLimitService + the `RateLimit` strategy enum: the server half of the
|
|
2
|
+
// `@ratelimit` route decorator, available as a no-import global (registered via
|
|
3
|
+
// the toilscript `--lib` mechanism, the same way `AuthService` and `crypto`
|
|
4
|
+
// are). The toilscript `@ratelimit(strategy, limit, window)` decorator lowers to
|
|
5
|
+
// a single `RateLimitService.guard(...)` call at the top of the route, before
|
|
6
|
+
// the `@auth` guard and the handler.
|
|
7
|
+
//
|
|
8
|
+
// The actual counting happens host-side in an EXACT limiter shared across all
|
|
9
|
+
// edge workers and keyed, by default, on the request's UNSPOOFABLE peer IP (the
|
|
10
|
+
// socket's remote address, never a forgeable header). Backed by the
|
|
11
|
+
// `ratelimit_check` host import (toil-backend `ratelimit_check_import.rs`, and
|
|
12
|
+
// the toiljs dev-server mock). A tenant that does not use `@ratelimit` never
|
|
13
|
+
// references this namespace, so AssemblyScript tree-shakes it and the module
|
|
14
|
+
// never imports `ratelimit_check` (only opt-in routes pay anything).
|
|
15
|
+
|
|
16
|
+
import { Response } from 'toiljs/server/runtime';
|
|
17
|
+
|
|
18
|
+
// Host import: account one event against the route's shared limiter. Args are
|
|
19
|
+
// `(route_id, strategy_tag, limit, window, key_ptr, key_len)`; the two param
|
|
20
|
+
// slots mean `(limit, window_secs)` for the window strategies and
|
|
21
|
+
// `(burst, refill_per_sec)` for the token bucket. Returns the remaining budget
|
|
22
|
+
// (`>= 0`, allowed) or a NEGATIVE `Retry-After` in seconds (denied). When
|
|
23
|
+
// `key_len` is 0 the host keys on the peer IP.
|
|
24
|
+
// @ts-ignore: decorator
|
|
25
|
+
@external('env', 'ratelimit_check')
|
|
26
|
+
declare function __toilRateLimitCheck(
|
|
27
|
+
routeId: i32,
|
|
28
|
+
strategy: i32,
|
|
29
|
+
limit: i32,
|
|
30
|
+
window: i32,
|
|
31
|
+
keyPtr: usize,
|
|
32
|
+
keyLen: i32,
|
|
33
|
+
): i32;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rate-limit strategy, the first argument to `@ratelimit(...)`. The numeric
|
|
37
|
+
* values are the host's strategy tags (kept in sync with `Strategy` in
|
|
38
|
+
* toil-backend `ratelimit.rs`); the toilscript decorator transform reads the
|
|
39
|
+
* member by name, so reordering would only affect a bare-integer decorator arg.
|
|
40
|
+
*
|
|
41
|
+
* - `FixedWindow`: at most `limit` events per `window` seconds. Cheapest; a
|
|
42
|
+
* caller hammering the boundary can briefly land up to ~2x `limit`.
|
|
43
|
+
* - `SlidingWindow`: weights the previous window to smooth that boundary burst.
|
|
44
|
+
* - `TokenBucket`: `limit` is the burst size, `window` the refill-per-second;
|
|
45
|
+
* allows an initial burst then a steady rate.
|
|
46
|
+
*/
|
|
47
|
+
export enum RateLimit {
|
|
48
|
+
FixedWindow = 0,
|
|
49
|
+
SlidingWindow = 1,
|
|
50
|
+
TokenBucket = 2,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export namespace RateLimitService {
|
|
54
|
+
/**
|
|
55
|
+
* The `@ratelimit` decorator's guard. Accounts one event for this request
|
|
56
|
+
* against the route's shared limiter, keyed on the UNSPOOFABLE peer IP.
|
|
57
|
+
* Returns a `429 Too Many Requests` (with a `Retry-After` header) when over
|
|
58
|
+
* the limit, or `null` to let the request proceed.
|
|
59
|
+
*/
|
|
60
|
+
export function guard(routeId: i32, strategy: i32, limit: i32, window: i32): Response | null {
|
|
61
|
+
const r = __toilRateLimitCheck(routeId, strategy, limit, window, 0, 0);
|
|
62
|
+
return r >= 0 ? null : tooMany(-r);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Like {@link guard} but keyed on a tenant-chosen identity `key` (e.g. an
|
|
67
|
+
* authenticated user id) instead of the peer IP, for per-user limits. An
|
|
68
|
+
* empty `key` falls back to the peer IP (host-side).
|
|
69
|
+
*/
|
|
70
|
+
export function guardKeyed(
|
|
71
|
+
routeId: i32,
|
|
72
|
+
strategy: i32,
|
|
73
|
+
limit: i32,
|
|
74
|
+
window: i32,
|
|
75
|
+
key: string,
|
|
76
|
+
): Response | null {
|
|
77
|
+
const kb = Uint8Array.wrap(String.UTF8.encode(key));
|
|
78
|
+
const r = __toilRateLimitCheck(routeId, strategy, limit, window, kb.dataStart, kb.length);
|
|
79
|
+
return r >= 0 ? null : tooMany(-r);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A `429` response carrying the host-computed `Retry-After` (whole seconds). */
|
|
83
|
+
function tooMany(retryAfterSecs: i32): Response {
|
|
84
|
+
return Response.text('Too Many Requests\n', 429).setHeader(
|
|
85
|
+
'Retry-After',
|
|
86
|
+
retryAfterSecs.toString(),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// TwoFactor: a STATELESS email verification-code primitive (2FA / confirm /
|
|
2
|
+
// magic-code), available as a no-import global (the toilscript `--lib`
|
|
3
|
+
// mechanism, like `AuthService`, `EmailService`, `RateLimitService`). No
|
|
4
|
+
// database: the server stores nothing between mint and verify.
|
|
5
|
+
//
|
|
6
|
+
// How stateless verification works: `mint`/`issue` generates a random code,
|
|
7
|
+
// emails it to the recipient, and returns a signed TOKEN that commits to the
|
|
8
|
+
// code via HMAC over `(recipient, purpose, exp, code)` -- WITHOUT putting the
|
|
9
|
+
// code in the token (the code is only in the email). Hand the token to the
|
|
10
|
+
// client (a cookie or hidden field). On `verify(token, recipient, code)` the
|
|
11
|
+
// server recomputes the HMAC from the token's `(purpose, exp)` + the caller's
|
|
12
|
+
// `(recipient, code)` and constant-time compares. A valid `(token, code)` pair
|
|
13
|
+
// can only be produced by someone who BOTH received the email (knows the code)
|
|
14
|
+
// AND holds the token. The HMAC binds the recipient + purpose + expiry, so a
|
|
15
|
+
// token can't be replayed for another address, flow, or past its TTL.
|
|
16
|
+
//
|
|
17
|
+
// LIMITATION: this is integrity + expiry, NOT single-use. A valid code can be
|
|
18
|
+
// verified multiple times within its (short) TTL -- there is no server state to
|
|
19
|
+
// burn it. For true single-use, keep a per-recipient last-verified-at (a DB /
|
|
20
|
+
// the edge store) and reject codes at or before it, plus a short TTL.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
CryptoKey,
|
|
24
|
+
HmacImportParams,
|
|
25
|
+
HmacParams,
|
|
26
|
+
ALG_SHA_256,
|
|
27
|
+
USAGE_SIGN,
|
|
28
|
+
USAGE_VERIFY,
|
|
29
|
+
} from 'crypto';
|
|
30
|
+
import { DataWriter, DataReader } from 'data';
|
|
31
|
+
|
|
32
|
+
import { base64UrlEncode, base64UrlDecode, Time } from 'toiljs/server/runtime';
|
|
33
|
+
|
|
34
|
+
// HMAC key for the verification tokens. The SAME secret must be configured on
|
|
35
|
+
// every edge instance and kept out of any client bundle. The default is a loud
|
|
36
|
+
// DEV placeholder; a real deployment calls `TwoFactor.setSecret(...)` at startup
|
|
37
|
+
// (a build-time constant is consistent across instances).
|
|
38
|
+
// TODO(secret): move to a per-deployment host-config secret.
|
|
39
|
+
let __twofaSecret: Uint8Array = Uint8Array.wrap(
|
|
40
|
+
String.UTF8.encode('toil-dev-insecure-2fa-secret-CHANGE-ME'),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Token format version (first byte of both the token and the signed message). */
|
|
44
|
+
const TWOFA_VERSION: u8 = 1;
|
|
45
|
+
|
|
46
|
+
function importHmac(key: Uint8Array): CryptoKey {
|
|
47
|
+
return crypto.subtle.importKey(
|
|
48
|
+
'raw',
|
|
49
|
+
key,
|
|
50
|
+
new HmacImportParams(ALG_SHA_256),
|
|
51
|
+
false,
|
|
52
|
+
USAGE_SIGN | USAGE_VERIFY,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** HMAC-SHA256 over `msg` with the configured secret. */
|
|
57
|
+
function hmac(msg: Uint8Array): Uint8Array {
|
|
58
|
+
return crypto.subtle.sign(new HmacParams(), importHmac(__twofaSecret), msg);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Constant-time HMAC verify (the host's `crypto.verify` compares in constant time). */
|
|
62
|
+
function hmacVerify(mac: Uint8Array, msg: Uint8Array): bool {
|
|
63
|
+
return crypto.subtle.verify(new HmacParams(), importHmac(__twofaSecret), mac, msg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The canonical signed message, a FIXED length-prefixed binary layout (no JSON,
|
|
68
|
+
* so fields can't be confused): `u8 version | str recipient | str purpose |
|
|
69
|
+
* u64 exp | str code`. The code is signed here but NEVER stored in the token.
|
|
70
|
+
*/
|
|
71
|
+
function canonicalMessage(recipient: string, purpose: string, exp: u64, code: string): Uint8Array {
|
|
72
|
+
const w = new DataWriter();
|
|
73
|
+
w.writeU8(TWOFA_VERSION);
|
|
74
|
+
w.writeString(recipient);
|
|
75
|
+
w.writeString(purpose);
|
|
76
|
+
w.writeU64(exp);
|
|
77
|
+
w.writeString(code);
|
|
78
|
+
return w.toBytes();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The client token: `u8 version | str purpose | u64 exp | bytes mac` (base64url). */
|
|
82
|
+
function encodeToken(purpose: string, exp: u64, mac: Uint8Array): string {
|
|
83
|
+
const w = new DataWriter();
|
|
84
|
+
w.writeU8(TWOFA_VERSION);
|
|
85
|
+
w.writeString(purpose);
|
|
86
|
+
w.writeU64(exp);
|
|
87
|
+
w.writeBytes(mac);
|
|
88
|
+
return base64UrlEncode(w.toBytes());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A random numeric code of `digits` digits, from the CSPRNG. */
|
|
92
|
+
function randomCode(digits: i32): string {
|
|
93
|
+
const buf = new Uint8Array(digits);
|
|
94
|
+
crypto.getRandomValues(buf);
|
|
95
|
+
let s = '';
|
|
96
|
+
for (let i = 0; i < digits; i++) {
|
|
97
|
+
// byte % 10: the tiny modulo bias is irrelevant for a short-lived code.
|
|
98
|
+
s += (<i32>buf[i] % 10).toString();
|
|
99
|
+
}
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clampDigits(digits: i32): i32 {
|
|
104
|
+
if (digits < 4) return 4;
|
|
105
|
+
if (digits > 10) return 10;
|
|
106
|
+
return digits;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A generated code + its token, for the bring-your-own-email path ({@link TwoFactor.issue}). */
|
|
110
|
+
export class TwoFactorIssue {
|
|
111
|
+
code: string;
|
|
112
|
+
token: string;
|
|
113
|
+
constructor(code: string, token: string) {
|
|
114
|
+
this.code = code;
|
|
115
|
+
this.token = token;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The result of {@link TwoFactor.send}: the token to hand the client + the email status. */
|
|
120
|
+
export class TwoFactorChallenge {
|
|
121
|
+
token: string;
|
|
122
|
+
status: EmailStatus;
|
|
123
|
+
constructor(token: string, status: EmailStatus) {
|
|
124
|
+
this.token = token;
|
|
125
|
+
this.status = status;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export namespace TwoFactor {
|
|
130
|
+
/** Default code lifetime if none is given. Keep it short (stateless => not single-use). */
|
|
131
|
+
export const DEFAULT_TTL_SECS: u64 = 600; // 10 minutes
|
|
132
|
+
/** Default number of digits in a code. */
|
|
133
|
+
export const DEFAULT_DIGITS: i32 = 6;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Configure the HMAC secret used to sign verification tokens. Call once at
|
|
137
|
+
* startup from `main.ts`. Must be identical on every edge instance and kept
|
|
138
|
+
* out of any client bundle.
|
|
139
|
+
*/
|
|
140
|
+
export function setSecret(secret: Uint8Array): void {
|
|
141
|
+
__twofaSecret = secret;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Generate a code + token WITHOUT sending an email (bring your own email,
|
|
146
|
+
* e.g. an `EmailTemplate`): mint the code yourself-friendly path. Email
|
|
147
|
+
* `result.code` to `recipient` and hand `result.token` to the client.
|
|
148
|
+
*/
|
|
149
|
+
export function issue(
|
|
150
|
+
recipient: string,
|
|
151
|
+
purpose: string,
|
|
152
|
+
ttlSecs: u64 = DEFAULT_TTL_SECS,
|
|
153
|
+
digits: i32 = DEFAULT_DIGITS,
|
|
154
|
+
): TwoFactorIssue {
|
|
155
|
+
const code = randomCode(clampDigits(digits));
|
|
156
|
+
const exp = Time.nowSeconds() + ttlSecs;
|
|
157
|
+
const mac = hmac(canonicalMessage(recipient, purpose, exp, code));
|
|
158
|
+
return new TwoFactorIssue(code, encodeToken(purpose, exp, mac));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Issue a code and send it in a built-in email; returns the token + the send
|
|
163
|
+
* {@link EmailStatus}. For a branded email, use {@link issue} + your own
|
|
164
|
+
* `EmailTemplate`/`EmailService.send` instead.
|
|
165
|
+
*/
|
|
166
|
+
export function send(
|
|
167
|
+
recipient: string,
|
|
168
|
+
purpose: string,
|
|
169
|
+
ttlSecs: u64 = DEFAULT_TTL_SECS,
|
|
170
|
+
digits: i32 = DEFAULT_DIGITS,
|
|
171
|
+
): TwoFactorChallenge {
|
|
172
|
+
const issued = issue(recipient, purpose, ttlSecs, digits);
|
|
173
|
+
const subject = 'Your verification code';
|
|
174
|
+
const text =
|
|
175
|
+
'Your verification code is ' +
|
|
176
|
+
issued.code +
|
|
177
|
+
'. It expires shortly. If you did not request it, ignore this email.';
|
|
178
|
+
const html =
|
|
179
|
+
'<table width="100%" style="font-family:Arial,sans-serif"><tbody><tr><td style="padding:24px">' +
|
|
180
|
+
'<p style="color:#111827;font-size:15px;margin:0 0 8px">Your verification code is</p>' +
|
|
181
|
+
'<p style="font-size:30px;font-weight:bold;letter-spacing:6px;color:#111827;margin:0">' +
|
|
182
|
+
issued.code +
|
|
183
|
+
'</p>' +
|
|
184
|
+
'<p style="color:#9ca3af;font-size:12px;margin-top:20px">It expires shortly. If you did not request it, ignore this email.</p>' +
|
|
185
|
+
'</td></tr></tbody></table>';
|
|
186
|
+
const status = EmailService.send(recipient, subject, text, purpose, html);
|
|
187
|
+
return new TwoFactorChallenge(issued.token, status);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Verify a `(token, code)` pair for `recipient`. Stateless: recomputes the
|
|
192
|
+
* HMAC from the token's `(purpose, exp)` + the supplied `(recipient, code)`
|
|
193
|
+
* and constant-time compares, after checking the version and expiry. Returns
|
|
194
|
+
* `true` only for a code that was issued for this recipient and has not
|
|
195
|
+
* expired. NOT single-use within the TTL (see the file header).
|
|
196
|
+
*/
|
|
197
|
+
export function verify(token: string, recipient: string, code: string): bool {
|
|
198
|
+
const raw = base64UrlDecode(token);
|
|
199
|
+
if (raw == null) return false;
|
|
200
|
+
|
|
201
|
+
const r = new DataReader(raw);
|
|
202
|
+
if (r.readU8() != TWOFA_VERSION) return false;
|
|
203
|
+
const purpose = r.readString();
|
|
204
|
+
const exp = r.readU64();
|
|
205
|
+
const mac = r.readBytes();
|
|
206
|
+
if (!r.ok) return false; // truncated / malformed token
|
|
207
|
+
|
|
208
|
+
if (Time.nowSeconds() >= exp) return false; // expired
|
|
209
|
+
|
|
210
|
+
return hmacVerify(mac, canonicalMessage(recipient, purpose, exp, code));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { Request } from '../request';
|
|
9
9
|
|
|
10
|
+
// Host import: write the request's UNSPOOFABLE peer IP (the socket's remote
|
|
11
|
+
// address, NOT a client-supplied header) into a guest buffer. Returns the byte
|
|
12
|
+
// length written, 0 when there is no peer IP, or -1 if the buffer is too small.
|
|
13
|
+
// Backed by `client_ip_import.rs` on the edge and the dev-server host.
|
|
14
|
+
// @ts-ignore: decorator
|
|
15
|
+
@external('env', 'client_ip')
|
|
16
|
+
declare function __toilClientIp(ptr: usize, cap: i32): i32;
|
|
17
|
+
|
|
10
18
|
export class RouteContext {
|
|
11
19
|
/** The raw incoming request (method, path, headers, body). */
|
|
12
20
|
request: Request;
|
|
@@ -55,6 +63,20 @@ export class RouteContext {
|
|
|
55
63
|
return String.UTF8.decodeUnsafe(body.dataStart, body.byteLength);
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The connecting client's IP as a string ("203.0.113.7" or an IPv6 form),
|
|
68
|
+
* or "" if unavailable. This is the TRUSTED socket peer the edge observed,
|
|
69
|
+
* not a forgeable header like X-Forwarded-For, so it is safe to key rate
|
|
70
|
+
* limits, geo, or audit logs on it.
|
|
71
|
+
*/
|
|
72
|
+
clientIp(): string {
|
|
73
|
+
const cap = 64; // max IPv6 text is 45 bytes; 64 leaves headroom
|
|
74
|
+
const buf = new Uint8Array(cap);
|
|
75
|
+
const n = __toilClientIp(buf.dataStart, cap);
|
|
76
|
+
if (n <= 0) return '';
|
|
77
|
+
return String.UTF8.decodeUnsafe(buf.dataStart, n);
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
private ensureQuery(): void {
|
|
59
81
|
if (this.queryKeys != null) return;
|
|
60
82
|
const keys = new Array<string>();
|
package/src/cli/create.ts
CHANGED
|
@@ -295,6 +295,17 @@ declare const Cookies: typeof import('toiljs/server/runtime/http/cookies').Cooki
|
|
|
295
295
|
type Cookies = import('toiljs/server/runtime/http/cookies').Cookies;
|
|
296
296
|
declare const SecureCookies: typeof import('toiljs/server/runtime/http/securecookies').SecureCookies;
|
|
297
297
|
type SecureCookies = import('toiljs/server/runtime/http/securecookies').SecureCookies;
|
|
298
|
+
// Email, rate-limit, 2FA, and auth globals (server/globals/*), hand-declared
|
|
299
|
+
// because their AssemblyScript source can't be type-aliased from tsc.
|
|
300
|
+
declare enum EmailStatus { Sent, Disabled, Budget, RecipientCapped, Deduped, TryLater, BadRecipient, ProviderError }
|
|
301
|
+
declare namespace EmailService { function send(to: string, subject: string, body: string, purpose?: string, html?: string): EmailStatus; }
|
|
302
|
+
declare class RenderedEmail { subject: string; body: string; html: string; constructor(subject: string, body: string, html: string); }
|
|
303
|
+
declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }
|
|
304
|
+
declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }
|
|
305
|
+
declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }
|
|
306
|
+
declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }
|
|
307
|
+
declare namespace TwoFactor { function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }
|
|
308
|
+
declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }
|
|
298
309
|
`;
|
|
299
310
|
|
|
300
311
|
/**
|