@backstage/backend-app-api 0.7.6-next.3 → 0.7.6

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/dist/index.cjs.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ var configLoader = require('@backstage/config-loader');
4
+ var getPackages = require('@manypkg/get-packages');
3
5
  var path = require('path');
4
6
  var parseArgs = require('minimist');
5
7
  var cliCommon = require('@backstage/cli-common');
6
- var configLoader = require('@backstage/config-loader');
7
8
  var config = require('@backstage/config');
8
- var getPackages = require('@manypkg/get-packages');
9
9
  var http = require('http');
10
10
  var https = require('https');
11
11
  var stoppableServer = require('stoppable');
@@ -19,24 +19,25 @@ var kebabCase = require('lodash/kebabCase');
19
19
  var minimatch = require('minimatch');
20
20
  var errors = require('@backstage/errors');
21
21
  var crypto = require('crypto');
22
+ var backendPluginApi = require('@backstage/backend-plugin-api');
22
23
  var winston = require('winston');
23
24
  var tripleBeam = require('triple-beam');
24
- var backendPluginApi = require('@backstage/backend-plugin-api');
25
25
  var alpha = require('@backstage/backend-plugin-api/alpha');
26
- var luxon = require('luxon');
27
- var jose = require('jose');
28
- var uuid = require('uuid');
26
+ var backendCommon = require('@backstage/backend-common');
29
27
  var pluginAuthNode = require('@backstage/plugin-auth-node');
28
+ var pluginPermissionNode = require('@backstage/plugin-permission-node');
29
+ var jose = require('jose');
30
30
  var types = require('@backstage/types');
31
- var backendCommon = require('@backstage/backend-common');
32
- var backendAppApi = require('@backstage/backend-app-api');
31
+ var uuid = require('uuid');
32
+ var luxon = require('luxon');
33
+ var fs$1 = require('fs');
33
34
  var cookie = require('cookie');
34
35
  var Router = require('express-promise-router');
35
36
  var pathToRegexp = require('path-to-regexp');
36
- var pluginPermissionNode = require('@backstage/plugin-permission-node');
37
37
  var express = require('express');
38
38
  var trimEnd = require('lodash/trimEnd');
39
39
  var backendTasks = require('@backstage/backend-tasks');
40
+ var fetch$1 = require('node-fetch');
40
41
 
41
42
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
42
43
 
@@ -72,6 +73,33 @@ var kebabCase__default = /*#__PURE__*/_interopDefaultCompat(kebabCase);
72
73
  var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
73
74
  var express__default = /*#__PURE__*/_interopDefaultCompat(express);
74
75
  var trimEnd__default = /*#__PURE__*/_interopDefaultCompat(trimEnd);
76
+ var fetch__default = /*#__PURE__*/_interopDefaultCompat(fetch$1);
77
+
78
+ async function createConfigSecretEnumerator$1(options) {
79
+ const { logger, dir = process.cwd() } = options;
80
+ const { packages } = await getPackages.getPackages(dir);
81
+ const schema = options.schema ?? await configLoader.loadConfigSchema({
82
+ dependencies: packages.map((p) => p.packageJson.name)
83
+ });
84
+ return (config) => {
85
+ const [secretsData] = schema.process(
86
+ [{ data: config.getOptional() ?? {}, context: "schema-enumerator" }],
87
+ {
88
+ visibility: ["secret"],
89
+ ignoreSchemaErrors: true
90
+ }
91
+ );
92
+ const secrets = /* @__PURE__ */ new Set();
93
+ JSON.parse(
94
+ JSON.stringify(secretsData.data),
95
+ (_, v) => typeof v === "string" && secrets.add(v)
96
+ );
97
+ logger.info(
98
+ `Found ${secrets.size} new secrets in config that will be redacted`
99
+ );
100
+ return secrets;
101
+ };
102
+ }
75
103
 
76
104
  class ObservableConfigProxy {
77
105
  constructor(parent, parentKey) {
@@ -181,31 +209,7 @@ function isValidUrl(url) {
181
209
  }
182
210
  }
183
211
 
184
- async function createConfigSecretEnumerator(options) {
185
- const { logger, dir = process.cwd() } = options;
186
- const { packages } = await getPackages.getPackages(dir);
187
- const schema = options.schema ?? await configLoader.loadConfigSchema({
188
- dependencies: packages.map((p) => p.packageJson.name)
189
- });
190
- return (config) => {
191
- const [secretsData] = schema.process(
192
- [{ data: config.getOptional() ?? {}, context: "schema-enumerator" }],
193
- {
194
- visibility: ["secret"],
195
- ignoreSchemaErrors: true
196
- }
197
- );
198
- const secrets = /* @__PURE__ */ new Set();
199
- JSON.parse(
200
- JSON.stringify(secretsData.data),
201
- (_, v) => typeof v === "string" && secrets.add(v)
202
- );
203
- logger.info(
204
- `Found ${secrets.size} new secrets in config that will be redacted`
205
- );
206
- return secrets;
207
- };
208
- }
212
+ const createConfigSecretEnumerator = createConfigSecretEnumerator$1;
209
213
  async function loadBackendConfig(options) {
210
214
  const args = parseArgs__default.default(options.argv);
211
215
  const configTargets = [args.config ?? []].flat().map((arg) => isValidUrl(arg) ? { url: arg } : { path: path.resolve(arg) });
@@ -251,7 +255,7 @@ async function loadBackendConfig(options) {
251
255
 
252
256
  const DEFAULT_PORT = 7007;
253
257
  const DEFAULT_HOST = "";
254
- function readHttpServerOptions(config) {
258
+ function readHttpServerOptions$1(config) {
255
259
  return {
256
260
  listen: readHttpListenOptions(config),
257
261
  https: readHttpsOptions(config)
@@ -426,7 +430,7 @@ async function generateCertificate(hostname) {
426
430
  );
427
431
  }
428
432
 
429
- async function createHttpServer(listener, options, deps) {
433
+ async function createHttpServer$1(listener, options, deps) {
430
434
  const server = await createServer(listener, options, deps);
431
435
  const stopper = stoppableServer__default.default(server, 0);
432
436
  const stopServer = stopper.stop.bind(stopper);
@@ -481,7 +485,7 @@ async function createServer(listener, options, deps) {
481
485
  return http__namespace.createServer(listener);
482
486
  }
483
487
 
484
- function readHelmetOptions(config) {
488
+ function readHelmetOptions$1(config) {
485
489
  const cspOptions = readCspDirectives(config);
486
490
  return {
487
491
  contentSecurityPolicy: {
@@ -530,7 +534,7 @@ function applyCspDirectives(directives) {
530
534
  return result;
531
535
  }
532
536
 
533
- function readCorsOptions(config) {
537
+ function readCorsOptions$1(config) {
534
538
  const cc = config?.getOptionalConfig("cors");
535
539
  if (!cc) {
536
540
  return { origin: false };
@@ -596,7 +600,7 @@ function applyInternalErrorFilter(error, logger) {
596
600
  return error;
597
601
  }
598
602
 
599
- class MiddlewareFactory {
603
+ let MiddlewareFactory$1 = class MiddlewareFactory {
600
604
  #config;
601
605
  #logger;
602
606
  /**
@@ -671,7 +675,7 @@ class MiddlewareFactory {
671
675
  * @returns An Express request handler
672
676
  */
673
677
  helmet() {
674
- return helmet__default.default(readHelmetOptions(this.#config.getOptionalConfig("backend")));
678
+ return helmet__default.default(readHelmetOptions$1(this.#config.getOptionalConfig("backend")));
675
679
  }
676
680
  /**
677
681
  * Returns a middleware that implements the cors library.
@@ -686,7 +690,7 @@ class MiddlewareFactory {
686
690
  * @returns An Express request handler
687
691
  */
688
692
  cors() {
689
- return cors__default.default(readCorsOptions(this.#config.getOptionalConfig("backend")));
693
+ return cors__default.default(readCorsOptions$1(this.#config.getOptionalConfig("backend")));
690
694
  }
691
695
  /**
692
696
  * Express middleware to handle errors during request processing.
@@ -731,7 +735,7 @@ class MiddlewareFactory {
731
735
  res.status(statusCode).json(body);
732
736
  };
733
737
  }
734
- }
738
+ };
735
739
  function getStatusCode(error) {
736
740
  const knownStatusCodeFields = ["statusCode", "status"];
737
741
  for (const field of knownStatusCodeFields) {
@@ -762,11 +766,17 @@ function getStatusCode(error) {
762
766
  return 500;
763
767
  }
764
768
 
769
+ const readHttpServerOptions = readHttpServerOptions$1;
770
+ const createHttpServer = createHttpServer$1;
771
+ const readCorsOptions = readCorsOptions$1;
772
+ const readHelmetOptions = readHelmetOptions$1;
773
+ const MiddlewareFactory = MiddlewareFactory$1;
774
+
765
775
  const escapeRegExp = (text) => {
766
776
  return text.replace(/[.*+?^${}(\)|[\]\\]/g, "\\$&");
767
777
  };
768
778
 
769
- class WinstonLogger {
779
+ let WinstonLogger$1 = class WinstonLogger {
770
780
  #winston;
771
781
  #addRedactions;
772
782
  /**
@@ -870,6 +880,69 @@ class WinstonLogger {
870
880
  addRedactions(redactions) {
871
881
  this.#addRedactions?.(redactions);
872
882
  }
883
+ };
884
+
885
+ const rootLoggerServiceFactory$1 = backendPluginApi.createServiceFactory({
886
+ service: backendPluginApi.coreServices.rootLogger,
887
+ deps: {
888
+ config: backendPluginApi.coreServices.rootConfig
889
+ },
890
+ async factory({ config }) {
891
+ const logger = WinstonLogger$1.create({
892
+ meta: {
893
+ service: "backstage"
894
+ },
895
+ level: process.env.LOG_LEVEL || "info",
896
+ format: process.env.NODE_ENV === "production" ? winston.format.json() : WinstonLogger$1.colorFormat(),
897
+ transports: [new winston.transports.Console()]
898
+ });
899
+ const secretEnumerator = await createConfigSecretEnumerator$1({ logger });
900
+ logger.addRedactions(secretEnumerator(config));
901
+ config.subscribe?.(() => logger.addRedactions(secretEnumerator(config)));
902
+ return logger;
903
+ }
904
+ });
905
+
906
+ class WinstonLogger {
907
+ constructor(impl) {
908
+ this.impl = impl;
909
+ }
910
+ /**
911
+ * Creates a {@link WinstonLogger} instance.
912
+ */
913
+ static create(options) {
914
+ return new WinstonLogger(WinstonLogger$1.create(options));
915
+ }
916
+ /**
917
+ * Creates a winston log formatter for redacting secrets.
918
+ */
919
+ static redacter() {
920
+ return WinstonLogger$1.redacter();
921
+ }
922
+ /**
923
+ * Creates a pretty printed winston log formatter.
924
+ */
925
+ static colorFormat() {
926
+ return WinstonLogger$1.colorFormat();
927
+ }
928
+ error(message, meta) {
929
+ this.impl.error(message, meta);
930
+ }
931
+ warn(message, meta) {
932
+ this.impl.warn(message, meta);
933
+ }
934
+ info(message, meta) {
935
+ this.impl.info(message, meta);
936
+ }
937
+ debug(message, meta) {
938
+ this.impl.debug(message, meta);
939
+ }
940
+ child(meta) {
941
+ return this.impl.child(meta);
942
+ }
943
+ addRedactions(redactions) {
944
+ this.impl.addRedactions(redactions);
945
+ }
873
946
  }
874
947
 
875
948
  class Node {
@@ -1633,102 +1706,417 @@ function createSpecializedBackend(options) {
1633
1706
  return new BackstageBackend(services);
1634
1707
  }
1635
1708
 
1636
- const MIGRATIONS_TABLE = "backstage_backend_public_keys__knex_migrations";
1637
- const TABLE = "backstage_backend_public_keys__keys";
1638
- function applyDatabaseMigrations(knex) {
1639
- const migrationsDir = backendPluginApi.resolvePackagePath(
1640
- "@backstage/backend-app-api",
1641
- "migrations"
1642
- );
1643
- return knex.migrate.latest({
1644
- directory: migrationsDir,
1645
- tableName: MIGRATIONS_TABLE
1646
- });
1647
- }
1648
- class DatabaseKeyStore {
1649
- constructor(client, logger) {
1650
- this.client = client;
1651
- this.logger = logger;
1652
- }
1653
- static async create(options) {
1654
- const { database, logger } = options;
1655
- const client = await database.getClient();
1656
- if (!database.migrations?.skip) {
1657
- await applyDatabaseMigrations(client);
1658
- }
1659
- return new DatabaseKeyStore(client, logger);
1660
- }
1661
- async addKey(options) {
1662
- await this.client(TABLE).insert({
1663
- id: options.key.kid,
1664
- key: JSON.stringify(options.key),
1665
- expires_at: options.expiresAt.toISOString()
1666
- });
1709
+ const cacheServiceFactory = backendPluginApi.createServiceFactory({
1710
+ service: backendPluginApi.coreServices.cache,
1711
+ deps: {
1712
+ config: backendPluginApi.coreServices.rootConfig,
1713
+ logger: backendPluginApi.coreServices.rootLogger,
1714
+ plugin: backendPluginApi.coreServices.pluginMetadata
1715
+ },
1716
+ async createRootContext({ config, logger }) {
1717
+ return backendCommon.CacheManager.fromConfig(config, { logger });
1718
+ },
1719
+ async factory({ plugin }, manager) {
1720
+ return manager.forPlugin(plugin.getId()).getClient();
1667
1721
  }
1668
- async listKeys() {
1669
- const rows = await this.client(TABLE).select();
1670
- const keys = rows.map((row) => ({
1671
- id: row.id,
1672
- key: JSON.parse(row.key),
1673
- expiresAt: new Date(row.expires_at)
1674
- }));
1675
- const validKeys = [];
1676
- const expiredKeys = [];
1677
- for (const key of keys) {
1678
- if (luxon.DateTime.fromJSDate(key.expiresAt) < luxon.DateTime.local()) {
1679
- expiredKeys.push(key);
1680
- } else {
1681
- validKeys.push(key);
1682
- }
1683
- }
1684
- if (expiredKeys.length > 0) {
1685
- const kids = expiredKeys.map(({ key }) => key.kid);
1686
- this.logger.info(
1687
- `Removing expired plugin service keys, '${kids.join("', '")}'`
1688
- );
1689
- this.client(TABLE).delete().whereIn("id", kids).catch((error) => {
1690
- this.logger.error(
1691
- "Failed to remove expired plugin service keys",
1692
- error
1693
- );
1722
+ });
1723
+
1724
+ const rootConfigServiceFactory = backendPluginApi.createServiceFactory(
1725
+ (options) => ({
1726
+ service: backendPluginApi.coreServices.rootConfig,
1727
+ deps: {},
1728
+ async factory() {
1729
+ const source = configLoader.ConfigSources.default({
1730
+ argv: options?.argv,
1731
+ remote: options?.remote,
1732
+ watch: options?.watch
1694
1733
  });
1734
+ console.log(`Loading config from ${source}`);
1735
+ return await configLoader.ConfigSources.toConfig(source);
1695
1736
  }
1696
- return { keys: validKeys };
1737
+ })
1738
+ );
1739
+
1740
+ const databaseServiceFactory = backendPluginApi.createServiceFactory({
1741
+ service: backendPluginApi.coreServices.database,
1742
+ deps: {
1743
+ config: backendPluginApi.coreServices.rootConfig,
1744
+ lifecycle: backendPluginApi.coreServices.lifecycle,
1745
+ pluginMetadata: backendPluginApi.coreServices.pluginMetadata
1746
+ },
1747
+ async createRootContext({ config: config$1 }) {
1748
+ return config$1.getOptional("backend.database") ? backendCommon.DatabaseManager.fromConfig(config$1) : backendCommon.DatabaseManager.fromConfig(
1749
+ new config.ConfigReader({
1750
+ backend: {
1751
+ database: { client: "better-sqlite3", connection: ":memory:" }
1752
+ }
1753
+ })
1754
+ );
1755
+ },
1756
+ async factory({ pluginMetadata, lifecycle }, databaseManager) {
1757
+ return databaseManager.forPlugin(pluginMetadata.getId(), {
1758
+ pluginMetadata,
1759
+ lifecycle
1760
+ });
1697
1761
  }
1698
- }
1762
+ });
1699
1763
 
1700
- function createCredentialsWithServicePrincipal(sub, token, accessRestrictions) {
1701
- return {
1702
- $$type: "@backstage/BackstageCredentials",
1703
- version: "v1",
1704
- token,
1705
- principal: {
1706
- type: "service",
1707
- subject: sub,
1708
- accessRestrictions
1709
- }
1710
- };
1711
- }
1712
- function createCredentialsWithUserPrincipal(sub, token, expiresAt) {
1713
- return {
1714
- $$type: "@backstage/BackstageCredentials",
1715
- version: "v1",
1716
- token,
1717
- expiresAt,
1718
- principal: {
1719
- type: "user",
1720
- userEntityRef: sub
1721
- }
1722
- };
1723
- }
1724
- function createCredentialsWithNonePrincipal() {
1725
- return {
1726
- $$type: "@backstage/BackstageCredentials",
1727
- version: "v1",
1728
- principal: {
1729
- type: "none"
1730
- }
1731
- };
1764
+ let HostDiscovery$1 = class HostDiscovery {
1765
+ constructor(internalBaseUrl, externalBaseUrl, discoveryConfig) {
1766
+ this.internalBaseUrl = internalBaseUrl;
1767
+ this.externalBaseUrl = externalBaseUrl;
1768
+ this.discoveryConfig = discoveryConfig;
1769
+ }
1770
+ /**
1771
+ * Creates a new HostDiscovery discovery instance by reading
1772
+ * from the `backend` config section, specifically the `.baseUrl` for
1773
+ * discovering the external URL, and the `.listen` and `.https` config
1774
+ * for the internal one.
1775
+ *
1776
+ * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
1777
+ * eg.
1778
+ * ```yaml
1779
+ * discovery:
1780
+ * endpoints:
1781
+ * - target: https://internal.example.com/internal-catalog
1782
+ * plugins: [catalog]
1783
+ * - target: https://internal.example.com/secure/api/{{pluginId}}
1784
+ * plugins: [auth, permission]
1785
+ * - target:
1786
+ * internal: https://internal.example.com/search
1787
+ * external: https://example.com/search
1788
+ * plugins: [search]
1789
+ * ```
1790
+ *
1791
+ * The basePath defaults to `/api`, meaning the default full internal
1792
+ * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
1793
+ */
1794
+ static fromConfig(config, options) {
1795
+ const basePath = options?.basePath ?? "/api";
1796
+ const externalBaseUrl = config.getString("backend.baseUrl").replace(/\/+$/, "");
1797
+ const {
1798
+ listen: { host: listenHost = "::", port: listenPort }
1799
+ } = readHttpServerOptions$1(config.getConfig("backend"));
1800
+ const protocol = config.has("backend.https") ? "https" : "http";
1801
+ let host = listenHost;
1802
+ if (host === "::" || host === "") {
1803
+ host = "localhost";
1804
+ } else if (host === "0.0.0.0") {
1805
+ host = "127.0.0.1";
1806
+ }
1807
+ if (host.includes(":")) {
1808
+ host = `[${host}]`;
1809
+ }
1810
+ const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
1811
+ return new HostDiscovery(
1812
+ internalBaseUrl + basePath,
1813
+ externalBaseUrl + basePath,
1814
+ config.getOptionalConfig("discovery")
1815
+ );
1816
+ }
1817
+ getTargetFromConfig(pluginId, type) {
1818
+ const endpoints = this.discoveryConfig?.getOptionalConfigArray("endpoints");
1819
+ const target = endpoints?.find((endpoint) => endpoint.getStringArray("plugins").includes(pluginId))?.get("target");
1820
+ if (!target) {
1821
+ const baseUrl = type === "external" ? this.externalBaseUrl : this.internalBaseUrl;
1822
+ return `${baseUrl}/${encodeURIComponent(pluginId)}`;
1823
+ }
1824
+ if (typeof target === "string") {
1825
+ return target.replace(
1826
+ /\{\{\s*pluginId\s*\}\}/g,
1827
+ encodeURIComponent(pluginId)
1828
+ );
1829
+ }
1830
+ return target[type].replace(
1831
+ /\{\{\s*pluginId\s*\}\}/g,
1832
+ encodeURIComponent(pluginId)
1833
+ );
1834
+ }
1835
+ async getBaseUrl(pluginId) {
1836
+ return this.getTargetFromConfig(pluginId, "internal");
1837
+ }
1838
+ async getExternalBaseUrl(pluginId) {
1839
+ return this.getTargetFromConfig(pluginId, "external");
1840
+ }
1841
+ };
1842
+
1843
+ backendPluginApi.createServiceFactory({
1844
+ service: backendPluginApi.coreServices.discovery,
1845
+ deps: {
1846
+ config: backendPluginApi.coreServices.rootConfig
1847
+ },
1848
+ async factory({ config }) {
1849
+ return HostDiscovery$1.fromConfig(config);
1850
+ }
1851
+ });
1852
+
1853
+ class HostDiscovery {
1854
+ constructor(impl) {
1855
+ this.impl = impl;
1856
+ }
1857
+ /**
1858
+ * Creates a new HostDiscovery discovery instance by reading
1859
+ * from the `backend` config section, specifically the `.baseUrl` for
1860
+ * discovering the external URL, and the `.listen` and `.https` config
1861
+ * for the internal one.
1862
+ *
1863
+ * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
1864
+ * eg.
1865
+ * ```yaml
1866
+ * discovery:
1867
+ * endpoints:
1868
+ * - target: https://internal.example.com/internal-catalog
1869
+ * plugins: [catalog]
1870
+ * - target: https://internal.example.com/secure/api/{{pluginId}}
1871
+ * plugins: [auth, permission]
1872
+ * - target:
1873
+ * internal: https://internal.example.com/search
1874
+ * external: https://example.com/search
1875
+ * plugins: [search]
1876
+ * ```
1877
+ *
1878
+ * The basePath defaults to `/api`, meaning the default full internal
1879
+ * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
1880
+ */
1881
+ static fromConfig(config, options) {
1882
+ return new HostDiscovery(HostDiscovery$1.fromConfig(config, options));
1883
+ }
1884
+ async getBaseUrl(pluginId) {
1885
+ return this.impl.getBaseUrl(pluginId);
1886
+ }
1887
+ async getExternalBaseUrl(pluginId) {
1888
+ return this.impl.getExternalBaseUrl(pluginId);
1889
+ }
1890
+ }
1891
+
1892
+ const discoveryServiceFactory = backendPluginApi.createServiceFactory({
1893
+ service: backendPluginApi.coreServices.discovery,
1894
+ deps: {
1895
+ config: backendPluginApi.coreServices.rootConfig
1896
+ },
1897
+ async factory({ config }) {
1898
+ return HostDiscovery.fromConfig(config);
1899
+ }
1900
+ });
1901
+
1902
+ const identityServiceFactory = backendPluginApi.createServiceFactory(
1903
+ (options) => ({
1904
+ service: backendPluginApi.coreServices.identity,
1905
+ deps: {
1906
+ discovery: backendPluginApi.coreServices.discovery
1907
+ },
1908
+ async factory({ discovery }) {
1909
+ return pluginAuthNode.DefaultIdentityClient.create({ discovery, ...options });
1910
+ }
1911
+ })
1912
+ );
1913
+
1914
+ class BackendPluginLifecycleImpl {
1915
+ constructor(logger, rootLifecycle, pluginMetadata) {
1916
+ this.logger = logger;
1917
+ this.rootLifecycle = rootLifecycle;
1918
+ this.pluginMetadata = pluginMetadata;
1919
+ }
1920
+ #hasStarted = false;
1921
+ #startupTasks = [];
1922
+ addStartupHook(hook, options) {
1923
+ if (this.#hasStarted) {
1924
+ throw new Error("Attempted to add startup hook after startup");
1925
+ }
1926
+ this.#startupTasks.push({ hook, options });
1927
+ }
1928
+ async startup() {
1929
+ if (this.#hasStarted) {
1930
+ return;
1931
+ }
1932
+ this.#hasStarted = true;
1933
+ this.logger.debug(
1934
+ `Running ${this.#startupTasks.length} plugin startup tasks...`
1935
+ );
1936
+ await Promise.all(
1937
+ this.#startupTasks.map(async ({ hook, options }) => {
1938
+ const logger = options?.logger ?? this.logger;
1939
+ try {
1940
+ await hook();
1941
+ logger.debug(`Plugin startup hook succeeded`);
1942
+ } catch (error) {
1943
+ logger.error(`Plugin startup hook failed, ${error}`);
1944
+ }
1945
+ })
1946
+ );
1947
+ }
1948
+ addShutdownHook(hook, options) {
1949
+ const plugin = this.pluginMetadata.getId();
1950
+ this.rootLifecycle.addShutdownHook(hook, {
1951
+ logger: options?.logger?.child({ plugin }) ?? this.logger
1952
+ });
1953
+ }
1954
+ }
1955
+ const lifecycleServiceFactory = backendPluginApi.createServiceFactory({
1956
+ service: backendPluginApi.coreServices.lifecycle,
1957
+ deps: {
1958
+ logger: backendPluginApi.coreServices.logger,
1959
+ rootLifecycle: backendPluginApi.coreServices.rootLifecycle,
1960
+ pluginMetadata: backendPluginApi.coreServices.pluginMetadata
1961
+ },
1962
+ async factory({ rootLifecycle, logger, pluginMetadata }) {
1963
+ return new BackendPluginLifecycleImpl(
1964
+ logger,
1965
+ rootLifecycle,
1966
+ pluginMetadata
1967
+ );
1968
+ }
1969
+ });
1970
+
1971
+ const permissionsServiceFactory = backendPluginApi.createServiceFactory({
1972
+ service: backendPluginApi.coreServices.permissions,
1973
+ deps: {
1974
+ auth: backendPluginApi.coreServices.auth,
1975
+ config: backendPluginApi.coreServices.rootConfig,
1976
+ discovery: backendPluginApi.coreServices.discovery,
1977
+ tokenManager: backendPluginApi.coreServices.tokenManager
1978
+ },
1979
+ async factory({ auth, config, discovery, tokenManager }) {
1980
+ return pluginPermissionNode.ServerPermissionClient.fromConfig(config, {
1981
+ auth,
1982
+ discovery,
1983
+ tokenManager
1984
+ });
1985
+ }
1986
+ });
1987
+
1988
+ class BackendLifecycleImpl {
1989
+ constructor(logger) {
1990
+ this.logger = logger;
1991
+ }
1992
+ #hasStarted = false;
1993
+ #startupTasks = [];
1994
+ addStartupHook(hook, options) {
1995
+ if (this.#hasStarted) {
1996
+ throw new Error("Attempted to add startup hook after startup");
1997
+ }
1998
+ this.#startupTasks.push({ hook, options });
1999
+ }
2000
+ async startup() {
2001
+ if (this.#hasStarted) {
2002
+ return;
2003
+ }
2004
+ this.#hasStarted = true;
2005
+ this.logger.debug(`Running ${this.#startupTasks.length} startup tasks...`);
2006
+ await Promise.all(
2007
+ this.#startupTasks.map(async ({ hook, options }) => {
2008
+ const logger = options?.logger ?? this.logger;
2009
+ try {
2010
+ await hook();
2011
+ logger.debug(`Startup hook succeeded`);
2012
+ } catch (error) {
2013
+ logger.error(`Startup hook failed, ${error}`);
2014
+ }
2015
+ })
2016
+ );
2017
+ }
2018
+ #hasShutdown = false;
2019
+ #shutdownTasks = [];
2020
+ addShutdownHook(hook, options) {
2021
+ if (this.#hasShutdown) {
2022
+ throw new Error("Attempted to add shutdown hook after shutdown");
2023
+ }
2024
+ this.#shutdownTasks.push({ hook, options });
2025
+ }
2026
+ async shutdown() {
2027
+ if (this.#hasShutdown) {
2028
+ return;
2029
+ }
2030
+ this.#hasShutdown = true;
2031
+ this.logger.debug(
2032
+ `Running ${this.#shutdownTasks.length} shutdown tasks...`
2033
+ );
2034
+ await Promise.all(
2035
+ this.#shutdownTasks.map(async ({ hook, options }) => {
2036
+ const logger = options?.logger ?? this.logger;
2037
+ try {
2038
+ await hook();
2039
+ logger.debug(`Shutdown hook succeeded`);
2040
+ } catch (error) {
2041
+ logger.error(`Shutdown hook failed, ${error}`);
2042
+ }
2043
+ })
2044
+ );
2045
+ }
2046
+ }
2047
+ const rootLifecycleServiceFactory = backendPluginApi.createServiceFactory({
2048
+ service: backendPluginApi.coreServices.rootLifecycle,
2049
+ deps: {
2050
+ logger: backendPluginApi.coreServices.rootLogger
2051
+ },
2052
+ async factory({ logger }) {
2053
+ return new BackendLifecycleImpl(logger);
2054
+ }
2055
+ });
2056
+
2057
+ const tokenManagerServiceFactory = backendPluginApi.createServiceFactory({
2058
+ service: backendPluginApi.coreServices.tokenManager,
2059
+ deps: {
2060
+ config: backendPluginApi.coreServices.rootConfig,
2061
+ logger: backendPluginApi.coreServices.rootLogger
2062
+ },
2063
+ createRootContext({ config, logger }) {
2064
+ return backendCommon.ServerTokenManager.fromConfig(config, {
2065
+ logger,
2066
+ allowDisabledTokenManager: true
2067
+ });
2068
+ },
2069
+ async factory(_deps, tokenManager) {
2070
+ return tokenManager;
2071
+ }
2072
+ });
2073
+
2074
+ const urlReaderServiceFactory = backendPluginApi.createServiceFactory({
2075
+ service: backendPluginApi.coreServices.urlReader,
2076
+ deps: {
2077
+ config: backendPluginApi.coreServices.rootConfig,
2078
+ logger: backendPluginApi.coreServices.logger
2079
+ },
2080
+ async factory({ config, logger }) {
2081
+ return backendCommon.UrlReaders.default({
2082
+ config,
2083
+ logger
2084
+ });
2085
+ }
2086
+ });
2087
+
2088
+ function createCredentialsWithServicePrincipal(sub, token, accessRestrictions) {
2089
+ return {
2090
+ $$type: "@backstage/BackstageCredentials",
2091
+ version: "v1",
2092
+ token,
2093
+ principal: {
2094
+ type: "service",
2095
+ subject: sub,
2096
+ accessRestrictions
2097
+ }
2098
+ };
2099
+ }
2100
+ function createCredentialsWithUserPrincipal(sub, token, expiresAt) {
2101
+ return {
2102
+ $$type: "@backstage/BackstageCredentials",
2103
+ version: "v1",
2104
+ token,
2105
+ expiresAt,
2106
+ principal: {
2107
+ type: "user",
2108
+ userEntityRef: sub
2109
+ }
2110
+ };
2111
+ }
2112
+ function createCredentialsWithNonePrincipal() {
2113
+ return {
2114
+ $$type: "@backstage/BackstageCredentials",
2115
+ version: "v1",
2116
+ principal: {
2117
+ type: "none"
2118
+ }
2119
+ };
1732
2120
  }
1733
2121
  function toInternalBackstageCredentials(credentials) {
1734
2122
  if (credentials.$$type !== "@backstage/BackstageCredentials") {
@@ -1744,17 +2132,16 @@ function toInternalBackstageCredentials(credentials) {
1744
2132
  }
1745
2133
 
1746
2134
  class DefaultAuthService {
1747
- constructor(userTokenHandler, pluginTokenHandler, externalTokenHandler, tokenManager, pluginId, disableDefaultAuthPolicy, publicKeyStore) {
2135
+ constructor(userTokenHandler, pluginTokenHandler, externalTokenHandler, tokenManager, pluginId, disableDefaultAuthPolicy, pluginKeySource) {
1748
2136
  this.userTokenHandler = userTokenHandler;
1749
2137
  this.pluginTokenHandler = pluginTokenHandler;
1750
2138
  this.externalTokenHandler = externalTokenHandler;
1751
2139
  this.tokenManager = tokenManager;
1752
2140
  this.pluginId = pluginId;
1753
2141
  this.disableDefaultAuthPolicy = disableDefaultAuthPolicy;
1754
- this.publicKeyStore = publicKeyStore;
2142
+ this.pluginKeySource = pluginKeySource;
1755
2143
  }
1756
- // allowLimitedAccess is currently ignored, since we currently always use the full user tokens
1757
- async authenticate(token) {
2144
+ async authenticate(token, options) {
1758
2145
  const pluginResult = await this.pluginTokenHandler.verifyToken(token);
1759
2146
  if (pluginResult) {
1760
2147
  if (pluginResult.limitedUserToken) {
@@ -1776,6 +2163,9 @@ class DefaultAuthService {
1776
2163
  }
1777
2164
  const userResult = await this.userTokenHandler.verifyToken(token);
1778
2165
  if (userResult) {
2166
+ if (!options?.allowLimitedAccess && this.userTokenHandler.isLimitedUserToken(token)) {
2167
+ throw new errors.AuthenticationError("Illegal limited user token");
2168
+ }
1779
2169
  return createCredentialsWithUserPrincipal(
1780
2170
  userResult.userEntityRef,
1781
2171
  token,
@@ -1788,920 +2178,972 @@ class DefaultAuthService {
1788
2178
  externalResult.subject,
1789
2179
  void 0,
1790
2180
  externalResult.accessRestrictions
1791
- );
1792
- }
1793
- throw new errors.AuthenticationError("Illegal token");
1794
- }
1795
- isPrincipal(credentials, type) {
1796
- const principal = credentials.principal;
1797
- if (type === "unknown") {
1798
- return true;
1799
- }
1800
- if (principal.type !== type) {
1801
- return false;
1802
- }
1803
- return true;
1804
- }
1805
- async getNoneCredentials() {
1806
- return createCredentialsWithNonePrincipal();
1807
- }
1808
- async getOwnServiceCredentials() {
1809
- return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`);
1810
- }
1811
- async getPluginRequestToken(options) {
1812
- const { targetPluginId } = options;
1813
- const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
1814
- const { type } = internalForward.principal;
1815
- if (type === "none" && this.disableDefaultAuthPolicy) {
1816
- return { token: "" };
1817
- }
1818
- const targetSupportsNewAuth = await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId);
1819
- switch (type) {
1820
- case "service":
1821
- if (targetSupportsNewAuth) {
1822
- return this.pluginTokenHandler.issueToken({
1823
- pluginId: this.pluginId,
1824
- targetPluginId
1825
- });
1826
- }
1827
- return this.tokenManager.getToken().catch((error) => {
1828
- throw new errors.ForwardedError(
1829
- `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`,
1830
- error
1831
- );
1832
- });
1833
- case "user": {
1834
- const { token } = internalForward;
1835
- if (!token) {
1836
- throw new Error("User credentials is unexpectedly missing token");
1837
- }
1838
- if (targetSupportsNewAuth) {
1839
- const onBehalfOf = await this.userTokenHandler.createLimitedUserToken(
1840
- token
1841
- );
1842
- return this.pluginTokenHandler.issueToken({
1843
- pluginId: this.pluginId,
1844
- targetPluginId,
1845
- onBehalfOf
1846
- });
1847
- }
1848
- if (this.userTokenHandler.isLimitedUserToken(token)) {
1849
- throw new errors.AuthenticationError(
1850
- `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`
1851
- );
1852
- }
1853
- return { token };
1854
- }
1855
- default:
1856
- throw new errors.AuthenticationError(
1857
- `Refused to issue service token for credential type '${type}'`
1858
- );
1859
- }
1860
- }
1861
- async getLimitedUserToken(credentials) {
1862
- const { token: backstageToken } = toInternalBackstageCredentials(credentials);
1863
- if (!backstageToken) {
1864
- throw new errors.AuthenticationError(
1865
- "User credentials is unexpectedly missing token"
1866
- );
1867
- }
1868
- return this.userTokenHandler.createLimitedUserToken(backstageToken);
1869
- }
1870
- async listPublicServiceKeys() {
1871
- const { keys } = await this.publicKeyStore.listKeys();
1872
- return { keys: keys.map(({ key }) => key) };
1873
- }
1874
- #getJwtExpiration(token) {
1875
- const { exp } = jose.decodeJwt(token);
1876
- if (!exp) {
1877
- throw new errors.AuthenticationError("User token is missing expiration");
1878
- }
1879
- return new Date(exp * 1e3);
1880
- }
1881
- }
1882
-
1883
- const CLOCK_MARGIN_S = 10;
1884
- class JwksClient {
1885
- constructor(getEndpoint) {
1886
- this.getEndpoint = getEndpoint;
1887
- }
1888
- #keyStore;
1889
- #keyStoreUpdated = 0;
1890
- get getKey() {
1891
- if (!this.#keyStore) {
1892
- throw new errors.AuthenticationError(
1893
- "refreshKeyStore must be called before jwksClient.getKey"
1894
- );
1895
- }
1896
- return this.#keyStore;
1897
- }
1898
- /**
1899
- * If the last keystore refresh is stale, update the keystore URL to the latest
1900
- */
1901
- async refreshKeyStore(rawJwtToken) {
1902
- const payload = await jose.decodeJwt(rawJwtToken);
1903
- const header = await jose.decodeProtectedHeader(rawJwtToken);
1904
- let keyStoreHasKey;
1905
- try {
1906
- if (this.#keyStore) {
1907
- const [_, rawPayload, rawSignature] = rawJwtToken.split(".");
1908
- keyStoreHasKey = await this.#keyStore(header, {
1909
- payload: rawPayload,
1910
- signature: rawSignature
1911
- });
1912
- }
1913
- } catch (error) {
1914
- keyStoreHasKey = false;
1915
- }
1916
- const issuedAfterLastRefresh = payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S;
1917
- if (!this.#keyStore || !keyStoreHasKey && issuedAfterLastRefresh) {
1918
- const endpoint = await this.getEndpoint();
1919
- this.#keyStore = jose.createRemoteJWKSet(endpoint);
1920
- this.#keyStoreUpdated = Date.now() / 1e3;
1921
- }
1922
- }
1923
- }
1924
-
1925
- const KEY_EXPIRATION_MARGIN_FACTOR = 3;
1926
- const SECONDS_IN_MS = 1e3;
1927
- const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
1928
- class PluginTokenHandler {
1929
- constructor(logger, ownPluginId, publicKeyStore, keyDurationSeconds, algorithm, discovery) {
1930
- this.logger = logger;
1931
- this.ownPluginId = ownPluginId;
1932
- this.publicKeyStore = publicKeyStore;
1933
- this.keyDurationSeconds = keyDurationSeconds;
1934
- this.algorithm = algorithm;
1935
- this.discovery = discovery;
1936
- }
1937
- privateKeyPromise;
1938
- keyExpiry;
1939
- jwksMap = /* @__PURE__ */ new Map();
1940
- // Tracking state for isTargetPluginSupported
1941
- supportedTargetPlugins = /* @__PURE__ */ new Set();
1942
- targetPluginInflightChecks = /* @__PURE__ */ new Map();
1943
- static create(options) {
1944
- return new PluginTokenHandler(
1945
- options.logger,
1946
- options.ownPluginId,
1947
- options.publicKeyStore,
1948
- Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
1949
- options.algorithm ?? "ES256",
1950
- options.discovery
1951
- );
1952
- }
1953
- async verifyToken(token) {
1954
- try {
1955
- const { typ } = jose.decodeProtectedHeader(token);
1956
- if (typ !== pluginAuthNode.tokenTypes.plugin.typParam) {
1957
- return void 0;
1958
- }
1959
- } catch {
1960
- return void 0;
1961
- }
1962
- const pluginId = String(jose.decodeJwt(token).sub);
1963
- if (!pluginId) {
1964
- throw new errors.AuthenticationError("Invalid plugin token: missing subject");
1965
- }
1966
- if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
1967
- throw new errors.AuthenticationError(
1968
- "Invalid plugin token: forbidden subject format"
1969
- );
1970
- }
1971
- const jwksClient = await this.getJwksClient(pluginId);
1972
- await jwksClient.refreshKeyStore(token);
1973
- const { payload } = await jose.jwtVerify(
1974
- token,
1975
- jwksClient.getKey,
1976
- {
1977
- typ: pluginAuthNode.tokenTypes.plugin.typParam,
1978
- audience: this.ownPluginId,
1979
- requiredClaims: ["iat", "exp", "sub", "aud"]
1980
- }
1981
- ).catch((e) => {
1982
- throw new errors.AuthenticationError("Invalid plugin token", e);
1983
- });
1984
- return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
1985
- }
1986
- async issueToken(options) {
1987
- const { pluginId, targetPluginId, onBehalfOf } = options;
1988
- const key = await this.getKey();
1989
- const sub = pluginId;
1990
- const aud = targetPluginId;
1991
- const iat = Math.floor(Date.now() / SECONDS_IN_MS);
1992
- const ourExp = iat + this.keyDurationSeconds;
1993
- const exp = onBehalfOf ? Math.min(
1994
- ourExp,
1995
- Math.floor(onBehalfOf.expiresAt.getTime() / SECONDS_IN_MS)
1996
- ) : ourExp;
1997
- const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
1998
- const token = await new jose.SignJWT(claims).setProtectedHeader({
1999
- typ: pluginAuthNode.tokenTypes.plugin.typParam,
2000
- alg: this.algorithm,
2001
- kid: key.kid
2002
- }).setAudience(aud).setSubject(sub).setIssuedAt(iat).setExpirationTime(exp).sign(await jose.importJWK(key));
2003
- return { token };
2181
+ );
2182
+ }
2183
+ throw new errors.AuthenticationError("Illegal token");
2004
2184
  }
2005
- async isTargetPluginSupported(targetPluginId) {
2006
- if (this.supportedTargetPlugins.has(targetPluginId)) {
2185
+ isPrincipal(credentials, type) {
2186
+ const principal = credentials.principal;
2187
+ if (type === "unknown") {
2007
2188
  return true;
2008
2189
  }
2009
- const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
2010
- if (inFlight) {
2011
- return inFlight;
2190
+ if (principal.type !== type) {
2191
+ return false;
2012
2192
  }
2013
- const doCheck = async () => {
2014
- try {
2015
- const res = await fetch(
2016
- `${await this.discovery.getBaseUrl(
2193
+ return true;
2194
+ }
2195
+ async getNoneCredentials() {
2196
+ return createCredentialsWithNonePrincipal();
2197
+ }
2198
+ async getOwnServiceCredentials() {
2199
+ return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`);
2200
+ }
2201
+ async getPluginRequestToken(options) {
2202
+ const { targetPluginId } = options;
2203
+ const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
2204
+ const { type } = internalForward.principal;
2205
+ if (type === "none" && this.disableDefaultAuthPolicy) {
2206
+ return { token: "" };
2207
+ }
2208
+ const targetSupportsNewAuth = await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId);
2209
+ switch (type) {
2210
+ case "service":
2211
+ if (targetSupportsNewAuth) {
2212
+ return this.pluginTokenHandler.issueToken({
2213
+ pluginId: this.pluginId,
2017
2214
  targetPluginId
2018
- )}/.backstage/auth/v1/jwks.json`
2019
- );
2020
- if (res.status === 404) {
2021
- return false;
2215
+ });
2022
2216
  }
2023
- if (!res.ok) {
2024
- throw new Error(`Failed to fetch jwks.json, ${res.status}`);
2217
+ return this.tokenManager.getToken().catch((error) => {
2218
+ throw new errors.ForwardedError(
2219
+ `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`,
2220
+ error
2221
+ );
2222
+ });
2223
+ case "user": {
2224
+ const { token } = internalForward;
2225
+ if (!token) {
2226
+ throw new Error("User credentials is unexpectedly missing token");
2025
2227
  }
2026
- const data = await res.json();
2027
- if (!data.keys) {
2028
- throw new Error(`Invalid jwks.json response, missing keys`);
2228
+ if (targetSupportsNewAuth) {
2229
+ const onBehalfOf = await this.userTokenHandler.createLimitedUserToken(
2230
+ token
2231
+ );
2232
+ return this.pluginTokenHandler.issueToken({
2233
+ pluginId: this.pluginId,
2234
+ targetPluginId,
2235
+ onBehalfOf
2236
+ });
2029
2237
  }
2030
- this.supportedTargetPlugins.add(targetPluginId);
2031
- return true;
2032
- } catch (error) {
2033
- this.logger.error("Unexpected failure for target JWKS check", error);
2034
- return false;
2035
- } finally {
2036
- this.targetPluginInflightChecks.delete(targetPluginId);
2238
+ if (this.userTokenHandler.isLimitedUserToken(token)) {
2239
+ throw new errors.AuthenticationError(
2240
+ `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`
2241
+ );
2242
+ }
2243
+ return { token };
2037
2244
  }
2038
- };
2039
- const check = doCheck();
2040
- this.targetPluginInflightChecks.set(targetPluginId, check);
2041
- return check;
2042
- }
2043
- async getJwksClient(pluginId) {
2044
- const client = this.jwksMap.get(pluginId);
2045
- if (client) {
2046
- return client;
2245
+ default:
2246
+ throw new errors.AuthenticationError(
2247
+ `Refused to issue service token for credential type '${type}'`
2248
+ );
2047
2249
  }
2048
- if (!await this.isTargetPluginSupported(pluginId)) {
2250
+ }
2251
+ async getLimitedUserToken(credentials) {
2252
+ const { token: backstageToken } = toInternalBackstageCredentials(credentials);
2253
+ if (!backstageToken) {
2049
2254
  throw new errors.AuthenticationError(
2050
- `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`
2255
+ "User credentials is unexpectedly missing token"
2051
2256
  );
2052
2257
  }
2053
- const newClient = new JwksClient(async () => {
2054
- return new URL(
2055
- `${await this.discovery.getBaseUrl(
2056
- pluginId
2057
- )}/.backstage/auth/v1/jwks.json`
2258
+ return this.userTokenHandler.createLimitedUserToken(backstageToken);
2259
+ }
2260
+ async listPublicServiceKeys() {
2261
+ const { keys } = await this.pluginKeySource.listKeys();
2262
+ return { keys: keys.map(({ key }) => key) };
2263
+ }
2264
+ #getJwtExpiration(token) {
2265
+ const { exp } = jose.decodeJwt(token);
2266
+ if (!exp) {
2267
+ throw new errors.AuthenticationError("User token is missing expiration");
2268
+ }
2269
+ return new Date(exp * 1e3);
2270
+ }
2271
+ }
2272
+
2273
+ function readAccessRestrictionsFromConfig(externalAccessEntryConfig) {
2274
+ const configs = externalAccessEntryConfig.getOptionalConfigArray("accessRestrictions") ?? [];
2275
+ const result = /* @__PURE__ */ new Map();
2276
+ for (const config of configs) {
2277
+ const validKeys = ["plugin", "permission", "permissionAttribute"];
2278
+ for (const key of config.keys()) {
2279
+ if (!validKeys.includes(key)) {
2280
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
2281
+ throw new Error(
2282
+ `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
2283
+ );
2284
+ }
2285
+ }
2286
+ const pluginId = config.getString("plugin");
2287
+ const permissionNames = readPermissionNames(config);
2288
+ const permissionAttributes = readPermissionAttributes(config);
2289
+ if (result.has(pluginId)) {
2290
+ throw new Error(
2291
+ `Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`
2058
2292
  );
2293
+ }
2294
+ result.set(pluginId, {
2295
+ ...permissionNames ? { permissionNames } : {},
2296
+ ...permissionAttributes ? { permissionAttributes } : {}
2059
2297
  });
2060
- this.jwksMap.set(pluginId, newClient);
2061
- return newClient;
2062
2298
  }
2063
- async getKey() {
2064
- if (this.privateKeyPromise) {
2065
- if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
2066
- return this.privateKeyPromise;
2299
+ return result.size ? result : void 0;
2300
+ }
2301
+ function readStringOrStringArrayFromConfig(root, key, validValues) {
2302
+ if (!root.has(key)) {
2303
+ return void 0;
2304
+ }
2305
+ const rawValues = Array.isArray(root.get(key)) ? root.getStringArray(key) : [root.getString(key)];
2306
+ const values = [
2307
+ ...new Set(
2308
+ rawValues.map((v) => v.split(/[ ,]/)).flat().filter(Boolean)
2309
+ )
2310
+ ];
2311
+ if (!values.length) {
2312
+ return void 0;
2313
+ }
2314
+ if (validValues?.length) {
2315
+ for (const value of values) {
2316
+ if (!validValues.includes(value)) {
2317
+ const valid = validValues.map((k) => `'${k}'`).join(", ");
2318
+ throw new Error(
2319
+ `Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`
2320
+ );
2067
2321
  }
2068
- this.logger.info(`Signing key has expired, generating new key`);
2069
- delete this.privateKeyPromise;
2070
2322
  }
2071
- this.keyExpiry = new Date(
2072
- Date.now() + this.keyDurationSeconds * SECONDS_IN_MS
2073
- );
2074
- const promise = (async () => {
2075
- const kid = uuid.v4();
2076
- const key = await jose.generateKeyPair(this.algorithm);
2077
- const publicKey = await jose.exportJWK(key.publicKey);
2078
- const privateKey = await jose.exportJWK(key.privateKey);
2079
- publicKey.kid = privateKey.kid = kid;
2080
- publicKey.alg = privateKey.alg = this.algorithm;
2081
- this.logger.info(`Created new signing key ${kid}`);
2082
- await this.publicKeyStore.addKey({
2083
- id: kid,
2084
- key: publicKey,
2085
- expiresAt: new Date(
2086
- Date.now() + this.keyDurationSeconds * SECONDS_IN_MS * KEY_EXPIRATION_MARGIN_FACTOR
2087
- )
2088
- });
2089
- return privateKey;
2090
- })();
2091
- this.privateKeyPromise = promise;
2092
- try {
2093
- await promise;
2094
- } catch (error) {
2095
- this.logger.error(`Failed to generate new signing key, ${error}`);
2096
- delete this.keyExpiry;
2097
- delete this.privateKeyPromise;
2323
+ }
2324
+ return values;
2325
+ }
2326
+ function readPermissionNames(externalAccessEntryConfig) {
2327
+ return readStringOrStringArrayFromConfig(
2328
+ externalAccessEntryConfig,
2329
+ "permission"
2330
+ );
2331
+ }
2332
+ function readPermissionAttributes(externalAccessEntryConfig) {
2333
+ const config = externalAccessEntryConfig.getOptionalConfig(
2334
+ "permissionAttribute"
2335
+ );
2336
+ if (!config) {
2337
+ return void 0;
2338
+ }
2339
+ const validKeys = ["action"];
2340
+ for (const key of config.keys()) {
2341
+ if (!validKeys.includes(key)) {
2342
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
2343
+ throw new Error(
2344
+ `Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`
2345
+ );
2098
2346
  }
2099
- return promise;
2100
2347
  }
2348
+ const action = readStringOrStringArrayFromConfig(config, "action", [
2349
+ "create",
2350
+ "read",
2351
+ "update",
2352
+ "delete"
2353
+ ]);
2354
+ const result = {
2355
+ ...action ? { action } : {}
2356
+ };
2357
+ return Object.keys(result).length ? result : void 0;
2101
2358
  }
2102
2359
 
2103
- class UserTokenHandler {
2104
- constructor(jwksClient) {
2105
- this.jwksClient = jwksClient;
2360
+ class LegacyTokenHandler {
2361
+ #entries = new Array();
2362
+ add(config) {
2363
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2364
+ this.#doAdd(
2365
+ config.getString("options.secret"),
2366
+ config.getString("options.subject"),
2367
+ allAccessRestrictions
2368
+ );
2106
2369
  }
2107
- static create(options) {
2108
- const jwksClient = new JwksClient(async () => {
2109
- const url = await options.discovery.getBaseUrl("auth");
2110
- return new URL(`${url}/.well-known/jwks.json`);
2111
- });
2112
- return new UserTokenHandler(jwksClient);
2370
+ // used only for the old backend.auth.keys array
2371
+ addOld(config) {
2372
+ this.#doAdd(config.getString("secret"), "external:backstage-plugin");
2113
2373
  }
2114
- async verifyToken(token) {
2115
- const verifyOpts = this.#getTokenVerificationOptions(token);
2116
- if (!verifyOpts) {
2117
- return void 0;
2374
+ #doAdd(secret, subject, allAccessRestrictions) {
2375
+ if (!secret.match(/^\S+$/)) {
2376
+ throw new Error("Illegal secret, must be a valid base64 string");
2377
+ } else if (!subject.match(/^\S+$/)) {
2378
+ throw new Error("Illegal subject, must be a set of non-space characters");
2118
2379
  }
2119
- await this.jwksClient.refreshKeyStore(token);
2120
- const { payload } = await jose.jwtVerify(
2121
- token,
2122
- this.jwksClient.getKey,
2123
- verifyOpts
2124
- ).catch((e) => {
2125
- throw new errors.AuthenticationError("Invalid token", e);
2126
- });
2127
- const userEntityRef = payload.sub;
2128
- if (!userEntityRef) {
2129
- throw new errors.AuthenticationError("No user sub found in token");
2380
+ let key;
2381
+ try {
2382
+ key = jose.base64url.decode(secret);
2383
+ } catch {
2384
+ throw new Error("Illegal secret, must be a valid base64 string");
2130
2385
  }
2131
- return { userEntityRef };
2386
+ if (this.#entries.some((e) => e.key === key)) {
2387
+ throw new Error(
2388
+ "Legacy externalAccess token was declared more than once"
2389
+ );
2390
+ }
2391
+ this.#entries.push({
2392
+ key,
2393
+ result: {
2394
+ subject,
2395
+ allAccessRestrictions
2396
+ }
2397
+ });
2132
2398
  }
2133
- #getTokenVerificationOptions(token) {
2399
+ async verifyToken(token) {
2134
2400
  try {
2135
- const { typ } = jose.decodeProtectedHeader(token);
2136
- if (typ === pluginAuthNode.tokenTypes.user.typParam) {
2137
- return {
2138
- requiredClaims: ["iat", "exp", "sub"],
2139
- typ: pluginAuthNode.tokenTypes.user.typParam
2140
- };
2401
+ const { alg } = jose.decodeProtectedHeader(token);
2402
+ if (alg !== "HS256") {
2403
+ return void 0;
2141
2404
  }
2142
- if (typ === pluginAuthNode.tokenTypes.limitedUser.typParam) {
2143
- return {
2144
- requiredClaims: ["iat", "exp", "sub"],
2145
- typ: pluginAuthNode.tokenTypes.limitedUser.typParam
2146
- };
2405
+ const { sub, aud } = jose.decodeJwt(token);
2406
+ if (sub !== "backstage-server" || aud) {
2407
+ return void 0;
2147
2408
  }
2148
- const { aud } = jose.decodeJwt(token);
2149
- if (aud === pluginAuthNode.tokenTypes.user.audClaim) {
2150
- return {
2151
- audience: pluginAuthNode.tokenTypes.user.audClaim
2152
- };
2409
+ } catch (e) {
2410
+ return void 0;
2411
+ }
2412
+ for (const { key, result } of this.#entries) {
2413
+ try {
2414
+ await jose.jwtVerify(token, key);
2415
+ return result;
2416
+ } catch (e) {
2417
+ if (e.code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
2418
+ throw e;
2419
+ }
2153
2420
  }
2154
- } catch {
2155
2421
  }
2156
2422
  return void 0;
2157
2423
  }
2158
- createLimitedUserToken(backstageToken) {
2159
- const [headerRaw, payloadRaw] = backstageToken.split(".");
2160
- const header = JSON.parse(
2161
- new TextDecoder().decode(jose.base64url.decode(headerRaw))
2162
- );
2163
- const payload = JSON.parse(
2164
- new TextDecoder().decode(jose.base64url.decode(payloadRaw))
2165
- );
2166
- const tokenType = header.typ;
2167
- if (!tokenType || tokenType === pluginAuthNode.tokenTypes.limitedUser.typParam) {
2168
- return { token: backstageToken, expiresAt: new Date(payload.exp * 1e3) };
2424
+ }
2425
+
2426
+ const MIN_TOKEN_LENGTH = 8;
2427
+ class StaticTokenHandler {
2428
+ #entries = /* @__PURE__ */ new Map();
2429
+ add(config) {
2430
+ const token = config.getString("options.token");
2431
+ const subject = config.getString("options.subject");
2432
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2433
+ if (!token.match(/^\S+$/)) {
2434
+ throw new Error("Illegal token, must be a set of non-space characters");
2435
+ } else if (token.length < MIN_TOKEN_LENGTH) {
2436
+ throw new Error(
2437
+ `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`
2438
+ );
2439
+ } else if (!subject.match(/^\S+$/)) {
2440
+ throw new Error("Illegal subject, must be a set of non-space characters");
2441
+ } else if (this.#entries.has(token)) {
2442
+ throw new Error(
2443
+ "Static externalAccess token was declared more than once"
2444
+ );
2169
2445
  }
2170
- if (tokenType !== pluginAuthNode.tokenTypes.user.typParam) {
2171
- throw new errors.AuthenticationError(
2172
- "Failed to create limited user token, invalid token type"
2446
+ this.#entries.set(token, { subject, allAccessRestrictions });
2447
+ }
2448
+ async verifyToken(token) {
2449
+ return this.#entries.get(token);
2450
+ }
2451
+ }
2452
+
2453
+ class JWKSHandler {
2454
+ #entries = [];
2455
+ add(config) {
2456
+ if (!config.getString("options.url").match(/^\S+$/)) {
2457
+ throw new Error(
2458
+ "Illegal JWKS URL, must be a set of non-space characters"
2173
2459
  );
2174
2460
  }
2175
- const limitedUserToken = [
2176
- jose.base64url.encode(
2177
- JSON.stringify({
2178
- typ: pluginAuthNode.tokenTypes.limitedUser.typParam,
2179
- alg: header.alg,
2180
- kid: header.kid
2181
- })
2182
- ),
2183
- jose.base64url.encode(
2184
- JSON.stringify({
2185
- sub: payload.sub,
2186
- ent: payload.ent,
2187
- iat: payload.iat,
2188
- exp: payload.exp
2189
- })
2190
- ),
2191
- payload.uip
2192
- ].join(".");
2193
- return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1e3) };
2461
+ const algorithms = readStringOrStringArrayFromConfig(
2462
+ config,
2463
+ "options.algorithm"
2464
+ );
2465
+ const issuers = readStringOrStringArrayFromConfig(config, "options.issuer");
2466
+ const audiences = readStringOrStringArrayFromConfig(
2467
+ config,
2468
+ "options.audience"
2469
+ );
2470
+ const subjectPrefix = config.getOptionalString("options.subjectPrefix");
2471
+ const url = new URL(config.getString("options.url"));
2472
+ const jwks = jose.createRemoteJWKSet(url);
2473
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2474
+ this.#entries.push({
2475
+ algorithms,
2476
+ audiences,
2477
+ issuers,
2478
+ jwks,
2479
+ subjectPrefix,
2480
+ url,
2481
+ allAccessRestrictions
2482
+ });
2194
2483
  }
2195
- isLimitedUserToken(token) {
2196
- try {
2197
- const { typ } = jose.decodeProtectedHeader(token);
2198
- return typ === pluginAuthNode.tokenTypes.limitedUser.typParam;
2199
- } catch {
2200
- return false;
2484
+ async verifyToken(token) {
2485
+ for (const entry of this.#entries) {
2486
+ try {
2487
+ const {
2488
+ payload: { sub }
2489
+ } = await jose.jwtVerify(token, entry.jwks, {
2490
+ algorithms: entry.algorithms,
2491
+ issuer: entry.issuers,
2492
+ audience: entry.audiences
2493
+ });
2494
+ if (sub) {
2495
+ const prefix = entry.subjectPrefix ? `external:${entry.subjectPrefix}:` : "external:";
2496
+ return {
2497
+ subject: `${prefix}${sub}`,
2498
+ allAccessRestrictions: entry.allAccessRestrictions
2499
+ };
2500
+ }
2501
+ } catch {
2502
+ continue;
2503
+ }
2201
2504
  }
2505
+ return void 0;
2202
2506
  }
2203
2507
  }
2204
2508
 
2205
- function readAccessRestrictionsFromConfig(externalAccessEntryConfig) {
2206
- const configs = externalAccessEntryConfig.getOptionalConfigArray("accessRestrictions") ?? [];
2207
- const result = /* @__PURE__ */ new Map();
2208
- for (const config of configs) {
2209
- const validKeys = ["plugin", "permission", "permissionAttribute"];
2210
- for (const key of config.keys()) {
2211
- if (!validKeys.includes(key)) {
2212
- const valid = validKeys.map((k) => `'${k}'`).join(", ");
2509
+ const NEW_CONFIG_KEY = "backend.auth.externalAccess";
2510
+ const OLD_CONFIG_KEY = "backend.auth.keys";
2511
+ let loggedDeprecationWarning = false;
2512
+ class ExternalTokenHandler {
2513
+ constructor(ownPluginId, handlers) {
2514
+ this.ownPluginId = ownPluginId;
2515
+ this.handlers = handlers;
2516
+ }
2517
+ static create(options) {
2518
+ const { ownPluginId, config, logger } = options;
2519
+ const staticHandler = new StaticTokenHandler();
2520
+ const legacyHandler = new LegacyTokenHandler();
2521
+ const jwksHandler = new JWKSHandler();
2522
+ const handlers = {
2523
+ static: staticHandler,
2524
+ legacy: legacyHandler,
2525
+ jwks: jwksHandler
2526
+ };
2527
+ const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
2528
+ for (const handlerConfig of handlerConfigs) {
2529
+ const type = handlerConfig.getString("type");
2530
+ const handler = handlers[type];
2531
+ if (!handler) {
2532
+ const valid = Object.keys(handlers).map((k) => `'${k}'`).join(", ");
2213
2533
  throw new Error(
2214
- `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
2534
+ `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`
2215
2535
  );
2216
2536
  }
2537
+ handler.add(handlerConfig);
2217
2538
  }
2218
- const pluginId = config.getString("plugin");
2219
- const permissionNames = readPermissionNames(config);
2220
- const permissionAttributes = readPermissionAttributes(config);
2221
- if (result.has(pluginId)) {
2222
- throw new Error(
2223
- `Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`
2539
+ const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
2540
+ if (legacyConfigs.length && !loggedDeprecationWarning) {
2541
+ loggedDeprecationWarning = true;
2542
+ logger.warn(
2543
+ `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`
2224
2544
  );
2225
2545
  }
2226
- result.set(pluginId, {
2227
- ...permissionNames ? { permissionNames } : {},
2228
- ...permissionAttributes ? { permissionAttributes } : {}
2229
- });
2546
+ for (const handlerConfig of legacyConfigs) {
2547
+ legacyHandler.addOld(handlerConfig);
2548
+ }
2549
+ return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
2550
+ }
2551
+ async verifyToken(token) {
2552
+ for (const handler of this.handlers) {
2553
+ const result = await handler.verifyToken(token);
2554
+ if (result) {
2555
+ const { allAccessRestrictions, ...rest } = result;
2556
+ if (allAccessRestrictions) {
2557
+ const accessRestrictions = allAccessRestrictions.get(
2558
+ this.ownPluginId
2559
+ );
2560
+ if (!accessRestrictions) {
2561
+ const valid = [...allAccessRestrictions.keys()].map((k) => `'${k}'`).join(", ");
2562
+ throw new errors.NotAllowedError(
2563
+ `This token's access is restricted to plugin(s) ${valid}`
2564
+ );
2565
+ }
2566
+ return {
2567
+ ...rest,
2568
+ accessRestrictions
2569
+ };
2570
+ }
2571
+ return rest;
2572
+ }
2573
+ }
2574
+ return void 0;
2230
2575
  }
2231
- return result.size ? result : void 0;
2232
2576
  }
2233
- function readStringOrStringArrayFromConfig(root, key, validValues) {
2234
- if (!root.has(key)) {
2235
- return void 0;
2577
+
2578
+ const CLOCK_MARGIN_S = 10;
2579
+ class JwksClient {
2580
+ constructor(getEndpoint) {
2581
+ this.getEndpoint = getEndpoint;
2236
2582
  }
2237
- const rawValues = Array.isArray(root.get(key)) ? root.getStringArray(key) : [root.getString(key)];
2238
- const values = [
2239
- ...new Set(
2240
- rawValues.map((v) => v.split(/[ ,]/)).flat().filter(Boolean)
2241
- )
2242
- ];
2243
- if (!values.length) {
2244
- return void 0;
2583
+ #keyStore;
2584
+ #keyStoreUpdated = 0;
2585
+ get getKey() {
2586
+ if (!this.#keyStore) {
2587
+ throw new errors.AuthenticationError(
2588
+ "refreshKeyStore must be called before jwksClient.getKey"
2589
+ );
2590
+ }
2591
+ return this.#keyStore;
2245
2592
  }
2246
- if (validValues?.length) {
2247
- for (const value of values) {
2248
- if (!validValues.includes(value)) {
2249
- const valid = validValues.map((k) => `'${k}'`).join(", ");
2250
- throw new Error(
2251
- `Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`
2252
- );
2593
+ /**
2594
+ * If the last keystore refresh is stale, update the keystore URL to the latest
2595
+ */
2596
+ async refreshKeyStore(rawJwtToken) {
2597
+ const payload = await jose.decodeJwt(rawJwtToken);
2598
+ const header = await jose.decodeProtectedHeader(rawJwtToken);
2599
+ let keyStoreHasKey;
2600
+ try {
2601
+ if (this.#keyStore) {
2602
+ const [_, rawPayload, rawSignature] = rawJwtToken.split(".");
2603
+ keyStoreHasKey = await this.#keyStore(header, {
2604
+ payload: rawPayload,
2605
+ signature: rawSignature
2606
+ });
2253
2607
  }
2608
+ } catch (error) {
2609
+ keyStoreHasKey = false;
2254
2610
  }
2255
- }
2256
- return values;
2257
- }
2258
- function readPermissionNames(externalAccessEntryConfig) {
2259
- return readStringOrStringArrayFromConfig(
2260
- externalAccessEntryConfig,
2261
- "permission"
2262
- );
2263
- }
2264
- function readPermissionAttributes(externalAccessEntryConfig) {
2265
- const config = externalAccessEntryConfig.getOptionalConfig(
2266
- "permissionAttribute"
2267
- );
2268
- if (!config) {
2269
- return void 0;
2270
- }
2271
- const validKeys = ["action"];
2272
- for (const key of config.keys()) {
2273
- if (!validKeys.includes(key)) {
2274
- const valid = validKeys.map((k) => `'${k}'`).join(", ");
2275
- throw new Error(
2276
- `Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`
2277
- );
2611
+ const issuedAfterLastRefresh = payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S;
2612
+ if (!this.#keyStore || !keyStoreHasKey && issuedAfterLastRefresh) {
2613
+ const endpoint = await this.getEndpoint();
2614
+ this.#keyStore = jose.createRemoteJWKSet(endpoint);
2615
+ this.#keyStoreUpdated = Date.now() / 1e3;
2278
2616
  }
2279
2617
  }
2280
- const action = readStringOrStringArrayFromConfig(config, "action", [
2281
- "create",
2282
- "read",
2283
- "update",
2284
- "delete"
2285
- ]);
2286
- const result = {
2287
- ...action ? { action } : {}
2288
- };
2289
- return Object.keys(result).length ? result : void 0;
2290
2618
  }
2291
2619
 
2292
- class LegacyTokenHandler {
2293
- #entries = new Array();
2294
- add(config) {
2295
- const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2296
- this.#doAdd(
2297
- config.getString("options.secret"),
2298
- config.getString("options.subject"),
2299
- allAccessRestrictions
2300
- );
2620
+ const SECONDS_IN_MS$2 = 1e3;
2621
+ const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
2622
+ class PluginTokenHandler {
2623
+ constructor(logger, ownPluginId, keySource, algorithm, keyDurationSeconds, discovery) {
2624
+ this.logger = logger;
2625
+ this.ownPluginId = ownPluginId;
2626
+ this.keySource = keySource;
2627
+ this.algorithm = algorithm;
2628
+ this.keyDurationSeconds = keyDurationSeconds;
2629
+ this.discovery = discovery;
2301
2630
  }
2302
- // used only for the old backend.auth.keys array
2303
- addOld(config) {
2304
- this.#doAdd(config.getString("secret"), "external:backstage-plugin");
2631
+ jwksMap = /* @__PURE__ */ new Map();
2632
+ // Tracking state for isTargetPluginSupported
2633
+ supportedTargetPlugins = /* @__PURE__ */ new Set();
2634
+ targetPluginInflightChecks = /* @__PURE__ */ new Map();
2635
+ static create(options) {
2636
+ return new PluginTokenHandler(
2637
+ options.logger,
2638
+ options.ownPluginId,
2639
+ options.keySource,
2640
+ options.algorithm ?? "ES256",
2641
+ Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
2642
+ options.discovery
2643
+ );
2305
2644
  }
2306
- #doAdd(secret, subject, allAccessRestrictions) {
2307
- if (!secret.match(/^\S+$/)) {
2308
- throw new Error("Illegal secret, must be a valid base64 string");
2309
- } else if (!subject.match(/^\S+$/)) {
2310
- throw new Error("Illegal subject, must be a set of non-space characters");
2311
- }
2312
- let key;
2645
+ async verifyToken(token) {
2313
2646
  try {
2314
- key = jose.base64url.decode(secret);
2647
+ const { typ } = jose.decodeProtectedHeader(token);
2648
+ if (typ !== pluginAuthNode.tokenTypes.plugin.typParam) {
2649
+ return void 0;
2650
+ }
2315
2651
  } catch {
2316
- throw new Error("Illegal secret, must be a valid base64 string");
2652
+ return void 0;
2317
2653
  }
2318
- if (this.#entries.some((e) => e.key === key)) {
2319
- throw new Error(
2320
- "Legacy externalAccess token was declared more than once"
2654
+ const pluginId = String(jose.decodeJwt(token).sub);
2655
+ if (!pluginId) {
2656
+ throw new errors.AuthenticationError("Invalid plugin token: missing subject");
2657
+ }
2658
+ if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
2659
+ throw new errors.AuthenticationError(
2660
+ "Invalid plugin token: forbidden subject format"
2321
2661
  );
2322
2662
  }
2323
- this.#entries.push({
2324
- key,
2325
- result: {
2326
- subject,
2327
- allAccessRestrictions
2663
+ const jwksClient = await this.getJwksClient(pluginId);
2664
+ await jwksClient.refreshKeyStore(token);
2665
+ const { payload } = await jose.jwtVerify(
2666
+ token,
2667
+ jwksClient.getKey,
2668
+ {
2669
+ typ: pluginAuthNode.tokenTypes.plugin.typParam,
2670
+ audience: this.ownPluginId,
2671
+ requiredClaims: ["iat", "exp", "sub", "aud"]
2328
2672
  }
2673
+ ).catch((e) => {
2674
+ throw new errors.AuthenticationError("Invalid plugin token", e);
2329
2675
  });
2676
+ return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
2330
2677
  }
2331
- async verifyToken(token) {
2332
- try {
2333
- const { alg } = jose.decodeProtectedHeader(token);
2334
- if (alg !== "HS256") {
2335
- return void 0;
2336
- }
2337
- const { sub, aud } = jose.decodeJwt(token);
2338
- if (sub !== "backstage-server" || aud) {
2339
- return void 0;
2340
- }
2341
- } catch (e) {
2342
- return void 0;
2678
+ async issueToken(options) {
2679
+ const { pluginId, targetPluginId, onBehalfOf } = options;
2680
+ const key = await this.keySource.getPrivateSigningKey();
2681
+ const sub = pluginId;
2682
+ const aud = targetPluginId;
2683
+ const iat = Math.floor(Date.now() / SECONDS_IN_MS$2);
2684
+ const ourExp = iat + this.keyDurationSeconds;
2685
+ const exp = onBehalfOf ? Math.min(
2686
+ ourExp,
2687
+ Math.floor(onBehalfOf.expiresAt.getTime() / SECONDS_IN_MS$2)
2688
+ ) : ourExp;
2689
+ const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
2690
+ const token = await new jose.SignJWT(claims).setProtectedHeader({
2691
+ typ: pluginAuthNode.tokenTypes.plugin.typParam,
2692
+ alg: this.algorithm,
2693
+ kid: key.kid
2694
+ }).setAudience(aud).setSubject(sub).setIssuedAt(iat).setExpirationTime(exp).sign(await jose.importJWK(key));
2695
+ return { token };
2696
+ }
2697
+ async isTargetPluginSupported(targetPluginId) {
2698
+ if (this.supportedTargetPlugins.has(targetPluginId)) {
2699
+ return true;
2343
2700
  }
2344
- for (const { key, result } of this.#entries) {
2701
+ const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
2702
+ if (inFlight) {
2703
+ return inFlight;
2704
+ }
2705
+ const doCheck = async () => {
2345
2706
  try {
2346
- await jose.jwtVerify(token, key);
2347
- return result;
2348
- } catch (e) {
2349
- if (e.code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
2350
- throw e;
2707
+ const res = await fetch(
2708
+ `${await this.discovery.getBaseUrl(
2709
+ targetPluginId
2710
+ )}/.backstage/auth/v1/jwks.json`
2711
+ );
2712
+ if (res.status === 404) {
2713
+ return false;
2714
+ }
2715
+ if (!res.ok) {
2716
+ throw new Error(`Failed to fetch jwks.json, ${res.status}`);
2717
+ }
2718
+ const data = await res.json();
2719
+ if (!data.keys) {
2720
+ throw new Error(`Invalid jwks.json response, missing keys`);
2351
2721
  }
2722
+ this.supportedTargetPlugins.add(targetPluginId);
2723
+ return true;
2724
+ } catch (error) {
2725
+ this.logger.error("Unexpected failure for target JWKS check", error);
2726
+ return false;
2727
+ } finally {
2728
+ this.targetPluginInflightChecks.delete(targetPluginId);
2352
2729
  }
2353
- }
2354
- return void 0;
2730
+ };
2731
+ const check = doCheck();
2732
+ this.targetPluginInflightChecks.set(targetPluginId, check);
2733
+ return check;
2355
2734
  }
2356
- }
2357
-
2358
- const MIN_TOKEN_LENGTH = 8;
2359
- class StaticTokenHandler {
2360
- #entries = /* @__PURE__ */ new Map();
2361
- add(config) {
2362
- const token = config.getString("options.token");
2363
- const subject = config.getString("options.subject");
2364
- const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2365
- if (!token.match(/^\S+$/)) {
2366
- throw new Error("Illegal token, must be a set of non-space characters");
2367
- } else if (token.length < MIN_TOKEN_LENGTH) {
2368
- throw new Error(
2369
- `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`
2370
- );
2371
- } else if (!subject.match(/^\S+$/)) {
2372
- throw new Error("Illegal subject, must be a set of non-space characters");
2373
- } else if (this.#entries.has(token)) {
2374
- throw new Error(
2375
- "Static externalAccess token was declared more than once"
2735
+ async getJwksClient(pluginId) {
2736
+ const client = this.jwksMap.get(pluginId);
2737
+ if (client) {
2738
+ return client;
2739
+ }
2740
+ if (!await this.isTargetPluginSupported(pluginId)) {
2741
+ throw new errors.AuthenticationError(
2742
+ `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`
2376
2743
  );
2377
2744
  }
2378
- this.#entries.set(token, { subject, allAccessRestrictions });
2379
- }
2380
- async verifyToken(token) {
2381
- return this.#entries.get(token);
2745
+ const newClient = new JwksClient(async () => {
2746
+ return new URL(
2747
+ `${await this.discovery.getBaseUrl(
2748
+ pluginId
2749
+ )}/.backstage/auth/v1/jwks.json`
2750
+ );
2751
+ });
2752
+ this.jwksMap.set(pluginId, newClient);
2753
+ return newClient;
2382
2754
  }
2383
2755
  }
2384
2756
 
2385
- class JWKSHandler {
2386
- #entries = [];
2387
- add(config) {
2388
- if (!config.getString("options.url").match(/^\S+$/)) {
2389
- throw new Error(
2390
- "Illegal JWKS URL, must be a set of non-space characters"
2391
- );
2392
- }
2393
- const algorithms = readStringOrStringArrayFromConfig(
2394
- config,
2395
- "options.algorithm"
2396
- );
2397
- const issuers = readStringOrStringArrayFromConfig(config, "options.issuer");
2398
- const audiences = readStringOrStringArrayFromConfig(
2399
- config,
2400
- "options.audience"
2401
- );
2402
- const subjectPrefix = config.getOptionalString("options.subjectPrefix");
2403
- const url = new URL(config.getString("options.url"));
2404
- const jwks = jose.createRemoteJWKSet(url);
2405
- const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2406
- this.#entries.push({
2407
- algorithms,
2408
- audiences,
2409
- issuers,
2410
- jwks,
2411
- subjectPrefix,
2412
- url,
2413
- allAccessRestrictions
2414
- });
2757
+ const MIGRATIONS_TABLE = "backstage_backend_public_keys__knex_migrations";
2758
+ const TABLE = "backstage_backend_public_keys__keys";
2759
+ function applyDatabaseMigrations(knex) {
2760
+ const migrationsDir = backendPluginApi.resolvePackagePath(
2761
+ "@backstage/backend-defaults",
2762
+ "migrations/auth"
2763
+ );
2764
+ return knex.migrate.latest({
2765
+ directory: migrationsDir,
2766
+ tableName: MIGRATIONS_TABLE
2767
+ });
2768
+ }
2769
+ class DatabaseKeyStore {
2770
+ constructor(client, logger) {
2771
+ this.client = client;
2772
+ this.logger = logger;
2415
2773
  }
2416
- async verifyToken(token) {
2417
- for (const entry of this.#entries) {
2418
- try {
2419
- const {
2420
- payload: { sub }
2421
- } = await jose.jwtVerify(token, entry.jwks, {
2422
- algorithms: entry.algorithms,
2423
- issuer: entry.issuers,
2424
- audience: entry.audiences
2425
- });
2426
- if (sub) {
2427
- const prefix = entry.subjectPrefix ? `external:${entry.subjectPrefix}:` : "external:";
2428
- return {
2429
- subject: `${prefix}${sub}`,
2430
- allAccessRestrictions: entry.allAccessRestrictions
2431
- };
2432
- }
2433
- } catch {
2434
- continue;
2435
- }
2774
+ static async create(options) {
2775
+ const { database, logger } = options;
2776
+ const client = await database.getClient();
2777
+ if (!database.migrations?.skip) {
2778
+ await applyDatabaseMigrations(client);
2436
2779
  }
2437
- return void 0;
2780
+ return new DatabaseKeyStore(client, logger);
2438
2781
  }
2439
- }
2440
-
2441
- const NEW_CONFIG_KEY = "backend.auth.externalAccess";
2442
- const OLD_CONFIG_KEY = "backend.auth.keys";
2443
- let loggedDeprecationWarning = false;
2444
- class ExternalTokenHandler {
2445
- constructor(ownPluginId, handlers) {
2446
- this.ownPluginId = ownPluginId;
2447
- this.handlers = handlers;
2782
+ async addKey(options) {
2783
+ await this.client(TABLE).insert({
2784
+ id: options.key.kid,
2785
+ key: JSON.stringify(options.key),
2786
+ expires_at: options.expiresAt.toISOString()
2787
+ });
2448
2788
  }
2449
- static create(options) {
2450
- const { ownPluginId, config, logger } = options;
2451
- const staticHandler = new StaticTokenHandler();
2452
- const legacyHandler = new LegacyTokenHandler();
2453
- const jwksHandler = new JWKSHandler();
2454
- const handlers = {
2455
- static: staticHandler,
2456
- legacy: legacyHandler,
2457
- jwks: jwksHandler
2458
- };
2459
- const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
2460
- for (const handlerConfig of handlerConfigs) {
2461
- const type = handlerConfig.getString("type");
2462
- const handler = handlers[type];
2463
- if (!handler) {
2464
- const valid = Object.keys(handlers).map((k) => `'${k}'`).join(", ");
2465
- throw new Error(
2466
- `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`
2467
- );
2789
+ async listKeys() {
2790
+ const rows = await this.client(TABLE).select();
2791
+ const keys = rows.map((row) => ({
2792
+ id: row.id,
2793
+ key: JSON.parse(row.key),
2794
+ expiresAt: new Date(row.expires_at)
2795
+ }));
2796
+ const validKeys = [];
2797
+ const expiredKeys = [];
2798
+ for (const key of keys) {
2799
+ if (luxon.DateTime.fromJSDate(key.expiresAt) < luxon.DateTime.local()) {
2800
+ expiredKeys.push(key);
2801
+ } else {
2802
+ validKeys.push(key);
2468
2803
  }
2469
- handler.add(handlerConfig);
2470
2804
  }
2471
- const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
2472
- if (legacyConfigs.length && !loggedDeprecationWarning) {
2473
- loggedDeprecationWarning = true;
2474
- logger.warn(
2475
- `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`
2805
+ if (expiredKeys.length > 0) {
2806
+ const kids = expiredKeys.map(({ key }) => key.kid);
2807
+ this.logger.info(
2808
+ `Removing expired plugin service keys, '${kids.join("', '")}'`
2476
2809
  );
2810
+ this.client(TABLE).delete().whereIn("id", kids).catch((error) => {
2811
+ this.logger.error(
2812
+ "Failed to remove expired plugin service keys",
2813
+ error
2814
+ );
2815
+ });
2477
2816
  }
2478
- for (const handlerConfig of legacyConfigs) {
2479
- legacyHandler.addOld(handlerConfig);
2480
- }
2481
- return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
2482
- }
2483
- async verifyToken(token) {
2484
- for (const handler of this.handlers) {
2485
- const result = await handler.verifyToken(token);
2486
- if (result) {
2487
- const { allAccessRestrictions, ...rest } = result;
2488
- if (allAccessRestrictions) {
2489
- const accessRestrictions = allAccessRestrictions.get(
2490
- this.ownPluginId
2491
- );
2492
- if (!accessRestrictions) {
2493
- const valid = [...allAccessRestrictions.keys()].map((k) => `'${k}'`).join(", ");
2494
- throw new errors.NotAllowedError(
2495
- `This token's access is restricted to plugin(s) ${valid}`
2496
- );
2497
- }
2498
- return {
2499
- ...rest,
2500
- accessRestrictions
2501
- };
2502
- }
2503
- return rest;
2504
- }
2505
- }
2506
- return void 0;
2817
+ return { keys: validKeys };
2507
2818
  }
2508
2819
  }
2509
2820
 
2510
- const authServiceFactory = backendPluginApi.createServiceFactory({
2511
- service: backendPluginApi.coreServices.auth,
2512
- deps: {
2513
- config: backendPluginApi.coreServices.rootConfig,
2514
- logger: backendPluginApi.coreServices.rootLogger,
2515
- discovery: backendPluginApi.coreServices.discovery,
2516
- plugin: backendPluginApi.coreServices.pluginMetadata,
2517
- database: backendPluginApi.coreServices.database,
2518
- // Re-using the token manager makes sure that we use the same generated keys for
2519
- // development as plugins that have not yet been migrated. It's important that this
2520
- // keeps working as long as there are plugins that have not been migrated to the
2521
- // new auth services in the new backend system.
2522
- tokenManager: backendPluginApi.coreServices.tokenManager
2523
- },
2524
- async factory({ config, discovery, plugin, tokenManager, logger, database }) {
2525
- const disableDefaultAuthPolicy = Boolean(
2526
- config.getOptionalBoolean(
2527
- "backend.auth.dangerouslyDisableDefaultAuthPolicy"
2528
- )
2529
- );
2530
- const publicKeyStore = await DatabaseKeyStore.create({
2531
- database,
2532
- logger
2533
- });
2534
- const userTokens = UserTokenHandler.create({
2535
- discovery
2536
- });
2537
- const pluginTokens = PluginTokenHandler.create({
2538
- ownPluginId: plugin.getId(),
2539
- keyDuration: { hours: 1 },
2540
- logger,
2541
- publicKeyStore,
2542
- discovery
2543
- });
2544
- const externalTokens = ExternalTokenHandler.create({
2545
- ownPluginId: plugin.getId(),
2546
- config,
2547
- logger
2821
+ const SECONDS_IN_MS$1 = 1e3;
2822
+ const KEY_EXPIRATION_MARGIN_FACTOR = 3;
2823
+ class DatabasePluginKeySource {
2824
+ constructor(keyStore, logger, keyDurationSeconds, algorithm) {
2825
+ this.keyStore = keyStore;
2826
+ this.logger = logger;
2827
+ this.keyDurationSeconds = keyDurationSeconds;
2828
+ this.algorithm = algorithm;
2829
+ }
2830
+ privateKeyPromise;
2831
+ keyExpiry;
2832
+ static async create(options) {
2833
+ const keyStore = await DatabaseKeyStore.create({
2834
+ database: options.database,
2835
+ logger: options.logger
2548
2836
  });
2549
- return new DefaultAuthService(
2550
- userTokens,
2551
- pluginTokens,
2552
- externalTokens,
2553
- tokenManager,
2554
- plugin.getId(),
2555
- disableDefaultAuthPolicy,
2556
- publicKeyStore
2837
+ return new DatabasePluginKeySource(
2838
+ keyStore,
2839
+ options.logger,
2840
+ Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
2841
+ options.algorithm ?? "ES256"
2842
+ );
2843
+ }
2844
+ async getPrivateSigningKey() {
2845
+ if (this.privateKeyPromise) {
2846
+ if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
2847
+ return this.privateKeyPromise;
2848
+ }
2849
+ this.logger.info(`Signing key has expired, generating new key`);
2850
+ delete this.privateKeyPromise;
2851
+ }
2852
+ this.keyExpiry = new Date(
2853
+ Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1
2557
2854
  );
2855
+ const promise = (async () => {
2856
+ const kid = uuid.v4();
2857
+ const key = await jose.generateKeyPair(this.algorithm);
2858
+ const publicKey = await jose.exportJWK(key.publicKey);
2859
+ const privateKey = await jose.exportJWK(key.privateKey);
2860
+ publicKey.kid = privateKey.kid = kid;
2861
+ publicKey.alg = privateKey.alg = this.algorithm;
2862
+ this.logger.info(`Created new signing key ${kid}`);
2863
+ await this.keyStore.addKey({
2864
+ id: kid,
2865
+ key: publicKey,
2866
+ expiresAt: new Date(
2867
+ Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1 * KEY_EXPIRATION_MARGIN_FACTOR
2868
+ )
2869
+ });
2870
+ return privateKey;
2871
+ })();
2872
+ this.privateKeyPromise = promise;
2873
+ try {
2874
+ await promise;
2875
+ } catch (error) {
2876
+ this.logger.error(`Failed to generate new signing key, ${error}`);
2877
+ delete this.keyExpiry;
2878
+ delete this.privateKeyPromise;
2879
+ }
2880
+ return promise;
2558
2881
  }
2559
- });
2560
-
2561
- const cacheServiceFactory = backendPluginApi.createServiceFactory({
2562
- service: backendPluginApi.coreServices.cache,
2563
- deps: {
2564
- config: backendPluginApi.coreServices.rootConfig,
2565
- logger: backendPluginApi.coreServices.rootLogger,
2566
- plugin: backendPluginApi.coreServices.pluginMetadata
2567
- },
2568
- async createRootContext({ config, logger }) {
2569
- return backendCommon.CacheManager.fromConfig(config, { logger });
2570
- },
2571
- async factory({ plugin }, manager) {
2572
- return manager.forPlugin(plugin.getId()).getClient();
2882
+ listKeys() {
2883
+ return this.keyStore.listKeys();
2573
2884
  }
2574
- });
2885
+ }
2575
2886
 
2576
- const rootConfigServiceFactory = backendPluginApi.createServiceFactory(
2577
- (options) => ({
2578
- service: backendPluginApi.coreServices.rootConfig,
2579
- deps: {},
2580
- async factory() {
2581
- const source = configLoader.ConfigSources.default({
2582
- argv: options?.argv,
2583
- remote: options?.remote,
2584
- watch: options?.watch
2585
- });
2586
- console.log(`Loading config from ${source}`);
2587
- return await configLoader.ConfigSources.toConfig(source);
2887
+ const DEFAULT_ALGORITHM = "ES256";
2888
+ const SECONDS_IN_MS = 1e3;
2889
+ class StaticConfigPluginKeySource {
2890
+ constructor(keyPairs, keyDurationSeconds) {
2891
+ this.keyPairs = keyPairs;
2892
+ this.keyDurationSeconds = keyDurationSeconds;
2893
+ }
2894
+ static async create(options) {
2895
+ const keyConfigs = options.sourceConfig.getConfigArray("static.keys").map((c) => {
2896
+ const staticKeyConfig = {
2897
+ publicKeyFile: c.getString("publicKeyFile"),
2898
+ privateKeyFile: c.getOptionalString("privateKeyFile"),
2899
+ keyId: c.getString("keyId"),
2900
+ algorithm: c.getOptionalString("algorithm") ?? DEFAULT_ALGORITHM
2901
+ };
2902
+ return staticKeyConfig;
2903
+ });
2904
+ const keyPairs = await Promise.all(
2905
+ keyConfigs.map(async (k) => await this.loadKeyPair(k))
2906
+ );
2907
+ if (keyPairs.length < 1) {
2908
+ throw new Error(
2909
+ "At least one key pair must be provided in static.keys, when the static key store type is used"
2910
+ );
2911
+ } else if (!keyPairs[0].privateKey) {
2912
+ throw new Error(
2913
+ "Private key for signing must be provided in the first key pair in static.keys, when the static key store type is used"
2914
+ );
2588
2915
  }
2589
- })
2590
- );
2591
-
2592
- const databaseServiceFactory = backendPluginApi.createServiceFactory({
2593
- service: backendPluginApi.coreServices.database,
2594
- deps: {
2595
- config: backendPluginApi.coreServices.rootConfig,
2596
- lifecycle: backendPluginApi.coreServices.lifecycle,
2597
- pluginMetadata: backendPluginApi.coreServices.pluginMetadata
2598
- },
2599
- async createRootContext({ config: config$1 }) {
2600
- return config$1.getOptional("backend.database") ? backendCommon.DatabaseManager.fromConfig(config$1) : backendCommon.DatabaseManager.fromConfig(
2601
- new config.ConfigReader({
2602
- backend: {
2603
- database: { client: "better-sqlite3", connection: ":memory:" }
2604
- }
2605
- })
2916
+ return new StaticConfigPluginKeySource(
2917
+ keyPairs,
2918
+ types.durationToMilliseconds(options.keyDuration) / SECONDS_IN_MS
2606
2919
  );
2607
- },
2608
- async factory({ pluginMetadata, lifecycle }, databaseManager) {
2609
- return databaseManager.forPlugin(pluginMetadata.getId(), {
2610
- pluginMetadata,
2611
- lifecycle
2920
+ }
2921
+ async getPrivateSigningKey() {
2922
+ return this.keyPairs[0].privateKey;
2923
+ }
2924
+ async listKeys() {
2925
+ const keys = this.keyPairs.map((k) => this.keyPairToStoredKey(k));
2926
+ return { keys };
2927
+ }
2928
+ static async loadKeyPair(options) {
2929
+ const algorithm = options.algorithm;
2930
+ const keyId = options.keyId;
2931
+ const publicKey = await this.loadPublicKeyFromFile(
2932
+ options.publicKeyFile,
2933
+ keyId,
2934
+ algorithm
2935
+ );
2936
+ const privateKey = options.privateKeyFile ? await this.loadPrivateKeyFromFile(
2937
+ options.privateKeyFile,
2938
+ keyId,
2939
+ algorithm
2940
+ ) : void 0;
2941
+ return { publicKey, privateKey, keyId };
2942
+ }
2943
+ static async loadPublicKeyFromFile(path, keyId, algorithm) {
2944
+ return this.loadKeyFromFile(path, keyId, algorithm, jose.importSPKI);
2945
+ }
2946
+ static async loadPrivateKeyFromFile(path, keyId, algorithm) {
2947
+ return this.loadKeyFromFile(path, keyId, algorithm, jose.importPKCS8);
2948
+ }
2949
+ static async loadKeyFromFile(path, keyId, algorithm, importer) {
2950
+ const content = await fs$1.promises.readFile(path, { encoding: "utf8", flag: "r" });
2951
+ const key = await importer(content, algorithm);
2952
+ const jwk = await jose.exportJWK(key);
2953
+ jwk.kid = keyId;
2954
+ jwk.alg = algorithm;
2955
+ return jwk;
2956
+ }
2957
+ keyPairToStoredKey(keyPair) {
2958
+ const publicKey = {
2959
+ ...keyPair.publicKey,
2960
+ kid: keyPair.keyId
2961
+ };
2962
+ return {
2963
+ key: publicKey,
2964
+ id: keyPair.keyId,
2965
+ expiresAt: new Date(Date.now() + this.keyDurationSeconds * SECONDS_IN_MS)
2966
+ };
2967
+ }
2968
+ }
2969
+
2970
+ const CONFIG_ROOT_KEY = "backend.auth.pluginKeyStore";
2971
+ async function createPluginKeySource(options) {
2972
+ const keyStoreConfig = options.config.getOptionalConfig(CONFIG_ROOT_KEY);
2973
+ const type = keyStoreConfig?.getOptionalString("type") ?? "database";
2974
+ if (!keyStoreConfig || type === "database") {
2975
+ return DatabasePluginKeySource.create({
2976
+ database: options.database,
2977
+ logger: options.logger,
2978
+ keyDuration: options.keyDuration,
2979
+ algorithm: options.algorithm
2980
+ });
2981
+ } else if (type === "static") {
2982
+ return StaticConfigPluginKeySource.create({
2983
+ sourceConfig: keyStoreConfig,
2984
+ keyDuration: options.keyDuration
2612
2985
  });
2613
2986
  }
2614
- });
2987
+ throw new Error(
2988
+ `Unsupported config value ${CONFIG_ROOT_KEY}.type '${type}'; expected one of 'database', 'static'`
2989
+ );
2990
+ }
2615
2991
 
2616
- class HostDiscovery {
2617
- constructor(internalBaseUrl, externalBaseUrl, discoveryConfig) {
2618
- this.internalBaseUrl = internalBaseUrl;
2619
- this.externalBaseUrl = externalBaseUrl;
2620
- this.discoveryConfig = discoveryConfig;
2992
+ class UserTokenHandler {
2993
+ constructor(jwksClient) {
2994
+ this.jwksClient = jwksClient;
2621
2995
  }
2622
- /**
2623
- * Creates a new HostDiscovery discovery instance by reading
2624
- * from the `backend` config section, specifically the `.baseUrl` for
2625
- * discovering the external URL, and the `.listen` and `.https` config
2626
- * for the internal one.
2627
- *
2628
- * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
2629
- * eg.
2630
- * ```yaml
2631
- * discovery:
2632
- * endpoints:
2633
- * - target: https://internal.example.com/internal-catalog
2634
- * plugins: [catalog]
2635
- * - target: https://internal.example.com/secure/api/{{pluginId}}
2636
- * plugins: [auth, permission]
2637
- * - target:
2638
- * internal: https://internal.example.com/search
2639
- * external: https://example.com/search
2640
- * plugins: [search]
2641
- * ```
2642
- *
2643
- * The basePath defaults to `/api`, meaning the default full internal
2644
- * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
2645
- */
2646
- static fromConfig(config, options) {
2647
- const basePath = options?.basePath ?? "/api";
2648
- const externalBaseUrl = config.getString("backend.baseUrl").replace(/\/+$/, "");
2649
- const {
2650
- listen: { host: listenHost = "::", port: listenPort }
2651
- } = backendAppApi.readHttpServerOptions(config.getConfig("backend"));
2652
- const protocol = config.has("backend.https") ? "https" : "http";
2653
- let host = listenHost;
2654
- if (host === "::" || host === "") {
2655
- host = "localhost";
2656
- } else if (host === "0.0.0.0") {
2657
- host = "127.0.0.1";
2996
+ static create(options) {
2997
+ const jwksClient = new JwksClient(async () => {
2998
+ const url = await options.discovery.getBaseUrl("auth");
2999
+ return new URL(`${url}/.well-known/jwks.json`);
3000
+ });
3001
+ return new UserTokenHandler(jwksClient);
3002
+ }
3003
+ async verifyToken(token) {
3004
+ const verifyOpts = this.#getTokenVerificationOptions(token);
3005
+ if (!verifyOpts) {
3006
+ return void 0;
2658
3007
  }
2659
- if (host.includes(":")) {
2660
- host = `[${host}]`;
3008
+ await this.jwksClient.refreshKeyStore(token);
3009
+ const { payload } = await jose.jwtVerify(
3010
+ token,
3011
+ this.jwksClient.getKey,
3012
+ verifyOpts
3013
+ ).catch((e) => {
3014
+ throw new errors.AuthenticationError("Invalid token", e);
3015
+ });
3016
+ const userEntityRef = payload.sub;
3017
+ if (!userEntityRef) {
3018
+ throw new errors.AuthenticationError("No user sub found in token");
2661
3019
  }
2662
- const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
2663
- return new HostDiscovery(
2664
- internalBaseUrl + basePath,
2665
- externalBaseUrl + basePath,
2666
- config.getOptionalConfig("discovery")
2667
- );
3020
+ return { userEntityRef };
2668
3021
  }
2669
- getTargetFromConfig(pluginId, type) {
2670
- const endpoints = this.discoveryConfig?.getOptionalConfigArray("endpoints");
2671
- const target = endpoints?.find((endpoint) => endpoint.getStringArray("plugins").includes(pluginId))?.get("target");
2672
- if (!target) {
2673
- const baseUrl = type === "external" ? this.externalBaseUrl : this.internalBaseUrl;
2674
- return `${baseUrl}/${encodeURIComponent(pluginId)}`;
3022
+ #getTokenVerificationOptions(token) {
3023
+ try {
3024
+ const { typ } = jose.decodeProtectedHeader(token);
3025
+ if (typ === pluginAuthNode.tokenTypes.user.typParam) {
3026
+ return {
3027
+ requiredClaims: ["iat", "exp", "sub"],
3028
+ typ: pluginAuthNode.tokenTypes.user.typParam
3029
+ };
3030
+ }
3031
+ if (typ === pluginAuthNode.tokenTypes.limitedUser.typParam) {
3032
+ return {
3033
+ requiredClaims: ["iat", "exp", "sub"],
3034
+ typ: pluginAuthNode.tokenTypes.limitedUser.typParam
3035
+ };
3036
+ }
3037
+ const { aud } = jose.decodeJwt(token);
3038
+ if (aud === pluginAuthNode.tokenTypes.user.audClaim) {
3039
+ return {
3040
+ audience: pluginAuthNode.tokenTypes.user.audClaim
3041
+ };
3042
+ }
3043
+ } catch {
2675
3044
  }
2676
- if (typeof target === "string") {
2677
- return target.replace(
2678
- /\{\{\s*pluginId\s*\}\}/g,
2679
- encodeURIComponent(pluginId)
3045
+ return void 0;
3046
+ }
3047
+ createLimitedUserToken(backstageToken) {
3048
+ const [headerRaw, payloadRaw] = backstageToken.split(".");
3049
+ const header = JSON.parse(
3050
+ new TextDecoder().decode(jose.base64url.decode(headerRaw))
3051
+ );
3052
+ const payload = JSON.parse(
3053
+ new TextDecoder().decode(jose.base64url.decode(payloadRaw))
3054
+ );
3055
+ const tokenType = header.typ;
3056
+ if (!tokenType || tokenType === pluginAuthNode.tokenTypes.limitedUser.typParam) {
3057
+ return { token: backstageToken, expiresAt: new Date(payload.exp * 1e3) };
3058
+ }
3059
+ if (tokenType !== pluginAuthNode.tokenTypes.user.typParam) {
3060
+ throw new errors.AuthenticationError(
3061
+ "Failed to create limited user token, invalid token type"
2680
3062
  );
2681
3063
  }
2682
- return target[type].replace(
2683
- /\{\{\s*pluginId\s*\}\}/g,
2684
- encodeURIComponent(pluginId)
2685
- );
2686
- }
2687
- async getBaseUrl(pluginId) {
2688
- return this.getTargetFromConfig(pluginId, "internal");
3064
+ const limitedUserToken = [
3065
+ jose.base64url.encode(
3066
+ JSON.stringify({
3067
+ typ: pluginAuthNode.tokenTypes.limitedUser.typParam,
3068
+ alg: header.alg,
3069
+ kid: header.kid
3070
+ })
3071
+ ),
3072
+ jose.base64url.encode(
3073
+ JSON.stringify({
3074
+ sub: payload.sub,
3075
+ iat: payload.iat,
3076
+ exp: payload.exp
3077
+ })
3078
+ ),
3079
+ payload.uip
3080
+ ].join(".");
3081
+ return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1e3) };
2689
3082
  }
2690
- async getExternalBaseUrl(pluginId) {
2691
- return this.getTargetFromConfig(pluginId, "external");
3083
+ isLimitedUserToken(token) {
3084
+ try {
3085
+ const { typ } = jose.decodeProtectedHeader(token);
3086
+ return typ === pluginAuthNode.tokenTypes.limitedUser.typParam;
3087
+ } catch {
3088
+ return false;
3089
+ }
2692
3090
  }
2693
3091
  }
2694
3092
 
2695
- const discoveryServiceFactory = backendPluginApi.createServiceFactory({
2696
- service: backendPluginApi.coreServices.discovery,
3093
+ const authServiceFactory$1 = backendPluginApi.createServiceFactory({
3094
+ service: backendPluginApi.coreServices.auth,
2697
3095
  deps: {
2698
- config: backendPluginApi.coreServices.rootConfig
3096
+ config: backendPluginApi.coreServices.rootConfig,
3097
+ logger: backendPluginApi.coreServices.rootLogger,
3098
+ discovery: backendPluginApi.coreServices.discovery,
3099
+ plugin: backendPluginApi.coreServices.pluginMetadata,
3100
+ database: backendPluginApi.coreServices.database,
3101
+ // Re-using the token manager makes sure that we use the same generated keys for
3102
+ // development as plugins that have not yet been migrated. It's important that this
3103
+ // keeps working as long as there are plugins that have not been migrated to the
3104
+ // new auth services in the new backend system.
3105
+ tokenManager: backendPluginApi.coreServices.tokenManager
2699
3106
  },
2700
- async factory({ config }) {
2701
- return HostDiscovery.fromConfig(config);
3107
+ async factory({ config, discovery, plugin, tokenManager, logger, database }) {
3108
+ const disableDefaultAuthPolicy = config.getOptionalBoolean(
3109
+ "backend.auth.dangerouslyDisableDefaultAuthPolicy"
3110
+ ) ?? false;
3111
+ const keyDuration = { hours: 1 };
3112
+ const keySource = await createPluginKeySource({
3113
+ config,
3114
+ database,
3115
+ logger,
3116
+ keyDuration
3117
+ });
3118
+ const userTokens = UserTokenHandler.create({
3119
+ discovery
3120
+ });
3121
+ const pluginTokens = PluginTokenHandler.create({
3122
+ ownPluginId: plugin.getId(),
3123
+ logger,
3124
+ keySource,
3125
+ keyDuration,
3126
+ discovery
3127
+ });
3128
+ const externalTokens = ExternalTokenHandler.create({
3129
+ ownPluginId: plugin.getId(),
3130
+ config,
3131
+ logger
3132
+ });
3133
+ return new DefaultAuthService(
3134
+ userTokens,
3135
+ pluginTokens,
3136
+ externalTokens,
3137
+ tokenManager,
3138
+ plugin.getId(),
3139
+ disableDefaultAuthPolicy,
3140
+ keySource
3141
+ );
2702
3142
  }
2703
3143
  });
2704
3144
 
3145
+ const authServiceFactory = authServiceFactory$1;
3146
+
2705
3147
  const FIVE_MINUTES_MS = 5 * 60 * 1e3;
2706
3148
  const BACKSTAGE_AUTH_COOKIE = "backstage-auth";
2707
3149
  function getTokenFromRequest(req) {
@@ -2874,7 +3316,7 @@ class DefaultHttpAuthService {
2874
3316
  }
2875
3317
  }
2876
3318
  }
2877
- const httpAuthServiceFactory = backendPluginApi.createServiceFactory({
3319
+ const httpAuthServiceFactory$1 = backendPluginApi.createServiceFactory({
2878
3320
  service: backendPluginApi.coreServices.httpAuth,
2879
3321
  deps: {
2880
3322
  auth: backendPluginApi.coreServices.auth,
@@ -2886,8 +3328,10 @@ const httpAuthServiceFactory = backendPluginApi.createServiceFactory({
2886
3328
  }
2887
3329
  });
2888
3330
 
3331
+ const httpAuthServiceFactory = httpAuthServiceFactory$1;
3332
+
2889
3333
  const DEFAULT_TIMEOUT = { seconds: 5 };
2890
- function createLifecycleMiddleware(options) {
3334
+ function createLifecycleMiddleware$1(options) {
2891
3335
  const { lifecycle, startupRequestPauseTimeout = DEFAULT_TIMEOUT } = options;
2892
3336
  let state = "init";
2893
3337
  const waiting = /* @__PURE__ */ new Set();
@@ -3011,7 +3455,7 @@ function createCookieAuthRefreshMiddleware(options) {
3011
3455
  return router;
3012
3456
  }
3013
3457
 
3014
- const httpRouterServiceFactory = backendPluginApi.createServiceFactory(
3458
+ const httpRouterServiceFactory$1 = backendPluginApi.createServiceFactory(
3015
3459
  (options) => ({
3016
3460
  service: backendPluginApi.coreServices.httpRouter,
3017
3461
  initialization: "always",
@@ -3047,7 +3491,7 @@ const httpRouterServiceFactory = backendPluginApi.createServiceFactory(
3047
3491
  config
3048
3492
  });
3049
3493
  router.use(createAuthIntegrationRouter({ auth }));
3050
- router.use(createLifecycleMiddleware({ lifecycle }));
3494
+ router.use(createLifecycleMiddleware$1({ lifecycle }));
3051
3495
  router.use(credentialsBarrier.middleware);
3052
3496
  router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
3053
3497
  return {
@@ -3062,76 +3506,11 @@ const httpRouterServiceFactory = backendPluginApi.createServiceFactory(
3062
3506
  })
3063
3507
  );
3064
3508
 
3065
- const identityServiceFactory = backendPluginApi.createServiceFactory(
3066
- (options) => ({
3067
- service: backendPluginApi.coreServices.identity,
3068
- deps: {
3069
- discovery: backendPluginApi.coreServices.discovery
3070
- },
3071
- async factory({ discovery }) {
3072
- return pluginAuthNode.DefaultIdentityClient.create({ discovery, ...options });
3073
- }
3074
- })
3075
- );
3509
+ const httpRouterServiceFactory = httpRouterServiceFactory$1;
3076
3510
 
3077
- class BackendPluginLifecycleImpl {
3078
- constructor(logger, rootLifecycle, pluginMetadata) {
3079
- this.logger = logger;
3080
- this.rootLifecycle = rootLifecycle;
3081
- this.pluginMetadata = pluginMetadata;
3082
- }
3083
- #hasStarted = false;
3084
- #startupTasks = [];
3085
- addStartupHook(hook, options) {
3086
- if (this.#hasStarted) {
3087
- throw new Error("Attempted to add startup hook after startup");
3088
- }
3089
- this.#startupTasks.push({ hook, options });
3090
- }
3091
- async startup() {
3092
- if (this.#hasStarted) {
3093
- return;
3094
- }
3095
- this.#hasStarted = true;
3096
- this.logger.debug(
3097
- `Running ${this.#startupTasks.length} plugin startup tasks...`
3098
- );
3099
- await Promise.all(
3100
- this.#startupTasks.map(async ({ hook, options }) => {
3101
- const logger = options?.logger ?? this.logger;
3102
- try {
3103
- await hook();
3104
- logger.debug(`Plugin startup hook succeeded`);
3105
- } catch (error) {
3106
- logger.error(`Plugin startup hook failed, ${error}`);
3107
- }
3108
- })
3109
- );
3110
- }
3111
- addShutdownHook(hook, options) {
3112
- const plugin = this.pluginMetadata.getId();
3113
- this.rootLifecycle.addShutdownHook(hook, {
3114
- logger: options?.logger?.child({ plugin }) ?? this.logger
3115
- });
3116
- }
3117
- }
3118
- const lifecycleServiceFactory = backendPluginApi.createServiceFactory({
3119
- service: backendPluginApi.coreServices.lifecycle,
3120
- deps: {
3121
- logger: backendPluginApi.coreServices.logger,
3122
- rootLifecycle: backendPluginApi.coreServices.rootLifecycle,
3123
- pluginMetadata: backendPluginApi.coreServices.pluginMetadata
3124
- },
3125
- async factory({ rootLifecycle, logger, pluginMetadata }) {
3126
- return new BackendPluginLifecycleImpl(
3127
- logger,
3128
- rootLifecycle,
3129
- pluginMetadata
3130
- );
3131
- }
3132
- });
3511
+ const createLifecycleMiddleware = createLifecycleMiddleware$1;
3133
3512
 
3134
- const loggerServiceFactory = backendPluginApi.createServiceFactory({
3513
+ const loggerServiceFactory$1 = backendPluginApi.createServiceFactory({
3135
3514
  service: backendPluginApi.coreServices.logger,
3136
3515
  deps: {
3137
3516
  rootLogger: backendPluginApi.coreServices.rootLogger,
@@ -3142,27 +3521,12 @@ const loggerServiceFactory = backendPluginApi.createServiceFactory({
3142
3521
  }
3143
3522
  });
3144
3523
 
3145
- const permissionsServiceFactory = backendPluginApi.createServiceFactory({
3146
- service: backendPluginApi.coreServices.permissions,
3147
- deps: {
3148
- auth: backendPluginApi.coreServices.auth,
3149
- config: backendPluginApi.coreServices.rootConfig,
3150
- discovery: backendPluginApi.coreServices.discovery,
3151
- tokenManager: backendPluginApi.coreServices.tokenManager
3152
- },
3153
- async factory({ auth, config, discovery, tokenManager }) {
3154
- return pluginPermissionNode.ServerPermissionClient.fromConfig(config, {
3155
- auth,
3156
- discovery,
3157
- tokenManager
3158
- });
3159
- }
3160
- });
3524
+ const loggerServiceFactory = loggerServiceFactory$1;
3161
3525
 
3162
3526
  function normalizePath(path) {
3163
3527
  return `${trimEnd__default.default(path, "/")}/`;
3164
3528
  }
3165
- class DefaultRootHttpRouter {
3529
+ let DefaultRootHttpRouter$1 = class DefaultRootHttpRouter {
3166
3530
  #indexPath;
3167
3531
  #router = express.Router();
3168
3532
  #namedRoutes = express.Router();
@@ -3223,12 +3587,12 @@ class DefaultRootHttpRouter {
3223
3587
  }
3224
3588
  return void 0;
3225
3589
  }
3226
- }
3590
+ };
3227
3591
 
3228
3592
  function defaultConfigure({ applyDefaults }) {
3229
3593
  applyDefaults();
3230
3594
  }
3231
- const rootHttpRouterServiceFactory = backendPluginApi.createServiceFactory(
3595
+ const rootHttpRouterServiceFactory$1 = backendPluginApi.createServiceFactory(
3232
3596
  (options) => ({
3233
3597
  service: backendPluginApi.coreServices.rootHttpRouter,
3234
3598
  deps: {
@@ -3240,12 +3604,12 @@ const rootHttpRouterServiceFactory = backendPluginApi.createServiceFactory(
3240
3604
  const { indexPath, configure = defaultConfigure } = options ?? {};
3241
3605
  const logger = rootLogger.child({ service: "rootHttpRouter" });
3242
3606
  const app = express__default.default();
3243
- const router = DefaultRootHttpRouter.create({ indexPath });
3244
- const middleware = MiddlewareFactory.create({ config, logger });
3607
+ const router = DefaultRootHttpRouter$1.create({ indexPath });
3608
+ const middleware = MiddlewareFactory$1.create({ config, logger });
3245
3609
  const routes = router.handler();
3246
- const server = await createHttpServer(
3610
+ const server = await createHttpServer$1(
3247
3611
  app,
3248
- readHttpServerOptions(config.getOptionalConfig("backend")),
3612
+ readHttpServerOptions$1(config.getOptionalConfig("backend")),
3249
3613
  { logger }
3250
3614
  );
3251
3615
  configure({
@@ -3273,128 +3637,46 @@ const rootHttpRouterServiceFactory = backendPluginApi.createServiceFactory(
3273
3637
  })
3274
3638
  );
3275
3639
 
3276
- class BackendLifecycleImpl {
3277
- constructor(logger) {
3278
- this.logger = logger;
3279
- }
3280
- #hasStarted = false;
3281
- #startupTasks = [];
3282
- addStartupHook(hook, options) {
3283
- if (this.#hasStarted) {
3284
- throw new Error("Attempted to add startup hook after startup");
3285
- }
3286
- this.#startupTasks.push({ hook, options });
3640
+ const rootHttpRouterServiceFactory = rootHttpRouterServiceFactory$1;
3641
+
3642
+ class DefaultRootHttpRouter {
3643
+ constructor(impl) {
3644
+ this.impl = impl;
3287
3645
  }
3288
- async startup() {
3289
- if (this.#hasStarted) {
3290
- return;
3291
- }
3292
- this.#hasStarted = true;
3293
- this.logger.debug(`Running ${this.#startupTasks.length} startup tasks...`);
3294
- await Promise.all(
3295
- this.#startupTasks.map(async ({ hook, options }) => {
3296
- const logger = options?.logger ?? this.logger;
3297
- try {
3298
- await hook();
3299
- logger.debug(`Startup hook succeeded`);
3300
- } catch (error) {
3301
- logger.error(`Startup hook failed, ${error}`);
3302
- }
3303
- })
3304
- );
3646
+ static create(options) {
3647
+ return new DefaultRootHttpRouter(DefaultRootHttpRouter$1.create(options));
3305
3648
  }
3306
- #hasShutdown = false;
3307
- #shutdownTasks = [];
3308
- addShutdownHook(hook, options) {
3309
- if (this.#hasShutdown) {
3310
- throw new Error("Attempted to add shutdown hook after shutdown");
3311
- }
3312
- this.#shutdownTasks.push({ hook, options });
3649
+ use(path, handler) {
3650
+ this.impl.use(path, handler);
3313
3651
  }
3314
- async shutdown() {
3315
- if (this.#hasShutdown) {
3316
- return;
3317
- }
3318
- this.#hasShutdown = true;
3319
- this.logger.debug(
3320
- `Running ${this.#shutdownTasks.length} shutdown tasks...`
3321
- );
3322
- await Promise.all(
3323
- this.#shutdownTasks.map(async ({ hook, options }) => {
3324
- const logger = options?.logger ?? this.logger;
3325
- try {
3326
- await hook();
3327
- logger.debug(`Shutdown hook succeeded`);
3328
- } catch (error) {
3329
- logger.error(`Shutdown hook failed, ${error}`);
3330
- }
3331
- })
3332
- );
3652
+ handler() {
3653
+ return this.impl.handler();
3333
3654
  }
3334
3655
  }
3335
- const rootLifecycleServiceFactory = backendPluginApi.createServiceFactory({
3336
- service: backendPluginApi.coreServices.rootLifecycle,
3337
- deps: {
3338
- logger: backendPluginApi.coreServices.rootLogger
3339
- },
3340
- async factory({ logger }) {
3341
- return new BackendLifecycleImpl(logger);
3342
- }
3343
- });
3344
-
3345
- const rootLoggerServiceFactory = backendPluginApi.createServiceFactory({
3346
- service: backendPluginApi.coreServices.rootLogger,
3347
- deps: {
3348
- config: backendPluginApi.coreServices.rootConfig
3349
- },
3350
- async factory({ config }) {
3351
- const logger = WinstonLogger.create({
3352
- meta: {
3353
- service: "backstage"
3354
- },
3355
- level: process.env.LOG_LEVEL || "info",
3356
- format: process.env.NODE_ENV === "production" ? winston.format.json() : WinstonLogger.colorFormat(),
3357
- transports: [new winston.transports.Console()]
3358
- });
3359
- const secretEnumerator = await createConfigSecretEnumerator({ logger });
3360
- logger.addRedactions(secretEnumerator(config));
3361
- config.subscribe?.(() => logger.addRedactions(secretEnumerator(config)));
3362
- return logger;
3363
- }
3364
- });
3365
3656
 
3366
- const tokenManagerServiceFactory = backendPluginApi.createServiceFactory({
3367
- service: backendPluginApi.coreServices.tokenManager,
3368
- deps: {
3369
- config: backendPluginApi.coreServices.rootConfig,
3370
- logger: backendPluginApi.coreServices.rootLogger
3371
- },
3372
- createRootContext({ config, logger }) {
3373
- return backendCommon.ServerTokenManager.fromConfig(config, {
3374
- logger,
3375
- allowDisabledTokenManager: true
3376
- });
3377
- },
3378
- async factory(_deps, tokenManager) {
3379
- return tokenManager;
3380
- }
3381
- });
3657
+ const rootLoggerServiceFactory = rootLoggerServiceFactory$1;
3382
3658
 
3383
- const urlReaderServiceFactory = backendPluginApi.createServiceFactory({
3384
- service: backendPluginApi.coreServices.urlReader,
3659
+ const schedulerServiceFactory = backendPluginApi.createServiceFactory({
3660
+ service: backendPluginApi.coreServices.scheduler,
3385
3661
  deps: {
3386
- config: backendPluginApi.coreServices.rootConfig,
3662
+ plugin: backendPluginApi.coreServices.pluginMetadata,
3663
+ databaseManager: backendPluginApi.coreServices.database,
3387
3664
  logger: backendPluginApi.coreServices.logger
3388
3665
  },
3389
- async factory({ config, logger }) {
3390
- return backendCommon.UrlReaders.default({
3391
- config,
3666
+ async factory({ plugin, databaseManager, logger }) {
3667
+ return backendTasks.TaskScheduler.forPlugin({
3668
+ pluginId: plugin.getId(),
3669
+ databaseManager,
3392
3670
  logger
3393
3671
  });
3394
3672
  }
3395
3673
  });
3396
3674
 
3397
3675
  class DefaultUserInfoService {
3676
+ discovery;
3677
+ constructor(options) {
3678
+ this.discovery = options.discovery;
3679
+ }
3398
3680
  async getUserInfo(credentials) {
3399
3681
  const internalCredentials = toInternalBackstageCredentials(credentials);
3400
3682
  if (internalCredentials.principal.type !== "user") {
@@ -3403,42 +3685,51 @@ class DefaultUserInfoService {
3403
3685
  if (!internalCredentials.token) {
3404
3686
  throw new Error("User credentials is unexpectedly missing token");
3405
3687
  }
3406
- const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = jose.decodeJwt(
3688
+ const { sub: userEntityRef, ent: tokenEnt } = jose.decodeJwt(
3407
3689
  internalCredentials.token
3408
3690
  );
3409
3691
  if (typeof userEntityRef !== "string") {
3410
3692
  throw new Error("User entity ref must be a string");
3411
3693
  }
3412
- if (!Array.isArray(ownershipEntityRefs) || ownershipEntityRefs.some((ref) => typeof ref !== "string")) {
3694
+ let ownershipEntityRefs = tokenEnt;
3695
+ if (!ownershipEntityRefs) {
3696
+ const userInfoResp = await fetch__default.default(
3697
+ `${await this.discovery.getBaseUrl("auth")}/v1/userinfo`,
3698
+ {
3699
+ headers: {
3700
+ Authorization: `Bearer ${internalCredentials.token}`
3701
+ }
3702
+ }
3703
+ );
3704
+ if (!userInfoResp.ok) {
3705
+ throw await errors.ResponseError.fromResponse(userInfoResp);
3706
+ }
3707
+ const {
3708
+ claims: { ent }
3709
+ } = await userInfoResp.json();
3710
+ ownershipEntityRefs = ent;
3711
+ }
3712
+ if (!ownershipEntityRefs) {
3713
+ throw new Error("Ownership entity refs can not be determined");
3714
+ } else if (!Array.isArray(ownershipEntityRefs) || ownershipEntityRefs.some((ref) => typeof ref !== "string")) {
3413
3715
  throw new Error("Ownership entity refs must be an array of strings");
3414
3716
  }
3415
3717
  return { userEntityRef, ownershipEntityRefs };
3416
3718
  }
3417
3719
  }
3418
- const userInfoServiceFactory = backendPluginApi.createServiceFactory({
3419
- service: backendPluginApi.coreServices.userInfo,
3420
- deps: {},
3421
- async factory() {
3422
- return new DefaultUserInfoService();
3423
- }
3424
- });
3425
3720
 
3426
- const schedulerServiceFactory = backendPluginApi.createServiceFactory({
3427
- service: backendPluginApi.coreServices.scheduler,
3721
+ const userInfoServiceFactory$1 = backendPluginApi.createServiceFactory({
3722
+ service: backendPluginApi.coreServices.userInfo,
3428
3723
  deps: {
3429
- plugin: backendPluginApi.coreServices.pluginMetadata,
3430
- databaseManager: backendPluginApi.coreServices.database,
3431
- logger: backendPluginApi.coreServices.logger
3724
+ discovery: backendPluginApi.coreServices.discovery
3432
3725
  },
3433
- async factory({ plugin, databaseManager, logger }) {
3434
- return backendTasks.TaskScheduler.forPlugin({
3435
- pluginId: plugin.getId(),
3436
- databaseManager,
3437
- logger
3438
- });
3726
+ async factory({ discovery }) {
3727
+ return new DefaultUserInfoService({ discovery });
3439
3728
  }
3440
3729
  });
3441
3730
 
3731
+ const userInfoServiceFactory = userInfoServiceFactory$1;
3732
+
3442
3733
  exports.DefaultRootHttpRouter = DefaultRootHttpRouter;
3443
3734
  exports.HostDiscovery = HostDiscovery;
3444
3735
  exports.MiddlewareFactory = MiddlewareFactory;