@spooky-sync/core 0.0.1-canary.260 → 0.0.1-canary.261
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.d.ts +159 -2
- package/dist/index.js +522 -27
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/boot/bucket-switch.saga.test.ts +30 -1
- package/src/boot/bucket-switch.saga.ts +17 -1
- package/src/client/services.ts +4 -0
- package/src/client/sp00ky-client.ts +8 -0
- package/src/kernel/effects.ts +2 -0
- package/src/modules/auth/events/index.ts +7 -1
- package/src/modules/auth/impersonation-banner.ts +112 -0
- package/src/modules/auth/impersonation.test.ts +282 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +240 -36
- package/src/modules/devtools/index.ts +71 -1
- package/src/services/database/cache-engine.ts +6 -0
- package/src/services/database/local.ts +7 -0
- package/src/services/database/sqlite-cache-engine.ts +17 -0
- package/src/testing/fake-services.ts +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -299,6 +299,12 @@ interface LocalStore extends LocalCacheEngine {
|
|
|
299
299
|
* client facade, so adding this needs no change on their side.
|
|
300
300
|
*/
|
|
301
301
|
readonly storageHealth?: StorageHealth;
|
|
302
|
+
/**
|
|
303
|
+
* Delete a bucket's persisted store. Never the open one. OPTIONAL and
|
|
304
|
+
* best-effort: used to remove an impersonated user's data from the admin's
|
|
305
|
+
* device once the impersonation ends.
|
|
306
|
+
*/
|
|
307
|
+
dropBucket?(bucketId: string): Promise<void>;
|
|
302
308
|
/** Fires immediately with the current snapshot, then on every change.
|
|
303
309
|
* Returns an unsubscribe function. */
|
|
304
310
|
subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.261",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
67
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
66
|
+
"@spooky-sync/query-builder": "0.0.1-canary.261",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.261",
|
|
68
68
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
69
69
|
"@surrealdb/wasm": "^3.0.3",
|
|
70
70
|
"blurhash": "^2.0.5",
|
|
@@ -5,7 +5,7 @@ import { fakeServices } from '../testing/services';
|
|
|
5
5
|
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
6
6
|
import * as R from '../state/reducers';
|
|
7
7
|
import { defaultEnv } from '../query/env';
|
|
8
|
-
import { bucketSwitch, rebindQueries } from './bucket-switch.saga';
|
|
8
|
+
import { bucketSwitch, dropImpersonatedBucket, rebindQueries } from './bucket-switch.saga';
|
|
9
9
|
|
|
10
10
|
const env = defaultEnv({ tables: [] } as any);
|
|
11
11
|
const base = (over: Partial<ReturnType<typeof buildState>> = {}) => ({ ...buildState(), pendingBucket: 'u2', bucketId: 'u1', ...over });
|
|
@@ -57,6 +57,7 @@ describe('bucketSwitch', () => {
|
|
|
57
57
|
'auth.sessionAuthId',
|
|
58
58
|
'auth.access',
|
|
59
59
|
'ssp.setSessionAuth',
|
|
60
|
+
'auth.consumeEndedImpersonation',
|
|
60
61
|
'auth.token',
|
|
61
62
|
'persistence.set',
|
|
62
63
|
]);
|
|
@@ -128,6 +129,34 @@ describe('bucketSwitch', () => {
|
|
|
128
129
|
});
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
describe('dropImpersonatedBucket', () => {
|
|
133
|
+
it('drops the bucket it just left when that was the impersonated user', async () => {
|
|
134
|
+
const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => 'user:bob' });
|
|
135
|
+
await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
|
|
136
|
+
expect(svc.calls).toEqual([
|
|
137
|
+
['auth.consumeEndedImpersonation', []],
|
|
138
|
+
['local.dropBucket', ['bob']],
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
it('leaves every other bucket alone', async () => {
|
|
142
|
+
for (const [ended, left] of [[null, 'bob'], ['user:bob', 'alice'], ['user:bob', 'anon']] as const) {
|
|
143
|
+
const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => ended });
|
|
144
|
+
await runPure(dropImpersonatedBucket(left), { state: buildState(), handlers: { service: svc.handler } });
|
|
145
|
+
expect(svc.names()).toEqual(['auth.consumeEndedImpersonation']);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
it('logs a failed delete instead of failing the switch', async () => {
|
|
149
|
+
const svc = fakeServices({
|
|
150
|
+
'auth.consumeEndedImpersonation': () => 'user:bob',
|
|
151
|
+
'local.dropBucket': async () => {
|
|
152
|
+
throw new Error('handles still open');
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const out = await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
|
|
156
|
+
expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(1);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
131
160
|
describe('rebindQueries', () => {
|
|
132
161
|
it('re-seeds from the new store, rebuilds the local view (tolerating failures), emits authority flips', async () => {
|
|
133
162
|
const s = R.compose(
|
|
@@ -5,7 +5,7 @@ import { fx } from '../kernel/effects';
|
|
|
5
5
|
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
6
6
|
import { seedLifecycle, isAuthoritative } from '../state/lifecycle';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
|
-
import { ANON_USER_ID } from '../modules/ref-tables';
|
|
8
|
+
import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
|
|
9
9
|
import type { SagaEnv } from '../query/env';
|
|
10
10
|
import { queryHashInput } from '../query/hash';
|
|
11
11
|
import { isResolvedBefore, parseViewRow } from '../query/membership';
|
|
@@ -62,6 +62,7 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
|
|
|
62
62
|
} finally {
|
|
63
63
|
gate();
|
|
64
64
|
}
|
|
65
|
+
yield* dropImpersonatedBucket(current);
|
|
65
66
|
const token = (yield fx.service('auth.token')) as string | null;
|
|
66
67
|
if (token) {
|
|
67
68
|
try {
|
|
@@ -82,6 +83,21 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
|
|
|
82
83
|
yield fx.dispatch({ type: 'Drain' });
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* An impersonation just ended and this switch left its bucket: delete that
|
|
88
|
+
* user's local data from the admin's device. Best-effort; a failure only
|
|
89
|
+
* leaves rows the server already let this admin read.
|
|
90
|
+
*/
|
|
91
|
+
export function* dropImpersonatedBucket(left: string): Saga<void> {
|
|
92
|
+
const ended = (yield fx.service('auth.consumeEndedImpersonation')) as string | null;
|
|
93
|
+
if (!ended || left === ANON_USER_ID || bucketIdForUser(ended) !== left) return;
|
|
94
|
+
try {
|
|
95
|
+
yield fx.service('local.dropBucket', left);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'could not delete the impersonated bucket', data: { bucket: left, error } });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
/** Re-seed every active query from the new store and rebuild its SSP view. */
|
|
86
102
|
export function* rebindQueries(): Saga<void> {
|
|
87
103
|
const [entries, sessionId] = (yield fx.state.read((s) => [[...s.queries.values()], s.sessionId])) as [QueryEntry[], string | null];
|
package/src/client/services.ts
CHANGED
|
@@ -249,6 +249,9 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
249
249
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
250
250
|
'local.currentBucketId': () => s.local.currentBucketId,
|
|
251
251
|
'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
|
|
252
|
+
'local.dropBucket': async (bucketId) => {
|
|
253
|
+
await s.local.dropBucket?.(bucketId);
|
|
254
|
+
},
|
|
252
255
|
'migrator.provision': () => s.migrator.provision(config.schemaSurql),
|
|
253
256
|
'blobs.start': async (bucketId) => {
|
|
254
257
|
s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
|
|
@@ -278,6 +281,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
278
281
|
'auth.access': () => s.auth.access,
|
|
279
282
|
'auth.token': () => s.auth.token,
|
|
280
283
|
'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
|
|
284
|
+
'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
|
|
281
285
|
'remote.connect': () => s.remote.connect(),
|
|
282
286
|
'remote.releaseViews': (ids) => {
|
|
283
287
|
const list = ids
|
|
@@ -54,6 +54,7 @@ import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../k
|
|
|
54
54
|
import { Runtime } from './runtime';
|
|
55
55
|
import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
|
|
56
56
|
import { BucketHandle } from './bucket-handle';
|
|
57
|
+
import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
|
|
57
58
|
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
58
59
|
|
|
59
60
|
const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
|
|
@@ -78,6 +79,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
78
79
|
private readonly runtime: Runtime;
|
|
79
80
|
private readonly featureFlags: FeatureFlagModule<S>;
|
|
80
81
|
private readonly appReleases: AppReleaseModule<S>;
|
|
82
|
+
/** Teardown for subscriptions the client itself owns. */
|
|
83
|
+
private readonly disposers: Array<() => void> = [];
|
|
81
84
|
private readonly devTools: DevToolsService;
|
|
82
85
|
private hub: LeaderSyncHub | null = null;
|
|
83
86
|
private forwarder: SyncForwarder | null = null;
|
|
@@ -174,6 +177,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
174
177
|
s.streamProcessor.addReceiver(receiver);
|
|
175
178
|
s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
|
|
176
179
|
s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
|
|
180
|
+
// The impersonation banner is not optional: whoever is looking at the page
|
|
181
|
+
// must be able to tell, and leave, whatever the app renders.
|
|
182
|
+
const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
|
|
183
|
+
this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
|
|
177
184
|
this.runtime.on('tabs:broadcast', (e) => {
|
|
178
185
|
if (e.type !== 'tabs:broadcast') return;
|
|
179
186
|
const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
|
|
@@ -224,6 +231,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
224
231
|
async close(): Promise<void> {
|
|
225
232
|
const s = this.services;
|
|
226
233
|
s.connectionSupervisor.dispose();
|
|
234
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
227
235
|
this.runtime.dispose();
|
|
228
236
|
await this.featureFlags.closeAll();
|
|
229
237
|
await this.appReleases.closeAll();
|
package/src/kernel/effects.ts
CHANGED
|
@@ -75,6 +75,7 @@ export interface ServiceCalls {
|
|
|
75
75
|
'local.beginSwitch': () => () => void;
|
|
76
76
|
'local.currentBucketId': () => string;
|
|
77
77
|
'local.usesSurqlSchema': () => boolean;
|
|
78
|
+
'local.dropBucket': (bucketId: string) => Promise<void>;
|
|
78
79
|
'migrator.provision': () => Promise<void>;
|
|
79
80
|
'blobs.start': (bucketId: string) => Promise<void>;
|
|
80
81
|
'blobs.setNamespace': (bucketId: string) => Promise<void>;
|
|
@@ -91,6 +92,7 @@ export interface ServiceCalls {
|
|
|
91
92
|
'auth.access': () => string | null;
|
|
92
93
|
'auth.token': () => string | null;
|
|
93
94
|
'auth.currentUser': () => Record<string, unknown> | null;
|
|
95
|
+
'auth.consumeEndedImpersonation': () => string | null;
|
|
94
96
|
'remote.connect': () => Promise<void>;
|
|
95
97
|
'remote.releaseViews': (ids: unknown[]) => void;
|
|
96
98
|
'supervisor.start': () => void;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { EventDefinition, EventSystem } from '../../../events/index';
|
|
2
2
|
import { createEventSystem } from '../../../events/index';
|
|
3
|
+
import type { ImpersonationInfo } from '../impersonation';
|
|
3
4
|
|
|
4
5
|
export const AuthEventTypes = {
|
|
5
6
|
AuthStateChanged: 'AUTH_STATE_CHANGED',
|
|
7
|
+
ImpersonationChanged: 'IMPERSONATION_CHANGED',
|
|
6
8
|
} as const;
|
|
7
9
|
|
|
8
10
|
export type AuthEventTypeMap = {
|
|
@@ -10,10 +12,14 @@ export type AuthEventTypeMap = {
|
|
|
10
12
|
typeof AuthEventTypes.AuthStateChanged,
|
|
11
13
|
string | null
|
|
12
14
|
>;
|
|
15
|
+
[AuthEventTypes.ImpersonationChanged]: EventDefinition<
|
|
16
|
+
typeof AuthEventTypes.ImpersonationChanged,
|
|
17
|
+
ImpersonationInfo | null
|
|
18
|
+
>;
|
|
13
19
|
};
|
|
14
20
|
|
|
15
21
|
export type AuthEventSystem = EventSystem<AuthEventTypeMap>;
|
|
16
22
|
|
|
17
23
|
export function createAuthEventSystem(): AuthEventSystem {
|
|
18
|
-
return createEventSystem([AuthEventTypes.AuthStateChanged]);
|
|
24
|
+
return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
|
|
19
25
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ImpersonationInfo } from './impersonation';
|
|
2
|
+
|
|
3
|
+
/** Height the banner occupies. Also published as the CSS variable
|
|
4
|
+
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
|
|
5
|
+
* an app can offset fixed headers. */
|
|
6
|
+
export const BANNER_HEIGHT_PX = 36;
|
|
7
|
+
|
|
8
|
+
const HOST_TAG = 'sp00ky-impersonation-banner';
|
|
9
|
+
|
|
10
|
+
// The shadow root keeps app CSS away from the bar, but the host element itself
|
|
11
|
+
// is in the page and matches the page's selectors (`div { display: none }`).
|
|
12
|
+
// An `!important` rule on `:host` wins over the page's own `!important`
|
|
13
|
+
// (inner tree context), which keeps the host rendered whatever the app says.
|
|
14
|
+
const STYLE = `
|
|
15
|
+
:host {
|
|
16
|
+
all: initial !important;
|
|
17
|
+
display: block !important;
|
|
18
|
+
visibility: visible !important;
|
|
19
|
+
opacity: 1 !important;
|
|
20
|
+
position: static !important;
|
|
21
|
+
transform: none !important;
|
|
22
|
+
clip-path: none !important;
|
|
23
|
+
}
|
|
24
|
+
.bar {
|
|
25
|
+
position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
|
|
26
|
+
display: flex; align-items: center; justify-content: center; gap: 12px; padding: 0 12px;
|
|
27
|
+
box-sizing: border-box; background: #b91c1c; color: #fff;
|
|
28
|
+
font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
29
|
+
box-shadow: 0 1px 4px rgba(0,0,0,.35);
|
|
30
|
+
}
|
|
31
|
+
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
32
|
+
.text b { font-weight: 800; }
|
|
33
|
+
button {
|
|
34
|
+
flex: none; cursor: pointer; border: 1px solid rgba(255,255,255,.8); border-radius: 6px;
|
|
35
|
+
background: #fff; color: #b91c1c; font: 700 12px/1 system-ui, sans-serif; padding: 6px 12px;
|
|
36
|
+
}
|
|
37
|
+
button:disabled { opacity: .6; cursor: progress; }
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The "you are impersonating" bar. Mounted by the client whenever the session
|
|
42
|
+
* is an impersonation, in its own closed shadow root so app styles cannot hide
|
|
43
|
+
* or restyle it. Every piece of text goes through `textContent`.
|
|
44
|
+
*/
|
|
45
|
+
export class ImpersonationBanner {
|
|
46
|
+
private host: HTMLElement | null = null;
|
|
47
|
+
private text: HTMLElement | null = null;
|
|
48
|
+
|
|
49
|
+
constructor(private onStop: () => Promise<void>) {}
|
|
50
|
+
|
|
51
|
+
static supported(): boolean {
|
|
52
|
+
return typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
update(info: ImpersonationInfo | null): void {
|
|
56
|
+
if (!ImpersonationBanner.supported()) return;
|
|
57
|
+
if (!info) {
|
|
58
|
+
this.unmount();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.mount();
|
|
62
|
+
const text = this.text!;
|
|
63
|
+
text.replaceChildren(
|
|
64
|
+
document.createTextNode('Impersonating '),
|
|
65
|
+
bold(info.target),
|
|
66
|
+
document.createTextNode(` as ${info.admin}`)
|
|
67
|
+
);
|
|
68
|
+
text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
unmount(): void {
|
|
72
|
+
this.host?.remove();
|
|
73
|
+
this.host = this.text = null;
|
|
74
|
+
if (typeof document !== 'undefined') {
|
|
75
|
+
document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private mount(): void {
|
|
80
|
+
if (this.host?.isConnected) return;
|
|
81
|
+
const host = document.createElement(HOST_TAG);
|
|
82
|
+
const root = host.attachShadow({ mode: 'closed' });
|
|
83
|
+
const style = document.createElement('style');
|
|
84
|
+
style.textContent = STYLE;
|
|
85
|
+
const bar = document.createElement('div');
|
|
86
|
+
bar.className = 'bar';
|
|
87
|
+
bar.setAttribute('role', 'alert');
|
|
88
|
+
const text = document.createElement('span');
|
|
89
|
+
text.className = 'text';
|
|
90
|
+
const button = document.createElement('button');
|
|
91
|
+
button.type = 'button';
|
|
92
|
+
button.textContent = 'Stop impersonating';
|
|
93
|
+
button.addEventListener('click', () => {
|
|
94
|
+
button.disabled = true;
|
|
95
|
+
this.onStop().finally(() => {
|
|
96
|
+
button.disabled = false;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
bar.append(text, button);
|
|
100
|
+
root.append(style, bar);
|
|
101
|
+
(document.body ?? document.documentElement).appendChild(host);
|
|
102
|
+
document.documentElement.style.setProperty('--sp00ky-impersonation-banner-height', `${BANNER_HEIGHT_PX}px`);
|
|
103
|
+
this.host = host;
|
|
104
|
+
this.text = text;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bold(value: string): HTMLElement {
|
|
109
|
+
const b = document.createElement('b');
|
|
110
|
+
b.textContent = value;
|
|
111
|
+
return b;
|
|
112
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthService } from './index';
|
|
3
|
+
import {
|
|
4
|
+
decodeTokenClaims,
|
|
5
|
+
impersonationFromToken,
|
|
6
|
+
ImpersonationTimers,
|
|
7
|
+
ORIGIN_TOKEN_KEY,
|
|
8
|
+
renewDelayMs,
|
|
9
|
+
SESSION_POLL_MS,
|
|
10
|
+
} from './impersonation';
|
|
11
|
+
|
|
12
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
13
|
+
const b64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
14
|
+
return `header.${b64}.signature`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ADMIN = jwt({ AC: 'account', ID: 'user:alice', exp: 9_999_999_999 });
|
|
18
|
+
const impToken = (n = 1, extra: Record<string, unknown> = {}) =>
|
|
19
|
+
jwt({
|
|
20
|
+
AC: '_00_impersonate',
|
|
21
|
+
ID: 'user:bob',
|
|
22
|
+
exp: Math.floor(Date.now() / 1000) + 900,
|
|
23
|
+
spky_imp: '_00_impersonation:s1',
|
|
24
|
+
spky_admin: 'user:alice',
|
|
25
|
+
n,
|
|
26
|
+
...extra,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const silentLogger = () =>
|
|
30
|
+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A fake server: tracks which token the socket is authenticated with, and
|
|
34
|
+
* answers the handful of statements the auth flow sends. `ended` simulates a
|
|
35
|
+
* stop or revocation made elsewhere.
|
|
36
|
+
*/
|
|
37
|
+
function makeServer(opts: { refuse?: Set<string>; unreachable?: boolean } = {}) {
|
|
38
|
+
const state = { socketToken: null as string | null, ended: false, stops: [] as unknown[], renews: 0 };
|
|
39
|
+
const idFor = (token: string | null) => (token ? decodeTokenClaims(token).userId : null);
|
|
40
|
+
const query = vi.fn(async (sql: string, vars?: Record<string, unknown>) => {
|
|
41
|
+
if (opts.unreachable && sql.includes('fn::_00_impersonate::stop')) {
|
|
42
|
+
throw new Error('There was a problem with the underlying connection');
|
|
43
|
+
}
|
|
44
|
+
if (sql.includes('fn::_00_impersonate::start')) return [{ token: impToken(), session: '_00_impersonation:s1' }];
|
|
45
|
+
if (sql.includes('fn::_00_impersonate::renew')) {
|
|
46
|
+
if (state.ended) throw new Error('Impersonation session has ended.');
|
|
47
|
+
state.renews++;
|
|
48
|
+
return [{ token: impToken(state.renews + 1) }];
|
|
49
|
+
}
|
|
50
|
+
if (sql.includes('fn::_00_impersonate::current')) return [state.ended ? null : { session: '_00_impersonation:s1' }];
|
|
51
|
+
if (sql.includes('fn::_00_impersonate::stop')) {
|
|
52
|
+
state.stops.push(vars?.session);
|
|
53
|
+
state.ended = true;
|
|
54
|
+
return [null];
|
|
55
|
+
}
|
|
56
|
+
if (sql.includes('fn::_00_impersonate::list_users')) return [[{ id: 'user:bob', is_admin: false }]];
|
|
57
|
+
if (sql.includes('$auth.id')) return [[{ id: idFor(state.socketToken) }]];
|
|
58
|
+
throw new Error(`unexpected statement ${sql}`);
|
|
59
|
+
});
|
|
60
|
+
const authenticate = vi.fn(async (token: string) => {
|
|
61
|
+
if (opts.refuse?.has(token) || (state.ended && decodeTokenClaims(token).impersonation)) {
|
|
62
|
+
throw new Error('Impersonation session is no longer valid.');
|
|
63
|
+
}
|
|
64
|
+
state.socketToken = token;
|
|
65
|
+
});
|
|
66
|
+
return { state, query, authenticate };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeAuth(server: ReturnType<typeof makeServer>, stored: Record<string, string> = {}) {
|
|
70
|
+
const store = new Map<string, unknown>(Object.entries(stored));
|
|
71
|
+
const persistence = {
|
|
72
|
+
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
73
|
+
set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
|
|
74
|
+
remove: vi.fn(async (k: string) => void store.delete(k)),
|
|
75
|
+
} as any;
|
|
76
|
+
const remote = {
|
|
77
|
+
query: server.query,
|
|
78
|
+
setAuthToken: vi.fn(),
|
|
79
|
+
getClient: () => ({ authenticate: server.authenticate, invalidate: vi.fn(async () => undefined) }),
|
|
80
|
+
} as any;
|
|
81
|
+
const auth = new AuthService({} as any, remote, persistence, silentLogger());
|
|
82
|
+
return { auth, store, remote };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function signedInAdmin(server = makeServer()) {
|
|
86
|
+
const ctx = makeAuth(server);
|
|
87
|
+
await ctx.auth.check(ADMIN);
|
|
88
|
+
return { ...ctx, server };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.useRealTimers();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('token claims', () => {
|
|
99
|
+
it('recognises only impersonation tokens', () => {
|
|
100
|
+
expect(impersonationFromToken(ADMIN)).toBeNull();
|
|
101
|
+
expect(impersonationFromToken(null)).toBeNull();
|
|
102
|
+
expect(impersonationFromToken('garbage')).toBeNull();
|
|
103
|
+
// A regular token cannot pose as one by carrying the claim.
|
|
104
|
+
expect(impersonationFromToken(jwt({ AC: 'account', ID: 'user:x', spky_imp: 's', spky_admin: 'a' }))).toBeNull();
|
|
105
|
+
const info = impersonationFromToken(impToken());
|
|
106
|
+
expect(info).toMatchObject({ session: '_00_impersonation:s1', target: 'user:bob', admin: 'user:alice' });
|
|
107
|
+
expect(info?.tokenExpiresAt).toBeInstanceOf(Date);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renews at 80% of the remaining life, never later than expiry', () => {
|
|
111
|
+
expect(renewDelayMs(1_000, 0)).toBe(800_000);
|
|
112
|
+
expect(renewDelayMs(10, 0)).toBe(8_000);
|
|
113
|
+
expect(renewDelayMs(4, 0)).toBe(0);
|
|
114
|
+
expect(renewDelayMs(1, 5_000)).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('impersonate / stopImpersonating', () => {
|
|
119
|
+
it('switches the session to the target and keeps the admin token aside', async () => {
|
|
120
|
+
const { auth, store, server } = await signedInAdmin();
|
|
121
|
+
const changes: (string | null)[] = [];
|
|
122
|
+
auth.subscribeImpersonation((i) => changes.push(i?.target ?? null));
|
|
123
|
+
|
|
124
|
+
const info = await auth.impersonate('user:bob', 'support ticket');
|
|
125
|
+
|
|
126
|
+
expect(info.target).toBe('user:bob');
|
|
127
|
+
expect(auth.currentUser.id).toBe('user:bob');
|
|
128
|
+
expect(auth.access).toBe('_00_impersonate');
|
|
129
|
+
expect(store.get(ORIGIN_TOKEN_KEY)).toBe(ADMIN);
|
|
130
|
+
expect(server.state.socketToken).toBe(auth.token);
|
|
131
|
+
expect(changes).toEqual([null, 'user:bob']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refuses to nest', async () => {
|
|
135
|
+
const { auth } = await signedInAdmin();
|
|
136
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
137
|
+
await expect(auth.impersonate('user:carol', 'again')).rejects.toThrow('Already impersonating');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('stays the admin when the server refuses the issued token', async () => {
|
|
141
|
+
const server = makeServer();
|
|
142
|
+
const refused = impToken();
|
|
143
|
+
server.query.mockImplementationOnce(async () => [{ token: refused }]);
|
|
144
|
+
server.authenticate.mockImplementation(async (token: string) => {
|
|
145
|
+
if (token === refused) throw new Error('Impersonation session is no longer valid.');
|
|
146
|
+
server.state.socketToken = token;
|
|
147
|
+
});
|
|
148
|
+
const { auth, store } = await signedInAdmin(server);
|
|
149
|
+
|
|
150
|
+
await expect(auth.impersonate('user:carol', 'support ticket')).rejects.toThrow('no longer valid');
|
|
151
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
152
|
+
expect(auth.impersonation).toBeNull();
|
|
153
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
154
|
+
expect(server.state.socketToken).toBe(ADMIN);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('stop ends the server session and returns to the admin', async () => {
|
|
158
|
+
const { auth, store, server } = await signedInAdmin();
|
|
159
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
160
|
+
|
|
161
|
+
await auth.stopImpersonating();
|
|
162
|
+
|
|
163
|
+
expect(server.state.stops).toEqual(['_00_impersonation:s1']);
|
|
164
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
165
|
+
expect(auth.token).toBe(ADMIN);
|
|
166
|
+
expect(auth.impersonation).toBeNull();
|
|
167
|
+
expect(store.get('sp00ky_auth_token')).toBe(ADMIN);
|
|
168
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
169
|
+
expect(auth.consumeEndedImpersonation()).toBe('user:bob');
|
|
170
|
+
expect(auth.consumeEndedImpersonation()).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('stop still leaves the target when the server is unreachable', async () => {
|
|
174
|
+
const { auth } = await signedInAdmin(makeServer({ unreachable: true }));
|
|
175
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
176
|
+
|
|
177
|
+
await auth.stopImpersonating();
|
|
178
|
+
|
|
179
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
180
|
+
expect(auth.impersonation).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('signs out entirely when the admin token is refused on the way back', async () => {
|
|
184
|
+
const server = makeServer({ refuse: new Set([ADMIN]) });
|
|
185
|
+
const { auth, store } = makeAuth(server, {
|
|
186
|
+
sp00ky_auth_token: impToken(),
|
|
187
|
+
[ORIGIN_TOKEN_KEY]: ADMIN,
|
|
188
|
+
});
|
|
189
|
+
await auth.check();
|
|
190
|
+
expect(auth.impersonation?.target).toBe('user:bob');
|
|
191
|
+
|
|
192
|
+
await auth.stopImpersonating();
|
|
193
|
+
|
|
194
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
195
|
+
expect(auth.currentUser).toBeNull();
|
|
196
|
+
expect(store.has('sp00ky_auth_token')).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('a revoked impersonation token at boot returns to the admin instead of signing out', async () => {
|
|
200
|
+
const server = makeServer();
|
|
201
|
+
server.state.ended = true;
|
|
202
|
+
const { auth } = makeAuth(server, { sp00ky_auth_token: impToken(), [ORIGIN_TOKEN_KEY]: ADMIN });
|
|
203
|
+
|
|
204
|
+
await auth.check();
|
|
205
|
+
|
|
206
|
+
expect(auth.isAuthenticated).toBe(true);
|
|
207
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('an impersonation token with no admin token behind it signs out', async () => {
|
|
211
|
+
const server = makeServer();
|
|
212
|
+
server.state.ended = true;
|
|
213
|
+
const { auth } = makeAuth(server, { sp00ky_auth_token: impToken() });
|
|
214
|
+
|
|
215
|
+
await auth.check();
|
|
216
|
+
|
|
217
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('signOut while impersonating ends the session and forgets the admin token', async () => {
|
|
221
|
+
const { auth, store, server } = await signedInAdmin();
|
|
222
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
223
|
+
|
|
224
|
+
await auth.signOut();
|
|
225
|
+
|
|
226
|
+
expect(server.state.stops).toEqual(['_00_impersonation:s1']);
|
|
227
|
+
expect(auth.isAuthenticated).toBe(false);
|
|
228
|
+
expect(store.has(ORIGIN_TOKEN_KEY)).toBe(false);
|
|
229
|
+
expect(store.has('sp00ky_auth_token')).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('search goes through the admin-only function', async () => {
|
|
233
|
+
const { auth } = await signedInAdmin();
|
|
234
|
+
expect(await auth.searchImpersonationTargets('bo')).toEqual([{ id: 'user:bob', is_admin: false }]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('timers', () => {
|
|
239
|
+
it('renews the token before it expires', async () => {
|
|
240
|
+
const { auth, server } = await signedInAdmin();
|
|
241
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
242
|
+
const first = auth.token;
|
|
243
|
+
|
|
244
|
+
await vi.advanceTimersByTimeAsync(800 * 1000);
|
|
245
|
+
|
|
246
|
+
expect(server.state.renews).toBe(1);
|
|
247
|
+
expect(auth.token).not.toBe(first);
|
|
248
|
+
expect(server.state.socketToken).toBe(auth.token);
|
|
249
|
+
expect(auth.impersonation?.target).toBe('user:bob');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('a session ended elsewhere drops the client back to the admin within a poll', async () => {
|
|
253
|
+
const { auth, server } = await signedInAdmin();
|
|
254
|
+
await auth.impersonate('user:bob', 'support ticket');
|
|
255
|
+
server.state.ended = true;
|
|
256
|
+
|
|
257
|
+
await vi.advanceTimersByTimeAsync(SESSION_POLL_MS);
|
|
258
|
+
|
|
259
|
+
expect(auth.currentUser.id).toBe('user:alice');
|
|
260
|
+
expect(auth.impersonation).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('keeps going through network failures on the poll', async () => {
|
|
264
|
+
let calls = 0;
|
|
265
|
+
const host = {
|
|
266
|
+
current: () => ({ session: 's', target: 't', admin: 'a', tokenExpiresAt: null }),
|
|
267
|
+
renew: vi.fn(),
|
|
268
|
+
stillActive: vi.fn(async () => {
|
|
269
|
+
calls++;
|
|
270
|
+
throw new Error('There was a problem with the underlying connection');
|
|
271
|
+
}),
|
|
272
|
+
endedRemotely: vi.fn(),
|
|
273
|
+
isNetworkError: () => true,
|
|
274
|
+
};
|
|
275
|
+
const timers = new ImpersonationTimers(host);
|
|
276
|
+
timers.sync();
|
|
277
|
+
await vi.advanceTimersByTimeAsync(SESSION_POLL_MS * 2);
|
|
278
|
+
expect(calls).toBe(2);
|
|
279
|
+
expect(host.endedRemotely).not.toHaveBeenCalled();
|
|
280
|
+
timers.clear();
|
|
281
|
+
});
|
|
282
|
+
});
|