@haverstack/server 0.8.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/.claude/settings.local.json +22 -0
- package/.dockerignore +10 -0
- package/.env.example +90 -0
- package/.github/pull_request_template.md +23 -0
- package/.github/workflows/ci.yml +55 -0
- package/.github/workflows/release.yml +99 -0
- package/.prettierignore +3 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +91 -0
- package/CONTRIBUTING.md +234 -0
- package/Dockerfile +30 -0
- package/LICENSE +121 -0
- package/README.md +107 -0
- package/SECURITY.md +54 -0
- package/docs/api.md +277 -0
- package/docs/deployment.md +212 -0
- package/eslint.config.mjs +18 -0
- package/package.json +42 -0
- package/pnpm-workspace.yaml +13 -0
- package/src/app.ts +86 -0
- package/src/config.ts +176 -0
- package/src/index.ts +54 -0
- package/src/lib/changeStreams.ts +23 -0
- package/src/lib/frameGate.ts +42 -0
- package/src/lib/json.ts +27 -0
- package/src/lib/nonceStore.ts +103 -0
- package/src/lib/queryLimit.ts +11 -0
- package/src/lib/queryWorker/pool.ts +324 -0
- package/src/lib/queryWorker/protocol.ts +39 -0
- package/src/lib/queryWorker/worker.ts +51 -0
- package/src/lib/resumeBuffer.ts +268 -0
- package/src/lib/resumeCursor.ts +48 -0
- package/src/lib/tokenSweep.ts +43 -0
- package/src/middleware/auth.ts +77 -0
- package/src/middleware/errors.ts +73 -0
- package/src/routes/attachments.ts +126 -0
- package/src/routes/auth.ts +137 -0
- package/src/routes/changes.ts +279 -0
- package/src/routes/entity.ts +33 -0
- package/src/routes/health.ts +8 -0
- package/src/routes/records.ts +259 -0
- package/src/routes/tokens.ts +85 -0
- package/src/routes/types.ts +57 -0
- package/src/routes/wellknown.ts +55 -0
- package/src/shutdown.ts +56 -0
- package/src/stack.ts +96 -0
- package/src/types.ts +9 -0
- package/src/wireError.ts +21 -0
- package/tests/app.test.ts +86 -0
- package/tests/changeFeedClient.ts +156 -0
- package/tests/config.test.ts +112 -0
- package/tests/conformance.test.ts +2322 -0
- package/tests/fixtures/blockingWorker.ts +27 -0
- package/tests/fixtures/brokenWorker.ts +7 -0
- package/tests/lib/changeFeedClient.test.ts +126 -0
- package/tests/lib/frameGate.test.ts +93 -0
- package/tests/lib/queryLimit.test.ts +33 -0
- package/tests/lib/queryWorker.test.ts +60 -0
- package/tests/lib/queryWorkerPool.test.ts +129 -0
- package/tests/lib/resumeBuffer.test.ts +341 -0
- package/tests/lib/resumeCursor.test.ts +46 -0
- package/tests/middleware/errors.test.ts +176 -0
- package/tests/routes/associations.test.ts +170 -0
- package/tests/routes/attachments.test.ts +621 -0
- package/tests/routes/auth.test.ts +448 -0
- package/tests/routes/changes.test.ts +567 -0
- package/tests/routes/contentQuery.test.ts +68 -0
- package/tests/routes/entity.test.ts +170 -0
- package/tests/routes/groups.test.ts +69 -0
- package/tests/routes/records.test.ts +1965 -0
- package/tests/routes/relatedToQuery.test.ts +182 -0
- package/tests/routes/tokens.test.ts +157 -0
- package/tests/routes/tombstones.test.ts +93 -0
- package/tests/routes/types.test.ts +129 -0
- package/tests/routes/versions.test.ts +177 -0
- package/tests/routes/wellknown.test.ts +71 -0
- package/tests/setup.ts +154 -0
- package/tests/shutdown.test.ts +121 -0
- package/tests/stack.test.ts +198 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { generateDidKeypair } from '@haverstack/core/did';
|
|
3
|
+
import { signAuthChallenge, base64urlEncode } from '@haverstack/core/wire';
|
|
4
|
+
import type { DidKeypair } from '@haverstack/core/did';
|
|
5
|
+
import {
|
|
6
|
+
buildTestApp,
|
|
7
|
+
req,
|
|
8
|
+
TEST_BASE_URL,
|
|
9
|
+
TEST_TOKEN,
|
|
10
|
+
OTHER_ENTITY_ID,
|
|
11
|
+
type TestApp,
|
|
12
|
+
} from '../setup.js';
|
|
13
|
+
|
|
14
|
+
const NOTE_TYPE_ID = 'com.example.test/note@1';
|
|
15
|
+
|
|
16
|
+
async function challengeAndSign(
|
|
17
|
+
t: TestApp,
|
|
18
|
+
keypair: DidKeypair,
|
|
19
|
+
opts: { origin?: string; overrideNonce?: string } = {},
|
|
20
|
+
) {
|
|
21
|
+
const { data } = await req(t.app, 'POST', '/auth/challenge', {
|
|
22
|
+
body: { did: keypair.did },
|
|
23
|
+
});
|
|
24
|
+
const { nonce } = data as { nonce: string; expiresAt: string };
|
|
25
|
+
const signedNonce = opts.overrideNonce ?? nonce;
|
|
26
|
+
const signature = await signAuthChallenge(keypair.privateKey, {
|
|
27
|
+
origin: opts.origin ?? TEST_BASE_URL,
|
|
28
|
+
did: keypair.did,
|
|
29
|
+
nonce: signedNonce,
|
|
30
|
+
});
|
|
31
|
+
return { nonce: signedNonce, signature: base64urlEncode(signature) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('POST /auth/challenge', () => {
|
|
35
|
+
let t: TestApp;
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
t = await buildTestApp();
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await t.cleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('issues a nonce for a well-formed DID, unauthenticated', async () => {
|
|
44
|
+
const keypair = await generateDidKeypair();
|
|
45
|
+
const { status, data } = await req(t.app, 'POST', '/auth/challenge', {
|
|
46
|
+
body: { did: keypair.did },
|
|
47
|
+
});
|
|
48
|
+
expect(status).toBe(200);
|
|
49
|
+
const d = data as { nonce: string; expiresAt: string };
|
|
50
|
+
expect(d.nonce).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
51
|
+
expect(new Date(d.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('issues a fresh, distinct nonce on each call', async () => {
|
|
55
|
+
const keypair = await generateDidKeypair();
|
|
56
|
+
const first = await req(t.app, 'POST', '/auth/challenge', { body: { did: keypair.did } });
|
|
57
|
+
const second = await req(t.app, 'POST', '/auth/challenge', { body: { did: keypair.did } });
|
|
58
|
+
expect((first.data as { nonce: string }).nonce).not.toBe(
|
|
59
|
+
(second.data as { nonce: string }).nonce,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects a malformed DID with 400 invalid_did', async () => {
|
|
64
|
+
const { status, data } = await req(t.app, 'POST', '/auth/challenge', {
|
|
65
|
+
body: { did: 'not-a-did' },
|
|
66
|
+
});
|
|
67
|
+
expect(status).toBe(400);
|
|
68
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects a missing did with 400 invalid_did', async () => {
|
|
72
|
+
const { status, data } = await req(t.app, 'POST', '/auth/challenge', { body: {} });
|
|
73
|
+
expect(status).toBe(400);
|
|
74
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('POST /auth/token', () => {
|
|
79
|
+
let t: TestApp;
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
t = await buildTestApp();
|
|
82
|
+
});
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
await t.cleanup();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('issues an undelegated bearer token for a valid handshake', async () => {
|
|
88
|
+
const keypair = await generateDidKeypair();
|
|
89
|
+
const { nonce, signature } = await challengeAndSign(t, keypair);
|
|
90
|
+
|
|
91
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
92
|
+
body: { did: keypair.did, nonce, signature },
|
|
93
|
+
});
|
|
94
|
+
expect(status).toBe(200);
|
|
95
|
+
const d = data as {
|
|
96
|
+
token: string;
|
|
97
|
+
expiresAt?: string;
|
|
98
|
+
principalId: string;
|
|
99
|
+
subjectId: string;
|
|
100
|
+
};
|
|
101
|
+
expect(typeof d.token).toBe('string');
|
|
102
|
+
expect(d.principalId).toBe(keypair.did);
|
|
103
|
+
expect(d.subjectId).toBe(keypair.did);
|
|
104
|
+
|
|
105
|
+
// The issued token actually authenticates as that DID.
|
|
106
|
+
const session = await t.ctx.tokens.lookupToken(d.token);
|
|
107
|
+
expect(session).toEqual({ principalId: keypair.did, subjectId: keypair.did });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('never lets the client name its own subject, even if it tries', async () => {
|
|
111
|
+
const keypair = await generateDidKeypair();
|
|
112
|
+
const { nonce, signature } = await challengeAndSign(t, keypair);
|
|
113
|
+
|
|
114
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
115
|
+
body: {
|
|
116
|
+
did: keypair.did,
|
|
117
|
+
nonce,
|
|
118
|
+
signature,
|
|
119
|
+
onBehalfOf: 'did:key:someone-else',
|
|
120
|
+
subjectId: 'did:key:someone-else',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(status).toBe(200);
|
|
124
|
+
const d = data as { principalId: string; subjectId: string };
|
|
125
|
+
expect(d.principalId).toBe(keypair.did);
|
|
126
|
+
expect(d.subjectId).toBe(keypair.did);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('is single-use: redeeming the same nonce twice fails the second time', async () => {
|
|
130
|
+
const keypair = await generateDidKeypair();
|
|
131
|
+
const { nonce, signature } = await challengeAndSign(t, keypair);
|
|
132
|
+
|
|
133
|
+
const first = await req(t.app, 'POST', '/auth/token', {
|
|
134
|
+
body: { did: keypair.did, nonce, signature },
|
|
135
|
+
});
|
|
136
|
+
expect(first.status).toBe(200);
|
|
137
|
+
|
|
138
|
+
const second = await req(t.app, 'POST', '/auth/token', {
|
|
139
|
+
body: { did: keypair.did, nonce, signature },
|
|
140
|
+
});
|
|
141
|
+
expect(second.status).toBe(401);
|
|
142
|
+
expect((second.data as { error: { code: string } }).error.code).toBe('unknown_nonce');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('rejects a nonce that was never issued with 401 unknown_nonce', async () => {
|
|
146
|
+
const keypair = await generateDidKeypair();
|
|
147
|
+
const signature = await signAuthChallenge(keypair.privateKey, {
|
|
148
|
+
origin: TEST_BASE_URL,
|
|
149
|
+
did: keypair.did,
|
|
150
|
+
nonce: 'never-issued-nonce',
|
|
151
|
+
});
|
|
152
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
153
|
+
body: {
|
|
154
|
+
did: keypair.did,
|
|
155
|
+
nonce: 'never-issued-nonce',
|
|
156
|
+
signature: base64urlEncode(signature),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
expect(status).toBe(401);
|
|
160
|
+
expect((data as { error: { code: string } }).error.code).toBe('unknown_nonce');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects a nonce bound to a different DID with 401 unknown_nonce, not invalid_signature', async () => {
|
|
164
|
+
const owner = await generateDidKeypair();
|
|
165
|
+
const impostor = await generateDidKeypair();
|
|
166
|
+
// Challenge is issued to `owner`, but `impostor` signs it correctly
|
|
167
|
+
// with its own key over the same nonce and origin.
|
|
168
|
+
const { data } = await req(t.app, 'POST', '/auth/challenge', { body: { did: owner.did } });
|
|
169
|
+
const { nonce } = data as { nonce: string };
|
|
170
|
+
const signature = await signAuthChallenge(impostor.privateKey, {
|
|
171
|
+
origin: TEST_BASE_URL,
|
|
172
|
+
did: impostor.did,
|
|
173
|
+
nonce,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const { status, data: body } = await req(t.app, 'POST', '/auth/token', {
|
|
177
|
+
body: { did: impostor.did, nonce, signature: base64urlEncode(signature) },
|
|
178
|
+
});
|
|
179
|
+
expect(status).toBe(401);
|
|
180
|
+
expect((body as { error: { code: string } }).error.code).toBe('unknown_nonce');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rejects an expired nonce with 401 expired_nonce', async () => {
|
|
184
|
+
const keypair = await generateDidKeypair();
|
|
185
|
+
const nonce = 'seeded-expired-nonce';
|
|
186
|
+
t.ctx.nonces.seedForTesting(nonce, keypair.did, new Date(Date.now() - 1000));
|
|
187
|
+
const signature = await signAuthChallenge(keypair.privateKey, {
|
|
188
|
+
origin: TEST_BASE_URL,
|
|
189
|
+
did: keypair.did,
|
|
190
|
+
nonce,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
194
|
+
body: { did: keypair.did, nonce, signature: base64urlEncode(signature) },
|
|
195
|
+
});
|
|
196
|
+
expect(status).toBe(401);
|
|
197
|
+
expect((data as { error: { code: string } }).error.code).toBe('expired_nonce');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects a well-formed but wrong signature with 401 invalid_signature', async () => {
|
|
201
|
+
const signer = await generateDidKeypair();
|
|
202
|
+
const { nonce, signature } = await challengeAndSign(t, signer);
|
|
203
|
+
// Corrupt the signature bytes while keeping it syntactically valid
|
|
204
|
+
// base64url and the right length — still claims `signer`'s DID and the
|
|
205
|
+
// nonce issued to it, so this pins verification failure specifically,
|
|
206
|
+
// distinct from the DID-binding case above.
|
|
207
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
208
|
+
body: { did: signer.did, nonce, signature: signature.slice(0, -4) + 'AAAA' },
|
|
209
|
+
});
|
|
210
|
+
expect(status).toBe(401);
|
|
211
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_signature');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('rejects a malformed (non-base64url) signature with 401 invalid_signature', async () => {
|
|
215
|
+
const keypair = await generateDidKeypair();
|
|
216
|
+
const { data } = await req(t.app, 'POST', '/auth/challenge', { body: { did: keypair.did } });
|
|
217
|
+
const { nonce } = data as { nonce: string };
|
|
218
|
+
|
|
219
|
+
const { status, data: body } = await req(t.app, 'POST', '/auth/token', {
|
|
220
|
+
body: { did: keypair.did, nonce, signature: 'not+valid/base64url==' },
|
|
221
|
+
});
|
|
222
|
+
expect(status).toBe(401);
|
|
223
|
+
expect((body as { error: { code: string } }).error.code).toBe('invalid_signature');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('rejects a malformed DID with 400 invalid_did before touching the nonce store', async () => {
|
|
227
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
228
|
+
body: { did: 'not-a-did', nonce: 'x', signature: 'y' },
|
|
229
|
+
});
|
|
230
|
+
expect(status).toBe(400);
|
|
231
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('builds the signed origin from server config, not a client-supplied Host header', async () => {
|
|
235
|
+
const keypair = await generateDidKeypair();
|
|
236
|
+
// Signed for the server's real configured origin (TEST_BASE_URL) while
|
|
237
|
+
// the request lies about its Host — must still succeed, since the
|
|
238
|
+
// server never derives the origin from the header.
|
|
239
|
+
const { nonce, signature } = await challengeAndSign(t, keypair);
|
|
240
|
+
const { status } = await req(t.app, 'POST', '/auth/token', {
|
|
241
|
+
body: { did: keypair.did, nonce, signature },
|
|
242
|
+
headers: { Host: 'evil.example.com', 'X-Forwarded-Host': 'evil.example.com' },
|
|
243
|
+
});
|
|
244
|
+
expect(status).toBe(200);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('rejects a signature scoped to a different origin even if a client claims it via headers', async () => {
|
|
248
|
+
const keypair = await generateDidKeypair();
|
|
249
|
+
// Signed for an origin that is *not* this server's configured one.
|
|
250
|
+
const { nonce, signature } = await challengeAndSign(t, keypair, {
|
|
251
|
+
origin: 'https://attacker.example.com',
|
|
252
|
+
});
|
|
253
|
+
const { status, data } = await req(t.app, 'POST', '/auth/token', {
|
|
254
|
+
body: { did: keypair.did, nonce, signature },
|
|
255
|
+
headers: { Host: 'attacker.example.com', 'X-Forwarded-Host': 'attacker.example.com' },
|
|
256
|
+
});
|
|
257
|
+
expect(status).toBe(401);
|
|
258
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_signature');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('handshake input hardening', () => {
|
|
263
|
+
let t: TestApp;
|
|
264
|
+
beforeEach(async () => {
|
|
265
|
+
t = await buildTestApp();
|
|
266
|
+
});
|
|
267
|
+
afterEach(async () => {
|
|
268
|
+
await t.cleanup();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// A DID whose method core cannot verify can never redeem the nonce a
|
|
272
|
+
// challenge would issue, so issuing one is pure storage spent on a
|
|
273
|
+
// handshake guaranteed to fail. Refused up front instead.
|
|
274
|
+
it.each(['did:web:example.com', 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'])(
|
|
275
|
+
'refuses to issue a challenge for a non-did:key DID (%s)',
|
|
276
|
+
async (did) => {
|
|
277
|
+
const { status, data } = await req(t.app, 'POST', '/auth/challenge', { body: { did } });
|
|
278
|
+
expect(status).toBe(400);
|
|
279
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// The generic DID grammar has no length bound, so without one an
|
|
284
|
+
// unauthenticated caller writes a body-limit's worth of "DID" into a
|
|
285
|
+
// nonce row per request, retained for the nonce TTL.
|
|
286
|
+
it('refuses an oversized DID and stores nothing for it', async () => {
|
|
287
|
+
const { status } = await req(t.app, 'POST', '/auth/challenge', {
|
|
288
|
+
body: { did: 'did:key:z' + 'a'.repeat(500_000) },
|
|
289
|
+
});
|
|
290
|
+
expect(status).toBe(400);
|
|
291
|
+
const rows = (
|
|
292
|
+
t.ctx.nonces as unknown as {
|
|
293
|
+
db: { prepare(sql: string): { get(): { n: number } } };
|
|
294
|
+
}
|
|
295
|
+
).db
|
|
296
|
+
.prepare('SELECT COUNT(*) AS n FROM nonces')
|
|
297
|
+
.get();
|
|
298
|
+
expect(rows.n).toBe(0);
|
|
299
|
+
// Tight timeout on purpose: refusing this rests on core screening the
|
|
300
|
+
// length before it decodes, and a lost screen surfaces as a hang rather
|
|
301
|
+
// than a wrong answer.
|
|
302
|
+
}, 5_000);
|
|
303
|
+
|
|
304
|
+
// `null` is valid JSON but not a readable body: indexing a field off it
|
|
305
|
+
// throws a bare TypeError, which surfaces as an unlabeled 500 — on an
|
|
306
|
+
// unauthenticated route.
|
|
307
|
+
it.each(['/auth/challenge', '/auth/token'])(
|
|
308
|
+
'answers 400, not 500, for a non-object JSON body (%s)',
|
|
309
|
+
async (path) => {
|
|
310
|
+
const res = await t.app.request(path, {
|
|
311
|
+
method: 'POST',
|
|
312
|
+
headers: { 'Content-Type': 'application/json' },
|
|
313
|
+
body: 'null',
|
|
314
|
+
});
|
|
315
|
+
expect(res.status).toBe(400);
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('handshake token bookkeeping', () => {
|
|
321
|
+
let t: TestApp;
|
|
322
|
+
beforeEach(async () => {
|
|
323
|
+
t = await buildTestApp();
|
|
324
|
+
});
|
|
325
|
+
afterEach(async () => {
|
|
326
|
+
await t.cleanup();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Without a label the owner's only view of what exists cannot tell a
|
|
330
|
+
// self-service token from one they minted deliberately.
|
|
331
|
+
it('labels handshake-issued tokens so the owner can tell them apart', async () => {
|
|
332
|
+
const keypair = await generateDidKeypair();
|
|
333
|
+
const { nonce, signature } = await challengeAndSign(t, keypair);
|
|
334
|
+
await req(t.app, 'POST', '/auth/token', { body: { did: keypair.did, nonce, signature } });
|
|
335
|
+
|
|
336
|
+
const { data } = await req(t.app, 'GET', '/tokens', { token: TEST_TOKEN });
|
|
337
|
+
const rows = (data as { tokens: Array<{ principalId: string; label: string | null }> }).tokens;
|
|
338
|
+
const issued = rows.find((r) => r.principalId === keypair.did);
|
|
339
|
+
expect(issued?.label).toBe('did-challenge');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// lookupToken() refuses an expired token but never deletes the row, and
|
|
343
|
+
// nothing else does either — so an unauthenticated route that mints one
|
|
344
|
+
// per call grows the table without bound.
|
|
345
|
+
it('reclaims expired token rows rather than letting them accumulate', async () => {
|
|
346
|
+
await t.ctx.tokens.createToken(OTHER_ENTITY_ID, { expiresAt: new Date(Date.now() - 1000) });
|
|
347
|
+
expect((await t.ctx.tokens.listTokens()).length).toBe(1);
|
|
348
|
+
|
|
349
|
+
const keypair = await generateDidKeypair();
|
|
350
|
+
const { nonce, signature } = await challengeAndSign(t, keypair);
|
|
351
|
+
await req(t.app, 'POST', '/auth/token', { body: { did: keypair.did, nonce, signature } });
|
|
352
|
+
|
|
353
|
+
const remaining = await t.ctx.tokens.listTokens();
|
|
354
|
+
expect(remaining.map((r) => r.principalId)).toEqual([keypair.did]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// The redeem is one DELETE ... RETURNING rather than a SELECT then a
|
|
358
|
+
// DELETE; the DID predicate sits in the WHERE clause precisely so a
|
|
359
|
+
// mismatched attempt still spends nothing.
|
|
360
|
+
it('does not let a wrong-DID attempt burn a nonce the rightful holder still needs', async () => {
|
|
361
|
+
const owner = await generateDidKeypair();
|
|
362
|
+
const impostor = await generateDidKeypair();
|
|
363
|
+
const { data } = await req(t.app, 'POST', '/auth/challenge', { body: { did: owner.did } });
|
|
364
|
+
const { nonce } = data as { nonce: string };
|
|
365
|
+
|
|
366
|
+
const impostorSig = await signAuthChallenge(impostor.privateKey, {
|
|
367
|
+
origin: TEST_BASE_URL,
|
|
368
|
+
did: impostor.did,
|
|
369
|
+
nonce,
|
|
370
|
+
});
|
|
371
|
+
const attempt = await req(t.app, 'POST', '/auth/token', {
|
|
372
|
+
body: { did: impostor.did, nonce, signature: base64urlEncode(impostorSig) },
|
|
373
|
+
});
|
|
374
|
+
expect(attempt.status).toBe(401);
|
|
375
|
+
|
|
376
|
+
const ownerSig = await signAuthChallenge(owner.privateKey, {
|
|
377
|
+
origin: TEST_BASE_URL,
|
|
378
|
+
did: owner.did,
|
|
379
|
+
nonce,
|
|
380
|
+
});
|
|
381
|
+
const { status } = await req(t.app, 'POST', '/auth/token', {
|
|
382
|
+
body: { did: owner.did, nonce, signature: base64urlEncode(ownerSig) },
|
|
383
|
+
});
|
|
384
|
+
expect(status).toBe(200);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// A present-but-invalid credential must 401, never a silent downgrade to
|
|
389
|
+
// anonymous: on an optional-auth route that would surface as a 200 with the
|
|
390
|
+
// public-only subset instead of telling the caller its session is dead.
|
|
391
|
+
describe('invalid bearer credentials', () => {
|
|
392
|
+
let t: TestApp;
|
|
393
|
+
beforeEach(async () => {
|
|
394
|
+
t = await buildTestApp();
|
|
395
|
+
await t.ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
|
|
396
|
+
body: { kind: 'text' as const, required: true as const },
|
|
397
|
+
});
|
|
398
|
+
await t.ctx.stack.create(
|
|
399
|
+
NOTE_TYPE_ID,
|
|
400
|
+
{ body: 'public content' },
|
|
401
|
+
{ permissions: [{ access: 'public' }] },
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
afterEach(async () => {
|
|
405
|
+
await t.cleanup();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('rejects an unknown token on an optional-auth route with 401, not an anonymous 200', async () => {
|
|
409
|
+
const { status, data } = await req(t.app, 'GET', '/records', { token: 'not-a-real-token' });
|
|
410
|
+
expect(status).toBe(401);
|
|
411
|
+
expect((data as { error: { code: string } }).error.code).toBe('unauthorized');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rejects an expired token on an optional-auth route with 401', async () => {
|
|
415
|
+
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID, {
|
|
416
|
+
expiresAt: new Date(Date.now() - 1000),
|
|
417
|
+
});
|
|
418
|
+
const { status } = await req(t.app, 'GET', '/records', { token });
|
|
419
|
+
expect(status).toBe(401);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('rejects a revoked token on an optional-auth route with 401', async () => {
|
|
423
|
+
const { id, token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
424
|
+
await t.ctx.tokens.revokeToken(id);
|
|
425
|
+
const { status } = await req(t.app, 'GET', '/records', { token });
|
|
426
|
+
expect(status).toBe(401);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('rejects a malformed Authorization header (non-Bearer scheme) with 401', async () => {
|
|
430
|
+
const { status } = await req(t.app, 'GET', '/records', {
|
|
431
|
+
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
|
|
432
|
+
});
|
|
433
|
+
expect(status).toBe(401);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// No credential at all is a different state from a bad one — it must
|
|
437
|
+
// still serve the public subset, not be swept up by the fix above.
|
|
438
|
+
it('still serves the public subset with no Authorization header at all', async () => {
|
|
439
|
+
const { status, data } = await req(t.app, 'GET', '/records');
|
|
440
|
+
expect(status).toBe(200);
|
|
441
|
+
expect((data as { records: unknown[] }).records.length).toBe(1);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('rejects an unknown token on a requireAuth() route with 401 too', async () => {
|
|
445
|
+
const { status } = await req(t.app, 'GET', '/entity', { token: 'not-a-real-token' });
|
|
446
|
+
expect(status).toBe(401);
|
|
447
|
+
});
|
|
448
|
+
});
|