@antasphere/chassis-server 1.3.0 → 1.5.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/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +17 -0
- package/dist/api/create-api.js.map +1 -1
- package/dist/apps/index.d.ts +11 -0
- package/dist/apps/index.d.ts.map +1 -0
- package/dist/apps/index.js +11 -0
- package/dist/apps/index.js.map +1 -0
- package/dist/apps/routes.d.ts +14 -0
- package/dist/apps/routes.d.ts.map +1 -0
- package/dist/apps/routes.js +23 -0
- package/dist/apps/routes.js.map +1 -0
- package/dist/apps/select.d.ts +64 -0
- package/dist/apps/select.d.ts.map +1 -0
- package/dist/apps/select.js +168 -0
- package/dist/apps/select.js.map +1 -0
- package/dist/apps/service.d.ts +74 -0
- package/dist/apps/service.d.ts.map +1 -0
- package/dist/apps/service.js +165 -0
- package/dist/apps/service.js.map +1 -0
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +5 -0
- package/dist/boot.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +2 -0
- package/dist/env.js.map +1 -1
- package/dist/identity/hub-grant.d.ts +70 -0
- package/dist/identity/hub-grant.d.ts.map +1 -1
- package/dist/identity/hub-grant.js +119 -14
- package/dist/identity/hub-grant.js.map +1 -1
- package/dist/identity/index.d.ts +1 -1
- package/dist/identity/index.d.ts.map +1 -1
- package/dist/identity/index.js +1 -1
- package/dist/identity/index.js.map +1 -1
- package/dist/testing/fake-hub.d.ts +20 -0
- package/dist/testing/fake-hub.d.ts.map +1 -1
- package/dist/testing/fake-hub.js +73 -1
- package/dist/testing/fake-hub.js.map +1 -1
- package/package.json +7 -3
- package/src/api/create-api.ts +21 -0
- package/src/apps/index.ts +17 -0
- package/src/apps/routes.ts +30 -0
- package/src/apps/select.ts +175 -0
- package/src/apps/service.ts +198 -0
- package/src/boot.ts +5 -0
- package/src/env.ts +2 -0
- package/src/identity/hub-grant.ts +187 -15
- package/src/identity/index.ts +6 -1
- package/src/testing/fake-hub.ts +83 -1
- package/test/integration/apps-route.test.ts +235 -0
- package/test/integration/hub-grant.test.ts +97 -0
- package/test/unit/apps.test.ts +544 -0
- package/test/unit/fake-hub-me-apps.test.ts +71 -0
- package/test/unit/hub-grant-refusal.test.ts +29 -0
- package/test/unit/hub-grant-sweep.test.ts +151 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
+
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
6
|
+
import {
|
|
7
|
+
createDatabase,
|
|
8
|
+
createTestApp,
|
|
9
|
+
extractCookie,
|
|
10
|
+
host,
|
|
11
|
+
readJson,
|
|
12
|
+
startPostgres,
|
|
13
|
+
SETUP_TOKEN,
|
|
14
|
+
type TestApp
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
import { FakeHub, type HubUserFixture } from '@antasphere/chassis-server/testing';
|
|
17
|
+
import { nextIp, ssoLogin } from './sso-helpers.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Apps page's route (`GET /apps`, `src/apps/`, PRDCT-3493) through the
|
|
21
|
+
* REAL boot of the minimal tool, on both editions:
|
|
22
|
+
*
|
|
23
|
+
* - self-hosted, with an operator's file: a browser session reads the list
|
|
24
|
+
* (this tool marked current, the account from the file's `account` entry,
|
|
25
|
+
* a non-http entry left out, the descriptor keys read), and a machine
|
|
26
|
+
* credential is refused before the handler, since no rule of the scope
|
|
27
|
+
* allowlist opens the path (fail-closed);
|
|
28
|
+
* - cloud, against the fake hub: the list is the hub's `GET /me/apps` as
|
|
29
|
+
* the signed-in person, asked for the workspace's hub organization, the
|
|
30
|
+
* tools the organization does not open to them left out, the descriptor
|
|
31
|
+
* carried; closing a tool at the hub takes it out of the list once the
|
|
32
|
+
* five-minute hold has passed (a fresh person reads it at once); a person
|
|
33
|
+
* whose organization the hub suspended reads an empty list that names the
|
|
34
|
+
* organization and its status, never the tools.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const OWNER = { email: 'owner@apps.test', name: 'Apps Owner', password: 'apps-owner-password-1' };
|
|
38
|
+
const DESCRIPTOR = {
|
|
39
|
+
description: 'Where the company keeps its figures.',
|
|
40
|
+
icon: 'landmark',
|
|
41
|
+
drawing: 'ledger',
|
|
42
|
+
accent: '#B5542A'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
let container: StartedPostgreSqlContainer;
|
|
46
|
+
|
|
47
|
+
const send = (app: TestApp, method: string, path: string, headers: Record<string, string>, body?: unknown) =>
|
|
48
|
+
app.app.request(`/api/v1${path}`, {
|
|
49
|
+
method,
|
|
50
|
+
headers: {
|
|
51
|
+
'x-forwarded-for': nextIp(),
|
|
52
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
53
|
+
...headers
|
|
54
|
+
},
|
|
55
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
container = await startPostgres();
|
|
60
|
+
}, 180_000);
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await container?.stop();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('GET /apps on a self-hosted instance with an operator file', () => {
|
|
67
|
+
let app: TestApp;
|
|
68
|
+
let cookie = '';
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
const dir = mkdtempSync(join(tmpdir(), 'apps-route-'));
|
|
72
|
+
const appsFile = join(dir, 'apps.json');
|
|
73
|
+
writeFileSync(
|
|
74
|
+
appsFile,
|
|
75
|
+
JSON.stringify([
|
|
76
|
+
{ slug: 'account', name: 'Antasphere account', launchUrl: 'https://account.example.com' },
|
|
77
|
+
{
|
|
78
|
+
slug: 'finance',
|
|
79
|
+
name: 'Antasphere Finance',
|
|
80
|
+
launchUrl: 'https://finance.example.com',
|
|
81
|
+
...DESCRIPTOR
|
|
82
|
+
},
|
|
83
|
+
{ slug: host.identity.slug, name: 'This Tool', launchUrl: 'https://thistool.example.com' },
|
|
84
|
+
{ slug: 'crm', name: 'Antasphere CRM', launchUrl: 'javascript:alert(1)' }
|
|
85
|
+
])
|
|
86
|
+
);
|
|
87
|
+
app = await createTestApp(await createDatabase(container, 'apps_route_oss'), { APPS_FILE: appsFile });
|
|
88
|
+
const setup = await send(
|
|
89
|
+
app,
|
|
90
|
+
'POST',
|
|
91
|
+
'/setup',
|
|
92
|
+
{},
|
|
93
|
+
{ setupToken: SETUP_TOKEN, instanceName: 'Apps', owner: OWNER }
|
|
94
|
+
);
|
|
95
|
+
expect(setup.status).toBe(201);
|
|
96
|
+
const signIn = await send(
|
|
97
|
+
app,
|
|
98
|
+
'POST',
|
|
99
|
+
'/auth/sign-in/email',
|
|
100
|
+
{},
|
|
101
|
+
{ email: OWNER.email, password: OWNER.password }
|
|
102
|
+
);
|
|
103
|
+
expect(signIn.status).toBe(200);
|
|
104
|
+
cookie = extractCookie(signIn);
|
|
105
|
+
}, 120_000);
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
await app?.stop();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('a session reads the file: the account, the tools with their descriptor, this tool current, the bad URL left out', async () => {
|
|
112
|
+
const res = await send(app, 'GET', '/apps', { cookie });
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
const body = await readJson(res);
|
|
115
|
+
expect(body.source).toBe('file');
|
|
116
|
+
expect(body.hub).toEqual({ url: 'https://account.example.com/', name: 'Antasphere account' });
|
|
117
|
+
expect(body.apps.map((a: { slug: string; current: boolean }) => [a.slug, a.current])).toEqual([
|
|
118
|
+
['finance', false],
|
|
119
|
+
[host.identity.slug, true]
|
|
120
|
+
]);
|
|
121
|
+
expect(body.apps[0].descriptor).toEqual(DESCRIPTOR);
|
|
122
|
+
expect(body.apps[1]).not.toHaveProperty('descriptor');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('a machine credential is refused before the handler, and no credential answers 401', async () => {
|
|
126
|
+
const minted = await send(
|
|
127
|
+
app,
|
|
128
|
+
'POST',
|
|
129
|
+
'/api-keys',
|
|
130
|
+
{ cookie },
|
|
131
|
+
{ name: 'reader', scopes: [host.scopes.read] }
|
|
132
|
+
);
|
|
133
|
+
expect(minted.status).toBe(201);
|
|
134
|
+
const key = (await readJson(minted)).key as string;
|
|
135
|
+
const asKey = await send(app, 'GET', '/apps', { authorization: `Bearer ${key}` });
|
|
136
|
+
expect(asKey.status).toBe(403);
|
|
137
|
+
expect((await readJson(asKey)).error.code).toBe('endpoint_not_allowed');
|
|
138
|
+
const anon = await send(app, 'GET', '/apps', {});
|
|
139
|
+
expect(anon.status).toBe(401);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('GET /apps on the cloud edition, against the hub', () => {
|
|
144
|
+
let hub: FakeHub;
|
|
145
|
+
let app: TestApp;
|
|
146
|
+
const ORG = '11111111-2222-4333-8444-555555555555';
|
|
147
|
+
const mira: HubUserFixture = {
|
|
148
|
+
sub: 'hub-user-mira',
|
|
149
|
+
email: 'mira@apps.test',
|
|
150
|
+
name: 'Mira Lund',
|
|
151
|
+
workspaceId: ORG,
|
|
152
|
+
role: 'member',
|
|
153
|
+
workspaceName: 'Acme'
|
|
154
|
+
};
|
|
155
|
+
const omar: HubUserFixture = {
|
|
156
|
+
...mira,
|
|
157
|
+
sub: 'hub-user-omar',
|
|
158
|
+
email: 'omar@apps.test',
|
|
159
|
+
name: 'Omar Diallo'
|
|
160
|
+
};
|
|
161
|
+
const lea: HubUserFixture = {
|
|
162
|
+
...mira,
|
|
163
|
+
sub: 'hub-user-lea',
|
|
164
|
+
email: 'lea@apps.test',
|
|
165
|
+
name: 'Léa Martin'
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
beforeAll(async () => {
|
|
169
|
+
hub = await FakeHub.start({ clientId: host.hubClientId });
|
|
170
|
+
hub.setTools([
|
|
171
|
+
{
|
|
172
|
+
slug: 'finance',
|
|
173
|
+
name: 'Antasphere Finance',
|
|
174
|
+
launchUrl: 'https://finance.example.com',
|
|
175
|
+
descriptor: DESCRIPTOR
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
slug: 'hiring',
|
|
179
|
+
name: 'Antasphere Hiring',
|
|
180
|
+
launchUrl: 'https://hiring.example.com',
|
|
181
|
+
descriptor: { icon: 'users' }
|
|
182
|
+
},
|
|
183
|
+
{ slug: host.identity.slug, name: 'This Tool', launchUrl: 'https://thistool.example.com' }
|
|
184
|
+
]);
|
|
185
|
+
hub.setToolClosed(omar.sub, 'hiring', ORG);
|
|
186
|
+
app = await createTestApp(await createDatabase(container, 'apps_route_cloud'), {
|
|
187
|
+
EDITION: 'cloud',
|
|
188
|
+
HUB_ISSUER_URL: hub.issuer,
|
|
189
|
+
HUB_CLIENT_ID: host.hubClientId,
|
|
190
|
+
HUB_CLIENT_SECRET: 'integration-test-hub-secret-0001'
|
|
191
|
+
});
|
|
192
|
+
}, 180_000);
|
|
193
|
+
|
|
194
|
+
afterAll(async () => {
|
|
195
|
+
await app?.stop();
|
|
196
|
+
await hub?.stop();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('a person signed in through the hub reads the catalogue for her organization, with the descriptors, this tool current', async () => {
|
|
200
|
+
const cookie = await ssoLogin(app, hub, mira);
|
|
201
|
+
const res = await send(app, 'GET', '/apps', { cookie });
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
const body = await readJson(res);
|
|
204
|
+
expect(body.source).toBe('hub');
|
|
205
|
+
expect(body.hub).toEqual({ url: `${hub.issuer}/`, name: 'Antasphere Account' });
|
|
206
|
+
expect(body.apps.map((a: { slug: string; current: boolean }) => [a.slug, a.current])).toEqual([
|
|
207
|
+
['finance', false],
|
|
208
|
+
['hiring', false],
|
|
209
|
+
[host.identity.slug, true]
|
|
210
|
+
]);
|
|
211
|
+
expect(body.apps[0].descriptor).toEqual(DESCRIPTOR);
|
|
212
|
+
expect(body.apps[1].descriptor).toEqual({ icon: 'users' });
|
|
213
|
+
// asked as the person, for the workspace's hub organization
|
|
214
|
+
const last = hub.meAppsRequests.at(-1)!;
|
|
215
|
+
expect(last.orgId).toBe(ORG);
|
|
216
|
+
expect(last.auth).toMatch(/^Bearer /);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('a tool the organization does not open to the person is left out of her list', async () => {
|
|
220
|
+
const cookie = await ssoLogin(app, hub, omar);
|
|
221
|
+
const body = await readJson(await send(app, 'GET', '/apps', { cookie }));
|
|
222
|
+
expect(body.source).toBe('hub');
|
|
223
|
+
expect(body.apps.map((a: { slug: string }) => a.slug)).toEqual(['finance', host.identity.slug]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('a person whose organization the hub suspended reads an empty list that says so, never the tools', async () => {
|
|
227
|
+
const cookie = await ssoLogin(app, hub, lea);
|
|
228
|
+
hub.setUserOrg(lea.sub, ORG, { name: 'Acme', role: 'member', status: 'suspended' });
|
|
229
|
+
const body = await readJson(await send(app, 'GET', '/apps', { cookie }));
|
|
230
|
+
expect(body.source).toBe('hub');
|
|
231
|
+
expect(body.apps).toEqual([]);
|
|
232
|
+
expect(body.organization).toEqual({ name: 'Acme', status: 'suspended' });
|
|
233
|
+
expect(body.hub).toEqual({ url: `${hub.issuer}/`, name: 'Antasphere Account' });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -503,3 +503,100 @@ describe('ambiguous outcomes: the presentation record + the RFC 7662 probe (PRDC
|
|
|
503
503
|
expect((await makeService(FAST).accessToken(aliceId)).kind).toBe('ok');
|
|
504
504
|
});
|
|
505
505
|
});
|
|
506
|
+
|
|
507
|
+
/** Bring the stored access token to the renewal window (two minutes left) — the row is what a renewal judges. */
|
|
508
|
+
async function nearExpiryStoredAccess(): Promise<void> {
|
|
509
|
+
await app.db.pool.query(
|
|
510
|
+
`UPDATE account SET access_token_expires_at = now() + interval '2 minutes'
|
|
511
|
+
WHERE provider_id = 'antasphere' AND user_id = $1`,
|
|
512
|
+
[aliceId]
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
describe('the background renewal (PRDCT-3584, verifier round 5)', () => {
|
|
517
|
+
it('keeps serving the cached token while the hub answers the rotation', async () => {
|
|
518
|
+
const service = makeService();
|
|
519
|
+
await sso.ssoLogin(app, hub, alice);
|
|
520
|
+
const row = await grantRow();
|
|
521
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
522
|
+
// the token is about to expire: inside the renewal window, outside the on-demand skew
|
|
523
|
+
service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
524
|
+
await nearExpiryStoredAccess();
|
|
525
|
+
hub.tokenDelayMs = 1_200;
|
|
526
|
+
try {
|
|
527
|
+
const before = hub.refreshCount();
|
|
528
|
+
const renewal = service.renew(aliceId);
|
|
529
|
+
const t0 = Date.now();
|
|
530
|
+
const during = await service.accessToken(aliceId);
|
|
531
|
+
const waited = Date.now() - t0;
|
|
532
|
+
expect(during).toEqual({ kind: 'ok', accessToken: current });
|
|
533
|
+
expect(waited).toBeLessThan(300);
|
|
534
|
+
const out = await renewal;
|
|
535
|
+
expect(out.kind).toBe('ok');
|
|
536
|
+
expect(hub.refreshCount()).toBe(before + 1);
|
|
537
|
+
// the renewed token is what the next demand serves, without a hub call
|
|
538
|
+
const after = await service.accessToken(aliceId);
|
|
539
|
+
expect(after.kind).toBe('ok');
|
|
540
|
+
expect((after as { accessToken: string }).accessToken).not.toBe(current);
|
|
541
|
+
expect(hub.refreshCount()).toBe(before + 1);
|
|
542
|
+
} finally {
|
|
543
|
+
hub.tokenDelayMs = 0;
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('a transient answer leaves the cached token in place', async () => {
|
|
548
|
+
const service = makeService();
|
|
549
|
+
await sso.ssoLogin(app, hub, alice);
|
|
550
|
+
const row = await grantRow();
|
|
551
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
552
|
+
service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
553
|
+
await nearExpiryStoredAccess();
|
|
554
|
+
hub.tokenMode = 'http500';
|
|
555
|
+
try {
|
|
556
|
+
expect(await service.renew(aliceId)).toEqual({ kind: 'inconclusive' });
|
|
557
|
+
} finally {
|
|
558
|
+
hub.tokenMode = 'ok';
|
|
559
|
+
}
|
|
560
|
+
const before = hub.refreshCount();
|
|
561
|
+
expect(await service.accessToken(aliceId)).toEqual({ kind: 'ok', accessToken: current });
|
|
562
|
+
expect(hub.refreshCount()).toBe(before);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('a sibling replica adopts the fresh token the first one stored instead of rotating again', async () => {
|
|
566
|
+
const a = makeService();
|
|
567
|
+
const b = makeService();
|
|
568
|
+
await sso.ssoLogin(app, hub, alice);
|
|
569
|
+
const row = await grantRow();
|
|
570
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
571
|
+
a.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
572
|
+
b.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
573
|
+
await nearExpiryStoredAccess();
|
|
574
|
+
const before = hub.refreshCount();
|
|
575
|
+
expect((await a.renew(aliceId)).kind).toBe('ok');
|
|
576
|
+
expect(hub.refreshCount()).toBe(before + 1);
|
|
577
|
+
const adopted = await b.renew(aliceId);
|
|
578
|
+
expect(adopted.kind).toBe('ok');
|
|
579
|
+
expect(hub.refreshCount()).toBe(before + 1); // no second rotation
|
|
580
|
+
const fresh = await grantRow();
|
|
581
|
+
expect((adopted as { accessToken: string }).accessToken).toBe(
|
|
582
|
+
await symmetricDecrypt({ key: AUTH_SECRET, data: fresh.access_token! })
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('a refused renewal marks the grant dead like a refused refresh', async () => {
|
|
587
|
+
const service = makeService();
|
|
588
|
+
await sso.ssoLogin(app, hub, alice);
|
|
589
|
+
const row = await grantRow();
|
|
590
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
591
|
+
service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
592
|
+
await nearExpiryStoredAccess();
|
|
593
|
+
hub.tokenMode = 'invalid_grant';
|
|
594
|
+
try {
|
|
595
|
+
expect(await service.renew(aliceId)).toEqual({ kind: 'grant_dead' });
|
|
596
|
+
} finally {
|
|
597
|
+
hub.tokenMode = 'ok';
|
|
598
|
+
}
|
|
599
|
+
expect(await service.accessToken(aliceId)).toEqual({ kind: 'grant_dead' });
|
|
600
|
+
await sso.ssoLogin(app, hub, alice); // heal for any later suite
|
|
601
|
+
});
|
|
602
|
+
});
|