@celilo/e2e 0.9.2 → 0.10.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/config/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/proxy/squid.conf +7 -2
- package/config/resolver/unbound.conf +39 -3
- package/config/routing/management-routes.sh +9 -4
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.cpanel-host-sim +70 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.minio +1 -1
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +36 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +10 -1
- package/docker/Dockerfile.target-machine-docker +1 -1
- package/docker/Dockerfile.target-technitium +1 -1
- package/package.json +1 -1
- package/registry-server/src/auth.ts +2 -2
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/server.test.ts +262 -0
- package/registry-server/src/server.ts +139 -10
- package/scripts/pack-celilo-packages.ts +79 -51
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +272 -0
- package/src/cli/build.ts +35 -9
- package/src/cli/command-registry.ts +34 -6
- package/src/cli/index.ts +59 -21
- package/src/container-manager.ts +6 -2
- package/src/docker-compose-generator.ts +108 -4
- package/src/network-builder.ts +52 -0
- package/src/runner.ts +128 -57
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +34 -0
- package/config/dns/example.net.zone +0 -16
- package/config/dns/iamtheinternet.org.zone +0 -16
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { ADMIN_SCOPE } from './auth';
|
|
3
|
+
import {
|
|
4
|
+
IntrospectionVerifier,
|
|
5
|
+
claimToScope,
|
|
6
|
+
introspectionConfigFromEnv,
|
|
7
|
+
} from './introspection';
|
|
8
|
+
|
|
9
|
+
const CONFIG = {
|
|
10
|
+
endpoint: 'https://idp.test/application/o/introspect/',
|
|
11
|
+
clientId: 'celilo-registry',
|
|
12
|
+
clientSecret: 'super-secret',
|
|
13
|
+
adminGroup: 'celilo-admins',
|
|
14
|
+
publisherGroup: 'celilo-authors',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Replace global fetch with a stub returning `body` at `status`; restore after. */
|
|
18
|
+
function stubFetch(handler: (url: string, init: RequestInit) => Response): void {
|
|
19
|
+
// @ts-expect-error - overriding the global for the duration of a test
|
|
20
|
+
globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) =>
|
|
21
|
+
handler(String(url), init ?? {});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const realFetch = globalThis.fetch;
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = realFetch;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function introspectResponse(payload: unknown, status = 200): Response {
|
|
30
|
+
return new Response(JSON.stringify(payload), {
|
|
31
|
+
status,
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('claimToScope', () => {
|
|
37
|
+
test('admin-group member → ADMIN_SCOPE', () => {
|
|
38
|
+
expect(claimToScope({ active: true, groups: ['celilo-admins'] }, 'celilo-admins')).toBe(
|
|
39
|
+
ADMIN_SCOPE,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('non-admin identity → null (owner table is ce-1ch)', () => {
|
|
44
|
+
expect(claimToScope({ active: true, groups: ['users'] }, 'celilo-admins')).toBeNull();
|
|
45
|
+
expect(claimToScope({ active: true }, 'celilo-admins')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('introspectionConfigFromEnv', () => {
|
|
50
|
+
const KEYS = [
|
|
51
|
+
'OIDC_INTROSPECTION_ENDPOINT',
|
|
52
|
+
'OIDC_CLIENT_ID',
|
|
53
|
+
'OIDC_CLIENT_SECRET',
|
|
54
|
+
'REGISTRY_ADMIN_GROUP',
|
|
55
|
+
'REGISTRY_PUBLISHER_GROUP',
|
|
56
|
+
];
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const k of KEYS) delete process.env[k];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('null when any required var is missing', () => {
|
|
62
|
+
expect(introspectionConfigFromEnv()).toBeNull();
|
|
63
|
+
process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
|
|
64
|
+
process.env.OIDC_CLIENT_ID = 'cid';
|
|
65
|
+
// secret still missing
|
|
66
|
+
expect(introspectionConfigFromEnv()).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('config when all required vars present; admin group defaults', () => {
|
|
70
|
+
process.env.OIDC_INTROSPECTION_ENDPOINT = 'https://idp.test/introspect/';
|
|
71
|
+
process.env.OIDC_CLIENT_ID = 'cid';
|
|
72
|
+
process.env.OIDC_CLIENT_SECRET = 'sec';
|
|
73
|
+
expect(introspectionConfigFromEnv()).toEqual({
|
|
74
|
+
endpoint: 'https://idp.test/introspect/',
|
|
75
|
+
clientId: 'cid',
|
|
76
|
+
clientSecret: 'sec',
|
|
77
|
+
adminGroup: 'celilo-admins',
|
|
78
|
+
publisherGroup: 'celilo-authors',
|
|
79
|
+
});
|
|
80
|
+
process.env.REGISTRY_ADMIN_GROUP = 'ops';
|
|
81
|
+
expect(introspectionConfigFromEnv()?.adminGroup).toBe('ops');
|
|
82
|
+
process.env.REGISTRY_PUBLISHER_GROUP = 'authors';
|
|
83
|
+
expect(introspectionConfigFromEnv()?.publisherGroup).toBe('authors');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('IntrospectionVerifier.scopeOf', () => {
|
|
88
|
+
const verifier = new IntrospectionVerifier(CONFIG);
|
|
89
|
+
|
|
90
|
+
test('active admin-group token → ADMIN_SCOPE', async () => {
|
|
91
|
+
stubFetch(() =>
|
|
92
|
+
introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }),
|
|
93
|
+
);
|
|
94
|
+
expect(await verifier.scopeOf('Bearer idp-token')).toBe(ADMIN_SCOPE);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('active non-admin token → null', async () => {
|
|
98
|
+
stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['users'] }));
|
|
99
|
+
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('active:false → null (revoked at idp)', async () => {
|
|
103
|
+
stubFetch(() => introspectResponse({ active: false }));
|
|
104
|
+
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('expired exp → null even if active', async () => {
|
|
108
|
+
const pastSec = Math.floor(Date.now() / 1000) - 60;
|
|
109
|
+
stubFetch(() =>
|
|
110
|
+
introspectResponse({ active: true, groups: ['celilo-admins'], exp: pastSec }),
|
|
111
|
+
);
|
|
112
|
+
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('future exp → honored', async () => {
|
|
116
|
+
const futureSec = Math.floor(Date.now() / 1000) + 3600;
|
|
117
|
+
stubFetch(() =>
|
|
118
|
+
introspectResponse({ active: true, groups: ['celilo-admins'], exp: futureSec }),
|
|
119
|
+
);
|
|
120
|
+
expect(await verifier.scopeOf('idp-token')).toBe(ADMIN_SCOPE);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('non-2xx response → null (fail closed)', async () => {
|
|
124
|
+
stubFetch(() => introspectResponse({ error: 'boom' }, 500));
|
|
125
|
+
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('fetch throws → null (fail closed)', async () => {
|
|
129
|
+
stubFetch(() => {
|
|
130
|
+
throw new Error('connection refused');
|
|
131
|
+
});
|
|
132
|
+
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('malformed body (no active field) → null', async () => {
|
|
136
|
+
stubFetch(() => introspectResponse({ sub: 'alice' }));
|
|
137
|
+
expect(await verifier.scopeOf('idp-token')).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('empty header → null without calling the idp', async () => {
|
|
141
|
+
let called = false;
|
|
142
|
+
stubFetch(() => {
|
|
143
|
+
called = true;
|
|
144
|
+
return introspectResponse({ active: true, groups: ['celilo-admins'] });
|
|
145
|
+
});
|
|
146
|
+
expect(await verifier.scopeOf('')).toBeNull();
|
|
147
|
+
expect(called).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('authenticates with HTTP Basic client creds; sends the token in the body', async () => {
|
|
151
|
+
let seenAuth = '';
|
|
152
|
+
let seenBody = '';
|
|
153
|
+
stubFetch((_url, init) => {
|
|
154
|
+
seenAuth = new Headers(init.headers).get('Authorization') ?? '';
|
|
155
|
+
seenBody = String(init.body ?? '');
|
|
156
|
+
return introspectResponse({ active: true, groups: ['celilo-admins'] });
|
|
157
|
+
});
|
|
158
|
+
await verifier.scopeOf('Bearer the-token');
|
|
159
|
+
const expected = `Basic ${Buffer.from('celilo-registry:super-secret').toString('base64')}`;
|
|
160
|
+
expect(seenAuth).toBe(expected);
|
|
161
|
+
expect(seenBody).toContain('token=the-token');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('does not log the token or client secret on failure', async () => {
|
|
165
|
+
const logs: string[] = [];
|
|
166
|
+
const realErr = console.error;
|
|
167
|
+
console.error = (...args: unknown[]) => {
|
|
168
|
+
logs.push(args.map(String).join(' '));
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
stubFetch(() => {
|
|
172
|
+
throw new Error('boom');
|
|
173
|
+
});
|
|
174
|
+
await verifier.scopeOf('Bearer top-secret-token');
|
|
175
|
+
} finally {
|
|
176
|
+
console.error = realErr;
|
|
177
|
+
}
|
|
178
|
+
const joined = logs.join('\n');
|
|
179
|
+
expect(joined).not.toContain('top-secret-token');
|
|
180
|
+
expect(joined).not.toContain('super-secret');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('IntrospectionVerifier.identify (ce-1ch)', () => {
|
|
185
|
+
const verifier = new IntrospectionVerifier(CONFIG);
|
|
186
|
+
|
|
187
|
+
test('admin-group member → isAdmin, group=adminGroup', async () => {
|
|
188
|
+
stubFetch(() => introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'] }));
|
|
189
|
+
expect(await verifier.identify('Bearer t')).toEqual({
|
|
190
|
+
sub: 'alice',
|
|
191
|
+
isAdmin: true,
|
|
192
|
+
isPublisher: false,
|
|
193
|
+
group: 'celilo-admins',
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('publisher-group member → isPublisher, group=publisherGroup', async () => {
|
|
198
|
+
stubFetch(() => introspectResponse({ active: true, sub: 'bob', groups: ['celilo-authors'] }));
|
|
199
|
+
expect(await verifier.identify('Bearer t')).toEqual({
|
|
200
|
+
sub: 'bob',
|
|
201
|
+
isAdmin: false,
|
|
202
|
+
isPublisher: true,
|
|
203
|
+
group: 'celilo-authors',
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('member of neither publish group → verified but no group', async () => {
|
|
208
|
+
stubFetch(() => introspectResponse({ active: true, sub: 'carol', groups: ['users'] }));
|
|
209
|
+
expect(await verifier.identify('Bearer t')).toEqual({
|
|
210
|
+
sub: 'carol',
|
|
211
|
+
isAdmin: false,
|
|
212
|
+
isPublisher: false,
|
|
213
|
+
group: undefined,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('admin with no sub → identified (admins publish without an ownership anchor)', async () => {
|
|
218
|
+
stubFetch(() => introspectResponse({ active: true, groups: ['celilo-admins'] }));
|
|
219
|
+
expect(await verifier.identify('Bearer t')).toEqual({
|
|
220
|
+
sub: undefined,
|
|
221
|
+
isAdmin: true,
|
|
222
|
+
isPublisher: false,
|
|
223
|
+
group: 'celilo-admins',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('active:false → null (fail closed)', async () => {
|
|
228
|
+
stubFetch(() => introspectResponse({ active: false }));
|
|
229
|
+
expect(await verifier.identify('Bearer t')).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('expired exp → null even if active', async () => {
|
|
233
|
+
const pastSec = Math.floor(Date.now() / 1000) - 60;
|
|
234
|
+
stubFetch(() =>
|
|
235
|
+
introspectResponse({ active: true, sub: 'alice', groups: ['celilo-admins'], exp: pastSec }),
|
|
236
|
+
);
|
|
237
|
+
expect(await verifier.identify('Bearer t')).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('admin group is configurable, not a literal', async () => {
|
|
241
|
+
const custom = new IntrospectionVerifier({ ...CONFIG, adminGroup: 'ops', publisherGroup: 'devs' });
|
|
242
|
+
stubFetch(() => introspectResponse({ active: true, sub: 'dan', groups: ['ops'] }));
|
|
243
|
+
const id = await custom.identify('Bearer t');
|
|
244
|
+
expect(id?.isAdmin).toBe(true);
|
|
245
|
+
expect(id?.group).toBe('ops');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { ADMIN_SCOPE, bearerToken } from './auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RFC 7662 token-introspection verify-bridge
|
|
5
|
+
* (SECURE_MODULE_PUBLISH.md §5[D-A], MODULE_PACKAGE_SIGNING.md Phase 1).
|
|
6
|
+
*
|
|
7
|
+
* The registry POSTs a presented publish token to the idp's introspection
|
|
8
|
+
* endpoint, authenticating with its own confidential OIDC client credentials
|
|
9
|
+
* (provisioned on install — ce-7aa). It reads back `{active, sub, groups, exp}`
|
|
10
|
+
* and maps a verified identity to a publish scope the request path already
|
|
11
|
+
* understands (ADMIN_SCOPE / per-package — see {@link claimToScope}).
|
|
12
|
+
*
|
|
13
|
+
* Two properties this module MUST preserve:
|
|
14
|
+
* 1. **Fail closed.** Any non-2xx response, network error, or malformed body
|
|
15
|
+
* denies the token (returns null). The idp being unreachable never
|
|
16
|
+
* degrades into "allow".
|
|
17
|
+
* 2. **No secrets in logs.** The token and client secret are never logged;
|
|
18
|
+
* only HTTP status / error class is.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Validated subset of an RFC 7662 introspection response. */
|
|
22
|
+
export interface IntrospectionResult {
|
|
23
|
+
active: boolean;
|
|
24
|
+
sub?: string;
|
|
25
|
+
groups?: string[];
|
|
26
|
+
exp?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IntrospectionConfig {
|
|
30
|
+
/** RFC 7662 introspection endpoint (OIDC_INTROSPECTION_ENDPOINT). */
|
|
31
|
+
endpoint: string;
|
|
32
|
+
/** Confidential OIDC client id (OIDC_CLIENT_ID). */
|
|
33
|
+
clientId: string;
|
|
34
|
+
/** Confidential OIDC client secret (OIDC_CLIENT_SECRET). */
|
|
35
|
+
clientSecret: string;
|
|
36
|
+
/** Group whose members map to ADMIN_SCOPE (REGISTRY_ADMIN_GROUP). */
|
|
37
|
+
adminGroup: string;
|
|
38
|
+
/**
|
|
39
|
+
* Group whose members may publish modules they own (REGISTRY_PUBLISHER_GROUP,
|
|
40
|
+
* ce-1ch). Configurable, not a literal. Members claim/publish per the
|
|
41
|
+
* module-owner table; admins additionally publish/reassign anything.
|
|
42
|
+
*/
|
|
43
|
+
publisherGroup: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A verified idp identity (ce-1ch). `identify()` resolves an Authorization
|
|
48
|
+
* header to this via introspection, or null when the token is inactive/expired/
|
|
49
|
+
* unverifiable or carries no `sub` (ownership requires a stable subject).
|
|
50
|
+
*/
|
|
51
|
+
export interface VerifiedIdentity {
|
|
52
|
+
/**
|
|
53
|
+
* Verified subject — the owner key in the module-owner table. Optional: an
|
|
54
|
+
* admin identity may publish without one (admins need no ownership anchor),
|
|
55
|
+
* but the publisher/owner path requires it to attribute a claim.
|
|
56
|
+
*/
|
|
57
|
+
sub?: string;
|
|
58
|
+
/** Member of the admin group → may publish/reassign any module. */
|
|
59
|
+
isAdmin: boolean;
|
|
60
|
+
/** Member of the publisher group → may claim + publish owned modules. */
|
|
61
|
+
isPublisher: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* The group that grants this identity's publish rights (admin group when
|
|
64
|
+
* admin, else publisher group when publisher, else undefined). Recorded as
|
|
65
|
+
* the owner entry's `sourceGroup` at claim time.
|
|
66
|
+
*/
|
|
67
|
+
group?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Default admin group name when REGISTRY_ADMIN_GROUP is unset. */
|
|
71
|
+
const DEFAULT_ADMIN_GROUP = 'celilo-admins';
|
|
72
|
+
|
|
73
|
+
/** Default publisher group name when REGISTRY_PUBLISHER_GROUP is unset. */
|
|
74
|
+
const DEFAULT_PUBLISHER_GROUP = 'celilo-authors';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read introspection config from the environment (delivered by the module's
|
|
78
|
+
* OIDC drop-in EnvironmentFile — ce-7aa). Returns null when introspection is
|
|
79
|
+
* not configured, in which case the server keeps the opaque-token-only path.
|
|
80
|
+
*/
|
|
81
|
+
export function introspectionConfigFromEnv(): IntrospectionConfig | null {
|
|
82
|
+
const endpoint = process.env.OIDC_INTROSPECTION_ENDPOINT?.trim();
|
|
83
|
+
const clientId = process.env.OIDC_CLIENT_ID?.trim();
|
|
84
|
+
const clientSecret = process.env.OIDC_CLIENT_SECRET?.trim();
|
|
85
|
+
if (!endpoint || !clientId || !clientSecret) return null;
|
|
86
|
+
return {
|
|
87
|
+
endpoint,
|
|
88
|
+
clientId,
|
|
89
|
+
clientSecret,
|
|
90
|
+
adminGroup: process.env.REGISTRY_ADMIN_GROUP?.trim() || DEFAULT_ADMIN_GROUP,
|
|
91
|
+
publisherGroup: process.env.REGISTRY_PUBLISHER_GROUP?.trim() || DEFAULT_PUBLISHER_GROUP,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Map a verified identity's claims to a publish scope the request path
|
|
97
|
+
* consumes. Today: membership in the admin group → ADMIN_SCOPE (`*`).
|
|
98
|
+
*
|
|
99
|
+
* ponytail: per-module ownership (a verified non-admin identity may publish
|
|
100
|
+
* only modules it owns) is the module-owner table — ce-1ch — which extends
|
|
101
|
+
* this seam. Until then a verified non-admin identity is denied (null).
|
|
102
|
+
*/
|
|
103
|
+
export function claimToScope(claims: IntrospectionResult, adminGroup: string): string | null {
|
|
104
|
+
if (claims.groups?.includes(adminGroup)) return ADMIN_SCOPE;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Hand-validate an untrusted introspection JSON body (no zod dep in this package). */
|
|
109
|
+
function parseIntrospection(json: unknown): IntrospectionResult | null {
|
|
110
|
+
if (typeof json !== 'object' || json === null) return null;
|
|
111
|
+
const o = json as Record<string, unknown>;
|
|
112
|
+
if (typeof o.active !== 'boolean') return null;
|
|
113
|
+
const groups = Array.isArray(o.groups)
|
|
114
|
+
? o.groups.filter((g): g is string => typeof g === 'string')
|
|
115
|
+
: undefined;
|
|
116
|
+
return {
|
|
117
|
+
active: o.active,
|
|
118
|
+
sub: typeof o.sub === 'string' ? o.sub : undefined,
|
|
119
|
+
groups,
|
|
120
|
+
exp: typeof o.exp === 'number' ? o.exp : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function errClass(error: unknown): string {
|
|
125
|
+
return error instanceof Error ? error.name : 'unknown';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class IntrospectionVerifier {
|
|
129
|
+
constructor(private readonly config: IntrospectionConfig) {}
|
|
130
|
+
|
|
131
|
+
/** Construct from env, or undefined when introspection isn't configured. */
|
|
132
|
+
static fromEnv(): IntrospectionVerifier | undefined {
|
|
133
|
+
const config = introspectionConfigFromEnv();
|
|
134
|
+
return config ? new IntrospectionVerifier(config) : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verify an Authorization header value via introspection and return the
|
|
139
|
+
* publish scope of the identity (ADMIN_SCOPE / per-package), or null when
|
|
140
|
+
* the token is inactive, expired, unverifiable, or maps to no scope.
|
|
141
|
+
* Fails CLOSED on every error.
|
|
142
|
+
*/
|
|
143
|
+
async scopeOf(header: string): Promise<string | null> {
|
|
144
|
+
const token = bearerToken(header);
|
|
145
|
+
if (!token) return null;
|
|
146
|
+
const claims = await this.introspect(token);
|
|
147
|
+
if (!claims || !claims.active) return null;
|
|
148
|
+
// RFC 7662 `active:false` already covers expiry, but double-check `exp`
|
|
149
|
+
// defensively (seconds since epoch) so a misbehaving idp can't slip an
|
|
150
|
+
// expired-but-active token past us.
|
|
151
|
+
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) return null;
|
|
152
|
+
return claimToScope(claims, this.config.adminGroup);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Verify an Authorization header and resolve the caller's identity for the
|
|
157
|
+
* hybrid group + owner-table authorization (ce-1ch). Returns null on the same
|
|
158
|
+
* fail-closed conditions as {@link scopeOf}. `sub` may be absent (an admin can
|
|
159
|
+
* publish without one); the owner path in the server requires it and denies
|
|
160
|
+
* a publisher identity that lacks it.
|
|
161
|
+
*/
|
|
162
|
+
async identify(header: string): Promise<VerifiedIdentity | null> {
|
|
163
|
+
const token = bearerToken(header);
|
|
164
|
+
if (!token) return null;
|
|
165
|
+
const claims = await this.introspect(token);
|
|
166
|
+
if (!claims || !claims.active) return null;
|
|
167
|
+
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) return null;
|
|
168
|
+
const isAdmin = claims.groups?.includes(this.config.adminGroup) ?? false;
|
|
169
|
+
const isPublisher = claims.groups?.includes(this.config.publisherGroup) ?? false;
|
|
170
|
+
const group = isAdmin
|
|
171
|
+
? this.config.adminGroup
|
|
172
|
+
: isPublisher
|
|
173
|
+
? this.config.publisherGroup
|
|
174
|
+
: undefined;
|
|
175
|
+
return { sub: claims.sub, isAdmin, isPublisher, group };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async introspect(token: string): Promise<IntrospectionResult | null> {
|
|
179
|
+
try {
|
|
180
|
+
const basic = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString(
|
|
181
|
+
'base64',
|
|
182
|
+
);
|
|
183
|
+
const resp = await fetch(this.config.endpoint, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
187
|
+
Authorization: `Basic ${basic}`,
|
|
188
|
+
Accept: 'application/json',
|
|
189
|
+
},
|
|
190
|
+
body: new URLSearchParams({ token, token_type_hint: 'access_token' }).toString(),
|
|
191
|
+
});
|
|
192
|
+
if (!resp.ok) {
|
|
193
|
+
console.error(`[registry] introspection HTTP ${resp.status} — denying (fail closed)`);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return parseIntrospection(await resp.json());
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.error(
|
|
199
|
+
`[registry] introspection request failed (${errClass(error)}) — denying (fail closed)`,
|
|
200
|
+
);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type ModuleOwnerEntry,
|
|
4
|
+
type ModuleOwnerPersistence,
|
|
5
|
+
ModuleOwnerStore,
|
|
6
|
+
} from './module-owner-store';
|
|
7
|
+
|
|
8
|
+
/** In-memory persistence seam so the store is testable without the filesystem. */
|
|
9
|
+
function memPersistence(initial: ModuleOwnerEntry[] = []): ModuleOwnerPersistence & {
|
|
10
|
+
saved: ModuleOwnerEntry[];
|
|
11
|
+
} {
|
|
12
|
+
const state = { saved: [...initial] };
|
|
13
|
+
return {
|
|
14
|
+
saved: state.saved,
|
|
15
|
+
load: () => [...state.saved],
|
|
16
|
+
save(entries) {
|
|
17
|
+
state.saved.length = 0;
|
|
18
|
+
state.saved.push(...entries);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fixedNow = () => '2026-01-01T00:00:00.000Z';
|
|
24
|
+
|
|
25
|
+
describe('ModuleOwnerStore', () => {
|
|
26
|
+
test('claim records ownership of an unclaimed name', () => {
|
|
27
|
+
const p = memPersistence();
|
|
28
|
+
const store = new ModuleOwnerStore(p, fixedNow);
|
|
29
|
+
const entry = store.claim('homebridge', 'alice', 'celilo-authors');
|
|
30
|
+
expect(entry).toEqual({
|
|
31
|
+
moduleName: 'homebridge',
|
|
32
|
+
ownerSub: 'alice',
|
|
33
|
+
claimedAt: fixedNow(),
|
|
34
|
+
sourceGroup: 'celilo-authors',
|
|
35
|
+
});
|
|
36
|
+
expect(store.get('homebridge')?.ownerSub).toBe('alice');
|
|
37
|
+
expect(p.saved).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('claim never steals an already-owned name (returns existing)', () => {
|
|
41
|
+
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
42
|
+
store.claim('homebridge', 'alice', 'celilo-authors');
|
|
43
|
+
const second = store.claim('homebridge', 'bob', 'celilo-authors');
|
|
44
|
+
expect(second.ownerSub).toBe('alice');
|
|
45
|
+
expect(store.get('homebridge')?.ownerSub).toBe('alice');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('reassign overwrites an existing owner (admin path)', () => {
|
|
49
|
+
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
50
|
+
store.claim('homebridge', 'alice', 'celilo-authors');
|
|
51
|
+
const reassigned = store.reassign('homebridge', 'bob', 'admin-reassign');
|
|
52
|
+
expect(reassigned.ownerSub).toBe('bob');
|
|
53
|
+
expect(store.get('homebridge')?.ownerSub).toBe('bob');
|
|
54
|
+
// No duplicate rows for the same name.
|
|
55
|
+
expect(store.list().filter((e) => e.moduleName === 'homebridge')).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('reassign creates a record when the name was unclaimed', () => {
|
|
59
|
+
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
60
|
+
store.reassign('caddy', 'carol', 'admin-reassign');
|
|
61
|
+
expect(store.get('caddy')?.ownerSub).toBe('carol');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('get returns undefined for an unclaimed name', () => {
|
|
65
|
+
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
66
|
+
expect(store.get('nope')).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('loads persisted entries on construction', () => {
|
|
70
|
+
const p = memPersistence([
|
|
71
|
+
{ moduleName: 'caddy', ownerSub: 'dan', claimedAt: fixedNow(), sourceGroup: 'celilo-admins' },
|
|
72
|
+
]);
|
|
73
|
+
const store = new ModuleOwnerStore(p, fixedNow);
|
|
74
|
+
expect(store.get('caddy')?.ownerSub).toBe('dan');
|
|
75
|
+
expect(store.list()).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('list returns a copy (mutating it does not affect the store)', () => {
|
|
79
|
+
const store = new ModuleOwnerStore(memPersistence(), fixedNow);
|
|
80
|
+
store.claim('a', 'x', 'g');
|
|
81
|
+
const list = store.list();
|
|
82
|
+
list.pop();
|
|
83
|
+
expect(store.list()).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persisted module-owner table (SECURE_MODULE_PUBLISH.md §5[D-C], ce-1ch).
|
|
6
|
+
*
|
|
7
|
+
* The introspection verify-bridge (ce-s7e) proves *who* a publish token belongs
|
|
8
|
+
* to (a verified `sub` + group claims). This table gates *which modules* that
|
|
9
|
+
* identity may publish: a module name is **first-publish-claims** — the first
|
|
10
|
+
* verified publisher of an unclaimed name records ownership; thereafter only the
|
|
11
|
+
* owner (or an admin) may publish it. This defends the confused-deputy case:
|
|
12
|
+
* Author-A holding a valid idp token cannot publish Author-B's module.
|
|
13
|
+
*
|
|
14
|
+
* Ownership is low-friction / npm-style: no pre-registration, admin can reassign.
|
|
15
|
+
* Mirrors {@link ScopedTokenStore} (JSON file + I/O seam for unit testing).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ModuleOwnerEntry {
|
|
19
|
+
/** Module name this ownership record governs. */
|
|
20
|
+
moduleName: string;
|
|
21
|
+
/** Verified idp subject that owns the name (from introspection `sub`). */
|
|
22
|
+
ownerSub: string;
|
|
23
|
+
/** ISO timestamp the name was first claimed. */
|
|
24
|
+
claimedAt: string;
|
|
25
|
+
/** Group claim that granted the claim (admin or publisher group at claim time). */
|
|
26
|
+
sourceGroup: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */
|
|
30
|
+
export interface ModuleOwnerPersistence {
|
|
31
|
+
load(): ModuleOwnerEntry[];
|
|
32
|
+
save(entries: ModuleOwnerEntry[]): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */
|
|
36
|
+
export function fileModuleOwnerPersistence(filePath: string): ModuleOwnerPersistence {
|
|
37
|
+
return {
|
|
38
|
+
load() {
|
|
39
|
+
if (!existsSync(filePath)) return [];
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
42
|
+
return Array.isArray(parsed) ? (parsed as ModuleOwnerEntry[]) : [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
save(entries) {
|
|
48
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
49
|
+
writeFileSync(filePath, JSON.stringify(entries, null, 2));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ModuleOwnerStore {
|
|
55
|
+
private entries: ModuleOwnerEntry[];
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly persistence: ModuleOwnerPersistence,
|
|
59
|
+
private readonly now: () => string = () => new Date().toISOString(),
|
|
60
|
+
) {
|
|
61
|
+
this.entries = persistence.load();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
list(): ModuleOwnerEntry[] {
|
|
65
|
+
return [...this.entries];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The ownership record for `moduleName`, or undefined when unclaimed. */
|
|
69
|
+
get(moduleName: string): ModuleOwnerEntry | undefined {
|
|
70
|
+
return this.entries.find((e) => e.moduleName === moduleName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record ownership of an **unclaimed** name for `ownerSub`. If the name is
|
|
75
|
+
* already claimed this is a no-op and returns the *existing* entry — claiming
|
|
76
|
+
* never steals a name (use {@link reassign} for admin overwrite). Returns the
|
|
77
|
+
* effective owner entry either way.
|
|
78
|
+
*/
|
|
79
|
+
claim(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry {
|
|
80
|
+
const existing = this.get(moduleName);
|
|
81
|
+
if (existing) return existing;
|
|
82
|
+
const entry: ModuleOwnerEntry = {
|
|
83
|
+
moduleName,
|
|
84
|
+
ownerSub,
|
|
85
|
+
claimedAt: this.now(),
|
|
86
|
+
sourceGroup,
|
|
87
|
+
};
|
|
88
|
+
this.entries = [...this.entries, entry];
|
|
89
|
+
this.persistence.save(this.entries);
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Overwrite (or create) the ownership record for `moduleName` — admin
|
|
95
|
+
* reassignment. Unlike {@link claim} this steals an already-owned name.
|
|
96
|
+
*/
|
|
97
|
+
reassign(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry {
|
|
98
|
+
const entry: ModuleOwnerEntry = {
|
|
99
|
+
moduleName,
|
|
100
|
+
ownerSub,
|
|
101
|
+
claimedAt: this.now(),
|
|
102
|
+
sourceGroup,
|
|
103
|
+
};
|
|
104
|
+
this.entries = [...this.entries.filter((e) => e.moduleName !== moduleName), entry];
|
|
105
|
+
this.persistence.save(this.entries);
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
}
|