@backstage/backend-defaults 0.3.0-next.2 → 0.3.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 (72) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/auth/package.json +6 -0
  3. package/cache/package.json +1 -1
  4. package/config.d.ts +277 -0
  5. package/database/package.json +1 -1
  6. package/discovery/package.json +1 -1
  7. package/dist/auth.cjs.js +1025 -0
  8. package/dist/auth.cjs.js.map +1 -0
  9. package/dist/auth.d.ts +14 -0
  10. package/dist/cache.cjs.js.map +1 -1
  11. package/dist/cache.d.ts +31 -37
  12. package/dist/cjs/config-BDOwXIyo.cjs.js +64 -0
  13. package/dist/cjs/config-BDOwXIyo.cjs.js.map +1 -0
  14. package/dist/cjs/createConfigSecretEnumerator-DShyoWWL.cjs.js +33 -0
  15. package/dist/cjs/createConfigSecretEnumerator-DShyoWWL.cjs.js.map +1 -0
  16. package/dist/cjs/helpers-D2f1CG0o.cjs.js +53 -0
  17. package/dist/cjs/helpers-D2f1CG0o.cjs.js.map +1 -0
  18. package/dist/database.cjs.js +59 -145
  19. package/dist/database.cjs.js.map +1 -1
  20. package/dist/database.d.ts +7 -2
  21. package/dist/discovery.cjs.js +6 -6
  22. package/dist/discovery.cjs.js.map +1 -1
  23. package/dist/discovery.d.ts +9 -1
  24. package/dist/httpAuth.cjs.js +192 -0
  25. package/dist/httpAuth.cjs.js.map +1 -0
  26. package/dist/httpAuth.d.ts +15 -0
  27. package/dist/httpRouter.cjs.js +191 -0
  28. package/dist/httpRouter.cjs.js.map +1 -0
  29. package/dist/httpRouter.d.ts +55 -0
  30. package/dist/index.cjs.js +14 -8
  31. package/dist/index.cjs.js.map +1 -1
  32. package/dist/lifecycle.cjs.js.map +1 -1
  33. package/dist/lifecycle.d.ts +5 -1
  34. package/dist/logger.cjs.js +17 -0
  35. package/dist/logger.cjs.js.map +1 -0
  36. package/dist/logger.d.ts +14 -0
  37. package/dist/permissions.cjs.js.map +1 -1
  38. package/dist/permissions.d.ts +6 -0
  39. package/dist/rootConfig.cjs.js +3 -0
  40. package/dist/rootConfig.cjs.js.map +1 -1
  41. package/dist/rootConfig.d.ts +17 -2
  42. package/dist/rootHttpRouter.cjs.js +629 -0
  43. package/dist/rootHttpRouter.cjs.js.map +1 -0
  44. package/dist/rootHttpRouter.d.ts +283 -0
  45. package/dist/rootLifecycle.cjs.js.map +1 -1
  46. package/dist/rootLifecycle.d.ts +5 -1
  47. package/dist/rootLogger.cjs.js +143 -0
  48. package/dist/rootLogger.cjs.js.map +1 -0
  49. package/dist/rootLogger.d.ts +58 -0
  50. package/dist/scheduler.cjs.js +11 -40
  51. package/dist/scheduler.cjs.js.map +1 -1
  52. package/dist/scheduler.d.ts +19 -2
  53. package/dist/urlReader.cjs.js +2932 -2
  54. package/dist/urlReader.cjs.js.map +1 -1
  55. package/dist/urlReader.d.ts +422 -4
  56. package/dist/userInfo.cjs.js +70 -0
  57. package/dist/userInfo.cjs.js.map +1 -0
  58. package/dist/userInfo.d.ts +14 -0
  59. package/httpAuth/package.json +6 -0
  60. package/httpRouter/package.json +6 -0
  61. package/lifecycle/package.json +1 -1
  62. package/logger/package.json +6 -0
  63. package/migrations/auth/20240327104803_public_keys.js +50 -0
  64. package/package.json +103 -11
  65. package/permissions/package.json +1 -1
  66. package/rootConfig/package.json +1 -1
  67. package/rootHttpRouter/package.json +6 -0
  68. package/rootLifecycle/package.json +1 -1
  69. package/rootLogger/package.json +6 -0
  70. package/scheduler/package.json +1 -1
  71. package/urlReader/package.json +1 -1
  72. package/userInfo/package.json +6 -0
@@ -0,0 +1,1025 @@
1
+ 'use strict';
2
+
3
+ var backendPluginApi = require('@backstage/backend-plugin-api');
4
+ var errors = require('@backstage/errors');
5
+ var jose = require('jose');
6
+ var helpers = require('./cjs/helpers-D2f1CG0o.cjs.js');
7
+ var pluginAuthNode = require('@backstage/plugin-auth-node');
8
+ var types = require('@backstage/types');
9
+ var uuid = require('uuid');
10
+ var luxon = require('luxon');
11
+ var fs = require('fs');
12
+
13
+ class DefaultAuthService {
14
+ constructor(userTokenHandler, pluginTokenHandler, externalTokenHandler, tokenManager, pluginId, disableDefaultAuthPolicy, pluginKeySource) {
15
+ this.userTokenHandler = userTokenHandler;
16
+ this.pluginTokenHandler = pluginTokenHandler;
17
+ this.externalTokenHandler = externalTokenHandler;
18
+ this.tokenManager = tokenManager;
19
+ this.pluginId = pluginId;
20
+ this.disableDefaultAuthPolicy = disableDefaultAuthPolicy;
21
+ this.pluginKeySource = pluginKeySource;
22
+ }
23
+ async authenticate(token, options) {
24
+ const pluginResult = await this.pluginTokenHandler.verifyToken(token);
25
+ if (pluginResult) {
26
+ if (pluginResult.limitedUserToken) {
27
+ const userResult2 = await this.userTokenHandler.verifyToken(
28
+ pluginResult.limitedUserToken
29
+ );
30
+ if (!userResult2) {
31
+ throw new errors.AuthenticationError(
32
+ "Invalid user token in plugin token obo claim"
33
+ );
34
+ }
35
+ return helpers.createCredentialsWithUserPrincipal(
36
+ userResult2.userEntityRef,
37
+ pluginResult.limitedUserToken,
38
+ this.#getJwtExpiration(pluginResult.limitedUserToken)
39
+ );
40
+ }
41
+ return helpers.createCredentialsWithServicePrincipal(pluginResult.subject);
42
+ }
43
+ const userResult = await this.userTokenHandler.verifyToken(token);
44
+ if (userResult) {
45
+ if (!options?.allowLimitedAccess && this.userTokenHandler.isLimitedUserToken(token)) {
46
+ throw new errors.AuthenticationError("Illegal limited user token");
47
+ }
48
+ return helpers.createCredentialsWithUserPrincipal(
49
+ userResult.userEntityRef,
50
+ token,
51
+ this.#getJwtExpiration(token)
52
+ );
53
+ }
54
+ const externalResult = await this.externalTokenHandler.verifyToken(token);
55
+ if (externalResult) {
56
+ return helpers.createCredentialsWithServicePrincipal(
57
+ externalResult.subject,
58
+ void 0,
59
+ externalResult.accessRestrictions
60
+ );
61
+ }
62
+ throw new errors.AuthenticationError("Illegal token");
63
+ }
64
+ isPrincipal(credentials, type) {
65
+ const principal = credentials.principal;
66
+ if (type === "unknown") {
67
+ return true;
68
+ }
69
+ if (principal.type !== type) {
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+ async getNoneCredentials() {
75
+ return helpers.createCredentialsWithNonePrincipal();
76
+ }
77
+ async getOwnServiceCredentials() {
78
+ return helpers.createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`);
79
+ }
80
+ async getPluginRequestToken(options) {
81
+ const { targetPluginId } = options;
82
+ const internalForward = helpers.toInternalBackstageCredentials(options.onBehalfOf);
83
+ const { type } = internalForward.principal;
84
+ if (type === "none" && this.disableDefaultAuthPolicy) {
85
+ return { token: "" };
86
+ }
87
+ const targetSupportsNewAuth = await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId);
88
+ switch (type) {
89
+ case "service":
90
+ if (targetSupportsNewAuth) {
91
+ return this.pluginTokenHandler.issueToken({
92
+ pluginId: this.pluginId,
93
+ targetPluginId
94
+ });
95
+ }
96
+ return this.tokenManager.getToken().catch((error) => {
97
+ throw new errors.ForwardedError(
98
+ `Unable to generate legacy token for communication with the '${targetPluginId}' plugin. You will typically encounter this error when attempting to call a plugin that does not exist, or is deployed with an old version of Backstage`,
99
+ error
100
+ );
101
+ });
102
+ case "user": {
103
+ const { token } = internalForward;
104
+ if (!token) {
105
+ throw new Error("User credentials is unexpectedly missing token");
106
+ }
107
+ if (targetSupportsNewAuth) {
108
+ const onBehalfOf = await this.userTokenHandler.createLimitedUserToken(
109
+ token
110
+ );
111
+ return this.pluginTokenHandler.issueToken({
112
+ pluginId: this.pluginId,
113
+ targetPluginId,
114
+ onBehalfOf
115
+ });
116
+ }
117
+ if (this.userTokenHandler.isLimitedUserToken(token)) {
118
+ throw new errors.AuthenticationError(
119
+ `Unable to call '${targetPluginId}' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens or the plugin doesn't exist`
120
+ );
121
+ }
122
+ return { token };
123
+ }
124
+ default:
125
+ throw new errors.AuthenticationError(
126
+ `Refused to issue service token for credential type '${type}'`
127
+ );
128
+ }
129
+ }
130
+ async getLimitedUserToken(credentials) {
131
+ const { token: backstageToken } = helpers.toInternalBackstageCredentials(credentials);
132
+ if (!backstageToken) {
133
+ throw new errors.AuthenticationError(
134
+ "User credentials is unexpectedly missing token"
135
+ );
136
+ }
137
+ return this.userTokenHandler.createLimitedUserToken(backstageToken);
138
+ }
139
+ async listPublicServiceKeys() {
140
+ const { keys } = await this.pluginKeySource.listKeys();
141
+ return { keys: keys.map(({ key }) => key) };
142
+ }
143
+ #getJwtExpiration(token) {
144
+ const { exp } = jose.decodeJwt(token);
145
+ if (!exp) {
146
+ throw new errors.AuthenticationError("User token is missing expiration");
147
+ }
148
+ return new Date(exp * 1e3);
149
+ }
150
+ }
151
+
152
+ function readAccessRestrictionsFromConfig(externalAccessEntryConfig) {
153
+ const configs = externalAccessEntryConfig.getOptionalConfigArray("accessRestrictions") ?? [];
154
+ const result = /* @__PURE__ */ new Map();
155
+ for (const config of configs) {
156
+ const validKeys = ["plugin", "permission", "permissionAttribute"];
157
+ for (const key of config.keys()) {
158
+ if (!validKeys.includes(key)) {
159
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
160
+ throw new Error(
161
+ `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
162
+ );
163
+ }
164
+ }
165
+ const pluginId = config.getString("plugin");
166
+ const permissionNames = readPermissionNames(config);
167
+ const permissionAttributes = readPermissionAttributes(config);
168
+ if (result.has(pluginId)) {
169
+ throw new Error(
170
+ `Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`
171
+ );
172
+ }
173
+ result.set(pluginId, {
174
+ ...permissionNames ? { permissionNames } : {},
175
+ ...permissionAttributes ? { permissionAttributes } : {}
176
+ });
177
+ }
178
+ return result.size ? result : void 0;
179
+ }
180
+ function readStringOrStringArrayFromConfig(root, key, validValues) {
181
+ if (!root.has(key)) {
182
+ return void 0;
183
+ }
184
+ const rawValues = Array.isArray(root.get(key)) ? root.getStringArray(key) : [root.getString(key)];
185
+ const values = [
186
+ ...new Set(
187
+ rawValues.map((v) => v.split(/[ ,]/)).flat().filter(Boolean)
188
+ )
189
+ ];
190
+ if (!values.length) {
191
+ return void 0;
192
+ }
193
+ if (validValues?.length) {
194
+ for (const value of values) {
195
+ if (!validValues.includes(value)) {
196
+ const valid = validValues.map((k) => `'${k}'`).join(", ");
197
+ throw new Error(
198
+ `Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`
199
+ );
200
+ }
201
+ }
202
+ }
203
+ return values;
204
+ }
205
+ function readPermissionNames(externalAccessEntryConfig) {
206
+ return readStringOrStringArrayFromConfig(
207
+ externalAccessEntryConfig,
208
+ "permission"
209
+ );
210
+ }
211
+ function readPermissionAttributes(externalAccessEntryConfig) {
212
+ const config = externalAccessEntryConfig.getOptionalConfig(
213
+ "permissionAttribute"
214
+ );
215
+ if (!config) {
216
+ return void 0;
217
+ }
218
+ const validKeys = ["action"];
219
+ for (const key of config.keys()) {
220
+ if (!validKeys.includes(key)) {
221
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
222
+ throw new Error(
223
+ `Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`
224
+ );
225
+ }
226
+ }
227
+ const action = readStringOrStringArrayFromConfig(config, "action", [
228
+ "create",
229
+ "read",
230
+ "update",
231
+ "delete"
232
+ ]);
233
+ const result = {
234
+ ...action ? { action } : {}
235
+ };
236
+ return Object.keys(result).length ? result : void 0;
237
+ }
238
+
239
+ class LegacyTokenHandler {
240
+ #entries = new Array();
241
+ add(config) {
242
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
243
+ this.#doAdd(
244
+ config.getString("options.secret"),
245
+ config.getString("options.subject"),
246
+ allAccessRestrictions
247
+ );
248
+ }
249
+ // used only for the old backend.auth.keys array
250
+ addOld(config) {
251
+ this.#doAdd(config.getString("secret"), "external:backstage-plugin");
252
+ }
253
+ #doAdd(secret, subject, allAccessRestrictions) {
254
+ if (!secret.match(/^\S+$/)) {
255
+ throw new Error("Illegal secret, must be a valid base64 string");
256
+ } else if (!subject.match(/^\S+$/)) {
257
+ throw new Error("Illegal subject, must be a set of non-space characters");
258
+ }
259
+ let key;
260
+ try {
261
+ key = jose.base64url.decode(secret);
262
+ } catch {
263
+ throw new Error("Illegal secret, must be a valid base64 string");
264
+ }
265
+ if (this.#entries.some((e) => e.key === key)) {
266
+ throw new Error(
267
+ "Legacy externalAccess token was declared more than once"
268
+ );
269
+ }
270
+ this.#entries.push({
271
+ key,
272
+ result: {
273
+ subject,
274
+ allAccessRestrictions
275
+ }
276
+ });
277
+ }
278
+ async verifyToken(token) {
279
+ try {
280
+ const { alg } = jose.decodeProtectedHeader(token);
281
+ if (alg !== "HS256") {
282
+ return void 0;
283
+ }
284
+ const { sub, aud } = jose.decodeJwt(token);
285
+ if (sub !== "backstage-server" || aud) {
286
+ return void 0;
287
+ }
288
+ } catch (e) {
289
+ return void 0;
290
+ }
291
+ for (const { key, result } of this.#entries) {
292
+ try {
293
+ await jose.jwtVerify(token, key);
294
+ return result;
295
+ } catch (e) {
296
+ if (e.code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
297
+ throw e;
298
+ }
299
+ }
300
+ }
301
+ return void 0;
302
+ }
303
+ }
304
+
305
+ const MIN_TOKEN_LENGTH = 8;
306
+ class StaticTokenHandler {
307
+ #entries = /* @__PURE__ */ new Map();
308
+ add(config) {
309
+ const token = config.getString("options.token");
310
+ const subject = config.getString("options.subject");
311
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
312
+ if (!token.match(/^\S+$/)) {
313
+ throw new Error("Illegal token, must be a set of non-space characters");
314
+ } else if (token.length < MIN_TOKEN_LENGTH) {
315
+ throw new Error(
316
+ `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`
317
+ );
318
+ } else if (!subject.match(/^\S+$/)) {
319
+ throw new Error("Illegal subject, must be a set of non-space characters");
320
+ } else if (this.#entries.has(token)) {
321
+ throw new Error(
322
+ "Static externalAccess token was declared more than once"
323
+ );
324
+ }
325
+ this.#entries.set(token, { subject, allAccessRestrictions });
326
+ }
327
+ async verifyToken(token) {
328
+ return this.#entries.get(token);
329
+ }
330
+ }
331
+
332
+ class JWKSHandler {
333
+ #entries = [];
334
+ add(config) {
335
+ if (!config.getString("options.url").match(/^\S+$/)) {
336
+ throw new Error(
337
+ "Illegal JWKS URL, must be a set of non-space characters"
338
+ );
339
+ }
340
+ const algorithms = readStringOrStringArrayFromConfig(
341
+ config,
342
+ "options.algorithm"
343
+ );
344
+ const issuers = readStringOrStringArrayFromConfig(config, "options.issuer");
345
+ const audiences = readStringOrStringArrayFromConfig(
346
+ config,
347
+ "options.audience"
348
+ );
349
+ const subjectPrefix = config.getOptionalString("options.subjectPrefix");
350
+ const url = new URL(config.getString("options.url"));
351
+ const jwks = jose.createRemoteJWKSet(url);
352
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
353
+ this.#entries.push({
354
+ algorithms,
355
+ audiences,
356
+ issuers,
357
+ jwks,
358
+ subjectPrefix,
359
+ url,
360
+ allAccessRestrictions
361
+ });
362
+ }
363
+ async verifyToken(token) {
364
+ for (const entry of this.#entries) {
365
+ try {
366
+ const {
367
+ payload: { sub }
368
+ } = await jose.jwtVerify(token, entry.jwks, {
369
+ algorithms: entry.algorithms,
370
+ issuer: entry.issuers,
371
+ audience: entry.audiences
372
+ });
373
+ if (sub) {
374
+ const prefix = entry.subjectPrefix ? `external:${entry.subjectPrefix}:` : "external:";
375
+ return {
376
+ subject: `${prefix}${sub}`,
377
+ allAccessRestrictions: entry.allAccessRestrictions
378
+ };
379
+ }
380
+ } catch {
381
+ continue;
382
+ }
383
+ }
384
+ return void 0;
385
+ }
386
+ }
387
+
388
+ const NEW_CONFIG_KEY = "backend.auth.externalAccess";
389
+ const OLD_CONFIG_KEY = "backend.auth.keys";
390
+ let loggedDeprecationWarning = false;
391
+ class ExternalTokenHandler {
392
+ constructor(ownPluginId, handlers) {
393
+ this.ownPluginId = ownPluginId;
394
+ this.handlers = handlers;
395
+ }
396
+ static create(options) {
397
+ const { ownPluginId, config, logger } = options;
398
+ const staticHandler = new StaticTokenHandler();
399
+ const legacyHandler = new LegacyTokenHandler();
400
+ const jwksHandler = new JWKSHandler();
401
+ const handlers = {
402
+ static: staticHandler,
403
+ legacy: legacyHandler,
404
+ jwks: jwksHandler
405
+ };
406
+ const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
407
+ for (const handlerConfig of handlerConfigs) {
408
+ const type = handlerConfig.getString("type");
409
+ const handler = handlers[type];
410
+ if (!handler) {
411
+ const valid = Object.keys(handlers).map((k) => `'${k}'`).join(", ");
412
+ throw new Error(
413
+ `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`
414
+ );
415
+ }
416
+ handler.add(handlerConfig);
417
+ }
418
+ const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
419
+ if (legacyConfigs.length && !loggedDeprecationWarning) {
420
+ loggedDeprecationWarning = true;
421
+ logger.warn(
422
+ `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`
423
+ );
424
+ }
425
+ for (const handlerConfig of legacyConfigs) {
426
+ legacyHandler.addOld(handlerConfig);
427
+ }
428
+ return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
429
+ }
430
+ async verifyToken(token) {
431
+ for (const handler of this.handlers) {
432
+ const result = await handler.verifyToken(token);
433
+ if (result) {
434
+ const { allAccessRestrictions, ...rest } = result;
435
+ if (allAccessRestrictions) {
436
+ const accessRestrictions = allAccessRestrictions.get(
437
+ this.ownPluginId
438
+ );
439
+ if (!accessRestrictions) {
440
+ const valid = [...allAccessRestrictions.keys()].map((k) => `'${k}'`).join(", ");
441
+ throw new errors.NotAllowedError(
442
+ `This token's access is restricted to plugin(s) ${valid}`
443
+ );
444
+ }
445
+ return {
446
+ ...rest,
447
+ accessRestrictions
448
+ };
449
+ }
450
+ return rest;
451
+ }
452
+ }
453
+ return void 0;
454
+ }
455
+ }
456
+
457
+ const CLOCK_MARGIN_S = 10;
458
+ class JwksClient {
459
+ constructor(getEndpoint) {
460
+ this.getEndpoint = getEndpoint;
461
+ }
462
+ #keyStore;
463
+ #keyStoreUpdated = 0;
464
+ get getKey() {
465
+ if (!this.#keyStore) {
466
+ throw new errors.AuthenticationError(
467
+ "refreshKeyStore must be called before jwksClient.getKey"
468
+ );
469
+ }
470
+ return this.#keyStore;
471
+ }
472
+ /**
473
+ * If the last keystore refresh is stale, update the keystore URL to the latest
474
+ */
475
+ async refreshKeyStore(rawJwtToken) {
476
+ const payload = await jose.decodeJwt(rawJwtToken);
477
+ const header = await jose.decodeProtectedHeader(rawJwtToken);
478
+ let keyStoreHasKey;
479
+ try {
480
+ if (this.#keyStore) {
481
+ const [_, rawPayload, rawSignature] = rawJwtToken.split(".");
482
+ keyStoreHasKey = await this.#keyStore(header, {
483
+ payload: rawPayload,
484
+ signature: rawSignature
485
+ });
486
+ }
487
+ } catch (error) {
488
+ keyStoreHasKey = false;
489
+ }
490
+ const issuedAfterLastRefresh = payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S;
491
+ if (!this.#keyStore || !keyStoreHasKey && issuedAfterLastRefresh) {
492
+ const endpoint = await this.getEndpoint();
493
+ this.#keyStore = jose.createRemoteJWKSet(endpoint);
494
+ this.#keyStoreUpdated = Date.now() / 1e3;
495
+ }
496
+ }
497
+ }
498
+
499
+ const SECONDS_IN_MS$2 = 1e3;
500
+ const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
501
+ class PluginTokenHandler {
502
+ constructor(logger, ownPluginId, keySource, algorithm, keyDurationSeconds, discovery) {
503
+ this.logger = logger;
504
+ this.ownPluginId = ownPluginId;
505
+ this.keySource = keySource;
506
+ this.algorithm = algorithm;
507
+ this.keyDurationSeconds = keyDurationSeconds;
508
+ this.discovery = discovery;
509
+ }
510
+ jwksMap = /* @__PURE__ */ new Map();
511
+ // Tracking state for isTargetPluginSupported
512
+ supportedTargetPlugins = /* @__PURE__ */ new Set();
513
+ targetPluginInflightChecks = /* @__PURE__ */ new Map();
514
+ static create(options) {
515
+ return new PluginTokenHandler(
516
+ options.logger,
517
+ options.ownPluginId,
518
+ options.keySource,
519
+ options.algorithm ?? "ES256",
520
+ Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
521
+ options.discovery
522
+ );
523
+ }
524
+ async verifyToken(token) {
525
+ try {
526
+ const { typ } = jose.decodeProtectedHeader(token);
527
+ if (typ !== pluginAuthNode.tokenTypes.plugin.typParam) {
528
+ return void 0;
529
+ }
530
+ } catch {
531
+ return void 0;
532
+ }
533
+ const pluginId = String(jose.decodeJwt(token).sub);
534
+ if (!pluginId) {
535
+ throw new errors.AuthenticationError("Invalid plugin token: missing subject");
536
+ }
537
+ if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
538
+ throw new errors.AuthenticationError(
539
+ "Invalid plugin token: forbidden subject format"
540
+ );
541
+ }
542
+ const jwksClient = await this.getJwksClient(pluginId);
543
+ await jwksClient.refreshKeyStore(token);
544
+ const { payload } = await jose.jwtVerify(
545
+ token,
546
+ jwksClient.getKey,
547
+ {
548
+ typ: pluginAuthNode.tokenTypes.plugin.typParam,
549
+ audience: this.ownPluginId,
550
+ requiredClaims: ["iat", "exp", "sub", "aud"]
551
+ }
552
+ ).catch((e) => {
553
+ throw new errors.AuthenticationError("Invalid plugin token", e);
554
+ });
555
+ return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
556
+ }
557
+ async issueToken(options) {
558
+ const { pluginId, targetPluginId, onBehalfOf } = options;
559
+ const key = await this.keySource.getPrivateSigningKey();
560
+ const sub = pluginId;
561
+ const aud = targetPluginId;
562
+ const iat = Math.floor(Date.now() / SECONDS_IN_MS$2);
563
+ const ourExp = iat + this.keyDurationSeconds;
564
+ const exp = onBehalfOf ? Math.min(
565
+ ourExp,
566
+ Math.floor(onBehalfOf.expiresAt.getTime() / SECONDS_IN_MS$2)
567
+ ) : ourExp;
568
+ const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
569
+ const token = await new jose.SignJWT(claims).setProtectedHeader({
570
+ typ: pluginAuthNode.tokenTypes.plugin.typParam,
571
+ alg: this.algorithm,
572
+ kid: key.kid
573
+ }).setAudience(aud).setSubject(sub).setIssuedAt(iat).setExpirationTime(exp).sign(await jose.importJWK(key));
574
+ return { token };
575
+ }
576
+ async isTargetPluginSupported(targetPluginId) {
577
+ if (this.supportedTargetPlugins.has(targetPluginId)) {
578
+ return true;
579
+ }
580
+ const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
581
+ if (inFlight) {
582
+ return inFlight;
583
+ }
584
+ const doCheck = async () => {
585
+ try {
586
+ const res = await fetch(
587
+ `${await this.discovery.getBaseUrl(
588
+ targetPluginId
589
+ )}/.backstage/auth/v1/jwks.json`
590
+ );
591
+ if (res.status === 404) {
592
+ return false;
593
+ }
594
+ if (!res.ok) {
595
+ throw new Error(`Failed to fetch jwks.json, ${res.status}`);
596
+ }
597
+ const data = await res.json();
598
+ if (!data.keys) {
599
+ throw new Error(`Invalid jwks.json response, missing keys`);
600
+ }
601
+ this.supportedTargetPlugins.add(targetPluginId);
602
+ return true;
603
+ } catch (error) {
604
+ this.logger.error("Unexpected failure for target JWKS check", error);
605
+ return false;
606
+ } finally {
607
+ this.targetPluginInflightChecks.delete(targetPluginId);
608
+ }
609
+ };
610
+ const check = doCheck();
611
+ this.targetPluginInflightChecks.set(targetPluginId, check);
612
+ return check;
613
+ }
614
+ async getJwksClient(pluginId) {
615
+ const client = this.jwksMap.get(pluginId);
616
+ if (client) {
617
+ return client;
618
+ }
619
+ if (!await this.isTargetPluginSupported(pluginId)) {
620
+ throw new errors.AuthenticationError(
621
+ `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`
622
+ );
623
+ }
624
+ const newClient = new JwksClient(async () => {
625
+ return new URL(
626
+ `${await this.discovery.getBaseUrl(
627
+ pluginId
628
+ )}/.backstage/auth/v1/jwks.json`
629
+ );
630
+ });
631
+ this.jwksMap.set(pluginId, newClient);
632
+ return newClient;
633
+ }
634
+ }
635
+
636
+ const MIGRATIONS_TABLE = "backstage_backend_public_keys__knex_migrations";
637
+ const TABLE = "backstage_backend_public_keys__keys";
638
+ function applyDatabaseMigrations(knex) {
639
+ const migrationsDir = backendPluginApi.resolvePackagePath(
640
+ "@backstage/backend-defaults",
641
+ "migrations/auth"
642
+ );
643
+ return knex.migrate.latest({
644
+ directory: migrationsDir,
645
+ tableName: MIGRATIONS_TABLE
646
+ });
647
+ }
648
+ class DatabaseKeyStore {
649
+ constructor(client, logger) {
650
+ this.client = client;
651
+ this.logger = logger;
652
+ }
653
+ static async create(options) {
654
+ const { database, logger } = options;
655
+ const client = await database.getClient();
656
+ if (!database.migrations?.skip) {
657
+ await applyDatabaseMigrations(client);
658
+ }
659
+ return new DatabaseKeyStore(client, logger);
660
+ }
661
+ async addKey(options) {
662
+ await this.client(TABLE).insert({
663
+ id: options.key.kid,
664
+ key: JSON.stringify(options.key),
665
+ expires_at: options.expiresAt.toISOString()
666
+ });
667
+ }
668
+ async listKeys() {
669
+ const rows = await this.client(TABLE).select();
670
+ const keys = rows.map((row) => ({
671
+ id: row.id,
672
+ key: JSON.parse(row.key),
673
+ expiresAt: new Date(row.expires_at)
674
+ }));
675
+ const validKeys = [];
676
+ const expiredKeys = [];
677
+ for (const key of keys) {
678
+ if (luxon.DateTime.fromJSDate(key.expiresAt) < luxon.DateTime.local()) {
679
+ expiredKeys.push(key);
680
+ } else {
681
+ validKeys.push(key);
682
+ }
683
+ }
684
+ if (expiredKeys.length > 0) {
685
+ const kids = expiredKeys.map(({ key }) => key.kid);
686
+ this.logger.info(
687
+ `Removing expired plugin service keys, '${kids.join("', '")}'`
688
+ );
689
+ this.client(TABLE).delete().whereIn("id", kids).catch((error) => {
690
+ this.logger.error(
691
+ "Failed to remove expired plugin service keys",
692
+ error
693
+ );
694
+ });
695
+ }
696
+ return { keys: validKeys };
697
+ }
698
+ }
699
+
700
+ const SECONDS_IN_MS$1 = 1e3;
701
+ const KEY_EXPIRATION_MARGIN_FACTOR = 3;
702
+ class DatabasePluginKeySource {
703
+ constructor(keyStore, logger, keyDurationSeconds, algorithm) {
704
+ this.keyStore = keyStore;
705
+ this.logger = logger;
706
+ this.keyDurationSeconds = keyDurationSeconds;
707
+ this.algorithm = algorithm;
708
+ }
709
+ privateKeyPromise;
710
+ keyExpiry;
711
+ static async create(options) {
712
+ const keyStore = await DatabaseKeyStore.create({
713
+ database: options.database,
714
+ logger: options.logger
715
+ });
716
+ return new DatabasePluginKeySource(
717
+ keyStore,
718
+ options.logger,
719
+ Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
720
+ options.algorithm ?? "ES256"
721
+ );
722
+ }
723
+ async getPrivateSigningKey() {
724
+ if (this.privateKeyPromise) {
725
+ if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
726
+ return this.privateKeyPromise;
727
+ }
728
+ this.logger.info(`Signing key has expired, generating new key`);
729
+ delete this.privateKeyPromise;
730
+ }
731
+ this.keyExpiry = new Date(
732
+ Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1
733
+ );
734
+ const promise = (async () => {
735
+ const kid = uuid.v4();
736
+ const key = await jose.generateKeyPair(this.algorithm);
737
+ const publicKey = await jose.exportJWK(key.publicKey);
738
+ const privateKey = await jose.exportJWK(key.privateKey);
739
+ publicKey.kid = privateKey.kid = kid;
740
+ publicKey.alg = privateKey.alg = this.algorithm;
741
+ this.logger.info(`Created new signing key ${kid}`);
742
+ await this.keyStore.addKey({
743
+ id: kid,
744
+ key: publicKey,
745
+ expiresAt: new Date(
746
+ Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1 * KEY_EXPIRATION_MARGIN_FACTOR
747
+ )
748
+ });
749
+ return privateKey;
750
+ })();
751
+ this.privateKeyPromise = promise;
752
+ try {
753
+ await promise;
754
+ } catch (error) {
755
+ this.logger.error(`Failed to generate new signing key, ${error}`);
756
+ delete this.keyExpiry;
757
+ delete this.privateKeyPromise;
758
+ }
759
+ return promise;
760
+ }
761
+ listKeys() {
762
+ return this.keyStore.listKeys();
763
+ }
764
+ }
765
+
766
+ const DEFAULT_ALGORITHM = "ES256";
767
+ const SECONDS_IN_MS = 1e3;
768
+ class StaticConfigPluginKeySource {
769
+ constructor(keyPairs, keyDurationSeconds) {
770
+ this.keyPairs = keyPairs;
771
+ this.keyDurationSeconds = keyDurationSeconds;
772
+ }
773
+ static async create(options) {
774
+ const keyConfigs = options.sourceConfig.getConfigArray("static.keys").map((c) => {
775
+ const staticKeyConfig = {
776
+ publicKeyFile: c.getString("publicKeyFile"),
777
+ privateKeyFile: c.getOptionalString("privateKeyFile"),
778
+ keyId: c.getString("keyId"),
779
+ algorithm: c.getOptionalString("algorithm") ?? DEFAULT_ALGORITHM
780
+ };
781
+ return staticKeyConfig;
782
+ });
783
+ const keyPairs = await Promise.all(
784
+ keyConfigs.map(async (k) => await this.loadKeyPair(k))
785
+ );
786
+ if (keyPairs.length < 1) {
787
+ throw new Error(
788
+ "At least one key pair must be provided in static.keys, when the static key store type is used"
789
+ );
790
+ } else if (!keyPairs[0].privateKey) {
791
+ throw new Error(
792
+ "Private key for signing must be provided in the first key pair in static.keys, when the static key store type is used"
793
+ );
794
+ }
795
+ return new StaticConfigPluginKeySource(
796
+ keyPairs,
797
+ types.durationToMilliseconds(options.keyDuration) / SECONDS_IN_MS
798
+ );
799
+ }
800
+ async getPrivateSigningKey() {
801
+ return this.keyPairs[0].privateKey;
802
+ }
803
+ async listKeys() {
804
+ const keys = this.keyPairs.map((k) => this.keyPairToStoredKey(k));
805
+ return { keys };
806
+ }
807
+ static async loadKeyPair(options) {
808
+ const algorithm = options.algorithm;
809
+ const keyId = options.keyId;
810
+ const publicKey = await this.loadPublicKeyFromFile(
811
+ options.publicKeyFile,
812
+ keyId,
813
+ algorithm
814
+ );
815
+ const privateKey = options.privateKeyFile ? await this.loadPrivateKeyFromFile(
816
+ options.privateKeyFile,
817
+ keyId,
818
+ algorithm
819
+ ) : void 0;
820
+ return { publicKey, privateKey, keyId };
821
+ }
822
+ static async loadPublicKeyFromFile(path, keyId, algorithm) {
823
+ return this.loadKeyFromFile(path, keyId, algorithm, jose.importSPKI);
824
+ }
825
+ static async loadPrivateKeyFromFile(path, keyId, algorithm) {
826
+ return this.loadKeyFromFile(path, keyId, algorithm, jose.importPKCS8);
827
+ }
828
+ static async loadKeyFromFile(path, keyId, algorithm, importer) {
829
+ const content = await fs.promises.readFile(path, { encoding: "utf8", flag: "r" });
830
+ const key = await importer(content, algorithm);
831
+ const jwk = await jose.exportJWK(key);
832
+ jwk.kid = keyId;
833
+ jwk.alg = algorithm;
834
+ return jwk;
835
+ }
836
+ keyPairToStoredKey(keyPair) {
837
+ const publicKey = {
838
+ ...keyPair.publicKey,
839
+ kid: keyPair.keyId
840
+ };
841
+ return {
842
+ key: publicKey,
843
+ id: keyPair.keyId,
844
+ expiresAt: new Date(Date.now() + this.keyDurationSeconds * SECONDS_IN_MS)
845
+ };
846
+ }
847
+ }
848
+
849
+ const CONFIG_ROOT_KEY = "backend.auth.pluginKeyStore";
850
+ async function createPluginKeySource(options) {
851
+ const keyStoreConfig = options.config.getOptionalConfig(CONFIG_ROOT_KEY);
852
+ const type = keyStoreConfig?.getOptionalString("type") ?? "database";
853
+ if (!keyStoreConfig || type === "database") {
854
+ return DatabasePluginKeySource.create({
855
+ database: options.database,
856
+ logger: options.logger,
857
+ keyDuration: options.keyDuration,
858
+ algorithm: options.algorithm
859
+ });
860
+ } else if (type === "static") {
861
+ return StaticConfigPluginKeySource.create({
862
+ sourceConfig: keyStoreConfig,
863
+ keyDuration: options.keyDuration
864
+ });
865
+ }
866
+ throw new Error(
867
+ `Unsupported config value ${CONFIG_ROOT_KEY}.type '${type}'; expected one of 'database', 'static'`
868
+ );
869
+ }
870
+
871
+ class UserTokenHandler {
872
+ constructor(jwksClient) {
873
+ this.jwksClient = jwksClient;
874
+ }
875
+ static create(options) {
876
+ const jwksClient = new JwksClient(async () => {
877
+ const url = await options.discovery.getBaseUrl("auth");
878
+ return new URL(`${url}/.well-known/jwks.json`);
879
+ });
880
+ return new UserTokenHandler(jwksClient);
881
+ }
882
+ async verifyToken(token) {
883
+ const verifyOpts = this.#getTokenVerificationOptions(token);
884
+ if (!verifyOpts) {
885
+ return void 0;
886
+ }
887
+ await this.jwksClient.refreshKeyStore(token);
888
+ const { payload } = await jose.jwtVerify(
889
+ token,
890
+ this.jwksClient.getKey,
891
+ verifyOpts
892
+ ).catch((e) => {
893
+ throw new errors.AuthenticationError("Invalid token", e);
894
+ });
895
+ const userEntityRef = payload.sub;
896
+ if (!userEntityRef) {
897
+ throw new errors.AuthenticationError("No user sub found in token");
898
+ }
899
+ return { userEntityRef };
900
+ }
901
+ #getTokenVerificationOptions(token) {
902
+ try {
903
+ const { typ } = jose.decodeProtectedHeader(token);
904
+ if (typ === pluginAuthNode.tokenTypes.user.typParam) {
905
+ return {
906
+ requiredClaims: ["iat", "exp", "sub"],
907
+ typ: pluginAuthNode.tokenTypes.user.typParam
908
+ };
909
+ }
910
+ if (typ === pluginAuthNode.tokenTypes.limitedUser.typParam) {
911
+ return {
912
+ requiredClaims: ["iat", "exp", "sub"],
913
+ typ: pluginAuthNode.tokenTypes.limitedUser.typParam
914
+ };
915
+ }
916
+ const { aud } = jose.decodeJwt(token);
917
+ if (aud === pluginAuthNode.tokenTypes.user.audClaim) {
918
+ return {
919
+ audience: pluginAuthNode.tokenTypes.user.audClaim
920
+ };
921
+ }
922
+ } catch {
923
+ }
924
+ return void 0;
925
+ }
926
+ createLimitedUserToken(backstageToken) {
927
+ const [headerRaw, payloadRaw] = backstageToken.split(".");
928
+ const header = JSON.parse(
929
+ new TextDecoder().decode(jose.base64url.decode(headerRaw))
930
+ );
931
+ const payload = JSON.parse(
932
+ new TextDecoder().decode(jose.base64url.decode(payloadRaw))
933
+ );
934
+ const tokenType = header.typ;
935
+ if (!tokenType || tokenType === pluginAuthNode.tokenTypes.limitedUser.typParam) {
936
+ return { token: backstageToken, expiresAt: new Date(payload.exp * 1e3) };
937
+ }
938
+ if (tokenType !== pluginAuthNode.tokenTypes.user.typParam) {
939
+ throw new errors.AuthenticationError(
940
+ "Failed to create limited user token, invalid token type"
941
+ );
942
+ }
943
+ const limitedUserToken = [
944
+ jose.base64url.encode(
945
+ JSON.stringify({
946
+ typ: pluginAuthNode.tokenTypes.limitedUser.typParam,
947
+ alg: header.alg,
948
+ kid: header.kid
949
+ })
950
+ ),
951
+ jose.base64url.encode(
952
+ JSON.stringify({
953
+ sub: payload.sub,
954
+ iat: payload.iat,
955
+ exp: payload.exp
956
+ })
957
+ ),
958
+ payload.uip
959
+ ].join(".");
960
+ return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1e3) };
961
+ }
962
+ isLimitedUserToken(token) {
963
+ try {
964
+ const { typ } = jose.decodeProtectedHeader(token);
965
+ return typ === pluginAuthNode.tokenTypes.limitedUser.typParam;
966
+ } catch {
967
+ return false;
968
+ }
969
+ }
970
+ }
971
+
972
+ const authServiceFactory = backendPluginApi.createServiceFactory({
973
+ service: backendPluginApi.coreServices.auth,
974
+ deps: {
975
+ config: backendPluginApi.coreServices.rootConfig,
976
+ logger: backendPluginApi.coreServices.rootLogger,
977
+ discovery: backendPluginApi.coreServices.discovery,
978
+ plugin: backendPluginApi.coreServices.pluginMetadata,
979
+ database: backendPluginApi.coreServices.database,
980
+ // Re-using the token manager makes sure that we use the same generated keys for
981
+ // development as plugins that have not yet been migrated. It's important that this
982
+ // keeps working as long as there are plugins that have not been migrated to the
983
+ // new auth services in the new backend system.
984
+ tokenManager: backendPluginApi.coreServices.tokenManager
985
+ },
986
+ async factory({ config, discovery, plugin, tokenManager, logger, database }) {
987
+ const disableDefaultAuthPolicy = config.getOptionalBoolean(
988
+ "backend.auth.dangerouslyDisableDefaultAuthPolicy"
989
+ ) ?? false;
990
+ const keyDuration = { hours: 1 };
991
+ const keySource = await createPluginKeySource({
992
+ config,
993
+ database,
994
+ logger,
995
+ keyDuration
996
+ });
997
+ const userTokens = UserTokenHandler.create({
998
+ discovery
999
+ });
1000
+ const pluginTokens = PluginTokenHandler.create({
1001
+ ownPluginId: plugin.getId(),
1002
+ logger,
1003
+ keySource,
1004
+ keyDuration,
1005
+ discovery
1006
+ });
1007
+ const externalTokens = ExternalTokenHandler.create({
1008
+ ownPluginId: plugin.getId(),
1009
+ config,
1010
+ logger
1011
+ });
1012
+ return new DefaultAuthService(
1013
+ userTokens,
1014
+ pluginTokens,
1015
+ externalTokens,
1016
+ tokenManager,
1017
+ plugin.getId(),
1018
+ disableDefaultAuthPolicy,
1019
+ keySource
1020
+ );
1021
+ }
1022
+ });
1023
+
1024
+ exports.authServiceFactory = authServiceFactory;
1025
+ //# sourceMappingURL=auth.cjs.js.map