@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.
Files changed (61) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +21 -2
  3. package/src/lib/oidc-issuer-key.js +124 -0
  4. package/src/lib/relay-services.js +4 -0
  5. package/src/lib/service-config.js +16 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/authentication.md +45 -1
  10. package/vendor/docs/01-concepts/certificates.md +3 -3
  11. package/vendor/docs/01-concepts/mtls.md +2 -2
  12. package/vendor/docs/01-concepts/tickets.md +2 -2
  13. package/vendor/docs/01-concepts/tunneling.md +4 -4
  14. package/vendor/docs/02-guides/agent-setup.md +3 -3
  15. package/vendor/docs/02-guides/certificate-management.md +2 -2
  16. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  17. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  18. package/vendor/docs/02-guides/installation.md +3 -3
  19. package/vendor/docs/02-guides/onboarding.md +2 -2
  20. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  21. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  22. package/vendor/docs/03-architecture/installer.md +6 -5
  23. package/vendor/docs/03-architecture/management-flow.md +1 -1
  24. package/vendor/docs/03-architecture/overview.md +5 -5
  25. package/vendor/docs/03-architecture/panel-client.md +6 -6
  26. package/vendor/docs/03-architecture/panel-server.md +2 -2
  27. package/vendor/docs/03-architecture/state-management.md +2 -2
  28. package/vendor/docs/03-architecture/system-overview.md +1 -1
  29. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  30. package/vendor/docs/04-api-reference/overview.md +111 -105
  31. package/vendor/docs/04-api-reference/registry-and-oidc.md +182 -0
  32. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  33. package/vendor/docs/05-operations/uninstalling.md +1 -1
  34. package/vendor/docs/05-operations/upgrades.md +6 -5
  35. package/vendor/docs/06-reference/config-files.md +103 -45
  36. package/vendor/docs/06-reference/glossary.md +3 -3
  37. package/vendor/docs/06-reference/installer-flags.md +13 -13
  38. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  39. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  40. package/vendor/docs/_index.json +5 -0
  41. package/vendor/docs/gatekeeper-design.md +8 -8
  42. package/vendor/docs/identity-directory-design.md +443 -0
  43. package/vendor/docs/package.json +1 -1
  44. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  45. package/vendor/gatekeeper/package.json +1 -1
  46. package/vendor/server/bin/lamaste-server.js +10 -1
  47. package/vendor/server/package.json +4 -3
  48. package/vendor/server/src/commands/plugins.js +87 -0
  49. package/vendor/server/src/commands/registry-credential.js +142 -0
  50. package/vendor/server/src/panel-api.js +83 -38
  51. package/vendor/serverd/package.json +5 -2
  52. package/vendor/serverd/src/index.js +11 -0
  53. package/vendor/serverd/src/lib/authelia.js +11 -0
  54. package/vendor/serverd/src/lib/plugin-oidc.js +216 -0
  55. package/vendor/serverd/src/lib/plugins.js +26 -2
  56. package/vendor/serverd/src/lib/registry-credential.js +145 -0
  57. package/vendor/serverd/src/routes/management/agents.js +81 -0
  58. package/vendor/serverd/src/routes/management/plugins.js +65 -2
  59. package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
  60. package/vendor/serverd/src/routes/management/users.js +31 -0
  61. 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
- return entry;
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
- return { ok: true, name, status: 'enabled' };
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
- const plugin = await installPlugin(body.packageName, request.log);
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
  // ===========================================================================