@lamalibre/create-lamaste 4.0.0 → 4.1.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/package.json +2 -2
- package/scripts/lamaste-priv +21 -2
- package/src/lib/oidc-issuer-key.js +124 -0
- package/src/lib/relay-services.js +4 -0
- package/src/lib/service-config.js +16 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +45 -1
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +3 -3
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +6 -5
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +111 -105
- package/vendor/docs/04-api-reference/registry-and-oidc.md +182 -0
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -5
- package/vendor/docs/06-reference/config-files.md +103 -45
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/_index.json +5 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +443 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/package.json +1 -1
- package/vendor/server/bin/lamaste-server.js +10 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/plugins.js +87 -0
- package/vendor/server/src/commands/registry-credential.js +142 -0
- package/vendor/server/src/panel-api.js +83 -38
- package/vendor/serverd/package.json +5 -2
- package/vendor/serverd/src/index.js +11 -0
- package/vendor/serverd/src/lib/authelia.js +11 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +216 -0
- package/vendor/serverd/src/lib/plugins.js +26 -2
- package/vendor/serverd/src/lib/registry-credential.js +145 -0
- package/vendor/serverd/src/routes/management/agents.js +81 -0
- package/vendor/serverd/src/routes/management/plugins.js +65 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
- package/vendor/serverd/src/routes/management/users.js +31 -0
- package/vendor/serverd/src/routes/management.js +2 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC clients → Authelia.
|
|
3
|
+
*
|
|
4
|
+
* Two sources declare clients on the relay's Authelia:
|
|
5
|
+
*
|
|
6
|
+
* - every installed server plugin may declare them in its manifest
|
|
7
|
+
* (`oidcClients`, see the core `ManifestSchema`); only the clients of
|
|
8
|
+
* enabled plugins are served;
|
|
9
|
+
* - the panel itself declares the confidential `lamaste-server` client
|
|
10
|
+
* when the registry credential uses `auth.kind: 'oidc'`.
|
|
11
|
+
*
|
|
12
|
+
* After any plugin lifecycle change, or a registry-credential change, the
|
|
13
|
+
* set is recomputed, the secrets of confidential clients are reconciled
|
|
14
|
+
* (`oidc-clients.json`: minted on first appearance, deleted when a client is
|
|
15
|
+
* gone — a secret belongs to an installed plugin, enabled or not, so
|
|
16
|
+
* disabling a plugin does not rotate it) and Authelia's
|
|
17
|
+
* `identity_providers.oidc` section is written; Authelia is restarted only
|
|
18
|
+
* when the section actually changed.
|
|
19
|
+
*
|
|
20
|
+
* Best effort by design where a plugin lifecycle calls it: a failure here
|
|
21
|
+
* must not fail the plugin operation — the caller logs it and the next
|
|
22
|
+
* lifecycle change retries.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile } from 'node:fs/promises';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { validateManifest } from '@lamalibre/lamaste';
|
|
28
|
+
import {
|
|
29
|
+
LAMASTE_REGISTRY_OIDC_SCOPES,
|
|
30
|
+
lamasteServerOidcClient,
|
|
31
|
+
listOidcClientSecrets,
|
|
32
|
+
reconcileOidcClientSecrets,
|
|
33
|
+
readOidcClientSecretDigests,
|
|
34
|
+
rotateOidcClientSecret,
|
|
35
|
+
} from '@lamalibre/lamaste/server';
|
|
36
|
+
|
|
37
|
+
import { getConfig } from './config.js';
|
|
38
|
+
import { updateOidcProvider } from './authelia.js';
|
|
39
|
+
import { readPlugins } from './plugins.js';
|
|
40
|
+
import { readRegistryCredentialConfig } from './registry-credential.js';
|
|
41
|
+
|
|
42
|
+
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The OIDC clients every installed plugin declares, tagged with the plugin
|
|
46
|
+
* name and whether the plugin is enabled. Manifests are re-read from the
|
|
47
|
+
* installed packages — the plugin registry does not store them.
|
|
48
|
+
* @returns {Promise<Array<import('@lamalibre/lamaste/server').AutheliaOidcClient & { enabled: boolean }>>}
|
|
49
|
+
*/
|
|
50
|
+
export async function collectPluginOidcClients() {
|
|
51
|
+
const require = createRequire(`${STATE_DIR}/`);
|
|
52
|
+
const clients = [];
|
|
53
|
+
for (const plugin of (await readPlugins()).plugins) {
|
|
54
|
+
let manifest;
|
|
55
|
+
try {
|
|
56
|
+
const manifestPath = require.resolve(`${plugin.packageName}/lamaste-plugin.json`);
|
|
57
|
+
manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
|
|
58
|
+
} catch {
|
|
59
|
+
continue; // an unreadable manifest contributes no clients
|
|
60
|
+
}
|
|
61
|
+
for (const client of manifest.oidcClients) {
|
|
62
|
+
clients.push({ ...client, plugin: manifest.name, enabled: plugin.status === 'enabled' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return clients;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The panel's own client, when the registry credential (stored, or the
|
|
70
|
+
* candidate being verified) uses it.
|
|
71
|
+
* @param {{ registryUrl: string } | null | undefined} registryAuth
|
|
72
|
+
*/
|
|
73
|
+
function lamasteOwnClient(registryAuth) {
|
|
74
|
+
if (!registryAuth) return [];
|
|
75
|
+
return [{ ...lamasteServerOidcClient(registryAuth.registryUrl), enabled: true }];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Everything Authelia should know, from the stored state.
|
|
80
|
+
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides]
|
|
81
|
+
* `registryAuth` replaces the stored registry credential's say in the
|
|
82
|
+
* matter: `{ registryUrl }` while a candidate oidc credential is being
|
|
83
|
+
* verified, `null` to leave the panel's client out.
|
|
84
|
+
*/
|
|
85
|
+
async function collectAllClients(overrides = {}) {
|
|
86
|
+
let registryAuth;
|
|
87
|
+
if (overrides.registryAuth !== undefined) {
|
|
88
|
+
registryAuth = overrides.registryAuth;
|
|
89
|
+
} else {
|
|
90
|
+
const stored = await readRegistryCredentialConfig().catch(() => null);
|
|
91
|
+
registryAuth =
|
|
92
|
+
stored && stored.auth.kind === 'oidc' ? { registryUrl: stored.registryUrl } : null;
|
|
93
|
+
}
|
|
94
|
+
return [...(await collectPluginOidcClients()), ...lamasteOwnClient(registryAuth)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sync Authelia's OIDC clients and the secret store with the plugins and
|
|
99
|
+
* the registry credential. Returns what was done, or `null` when the panel
|
|
100
|
+
* has no domain yet (onboarding).
|
|
101
|
+
*
|
|
102
|
+
* @param {import('pino').Logger} logger
|
|
103
|
+
* @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
|
|
104
|
+
* @returns {Promise<{ changed: boolean, clients: string[], createdSecrets: Array<{ id: string, plugin: string, secret: string }> } | null>}
|
|
105
|
+
*/
|
|
106
|
+
export async function syncOidcClients(logger, overrides = {}) {
|
|
107
|
+
let domain = null;
|
|
108
|
+
try {
|
|
109
|
+
domain = getConfig().domain;
|
|
110
|
+
} catch {
|
|
111
|
+
domain = null;
|
|
112
|
+
}
|
|
113
|
+
if (!domain) {
|
|
114
|
+
logger.debug('OIDC client sync skipped — no domain configured yet');
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const all = await collectAllClients(overrides);
|
|
119
|
+
const reconciled = await reconcileOidcClientSecrets(
|
|
120
|
+
STATE_DIR,
|
|
121
|
+
all.filter((c) => c.confidential).map((c) => ({ id: c.id, plugin: c.plugin })),
|
|
122
|
+
);
|
|
123
|
+
if (reconciled.created.length > 0 || reconciled.removed.length > 0) {
|
|
124
|
+
logger.info(
|
|
125
|
+
{ created: reconciled.created.map((c) => c.id), removed: reconciled.removed },
|
|
126
|
+
'OIDC client secrets reconciled',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const served = all.filter((c) => c.enabled).map(({ enabled: _enabled, ...client }) => client);
|
|
131
|
+
const scopes = served.some((c) => c.plugin === 'lamaste') ? LAMASTE_REGISTRY_OIDC_SCOPES : {};
|
|
132
|
+
const result = await updateOidcProvider(domain, served, {
|
|
133
|
+
secretDigests: reconciled.digests,
|
|
134
|
+
scopes,
|
|
135
|
+
});
|
|
136
|
+
if (result.changed) {
|
|
137
|
+
logger.info({ clients: served.map((c) => c.id) }, 'Authelia OIDC provider updated');
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
changed: result.changed,
|
|
141
|
+
clients: served.map((c) => c.id),
|
|
142
|
+
createdSecrets: reconciled.created,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Metadata of a plugin's OIDC clients: what the manifest declares, whether
|
|
148
|
+
* it is served, and — for confidential clients — when the secret was
|
|
149
|
+
* created and last rotated. Never the secret.
|
|
150
|
+
* @param {string} pluginName
|
|
151
|
+
*/
|
|
152
|
+
export async function listPluginOidcClients(pluginName) {
|
|
153
|
+
const declared = (await collectPluginOidcClients()).filter((c) => c.plugin === pluginName);
|
|
154
|
+
const secrets = new Map((await listOidcClientSecrets(STATE_DIR)).map((m) => [m.id, m]));
|
|
155
|
+
return declared.map((client) => {
|
|
156
|
+
const secret = client.confidential ? (secrets.get(client.id) ?? null) : null;
|
|
157
|
+
return {
|
|
158
|
+
id: client.id,
|
|
159
|
+
name: client.name ?? null,
|
|
160
|
+
confidential: client.confidential,
|
|
161
|
+
grantTypes: client.grantTypes,
|
|
162
|
+
scopes: client.scopes,
|
|
163
|
+
audience: client.audience,
|
|
164
|
+
accessTokenClaims: client.accessTokenClaims,
|
|
165
|
+
enabled: client.enabled,
|
|
166
|
+
secretCreatedAt: secret?.createdAt ?? null,
|
|
167
|
+
secretRotatedAt: secret?.rotatedAt ?? null,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Replace the secret of one of a plugin's confidential clients and write
|
|
174
|
+
* the new digest to Authelia. Returns the new secret — the only time it is
|
|
175
|
+
* shown. Throws with `statusCode` 404 for an unknown client and 400 for a
|
|
176
|
+
* public one.
|
|
177
|
+
* @param {string} pluginName
|
|
178
|
+
* @param {string} clientId
|
|
179
|
+
* @param {import('pino').Logger} logger
|
|
180
|
+
*/
|
|
181
|
+
export async function rotatePluginOidcClientSecret(pluginName, clientId, logger) {
|
|
182
|
+
const client = (await collectPluginOidcClients()).find(
|
|
183
|
+
(c) => c.plugin === pluginName && c.id === clientId,
|
|
184
|
+
);
|
|
185
|
+
if (!client) {
|
|
186
|
+
throw Object.assign(new Error(`Plugin "${pluginName}" declares no OIDC client "${clientId}"`), {
|
|
187
|
+
statusCode: 404,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (!client.confidential) {
|
|
191
|
+
throw Object.assign(new Error(`OIDC client "${clientId}" is public and has no secret`), {
|
|
192
|
+
statusCode: 400,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// The store is reconciled first so a client that was never synced (an
|
|
196
|
+
// earlier Authelia failure) gets its record now.
|
|
197
|
+
await syncOidcClients(logger);
|
|
198
|
+
const rotated = await rotateOidcClientSecret(STATE_DIR, clientId);
|
|
199
|
+
const digests = await readOidcClientSecretDigests(STATE_DIR);
|
|
200
|
+
if (client.enabled) {
|
|
201
|
+
const domain = getConfig().domain;
|
|
202
|
+
const all = await collectAllClients();
|
|
203
|
+
const served = all.filter((c) => c.enabled).map(({ enabled: _enabled, ...c }) => c);
|
|
204
|
+
await updateOidcProvider(domain, served, {
|
|
205
|
+
secretDigests: digests,
|
|
206
|
+
scopes: served.some((c) => c.plugin === 'lamaste') ? LAMASTE_REGISTRY_OIDC_SCOPES : {},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
logger.info({ plugin: pluginName, clientId }, 'OIDC client secret rotated');
|
|
210
|
+
return {
|
|
211
|
+
id: rotated.id,
|
|
212
|
+
plugin: rotated.plugin,
|
|
213
|
+
secret: rotated.secret,
|
|
214
|
+
rotatedAt: rotated.rotatedAt,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -210,6 +210,26 @@ function assertSafeLamalibrePackage(packageName) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* After a lifecycle change, bring Authelia's OIDC clients and the secret
|
|
215
|
+
* store in line with the installed plugins. Best effort — logged, never
|
|
216
|
+
* thrown — so a plugin operation does not fail because Authelia could not
|
|
217
|
+
* be restarted. Returns the secrets of confidential clients this change
|
|
218
|
+
* created (`[{ id, secret }]`), shown to the admin exactly once.
|
|
219
|
+
* @param {import('pino').Logger} logger
|
|
220
|
+
* @returns {Promise<Array<{ id: string, secret: string }>>}
|
|
221
|
+
*/
|
|
222
|
+
async function syncOidcClientsBestEffort(logger) {
|
|
223
|
+
try {
|
|
224
|
+
const { syncOidcClients } = await import('./plugin-oidc.js');
|
|
225
|
+
const result = await syncOidcClients(logger);
|
|
226
|
+
return (result?.createdSecrets ?? []).map(({ id, secret }) => ({ id, secret }));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
logger.warn({ err: err.message }, 'Failed to sync plugin OIDC clients to Authelia');
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
213
233
|
export function installPlugin(packageName, logger) {
|
|
214
234
|
return withPluginLock(async () => {
|
|
215
235
|
// Validate package scope
|
|
@@ -367,7 +387,8 @@ export function installPlugin(packageName, logger) {
|
|
|
367
387
|
}
|
|
368
388
|
|
|
369
389
|
logger.info({ name: manifest.name, packageName }, 'Plugin installed');
|
|
370
|
-
|
|
390
|
+
const oidcClients = await syncOidcClientsBestEffort(logger);
|
|
391
|
+
return { ...entry, oidcClients };
|
|
371
392
|
});
|
|
372
393
|
}
|
|
373
394
|
|
|
@@ -436,6 +457,7 @@ export function uninstallPlugin(name, logger) {
|
|
|
436
457
|
}
|
|
437
458
|
|
|
438
459
|
logger.info({ name }, 'Plugin uninstalled');
|
|
460
|
+
await syncOidcClientsBestEffort(logger);
|
|
439
461
|
return { ok: true, name };
|
|
440
462
|
});
|
|
441
463
|
}
|
|
@@ -470,7 +492,8 @@ export function enablePlugin(name, logger) {
|
|
|
470
492
|
}
|
|
471
493
|
|
|
472
494
|
logger?.info({ name }, 'Plugin enabled');
|
|
473
|
-
|
|
495
|
+
const oidcClients = await syncOidcClientsBestEffort(logger);
|
|
496
|
+
return { ok: true, name, status: 'enabled', oidcClients };
|
|
474
497
|
});
|
|
475
498
|
}
|
|
476
499
|
|
|
@@ -504,6 +527,7 @@ export function disablePlugin(name, logger) {
|
|
|
504
527
|
}
|
|
505
528
|
|
|
506
529
|
logger?.info({ name }, 'Plugin disabled');
|
|
530
|
+
await syncOidcClientsBestEffort(logger);
|
|
507
531
|
return { ok: true, name, status: 'disabled' };
|
|
508
532
|
});
|
|
509
533
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry credential (Feria) — daemon shim over `@lamalibre/lamaste/server`.
|
|
3
|
+
*
|
|
4
|
+
* Binds the core module to this daemon's state directory and holds the
|
|
5
|
+
* per-process state the core keeps pure:
|
|
6
|
+
*
|
|
7
|
+
* - the panel's own registry token (`RegistryServiceTokenCache`): a pasted
|
|
8
|
+
* token as is, or — with `auth.kind: 'oidc'` — a Feria token obtained
|
|
9
|
+
* through Authelia's client-credentials grant and reused until five
|
|
10
|
+
* minutes before it expires;
|
|
11
|
+
* - one brokered token per agent (`AgentTokenBroker`), reused for every
|
|
12
|
+
* install within the hour and limited to ten new tokens per agent per
|
|
13
|
+
* hour.
|
|
14
|
+
*
|
|
15
|
+
* Also the one piece of policy that belongs here: when a user loses a
|
|
16
|
+
* `feria-*` group or is deleted, their registry tokens are revoked (best
|
|
17
|
+
* effort, never blocks the user operation).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
readRegistryCredentialConfig as readCore,
|
|
22
|
+
writeRegistryCredentialConfig as writeCore,
|
|
23
|
+
clearRegistryCredentialConfig as clearCore,
|
|
24
|
+
verifyRegistryCredential as verifyCore,
|
|
25
|
+
brokerAgentRegistryToken,
|
|
26
|
+
revokeRegistryTokensForOwner,
|
|
27
|
+
lostFeriaGroups,
|
|
28
|
+
readOidcClientSecret,
|
|
29
|
+
RegistryServiceTokenCache,
|
|
30
|
+
AgentTokenBroker,
|
|
31
|
+
RegistryCredentialError,
|
|
32
|
+
LAMASTE_SERVER_OIDC_CLIENT_ID,
|
|
33
|
+
} from '@lamalibre/lamaste/server';
|
|
34
|
+
|
|
35
|
+
import { getConfig } from './config.js';
|
|
36
|
+
|
|
37
|
+
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
38
|
+
|
|
39
|
+
const serviceTokens = new RegistryServiceTokenCache();
|
|
40
|
+
const agentTokens = new AgentTokenBroker();
|
|
41
|
+
|
|
42
|
+
export function readRegistryCredentialConfig() {
|
|
43
|
+
return readCore(STATE_DIR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store a credential and forget every token obtained with the previous one. */
|
|
47
|
+
export async function writeRegistryCredentialConfig(config) {
|
|
48
|
+
await writeCore(STATE_DIR, config);
|
|
49
|
+
resetRegistryTokenState();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function clearRegistryCredentialConfig() {
|
|
53
|
+
const removed = await clearCore(STATE_DIR);
|
|
54
|
+
resetRegistryTokenState();
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop the cached service token and every agent's cached token and issuance record. */
|
|
59
|
+
export function resetRegistryTokenState() {
|
|
60
|
+
serviceTokens.invalidate();
|
|
61
|
+
agentTokens.reset();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What the core needs to run the OIDC chain for a credential: the relay
|
|
66
|
+
* domain and the `lamaste-server` client secret. Pasted-token credentials
|
|
67
|
+
* need nothing.
|
|
68
|
+
* @param {{ auth: { kind: string } }} config
|
|
69
|
+
*/
|
|
70
|
+
async function oidcDeps(config) {
|
|
71
|
+
if (config.auth.kind !== 'oidc') return {};
|
|
72
|
+
const domain = getConfig().domain;
|
|
73
|
+
if (!domain) {
|
|
74
|
+
throw new RegistryCredentialError(
|
|
75
|
+
'the panel has no domain yet — complete onboarding first',
|
|
76
|
+
503,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const clientSecret = await readOidcClientSecret(STATE_DIR, LAMASTE_SERVER_OIDC_CLIENT_ID);
|
|
80
|
+
if (!clientSecret) {
|
|
81
|
+
throw new RegistryCredentialError(
|
|
82
|
+
`the ${LAMASTE_SERVER_OIDC_CLIENT_ID} OIDC client has no secret yet — set the registry credential again`,
|
|
83
|
+
500,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { oidc: { domain, clientSecret } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Verify a candidate credential end to end (for `oidc`: Authelia token
|
|
91
|
+
* endpoint → Feria login → whoami with the admin scope). The
|
|
92
|
+
* `lamaste-server` client must already be in Authelia — the route syncs it
|
|
93
|
+
* first.
|
|
94
|
+
* @param {{ registryUrl: string, auth: { kind: 'token', token: string } | { kind: 'oidc', clientId: string } }} candidate
|
|
95
|
+
*/
|
|
96
|
+
export async function verifyRegistryCredential(candidate) {
|
|
97
|
+
return verifyCore(candidate, await oidcDeps(candidate));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The panel's registry token for the stored credential (cached per process). */
|
|
101
|
+
async function serviceToken(config) {
|
|
102
|
+
return (await serviceTokens.resolve(config, await oidcDeps(config))).token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The calling agent's registry token: the cached one when it is still good
|
|
107
|
+
* for an install, otherwise a freshly brokered one-hour token — unless the
|
|
108
|
+
* agent already had ten new tokens this hour. `null` when no credential is
|
|
109
|
+
* configured. Throws RegistryCredentialError (with `statusCode`) when the
|
|
110
|
+
* registry refuses.
|
|
111
|
+
* @param {string} agentLabel
|
|
112
|
+
* @returns {Promise<import('@lamalibre/lamaste/server').AgentTokenAcquisition | null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function acquireAgentToken(agentLabel) {
|
|
115
|
+
const config = await readRegistryCredentialConfig();
|
|
116
|
+
if (!config) return null;
|
|
117
|
+
return agentTokens.acquire(agentLabel, async () =>
|
|
118
|
+
brokerAgentRegistryToken(config, agentLabel, { serviceToken: await serviceToken(config) }),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Revoke a user's registry tokens after their `feria-*` groups changed or
|
|
124
|
+
* they were deleted. Returns the number revoked, or `null` when nothing
|
|
125
|
+
* needed doing (no credential configured, or no feria group lost).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} username
|
|
128
|
+
* @param {{ before: string[], after: string[] } | { deleted: true }} change
|
|
129
|
+
* @param {import('pino').Logger} log
|
|
130
|
+
*/
|
|
131
|
+
export async function revokeUserRegistryTokens(username, change, log) {
|
|
132
|
+
const config = await readRegistryCredentialConfig();
|
|
133
|
+
if (!config) return null;
|
|
134
|
+
|
|
135
|
+
const lost =
|
|
136
|
+
'deleted' in change ? ['(deleted)'] : lostFeriaGroups(change.before ?? [], change.after ?? []);
|
|
137
|
+
if (lost.length === 0) return null;
|
|
138
|
+
|
|
139
|
+
const owner = `user:${username}`;
|
|
140
|
+
const revoked = await revokeRegistryTokensForOwner(config, owner, {
|
|
141
|
+
serviceToken: await serviceToken(config),
|
|
142
|
+
});
|
|
143
|
+
log.info({ username, lost, revoked }, 'Revoked registry tokens after group change');
|
|
144
|
+
return revoked;
|
|
145
|
+
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from '../../lib/mtls.js';
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
|
|
17
|
+
import { RegistryCredentialError } from '@lamalibre/lamaste/server';
|
|
18
|
+
import { acquireAgentToken } from '../../lib/registry-credential.js';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* An agent may rotate its own chisel credential at most this often. Every
|
|
@@ -113,6 +115,85 @@ export default async function agentsRoutes(fastify, _opts) {
|
|
|
113
115
|
},
|
|
114
116
|
);
|
|
115
117
|
|
|
118
|
+
// ------------------------------------------------------------------
|
|
119
|
+
// GET /agents/me/registry-credential
|
|
120
|
+
//
|
|
121
|
+
// Brokers a one-hour, read-only npm registry token for the calling agent
|
|
122
|
+
// so it can `npm install` a plugin from the Lamalibre registry (Feria)
|
|
123
|
+
// without ever holding a long-lived credential. Identity comes strictly
|
|
124
|
+
// from the mTLS cert; the token is owned by `agent:<label>` on the
|
|
125
|
+
// registry, so its audit log says which agent installed what.
|
|
126
|
+
//
|
|
127
|
+
// The token is cached per agent until five minutes before it expires, so
|
|
128
|
+
// repeated installs within the hour get the same one; an agent may be
|
|
129
|
+
// issued at most ten new tokens per hour (429 + Retry-After beyond that).
|
|
130
|
+
// The per-IP limit stays as a backstop.
|
|
131
|
+
//
|
|
132
|
+
// 404 when the panel has no registry credential configured — the agent
|
|
133
|
+
// then installs from the public registry as before.
|
|
134
|
+
// ------------------------------------------------------------------
|
|
135
|
+
fastify.get(
|
|
136
|
+
'/agents/me/registry-credential',
|
|
137
|
+
{
|
|
138
|
+
preHandler: fastify.requireRole(['agent']),
|
|
139
|
+
// One token per install is the expected rate; this only stops a loop.
|
|
140
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
141
|
+
},
|
|
142
|
+
async (request, reply) => {
|
|
143
|
+
// requireRole lets the admin master role through every guard; an admin
|
|
144
|
+
// certificate has no agent identity to broker for. Plugin-agents pass
|
|
145
|
+
// here and are answered 400 below.
|
|
146
|
+
if (request.certRole !== 'agent' && request.certRole !== 'plugin-agent') {
|
|
147
|
+
return reply
|
|
148
|
+
.code(403)
|
|
149
|
+
.send({ error: 'Only an agent certificate can obtain a registry credential' });
|
|
150
|
+
}
|
|
151
|
+
const label = request.certLabel;
|
|
152
|
+
if (!label) {
|
|
153
|
+
return reply.code(400).send({
|
|
154
|
+
error: 'Agent label could not be determined from client certificate',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (!AGENT_LABEL_REGEX.test(label)) {
|
|
158
|
+
return reply.code(400).send({ error: 'Plugin-agents do not install plugins' });
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const acquired = await acquireAgentToken(label);
|
|
162
|
+
if (!acquired) {
|
|
163
|
+
return reply.code(404).send({
|
|
164
|
+
error: 'No registry credential is configured on this panel',
|
|
165
|
+
hint: 'lamaste-server registry-credential set --url <registry> --oidc',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (!acquired.ok) {
|
|
169
|
+
request.log.warn(
|
|
170
|
+
{ label, retryAfterSeconds: acquired.retryAfterSeconds },
|
|
171
|
+
'Agent exceeded its registry token issuance limit',
|
|
172
|
+
);
|
|
173
|
+
return reply.code(429).header('Retry-After', String(acquired.retryAfterSeconds)).send({
|
|
174
|
+
error: 'This agent has been issued its hourly share of registry tokens',
|
|
175
|
+
retryAfterSeconds: acquired.retryAfterSeconds,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const { credential } = acquired;
|
|
179
|
+
request.log.info(
|
|
180
|
+
{
|
|
181
|
+
label,
|
|
182
|
+
tokenId: credential.tokenId,
|
|
183
|
+
expiresAt: credential.expiresAt,
|
|
184
|
+
cached: acquired.cached,
|
|
185
|
+
},
|
|
186
|
+
acquired.cached ? 'Reused registry token for agent' : 'Brokered registry token for agent',
|
|
187
|
+
);
|
|
188
|
+
return credential;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
|
|
191
|
+
request.log.warn({ label, err: err.message }, 'Failed to broker registry token');
|
|
192
|
+
return reply.code(status).send({ error: err.message || 'Failed to broker registry token' });
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
116
197
|
// ------------------------------------------------------------------
|
|
117
198
|
// GET /agents/me/capabilities
|
|
118
199
|
//
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getPluginCapabilities,
|
|
9
9
|
} from '../../lib/plugins.js';
|
|
10
10
|
import { setPluginCapabilities, loadAgentRegistry } from '../../lib/mtls.js';
|
|
11
|
+
import { listPluginOidcClients, rotatePluginOidcClientSecret } from '../../lib/plugin-oidc.js';
|
|
11
12
|
import {
|
|
12
13
|
readPushInstallConfig,
|
|
13
14
|
readPushInstallSessions,
|
|
@@ -31,6 +32,14 @@ const PluginNameParamSchema = z.object({
|
|
|
31
32
|
.regex(/^[a-z0-9-]+$/, 'Plugin name must contain only lowercase letters, numbers, and hyphens'),
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
const OidcClientIdParamSchema = z.object({
|
|
36
|
+
clientId: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(100)
|
|
40
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, 'Client id must be lowercase letters, numbers and hyphens'),
|
|
41
|
+
});
|
|
42
|
+
|
|
34
43
|
const InstallBodySchema = z.object({
|
|
35
44
|
packageName: z
|
|
36
45
|
.string()
|
|
@@ -188,10 +197,12 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
188
197
|
const body = InstallBodySchema.parse(request.body);
|
|
189
198
|
|
|
190
199
|
try {
|
|
191
|
-
|
|
200
|
+
// `oidcClients` carries the secrets of confidential OIDC clients this
|
|
201
|
+
// install created — shown once, never stored in the registry row.
|
|
202
|
+
const { oidcClients, ...plugin } = await installPlugin(body.packageName, request.log);
|
|
192
203
|
// Refresh in-memory capability list so new plugin caps are valid immediately
|
|
193
204
|
setPluginCapabilities(await getPluginCapabilities());
|
|
194
|
-
return { ok: true, plugin };
|
|
205
|
+
return { ok: true, plugin, oidcClients };
|
|
195
206
|
} catch (err) {
|
|
196
207
|
const statusCode = err.statusCode || 500;
|
|
197
208
|
return reply.code(statusCode).send({
|
|
@@ -273,6 +284,58 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
273
284
|
},
|
|
274
285
|
);
|
|
275
286
|
|
|
287
|
+
// ------------------------------------------------------------------
|
|
288
|
+
// GET /plugins/:name/oidc-clients — the OIDC clients a plugin declares
|
|
289
|
+
//
|
|
290
|
+
// Metadata only: a confidential client's secret is shown by the install
|
|
291
|
+
// or enable response that created it, and by rotate-secret — never here.
|
|
292
|
+
// ------------------------------------------------------------------
|
|
293
|
+
fastify.get(
|
|
294
|
+
'/plugins/:name/oidc-clients',
|
|
295
|
+
{
|
|
296
|
+
preHandler: fastify.requireRole(['admin']),
|
|
297
|
+
},
|
|
298
|
+
async (request, reply) => {
|
|
299
|
+
const params = PluginNameParamSchema.parse(request.params);
|
|
300
|
+
const registry = await readPlugins();
|
|
301
|
+
if (!registry.plugins.some((p) => p.name === params.name)) {
|
|
302
|
+
return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
|
|
303
|
+
}
|
|
304
|
+
return { clients: await listPluginOidcClients(params.name) };
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// ------------------------------------------------------------------
|
|
309
|
+
// POST /plugins/:name/oidc-clients/:clientId/rotate-secret
|
|
310
|
+
//
|
|
311
|
+
// Replaces a confidential client's secret in the store and in Authelia.
|
|
312
|
+
// The new secret is in the response and nowhere else.
|
|
313
|
+
// ------------------------------------------------------------------
|
|
314
|
+
fastify.post(
|
|
315
|
+
'/plugins/:name/oidc-clients/:clientId/rotate-secret',
|
|
316
|
+
{
|
|
317
|
+
preHandler: fastify.requireRole(['admin']),
|
|
318
|
+
config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
|
|
319
|
+
},
|
|
320
|
+
async (request, reply) => {
|
|
321
|
+
const params = PluginNameParamSchema.parse(request.params);
|
|
322
|
+
const { clientId } = OidcClientIdParamSchema.parse(request.params);
|
|
323
|
+
const registry = await readPlugins();
|
|
324
|
+
if (!registry.plugins.some((p) => p.name === params.name)) {
|
|
325
|
+
return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const rotated = await rotatePluginOidcClientSecret(params.name, clientId, request.log);
|
|
329
|
+
return { ok: true, ...rotated };
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const statusCode = err.statusCode || 500;
|
|
332
|
+
return reply.code(statusCode).send({
|
|
333
|
+
error: err.message || 'Failed to rotate the client secret',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
276
339
|
// ===========================================================================
|
|
277
340
|
// Push install routes
|
|
278
341
|
// ===========================================================================
|