toiljs 0.0.84 → 0.0.86
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 +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +3 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +44 -5
- package/build/compiler/toil-docs.generated.js +6 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +113 -33
- package/build/devserver/runtime/module.js +1 -0
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +170 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/index.md +102 -0
- package/docs/auth/usage.md +188 -0
- package/docs/auth.md +6 -0
- package/examples/basic/client/routes/analytics.tsx +164 -26
- package/examples/basic/server/models/SiteAnalytics.ts +139 -11
- package/examples/basic/server/routes/Analytics.ts +89 -13
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +5 -1
- package/scripts/gen-toil-docs.mjs +15 -6
- package/server/auth/AuthController.ts +335 -0
- package/server/auth/AuthUser.ts +49 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/generate.ts +3 -2
- package/src/compiler/index.ts +74 -5
- package/src/compiler/toil-docs.generated.ts +6 -1
- package/src/devserver/analytics/index.ts +139 -38
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +76 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
|
|
3
|
+
import { SeriesData, SiteAnalytics } from '../models/SiteAnalytics';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* This site's own analytics, mounted at `/analytics`. On the client:
|
|
@@ -14,17 +16,91 @@ import { SiteAnalytics } from '../models/SiteAnalytics';
|
|
|
14
16
|
class AnalyticsRoutes {
|
|
15
17
|
@get('/')
|
|
16
18
|
public self(): SiteAnalytics {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
const stats = Analytics.self();
|
|
20
|
+
const out = new SiteAnalytics();
|
|
21
|
+
|
|
22
|
+
// Requests / L1 edge (all-time totals). Guest getters are u64, model fields are u64: no casts.
|
|
23
|
+
out.totalRequests = stats.requests;
|
|
24
|
+
out.totalBytesOutL1 = stats.bytesOutL1;
|
|
25
|
+
out.totalBytesInL1 = stats.bytesInL1;
|
|
26
|
+
out.totalStatus2xx = stats.status2xx;
|
|
27
|
+
out.totalStatus3xx = stats.status3xx;
|
|
28
|
+
out.totalStatus4xx = stats.status4xx;
|
|
29
|
+
out.totalStatus5xx = stats.status5xx;
|
|
30
|
+
out.totalStaticHits = stats.staticHits;
|
|
31
|
+
out.totalWasmDispatches = stats.wasmDispatches;
|
|
32
|
+
out.totalExecutorFullRejects = stats.executorFullRejects;
|
|
33
|
+
out.totalUnknownHostRejects = stats.unknownHostRejects;
|
|
34
|
+
out.totalRateLimitedRejects = stats.rateLimitedRejects;
|
|
35
|
+
out.totalGasUsed = stats.gasUsed;
|
|
36
|
+
|
|
37
|
+
// Database (all-time totals).
|
|
38
|
+
out.totalDbOps = stats.dbOps;
|
|
39
|
+
out.totalDbReads = stats.dbReads;
|
|
40
|
+
out.totalDbWrites = stats.dbWrites;
|
|
41
|
+
out.totalDbErrors = stats.dbErrors;
|
|
42
|
+
out.totalDbLatencyNsSum = stats.dbLatencyNsSum;
|
|
43
|
+
|
|
44
|
+
// Streams / WebTransport (all-time totals).
|
|
45
|
+
out.totalStreamAccepts = stats.streamAccepts;
|
|
46
|
+
out.totalStreamRejectWrongNode = stats.streamRejectWrongNode;
|
|
47
|
+
out.totalStreamRejectCapacity = stats.streamRejectCapacity;
|
|
48
|
+
out.totalStreamRejectArtifact = stats.streamRejectArtifact;
|
|
49
|
+
out.totalStreamRejectGuest = stats.streamRejectGuest;
|
|
50
|
+
out.totalStreamTraps = stats.streamTraps;
|
|
51
|
+
out.totalStreamIdleTimeouts = stats.streamIdleTimeouts;
|
|
52
|
+
out.totalStreamBytesIn = stats.streamBytesIn;
|
|
53
|
+
out.totalStreamBytesOut = stats.streamBytesOut;
|
|
54
|
+
out.totalStreamBackpressureEvents = stats.streamBackpressureEvents;
|
|
55
|
+
out.totalStreamCloses = stats.streamCloses;
|
|
56
|
+
out.totalStreamDisconnects = stats.streamDisconnects;
|
|
57
|
+
|
|
58
|
+
// Daemons / L4 (all-time totals).
|
|
59
|
+
out.totalDaemonStarts = stats.daemonStarts;
|
|
60
|
+
out.totalDaemonStartFailures = stats.daemonStartFailures;
|
|
61
|
+
out.totalDaemonTicksFired = stats.daemonTicksFired;
|
|
62
|
+
out.totalDaemonTicksSkippedNotLeader = stats.daemonTicksSkippedNotLeader;
|
|
63
|
+
out.totalDaemonTicksFailed = stats.daemonTicksFailed;
|
|
64
|
+
out.totalDaemonLeaderAcquires = stats.daemonLeaderAcquires;
|
|
65
|
+
out.totalDaemonLeaderFenced = stats.daemonLeaderFenced;
|
|
66
|
+
out.totalDaemonHttpCallAttempts = stats.daemonHttpCallAttempts;
|
|
67
|
+
out.totalDaemonHttpCallFailures = stats.daemonHttpCallFailures;
|
|
68
|
+
|
|
69
|
+
// Memory / email / cache (all-time totals) + the derived cache-hit ratio.
|
|
70
|
+
out.totalMemGrownBytes = stats.memGrownBytes;
|
|
71
|
+
out.totalEmails = stats.emails;
|
|
72
|
+
out.totalCacheHits = stats.cacheHits;
|
|
73
|
+
out.totalCacheMisses = stats.cacheMisses;
|
|
74
|
+
out.cacheHitRatio = stats.cacheRatio;
|
|
75
|
+
|
|
76
|
+
// Live gauges (current level, not a total).
|
|
77
|
+
out.liveConnectedStreams = stats.connectedStreams;
|
|
78
|
+
out.liveCommittedMemoryBytes = stats.committedMemory;
|
|
79
|
+
|
|
80
|
+
// Request windows: current usage vs plan cap (cap 0 = unlimited).
|
|
81
|
+
out.requestsThisMinute = stats.reqMinuteUsed;
|
|
82
|
+
out.requestsThisMinuteCap = stats.reqMinuteCap;
|
|
83
|
+
out.requestsToday = stats.reqDayUsed;
|
|
84
|
+
out.requestsTodayCap = stats.reqDayCap;
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Any metric's historical time-series for a range, for graphs:
|
|
90
|
+
/// `await Server.REST.analytics.series({ query: { metric: MetricId, range: AnalyticsRange } })`
|
|
91
|
+
/// `metric` is a `MetricId` (0..44), `range` an `AnalyticsRange` (0=1h .. 7=30d, default 24h). Returns
|
|
92
|
+
/// the per-bucket totals oldest→newest; the client derives rates (value / bucketSecs) and draws them.
|
|
93
|
+
@get('/series')
|
|
94
|
+
public series(ctx: RouteContext): SeriesData {
|
|
95
|
+
const metric = <MetricId>i32.parse(ctx.query('metric'));
|
|
96
|
+
const rangeStr = ctx.query('range');
|
|
97
|
+
const range = rangeStr.length > 0 ? <AnalyticsRange>i32.parse(rangeStr) : AnalyticsRange.H24;
|
|
98
|
+
const s = Analytics.series(metric, range);
|
|
99
|
+
const out = new SeriesData();
|
|
100
|
+
out.metric = s.metric;
|
|
101
|
+
out.bucketSecs = s.bucketSecs;
|
|
102
|
+
out.headMs = s.headMs;
|
|
103
|
+
out.points = s.points;
|
|
104
|
+
return out;
|
|
29
105
|
}
|
|
30
106
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Response } from 'toiljs/server/runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Demo of `ToilUserId` — the stable 256-bit user identity derived from the ML-DSA login public key +
|
|
5
|
+
* identifier (email/username) + tenant domain. Deterministic, opaque, O(1) to compare with `==` / `!=`.
|
|
6
|
+
*/
|
|
7
|
+
@rest('userid')
|
|
8
|
+
class UserIdDemo {
|
|
9
|
+
@get('/demo')
|
|
10
|
+
public demo(): Response {
|
|
11
|
+
const key = new Uint8Array(1312); // an ML-DSA-44 public key (zeros for the demo)
|
|
12
|
+
const a = ToilUserId.derive(key, 'alice@example.com', 'acme.dacely.com');
|
|
13
|
+
const b = ToilUserId.derive(key, 'alice@example.com', 'acme.dacely.com');
|
|
14
|
+
const c = ToilUserId.derive(key, 'bob@example.com', 'acme.dacely.com');
|
|
15
|
+
// Same inputs => same id (a == b); a different identifier => a different id (a != c).
|
|
16
|
+
const same = a == b;
|
|
17
|
+
const diff = a != c;
|
|
18
|
+
return Response.text(
|
|
19
|
+
a.toHex() + '\nsame=' + same.toString() + ' diff=' + diff.toString() + '\n',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.86",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -67,6 +67,10 @@
|
|
|
67
67
|
"types": "./server/runtime/*.ts",
|
|
68
68
|
"default": "./server/runtime/*.ts"
|
|
69
69
|
},
|
|
70
|
+
"./server/auth": {
|
|
71
|
+
"types": "./server/auth/index.ts",
|
|
72
|
+
"default": "./server/auth/index.ts"
|
|
73
|
+
},
|
|
70
74
|
"./tsconfig": "./presets/tsconfig.json",
|
|
71
75
|
"./eslint": "./presets/eslint.js",
|
|
72
76
|
"./prettier": "./presets/prettier.json",
|
|
@@ -58,13 +58,22 @@ const ORDER = [
|
|
|
58
58
|
];
|
|
59
59
|
|
|
60
60
|
function collect() {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Recurse one+ levels so a topic FOLDER (e.g. `docs/auth/*.md`) is bundled too, keyed by its
|
|
62
|
+
// repo-relative path (`auth/index.md`). Adding a `docs/**/*.md` is picked up automatically.
|
|
63
|
+
const files = [];
|
|
64
|
+
const walk = (dir, prefix) => {
|
|
65
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
66
|
+
if (ent.isDirectory()) {
|
|
67
|
+
walk(path.join(dir, ent.name), prefix + ent.name + '/');
|
|
68
|
+
} else if (ent.name.endsWith('.md') && !EXCLUDE.has(prefix + ent.name) && !EXCLUDE.has(ent.name)) {
|
|
69
|
+
files.push(prefix + ent.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(docsDir, '');
|
|
65
74
|
const ordered = [
|
|
66
|
-
...ORDER.filter((f) =>
|
|
67
|
-
...
|
|
75
|
+
...ORDER.filter((f) => files.includes(f)),
|
|
76
|
+
...files.filter((f) => !ORDER.includes(f)),
|
|
68
77
|
];
|
|
69
78
|
const out = {};
|
|
70
79
|
for (const name of ordered) {
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
import { DataReader, DataWriter } from 'data';
|
|
3
|
+
|
|
4
|
+
import { encodeSessionUser } from './AuthUser';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Built-in Toil PQ-Auth controller: the full post-quantum password-login API
|
|
8
|
+
* (`/auth/register|login/start|finish`) plus sessions (`/auth/me`, `/auth/logout`),
|
|
9
|
+
* mounted automatically when an app opts in with `server: { auth: true }` (or
|
|
10
|
+
* `import 'toiljs/server/auth'`). It is a framework-shipped SOURCE file the build
|
|
11
|
+
* APPENDS to the toilscript entry set, so its `@data`/`@database`/`@rest` decorators
|
|
12
|
+
* weave and its `@rest` class self-mounts at `/auth/*` — no hand-written boilerplate.
|
|
13
|
+
*
|
|
14
|
+
* The password never leaves the browser. The client blinds it through the
|
|
15
|
+
* server-keyed OPRF (precomputation-resistant keyed salt), stretches the OPRF
|
|
16
|
+
* output with Argon2id into an ML-DSA-44 keypair, and registers only the public
|
|
17
|
+
* key (+ a proof-of-possession). Login is a challenge-response that also runs an
|
|
18
|
+
* ML-KEM-768 key encapsulation: the server proves its identity by returning a
|
|
19
|
+
* confirmation tag only derivable from the decapsulated shared secret (mutual
|
|
20
|
+
* auth). See `server/globals/auth.ts` (the `AuthService` global) and the client
|
|
21
|
+
* half in `toiljs/client` (`Auth.register` / `Auth.login`).
|
|
22
|
+
*
|
|
23
|
+
* STORAGE: backed by ToilDB (`@database AuthDb`). Accounts are a `record`
|
|
24
|
+
* collection keyed by username; login challenges are a `record` consumed exactly
|
|
25
|
+
* once with `getDelete` (atomic fetch-and-delete). The dev server emulates these
|
|
26
|
+
* `env.data.*` host imports in process (so register -> login spans requests under
|
|
27
|
+
* `toiljs dev`); the production edge backs the SAME API with ScyllaDB.
|
|
28
|
+
*
|
|
29
|
+
* Wire: every body/response is binary (`DataWriter`/`DataReader`), never JSON.
|
|
30
|
+
*
|
|
31
|
+
* Tuning (audience, TTLs, Argon2id params, rate-limit tuples) is fixed here for
|
|
32
|
+
* now; config-driven overrides are a documented follow-up. The secret trio
|
|
33
|
+
* (`AUTH_SESSION_SECRET` / `AUTH_OPRF_SEED` / `AUTH_KEM_SK`) resolves lazily from
|
|
34
|
+
* the tenant env store with DEV fallbacks, so this runs with zero config.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Demo-light Argon2id params (responsive in a browser tab). A real deployment
|
|
38
|
+
// uses >= 256 MiB / >= 3 iterations. The client derives against whatever it is
|
|
39
|
+
// handed, so this is the single source of truth.
|
|
40
|
+
const MEM_KIB: u32 = 32768; // 32 MiB
|
|
41
|
+
const ITERS: u32 = 2;
|
|
42
|
+
const PAR: u32 = 1;
|
|
43
|
+
|
|
44
|
+
const CHALLENGE_TTL_SECS: u64 = 120;
|
|
45
|
+
const SESSION_TTL_SECS: u64 = 3600;
|
|
46
|
+
|
|
47
|
+
// Resolved-and-cached per instance; the audience id (server config, never
|
|
48
|
+
// client-echoed). Read lazily (not at module top level) so the env host import
|
|
49
|
+
// runs during a dispatch, when the request host is bound — the same pattern
|
|
50
|
+
// AuthService uses for its secrets.
|
|
51
|
+
let __aud: string | null = null;
|
|
52
|
+
function aud(): string {
|
|
53
|
+
const cached = __aud;
|
|
54
|
+
if (cached != null) return cached;
|
|
55
|
+
const v = Environment.getSecure('TOIL_AUTH_AUDIENCE');
|
|
56
|
+
const resolved = v != null ? v : 'toil';
|
|
57
|
+
__aud = resolved;
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The tenant domain the stable {@link ToilUserId} is scoped to: the configured
|
|
63
|
+
* `TOIL_AUTH_DOMAIN` if set, else the request's `Host` header, else `localhost`.
|
|
64
|
+
* It only needs to be stable per deployment (it is one of the SHA-256 inputs of
|
|
65
|
+
* the user id), so any robust per-deployment host source is fine.
|
|
66
|
+
*/
|
|
67
|
+
function authDomain(ctx: RouteContext): string {
|
|
68
|
+
const configured = Environment.getSecure('TOIL_AUTH_DOMAIN');
|
|
69
|
+
if (configured != null) return configured;
|
|
70
|
+
const host = ctx.request.header('host');
|
|
71
|
+
if (host != null) return host;
|
|
72
|
+
return 'localhost';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function randomBytes(n: i32): Uint8Array {
|
|
76
|
+
const b = new Uint8Array(n);
|
|
77
|
+
crypto.getRandomValues(b);
|
|
78
|
+
return b;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function nowSecs(): u64 {
|
|
82
|
+
return <u64>(Date.now() / 1000);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One generic error on every failure path (anti-enumeration, anti-oracle). */
|
|
86
|
+
function fail(): Response {
|
|
87
|
+
return Response.text('auth: request failed\n', 401);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deterministic per-user Argon2id salt (16 bytes). With the OPRF providing
|
|
92
|
+
* precomputation resistance, a public/deterministic salt is fine: it only needs
|
|
93
|
+
* to be unique per user (the OPRF output already differs per user). Making it
|
|
94
|
+
* deterministic means register and login agree with NO stored salt, and an
|
|
95
|
+
* unknown user yields the SAME stable salt as a known one would -- no
|
|
96
|
+
* enumeration oracle.
|
|
97
|
+
*/
|
|
98
|
+
function deriveSalt(username: string): Uint8Array {
|
|
99
|
+
return crypto.sha256Text('toil-auth-salt-v1:' + username).slice(0, 16);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@data
|
|
103
|
+
class Username {
|
|
104
|
+
name: string = '';
|
|
105
|
+
constructor(name: string = '') {
|
|
106
|
+
this.name = name;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@data
|
|
111
|
+
class ChallengeId {
|
|
112
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
113
|
+
constructor(cid: Uint8Array = new Uint8Array(0)) {
|
|
114
|
+
this.cid = cid;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@data
|
|
119
|
+
class AuthAccount {
|
|
120
|
+
username: string = '';
|
|
121
|
+
salt: Uint8Array = new Uint8Array(0);
|
|
122
|
+
publicKey: Uint8Array = new Uint8Array(0);
|
|
123
|
+
memKiB: u32 = 0;
|
|
124
|
+
iterations: u32 = 0;
|
|
125
|
+
parallelism: u32 = 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@data
|
|
129
|
+
class Challenge {
|
|
130
|
+
cid: Uint8Array = new Uint8Array(0);
|
|
131
|
+
username: string = '';
|
|
132
|
+
nonce: Uint8Array = new Uint8Array(0);
|
|
133
|
+
iat: u64 = 0;
|
|
134
|
+
exp: u64 = 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@database
|
|
138
|
+
class AuthDb {
|
|
139
|
+
@collection static accounts: Documents<Username, AuthAccount>;
|
|
140
|
+
@collection static challenges: Documents<ChallengeId, Challenge>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@rest('auth')
|
|
144
|
+
class Auth {
|
|
145
|
+
/** POST /auth/register/start body: str(username) bytes(blinded)
|
|
146
|
+
* resp: u8(status=0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)
|
|
147
|
+
* No taken-oracle: always succeeds; register/finish rejects a duplicate. */
|
|
148
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
149
|
+
@post('/register/start')
|
|
150
|
+
public registerStart(ctx: RouteContext): Response {
|
|
151
|
+
const r = new DataReader(ctx.request.body);
|
|
152
|
+
const username = r.readString();
|
|
153
|
+
const blinded = r.readBytes();
|
|
154
|
+
if (!r.ok) return fail();
|
|
155
|
+
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
156
|
+
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
157
|
+
|
|
158
|
+
const w = new DataWriter();
|
|
159
|
+
w.writeU8(0);
|
|
160
|
+
w.writeU32(MEM_KIB);
|
|
161
|
+
w.writeU32(ITERS);
|
|
162
|
+
w.writeU32(PAR);
|
|
163
|
+
w.writeBytes(deriveSalt(username));
|
|
164
|
+
w.writeBytes(evaluated);
|
|
165
|
+
return Response.bytes(w.toBytes());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** POST /auth/register/finish body: str(username) bytes(pk) bytes(regProof)
|
|
169
|
+
* resp: u8(status) -- 0 = ok, 1 = username already registered. Verifies
|
|
170
|
+
* proof-of-possession before storing the key. */
|
|
171
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
172
|
+
@post('/register/finish')
|
|
173
|
+
public registerFinish(ctx: RouteContext): Response {
|
|
174
|
+
const r = new DataReader(ctx.request.body);
|
|
175
|
+
const username = r.readString();
|
|
176
|
+
const pk = r.readBytes();
|
|
177
|
+
const proof = r.readBytes();
|
|
178
|
+
if (!r.ok) return fail();
|
|
179
|
+
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
180
|
+
// Already registered: a distinguishable status (not the generic 401) so the
|
|
181
|
+
// client can say "username taken, log in instead" rather than a blank error.
|
|
182
|
+
if (AuthDb.accounts.exists(new Username(username))) {
|
|
183
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Proof-of-possession: the client signed buildRegisterMessage with the
|
|
187
|
+
// matching secret key, so we confirm it actually holds it.
|
|
188
|
+
const regMsg = AuthService.buildRegisterMessage(username, pk);
|
|
189
|
+
if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
|
|
190
|
+
|
|
191
|
+
const a = new AuthAccount();
|
|
192
|
+
a.username = username;
|
|
193
|
+
a.salt = deriveSalt(username);
|
|
194
|
+
a.publicKey = pk;
|
|
195
|
+
a.memKiB = MEM_KIB;
|
|
196
|
+
a.iterations = ITERS;
|
|
197
|
+
a.parallelism = PAR;
|
|
198
|
+
// create-if-absent: a racing duplicate registration loses here, not above.
|
|
199
|
+
if (!AuthDb.accounts.create(new Username(username), a)) {
|
|
200
|
+
return Response.bytes(new DataWriter().writeU8(1).toBytes());
|
|
201
|
+
}
|
|
202
|
+
return Response.bytes(new DataWriter().writeU8(0).toBytes());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** POST /auth/login/start body: str(username) bytes(blinded)
|
|
206
|
+
* resp: bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt)
|
|
207
|
+
* bytes(nonce) u64(iat) u64(exp) bytes(evaluated)
|
|
208
|
+
* Anti-enumeration: ALWAYS OPRF-evaluates (real or decoy key from the same
|
|
209
|
+
* seed+username), returns a deterministic per-user salt + constant params,
|
|
210
|
+
* and a fresh challenge -- a known and an unknown user are indistinguishable. */
|
|
211
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
212
|
+
@post('/login/start')
|
|
213
|
+
public loginStart(ctx: RouteContext): Response {
|
|
214
|
+
const r = new DataReader(ctx.request.body);
|
|
215
|
+
const username = r.readString();
|
|
216
|
+
const blinded = r.readBytes();
|
|
217
|
+
if (!r.ok) return fail();
|
|
218
|
+
const evaluated = AuthService.oprfEvaluate(username, blinded);
|
|
219
|
+
if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
|
|
220
|
+
|
|
221
|
+
const known = AuthDb.accounts.exists(new Username(username));
|
|
222
|
+
const cid = randomBytes(16);
|
|
223
|
+
const nonce = randomBytes(32);
|
|
224
|
+
const iat = nowSecs();
|
|
225
|
+
const exp = iat + CHALLENGE_TTL_SECS;
|
|
226
|
+
|
|
227
|
+
// Persist only for a real account; the response is identical either way,
|
|
228
|
+
// and login/finish for an unknown user fails generically at consume.
|
|
229
|
+
if (known) {
|
|
230
|
+
const c = new Challenge();
|
|
231
|
+
c.cid = cid;
|
|
232
|
+
c.username = username;
|
|
233
|
+
c.nonce = nonce;
|
|
234
|
+
c.iat = iat;
|
|
235
|
+
c.exp = exp;
|
|
236
|
+
AuthDb.challenges.create(new ChallengeId(cid), c);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const w = new DataWriter();
|
|
240
|
+
w.writeBytes(cid);
|
|
241
|
+
w.writeString(aud());
|
|
242
|
+
w.writeU32(MEM_KIB);
|
|
243
|
+
w.writeU32(ITERS);
|
|
244
|
+
w.writeU32(PAR);
|
|
245
|
+
w.writeBytes(deriveSalt(username));
|
|
246
|
+
w.writeBytes(nonce);
|
|
247
|
+
w.writeU64(iat);
|
|
248
|
+
w.writeU64(exp);
|
|
249
|
+
w.writeBytes(evaluated);
|
|
250
|
+
return Response.bytes(w.toBytes());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** POST /auth/login/finish body: bytes(cid) bytes(ct) bytes(sig)
|
|
254
|
+
* resp: u8(status) [+ bytes(sessionToken) bytes(serverConfirm)] + Set-Cookie */
|
|
255
|
+
@ratelimit(RateLimit.SlidingWindow, 5, 60)
|
|
256
|
+
@post('/login/finish')
|
|
257
|
+
public loginFinish(ctx: RouteContext): Response {
|
|
258
|
+
const r = new DataReader(ctx.request.body);
|
|
259
|
+
const cid = r.readBytes();
|
|
260
|
+
const ct = r.readBytes();
|
|
261
|
+
const sig = r.readBytes();
|
|
262
|
+
if (!r.ok) return fail();
|
|
263
|
+
|
|
264
|
+
// 1. CONSUME FIRST: atomic fetch-and-delete. Unknown/used/expired => fail.
|
|
265
|
+
const ch = AuthDb.challenges.getDelete(new ChallengeId(cid));
|
|
266
|
+
if (ch == null) return fail();
|
|
267
|
+
if (nowSecs() >= ch.exp) return fail();
|
|
268
|
+
|
|
269
|
+
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
270
|
+
// the bound params + server key id), load the account key, verify.
|
|
271
|
+
const acct = AuthDb.accounts.get(new Username(ch.username));
|
|
272
|
+
if (acct == null) return fail();
|
|
273
|
+
const message = AuthService.buildLoginMessage(
|
|
274
|
+
ch.username,
|
|
275
|
+
aud(),
|
|
276
|
+
cid,
|
|
277
|
+
ch.nonce,
|
|
278
|
+
ch.iat,
|
|
279
|
+
ch.exp,
|
|
280
|
+
ct,
|
|
281
|
+
MEM_KIB,
|
|
282
|
+
ITERS,
|
|
283
|
+
PAR,
|
|
284
|
+
AuthService.serverKemKeyId(),
|
|
285
|
+
);
|
|
286
|
+
if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
|
|
287
|
+
|
|
288
|
+
// 3. Decapsulate (proves WE hold the KEM key), derive the session key K
|
|
289
|
+
// bound to the transcript, and build the confirmation tag the client
|
|
290
|
+
// verifies for mutual auth.
|
|
291
|
+
const sharedSecret = AuthService.mlkemDecapsulate(ct);
|
|
292
|
+
if (sharedSecret.length != AuthService.SHARED_SECRET_LEN) return fail();
|
|
293
|
+
const transcriptHash = AuthService.sha256(message);
|
|
294
|
+
const sessionKey = AuthService.deriveSessionKey(sharedSecret, transcriptHash);
|
|
295
|
+
const confirm = AuthService.serverConfirmTag(sessionKey, transcriptHash);
|
|
296
|
+
|
|
297
|
+
// 4. Success: mint the session (the built-in @user codec, carrying the
|
|
298
|
+
// stable ToilUserId) and return {0, sessionToken, confirm}.
|
|
299
|
+
const domain = authDomain(ctx);
|
|
300
|
+
const userData = encodeSessionUser(acct.publicKey, ch.username, domain);
|
|
301
|
+
const w = new DataWriter();
|
|
302
|
+
w.writeU8(0);
|
|
303
|
+
w.writeBytes(userData); // opaque session token (the readable user payload)
|
|
304
|
+
w.writeBytes(confirm);
|
|
305
|
+
const resp = Response.bytes(w.toBytes());
|
|
306
|
+
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
|
|
307
|
+
resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
|
|
308
|
+
return resp;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** GET /auth/me (@auth: 401 without a valid session) -> the typed user
|
|
312
|
+
* (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
|
|
313
|
+
* to the built-in `@user` with no type argument. */
|
|
314
|
+
@auth
|
|
315
|
+
@get('/me')
|
|
316
|
+
public me(_ctx: RouteContext): Response {
|
|
317
|
+
const u = AuthService.getUser();
|
|
318
|
+
if (u == null) return Response.text('no session\n', 401);
|
|
319
|
+
const w = new DataWriter();
|
|
320
|
+
w.writeBytes(u.toilUserId);
|
|
321
|
+
w.writeString(u.username);
|
|
322
|
+
return Response.bytes(w.toBytes());
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** POST /auth/logout (@auth) -> clears both the signed session and the
|
|
326
|
+
* readable companion cookie. */
|
|
327
|
+
@auth
|
|
328
|
+
@post('/logout')
|
|
329
|
+
public logout(_ctx: RouteContext): Response {
|
|
330
|
+
const resp = Response.text('bye\n', 200);
|
|
331
|
+
resp.setCookie(AuthService.clearSession());
|
|
332
|
+
resp.setCookie(AuthService.clearUserCookie());
|
|
333
|
+
return resp;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's built-in authenticated-user shape for `server.auth` (the
|
|
3
|
+
* zero-config `/auth/*` PQ-login controller). Shipped as a SOURCE file that the
|
|
4
|
+
* `server.auth` build APPENDS to the toilscript entry set, so its `@user`
|
|
5
|
+
* decorator weaves exactly as a hand-written one would (a file under
|
|
6
|
+
* `server/globals` is a toilscript `lib` source, where decorators do NOT weave;
|
|
7
|
+
* this file is compiled as a user ENTRY instead).
|
|
8
|
+
*
|
|
9
|
+
* `@user` declares the authenticated user's shape: it becomes a binary codec
|
|
10
|
+
* (like `@data`) AND registers the type of `AuthService.getUser()` everywhere,
|
|
11
|
+
* server and generated client, with NO type argument. There is exactly ONE
|
|
12
|
+
* `@user` per program — the built-in owns it. An app that needs a different user
|
|
13
|
+
* shape hand-writes its own auth controller (and its own `@user`) instead of
|
|
14
|
+
* opting into `server.auth`.
|
|
15
|
+
*
|
|
16
|
+
* The class is named `SessionUser`, NOT `AuthUser`: the toilscript `@user`
|
|
17
|
+
* transform injects a `@global class AuthUser extends <thisClass>` binding for
|
|
18
|
+
* `AuthService.getUser()`, so naming this class `AuthUser` would produce
|
|
19
|
+
* `class AuthUser extends AuthUser` (a duplicate-identifier / self-extension
|
|
20
|
+
* compile error).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// @user: the authenticated-user shape. Exactly one per program; the built-in
|
|
24
|
+
// owns it. `toilUserId` is FIRST so `AuthService.userId()` can recover the
|
|
25
|
+
// stable id straight from the session codec (see server/globals/auth.ts).
|
|
26
|
+
@user
|
|
27
|
+
class SessionUser {
|
|
28
|
+
toilUserId: Uint8Array = new Uint8Array(0);
|
|
29
|
+
username: string = '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Encode the built-in `@user` session payload for a just-authenticated user.
|
|
34
|
+
* Derives the stable, tenant-scoped {@link ToilUserId} from the user's ML-DSA
|
|
35
|
+
* login public key + their username + the request's tenant `domain`, so the same
|
|
36
|
+
* user on the same site always maps to the same id. Exported as a FUNCTION (not
|
|
37
|
+
* the class, which would warn AS235) so the login controller mints a session via
|
|
38
|
+
* the same generated `@user` codec.
|
|
39
|
+
*/
|
|
40
|
+
export function encodeSessionUser(
|
|
41
|
+
mldsaPublicKey: Uint8Array,
|
|
42
|
+
username: string,
|
|
43
|
+
domain: string,
|
|
44
|
+
): Uint8Array {
|
|
45
|
+
const u = new SessionUser();
|
|
46
|
+
u.toilUserId = ToilUserId.derive(mldsaPublicKey, username, domain).toBytes();
|
|
47
|
+
u.username = username;
|
|
48
|
+
return u.encode();
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape-hatch marker for the built-in auth surface. A bare
|
|
3
|
+
* `import 'toiljs/server/auth';` in `server/main.ts` is the lighter-weight opt-in
|
|
4
|
+
* (an alternative to the canonical `server: { auth: true }` config flag): the
|
|
5
|
+
* toiljs build DETECTS this import and appends the shipped `@user` shape +
|
|
6
|
+
* `@rest('auth')` controller to the toilscript ENTRY set, so their decorators
|
|
7
|
+
* weave and the controller self-mounts at `/auth/*`.
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally EMPTY (a pure marker). Framework-shipped
|
|
10
|
+
* decorator sources under `node_modules` only weave when handed to toilscript as
|
|
11
|
+
* explicit ENTRIES; a transitive `import` resolves them under the `~lib/` LIBRARY
|
|
12
|
+
* prefix, where `@data`/`@rest`/`@user` do NOT weave — and, worse, would then
|
|
13
|
+
* collide with the entry-injected copies as duplicate modules. So the barrel must
|
|
14
|
+
* NOT itself import the controller; the build does the entry injection instead.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
package/server/globals/auth.ts
CHANGED
|
@@ -278,6 +278,37 @@ export namespace AuthService {
|
|
|
278
278
|
return bytes == null ? null : __toilDecodeAuthUser(bytes);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The stable, tenant-scoped {@link ToilUserId} of the current request's
|
|
283
|
+
* authenticated user, or `null` if there is no valid session. Recovered from
|
|
284
|
+
* the session codec directly (the built-in `server.auth` `@user` shape puts
|
|
285
|
+
* the 32-byte id FIRST, see `server/auth/AuthUser.ts`), so this stays generic
|
|
286
|
+
* — it does NOT depend on the app's injected `@user` type carrying a
|
|
287
|
+
* `toilUserId` field — while matching the built-in layout. An app whose custom
|
|
288
|
+
* `@user` does not lead with the id should read `getUser()` instead.
|
|
289
|
+
*
|
|
290
|
+
* NULL CHECK: `ToilUserId` overloads `==` for value equality, so `userId() ==
|
|
291
|
+
* null` does NOT type-check. Gate on {@link hasSession} first (then the result
|
|
292
|
+
* is non-null), e.g. `if (AuthService.hasSession()) { const id =
|
|
293
|
+
* AuthService.userId()!; ... }`.
|
|
294
|
+
*/
|
|
295
|
+
export function userId(): ToilUserId | null {
|
|
296
|
+
const bytes = getSessionBytes();
|
|
297
|
+
if (bytes == null) return null;
|
|
298
|
+
return ToilUserId.fromBytes(new DataReader(bytes).readBytes());
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A no-op discoverability marker for the built-in auth surface. The REAL
|
|
303
|
+
* opt-in is `server: { auth: true }` in `toil.config.ts` (or a bare
|
|
304
|
+
* `import 'toiljs/server/auth'`), which appends the shipped `@user` shape +
|
|
305
|
+
* `@rest('auth')` controller to the build's toilscript entry set so they weave
|
|
306
|
+
* and self-mount at `/auth/*`. `enable()` exists so `AuthService.enable()` in
|
|
307
|
+
* `main.ts` type-checks and the surface is discoverable via this namespace; it
|
|
308
|
+
* mounts nothing on its own.
|
|
309
|
+
*/
|
|
310
|
+
export function enable(): void {}
|
|
311
|
+
|
|
281
312
|
/**
|
|
282
313
|
* Mint a signed session cookie carrying `userData` (the `@user` codec bytes,
|
|
283
314
|
* i.e. `myUser.encode()`), valid for `ttlSecs`. Set it on the response with
|