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

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,118 @@ 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
+ class MiddlewareFactory {
774
+ constructor(impl) {
775
+ this.impl = impl;
776
+ }
777
+ /**
778
+ * Creates a new {@link MiddlewareFactory}.
779
+ */
780
+ static create(options) {
781
+ return MiddlewareFactory$1.create(options);
782
+ }
783
+ /**
784
+ * Returns a middleware that unconditionally produces a 404 error response.
785
+ *
786
+ * @remarks
787
+ *
788
+ * Typically you want to place this middleware at the end of the chain, such
789
+ * that it's the last one attempted after no other routes matched.
790
+ *
791
+ * @returns An Express request handler
792
+ */
793
+ notFound() {
794
+ return this.impl.notFound();
795
+ }
796
+ /**
797
+ * Returns the compression middleware.
798
+ *
799
+ * @remarks
800
+ *
801
+ * The middleware will attempt to compress response bodies for all requests
802
+ * that traverse through the middleware.
803
+ */
804
+ compression() {
805
+ return this.impl.compression();
806
+ }
807
+ /**
808
+ * Returns a request logging middleware.
809
+ *
810
+ * @remarks
811
+ *
812
+ * Typically you want to place this middleware at the start of the chain, such
813
+ * that it always logs requests whether they are "caught" by handlers farther
814
+ * down or not.
815
+ *
816
+ * @returns An Express request handler
817
+ */
818
+ logging() {
819
+ return this.impl.logging();
820
+ }
821
+ /**
822
+ * Returns a middleware that implements the helmet library.
823
+ *
824
+ * @remarks
825
+ *
826
+ * This middleware applies security policies to incoming requests and outgoing
827
+ * responses. It is configured using config keys such as `backend.csp`.
828
+ *
829
+ * @see {@link https://helmetjs.github.io/}
830
+ *
831
+ * @returns An Express request handler
832
+ */
833
+ helmet() {
834
+ return this.impl.helmet();
835
+ }
836
+ /**
837
+ * Returns a middleware that implements the cors library.
838
+ *
839
+ * @remarks
840
+ *
841
+ * This middleware handles CORS. It is configured using the config key
842
+ * `backend.cors`.
843
+ *
844
+ * @see {@link https://github.com/expressjs/cors}
845
+ *
846
+ * @returns An Express request handler
847
+ */
848
+ cors() {
849
+ return this.impl.cors();
850
+ }
851
+ /**
852
+ * Express middleware to handle errors during request processing.
853
+ *
854
+ * @remarks
855
+ *
856
+ * This is commonly the very last middleware in the chain.
857
+ *
858
+ * Its primary purpose is not to do translation of business logic exceptions,
859
+ * but rather to be a global catch-all for uncaught "fatal" errors that are
860
+ * expected to result in a 500 error. However, it also does handle some common
861
+ * error types (such as http-error exceptions, and the well-known error types
862
+ * in the `@backstage/errors` package) and returns the enclosed status code
863
+ * accordingly.
864
+ *
865
+ * It will also produce a response body with a serialized form of the error,
866
+ * unless a previous handler already did send a body. See
867
+ * {@link @backstage/errors#ErrorResponseBody} for the response shape used.
868
+ *
869
+ * @returns An Express error request handler
870
+ */
871
+ error(options = {}) {
872
+ return this.impl.error(options);
873
+ }
874
+ }
875
+
765
876
  const escapeRegExp = (text) => {
766
877
  return text.replace(/[.*+?^${}(\)|[\]\\]/g, "\\$&");
767
878
  };
768
879
 
769
- class WinstonLogger {
880
+ let WinstonLogger$1 = class WinstonLogger {
770
881
  #winston;
771
882
  #addRedactions;
772
883
  /**
@@ -870,6 +981,69 @@ class WinstonLogger {
870
981
  addRedactions(redactions) {
871
982
  this.#addRedactions?.(redactions);
872
983
  }
984
+ };
985
+
986
+ const rootLoggerServiceFactory$1 = backendPluginApi.createServiceFactory({
987
+ service: backendPluginApi.coreServices.rootLogger,
988
+ deps: {
989
+ config: backendPluginApi.coreServices.rootConfig
990
+ },
991
+ async factory({ config }) {
992
+ const logger = WinstonLogger$1.create({
993
+ meta: {
994
+ service: "backstage"
995
+ },
996
+ level: process.env.LOG_LEVEL || "info",
997
+ format: process.env.NODE_ENV === "production" ? winston.format.json() : WinstonLogger$1.colorFormat(),
998
+ transports: [new winston.transports.Console()]
999
+ });
1000
+ const secretEnumerator = await createConfigSecretEnumerator$1({ logger });
1001
+ logger.addRedactions(secretEnumerator(config));
1002
+ config.subscribe?.(() => logger.addRedactions(secretEnumerator(config)));
1003
+ return logger;
1004
+ }
1005
+ });
1006
+
1007
+ class WinstonLogger {
1008
+ constructor(impl) {
1009
+ this.impl = impl;
1010
+ }
1011
+ /**
1012
+ * Creates a {@link WinstonLogger} instance.
1013
+ */
1014
+ static create(options) {
1015
+ return new WinstonLogger(WinstonLogger$1.create(options));
1016
+ }
1017
+ /**
1018
+ * Creates a winston log formatter for redacting secrets.
1019
+ */
1020
+ static redacter() {
1021
+ return WinstonLogger$1.redacter();
1022
+ }
1023
+ /**
1024
+ * Creates a pretty printed winston log formatter.
1025
+ */
1026
+ static colorFormat() {
1027
+ return WinstonLogger$1.colorFormat();
1028
+ }
1029
+ error(message, meta) {
1030
+ this.impl.error(message, meta);
1031
+ }
1032
+ warn(message, meta) {
1033
+ this.impl.warn(message, meta);
1034
+ }
1035
+ info(message, meta) {
1036
+ this.impl.info(message, meta);
1037
+ }
1038
+ debug(message, meta) {
1039
+ this.impl.debug(message, meta);
1040
+ }
1041
+ child(meta) {
1042
+ return this.impl.child(meta);
1043
+ }
1044
+ addRedactions(redactions) {
1045
+ this.impl.addRedactions(redactions);
1046
+ }
873
1047
  }
874
1048
 
875
1049
  class Node {
@@ -1633,69 +1807,384 @@ function createSpecializedBackend(options) {
1633
1807
  return new BackstageBackend(services);
1634
1808
  }
1635
1809
 
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;
1810
+ const cacheServiceFactory = backendPluginApi.createServiceFactory({
1811
+ service: backendPluginApi.coreServices.cache,
1812
+ deps: {
1813
+ config: backendPluginApi.coreServices.rootConfig,
1814
+ logger: backendPluginApi.coreServices.rootLogger,
1815
+ plugin: backendPluginApi.coreServices.pluginMetadata
1816
+ },
1817
+ async createRootContext({ config, logger }) {
1818
+ return backendCommon.CacheManager.fromConfig(config, { logger });
1819
+ },
1820
+ async factory({ plugin }, manager) {
1821
+ return manager.forPlugin(plugin.getId()).getClient();
1652
1822
  }
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);
1823
+ });
1824
+
1825
+ const rootConfigServiceFactory = backendPluginApi.createServiceFactory(
1826
+ (options) => ({
1827
+ service: backendPluginApi.coreServices.rootConfig,
1828
+ deps: {},
1829
+ async factory() {
1830
+ const source = configLoader.ConfigSources.default({
1831
+ argv: options?.argv,
1832
+ remote: options?.remote,
1833
+ watch: options?.watch
1834
+ });
1835
+ console.log(`Loading config from ${source}`);
1836
+ return await configLoader.ConfigSources.toConfig(source);
1658
1837
  }
1659
- return new DatabaseKeyStore(client, logger);
1838
+ })
1839
+ );
1840
+
1841
+ const databaseServiceFactory = backendPluginApi.createServiceFactory({
1842
+ service: backendPluginApi.coreServices.database,
1843
+ deps: {
1844
+ config: backendPluginApi.coreServices.rootConfig,
1845
+ lifecycle: backendPluginApi.coreServices.lifecycle,
1846
+ pluginMetadata: backendPluginApi.coreServices.pluginMetadata
1847
+ },
1848
+ async createRootContext({ config: config$1 }) {
1849
+ return config$1.getOptional("backend.database") ? backendCommon.DatabaseManager.fromConfig(config$1) : backendCommon.DatabaseManager.fromConfig(
1850
+ new config.ConfigReader({
1851
+ backend: {
1852
+ database: { client: "better-sqlite3", connection: ":memory:" }
1853
+ }
1854
+ })
1855
+ );
1856
+ },
1857
+ async factory({ pluginMetadata, lifecycle }, databaseManager) {
1858
+ return databaseManager.forPlugin(pluginMetadata.getId(), {
1859
+ pluginMetadata,
1860
+ lifecycle
1861
+ });
1660
1862
  }
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()
1863
+ });
1864
+
1865
+ let HostDiscovery$1 = class HostDiscovery {
1866
+ constructor(internalBaseUrl, externalBaseUrl, discoveryConfig) {
1867
+ this.internalBaseUrl = internalBaseUrl;
1868
+ this.externalBaseUrl = externalBaseUrl;
1869
+ this.discoveryConfig = discoveryConfig;
1870
+ }
1871
+ /**
1872
+ * Creates a new HostDiscovery discovery instance by reading
1873
+ * from the `backend` config section, specifically the `.baseUrl` for
1874
+ * discovering the external URL, and the `.listen` and `.https` config
1875
+ * for the internal one.
1876
+ *
1877
+ * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
1878
+ * eg.
1879
+ * ```yaml
1880
+ * discovery:
1881
+ * endpoints:
1882
+ * - target: https://internal.example.com/internal-catalog
1883
+ * plugins: [catalog]
1884
+ * - target: https://internal.example.com/secure/api/{{pluginId}}
1885
+ * plugins: [auth, permission]
1886
+ * - target:
1887
+ * internal: https://internal.example.com/search
1888
+ * external: https://example.com/search
1889
+ * plugins: [search]
1890
+ * ```
1891
+ *
1892
+ * The basePath defaults to `/api`, meaning the default full internal
1893
+ * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
1894
+ */
1895
+ static fromConfig(config, options) {
1896
+ const basePath = options?.basePath ?? "/api";
1897
+ const externalBaseUrl = config.getString("backend.baseUrl").replace(/\/+$/, "");
1898
+ const {
1899
+ listen: { host: listenHost = "::", port: listenPort }
1900
+ } = readHttpServerOptions$1(config.getConfig("backend"));
1901
+ const protocol = config.has("backend.https") ? "https" : "http";
1902
+ let host = listenHost;
1903
+ if (host === "::" || host === "") {
1904
+ host = "localhost";
1905
+ } else if (host === "0.0.0.0") {
1906
+ host = "127.0.0.1";
1907
+ }
1908
+ if (host.includes(":")) {
1909
+ host = `[${host}]`;
1910
+ }
1911
+ const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
1912
+ return new HostDiscovery(
1913
+ internalBaseUrl + basePath,
1914
+ externalBaseUrl + basePath,
1915
+ config.getOptionalConfig("discovery")
1916
+ );
1917
+ }
1918
+ getTargetFromConfig(pluginId, type) {
1919
+ const endpoints = this.discoveryConfig?.getOptionalConfigArray("endpoints");
1920
+ const target = endpoints?.find((endpoint) => endpoint.getStringArray("plugins").includes(pluginId))?.get("target");
1921
+ if (!target) {
1922
+ const baseUrl = type === "external" ? this.externalBaseUrl : this.internalBaseUrl;
1923
+ return `${baseUrl}/${encodeURIComponent(pluginId)}`;
1924
+ }
1925
+ if (typeof target === "string") {
1926
+ return target.replace(
1927
+ /\{\{\s*pluginId\s*\}\}/g,
1928
+ encodeURIComponent(pluginId)
1929
+ );
1930
+ }
1931
+ return target[type].replace(
1932
+ /\{\{\s*pluginId\s*\}\}/g,
1933
+ encodeURIComponent(pluginId)
1934
+ );
1935
+ }
1936
+ async getBaseUrl(pluginId) {
1937
+ return this.getTargetFromConfig(pluginId, "internal");
1938
+ }
1939
+ async getExternalBaseUrl(pluginId) {
1940
+ return this.getTargetFromConfig(pluginId, "external");
1941
+ }
1942
+ };
1943
+
1944
+ backendPluginApi.createServiceFactory({
1945
+ service: backendPluginApi.coreServices.discovery,
1946
+ deps: {
1947
+ config: backendPluginApi.coreServices.rootConfig
1948
+ },
1949
+ async factory({ config }) {
1950
+ return HostDiscovery$1.fromConfig(config);
1951
+ }
1952
+ });
1953
+
1954
+ class HostDiscovery {
1955
+ constructor(impl) {
1956
+ this.impl = impl;
1957
+ }
1958
+ /**
1959
+ * Creates a new HostDiscovery discovery instance by reading
1960
+ * from the `backend` config section, specifically the `.baseUrl` for
1961
+ * discovering the external URL, and the `.listen` and `.https` config
1962
+ * for the internal one.
1963
+ *
1964
+ * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
1965
+ * eg.
1966
+ * ```yaml
1967
+ * discovery:
1968
+ * endpoints:
1969
+ * - target: https://internal.example.com/internal-catalog
1970
+ * plugins: [catalog]
1971
+ * - target: https://internal.example.com/secure/api/{{pluginId}}
1972
+ * plugins: [auth, permission]
1973
+ * - target:
1974
+ * internal: https://internal.example.com/search
1975
+ * external: https://example.com/search
1976
+ * plugins: [search]
1977
+ * ```
1978
+ *
1979
+ * The basePath defaults to `/api`, meaning the default full internal
1980
+ * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
1981
+ */
1982
+ static fromConfig(config, options) {
1983
+ return new HostDiscovery(HostDiscovery$1.fromConfig(config, options));
1984
+ }
1985
+ async getBaseUrl(pluginId) {
1986
+ return this.impl.getBaseUrl(pluginId);
1987
+ }
1988
+ async getExternalBaseUrl(pluginId) {
1989
+ return this.impl.getExternalBaseUrl(pluginId);
1990
+ }
1991
+ }
1992
+
1993
+ const discoveryServiceFactory = backendPluginApi.createServiceFactory({
1994
+ service: backendPluginApi.coreServices.discovery,
1995
+ deps: {
1996
+ config: backendPluginApi.coreServices.rootConfig
1997
+ },
1998
+ async factory({ config }) {
1999
+ return HostDiscovery.fromConfig(config);
2000
+ }
2001
+ });
2002
+
2003
+ const identityServiceFactory = backendPluginApi.createServiceFactory(
2004
+ (options) => ({
2005
+ service: backendPluginApi.coreServices.identity,
2006
+ deps: {
2007
+ discovery: backendPluginApi.coreServices.discovery
2008
+ },
2009
+ async factory({ discovery }) {
2010
+ return pluginAuthNode.DefaultIdentityClient.create({ discovery, ...options });
2011
+ }
2012
+ })
2013
+ );
2014
+
2015
+ class BackendPluginLifecycleImpl {
2016
+ constructor(logger, rootLifecycle, pluginMetadata) {
2017
+ this.logger = logger;
2018
+ this.rootLifecycle = rootLifecycle;
2019
+ this.pluginMetadata = pluginMetadata;
2020
+ }
2021
+ #hasStarted = false;
2022
+ #startupTasks = [];
2023
+ addStartupHook(hook, options) {
2024
+ if (this.#hasStarted) {
2025
+ throw new Error("Attempted to add startup hook after startup");
2026
+ }
2027
+ this.#startupTasks.push({ hook, options });
2028
+ }
2029
+ async startup() {
2030
+ if (this.#hasStarted) {
2031
+ return;
2032
+ }
2033
+ this.#hasStarted = true;
2034
+ this.logger.debug(
2035
+ `Running ${this.#startupTasks.length} plugin startup tasks...`
2036
+ );
2037
+ await Promise.all(
2038
+ this.#startupTasks.map(async ({ hook, options }) => {
2039
+ const logger = options?.logger ?? this.logger;
2040
+ try {
2041
+ await hook();
2042
+ logger.debug(`Plugin startup hook succeeded`);
2043
+ } catch (error) {
2044
+ logger.error(`Plugin startup hook failed, ${error}`);
2045
+ }
2046
+ })
2047
+ );
2048
+ }
2049
+ addShutdownHook(hook, options) {
2050
+ const plugin = this.pluginMetadata.getId();
2051
+ this.rootLifecycle.addShutdownHook(hook, {
2052
+ logger: options?.logger?.child({ plugin }) ?? this.logger
2053
+ });
2054
+ }
2055
+ }
2056
+ const lifecycleServiceFactory = backendPluginApi.createServiceFactory({
2057
+ service: backendPluginApi.coreServices.lifecycle,
2058
+ deps: {
2059
+ logger: backendPluginApi.coreServices.logger,
2060
+ rootLifecycle: backendPluginApi.coreServices.rootLifecycle,
2061
+ pluginMetadata: backendPluginApi.coreServices.pluginMetadata
2062
+ },
2063
+ async factory({ rootLifecycle, logger, pluginMetadata }) {
2064
+ return new BackendPluginLifecycleImpl(
2065
+ logger,
2066
+ rootLifecycle,
2067
+ pluginMetadata
2068
+ );
2069
+ }
2070
+ });
2071
+
2072
+ const permissionsServiceFactory = backendPluginApi.createServiceFactory({
2073
+ service: backendPluginApi.coreServices.permissions,
2074
+ deps: {
2075
+ auth: backendPluginApi.coreServices.auth,
2076
+ config: backendPluginApi.coreServices.rootConfig,
2077
+ discovery: backendPluginApi.coreServices.discovery,
2078
+ tokenManager: backendPluginApi.coreServices.tokenManager
2079
+ },
2080
+ async factory({ auth, config, discovery, tokenManager }) {
2081
+ return pluginPermissionNode.ServerPermissionClient.fromConfig(config, {
2082
+ auth,
2083
+ discovery,
2084
+ tokenManager
2085
+ });
2086
+ }
2087
+ });
2088
+
2089
+ class BackendLifecycleImpl {
2090
+ constructor(logger) {
2091
+ this.logger = logger;
2092
+ }
2093
+ #hasStarted = false;
2094
+ #startupTasks = [];
2095
+ addStartupHook(hook, options) {
2096
+ if (this.#hasStarted) {
2097
+ throw new Error("Attempted to add startup hook after startup");
2098
+ }
2099
+ this.#startupTasks.push({ hook, options });
2100
+ }
2101
+ async startup() {
2102
+ if (this.#hasStarted) {
2103
+ return;
2104
+ }
2105
+ this.#hasStarted = true;
2106
+ this.logger.debug(`Running ${this.#startupTasks.length} startup tasks...`);
2107
+ await Promise.all(
2108
+ this.#startupTasks.map(async ({ hook, options }) => {
2109
+ const logger = options?.logger ?? this.logger;
2110
+ try {
2111
+ await hook();
2112
+ logger.debug(`Startup hook succeeded`);
2113
+ } catch (error) {
2114
+ logger.error(`Startup hook failed, ${error}`);
2115
+ }
2116
+ })
2117
+ );
2118
+ }
2119
+ #hasShutdown = false;
2120
+ #shutdownTasks = [];
2121
+ addShutdownHook(hook, options) {
2122
+ if (this.#hasShutdown) {
2123
+ throw new Error("Attempted to add shutdown hook after shutdown");
2124
+ }
2125
+ this.#shutdownTasks.push({ hook, options });
2126
+ }
2127
+ async shutdown() {
2128
+ if (this.#hasShutdown) {
2129
+ return;
2130
+ }
2131
+ this.#hasShutdown = true;
2132
+ this.logger.debug(
2133
+ `Running ${this.#shutdownTasks.length} shutdown tasks...`
2134
+ );
2135
+ await Promise.all(
2136
+ this.#shutdownTasks.map(async ({ hook, options }) => {
2137
+ const logger = options?.logger ?? this.logger;
2138
+ try {
2139
+ await hook();
2140
+ logger.debug(`Shutdown hook succeeded`);
2141
+ } catch (error) {
2142
+ logger.error(`Shutdown hook failed, ${error}`);
2143
+ }
2144
+ })
2145
+ );
2146
+ }
2147
+ }
2148
+ const rootLifecycleServiceFactory = backendPluginApi.createServiceFactory({
2149
+ service: backendPluginApi.coreServices.rootLifecycle,
2150
+ deps: {
2151
+ logger: backendPluginApi.coreServices.rootLogger
2152
+ },
2153
+ async factory({ logger }) {
2154
+ return new BackendLifecycleImpl(logger);
2155
+ }
2156
+ });
2157
+
2158
+ const tokenManagerServiceFactory = backendPluginApi.createServiceFactory({
2159
+ service: backendPluginApi.coreServices.tokenManager,
2160
+ deps: {
2161
+ config: backendPluginApi.coreServices.rootConfig,
2162
+ logger: backendPluginApi.coreServices.rootLogger
2163
+ },
2164
+ createRootContext({ config, logger }) {
2165
+ return backendCommon.ServerTokenManager.fromConfig(config, {
2166
+ logger,
2167
+ allowDisabledTokenManager: true
1666
2168
  });
2169
+ },
2170
+ async factory(_deps, tokenManager) {
2171
+ return tokenManager;
1667
2172
  }
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
- );
1694
- });
1695
- }
1696
- return { keys: validKeys };
2173
+ });
2174
+
2175
+ const urlReaderServiceFactory = backendPluginApi.createServiceFactory({
2176
+ service: backendPluginApi.coreServices.urlReader,
2177
+ deps: {
2178
+ config: backendPluginApi.coreServices.rootConfig,
2179
+ logger: backendPluginApi.coreServices.logger
2180
+ },
2181
+ async factory({ config, logger }) {
2182
+ return backendCommon.UrlReaders.default({
2183
+ config,
2184
+ logger
2185
+ });
1697
2186
  }
1698
- }
2187
+ });
1699
2188
 
1700
2189
  function createCredentialsWithServicePrincipal(sub, token, accessRestrictions) {
1701
2190
  return {
@@ -1744,17 +2233,16 @@ function toInternalBackstageCredentials(credentials) {
1744
2233
  }
1745
2234
 
1746
2235
  class DefaultAuthService {
1747
- constructor(userTokenHandler, pluginTokenHandler, externalTokenHandler, tokenManager, pluginId, disableDefaultAuthPolicy, publicKeyStore) {
2236
+ constructor(userTokenHandler, pluginTokenHandler, externalTokenHandler, tokenManager, pluginId, disableDefaultAuthPolicy, pluginKeySource) {
1748
2237
  this.userTokenHandler = userTokenHandler;
1749
2238
  this.pluginTokenHandler = pluginTokenHandler;
1750
2239
  this.externalTokenHandler = externalTokenHandler;
1751
2240
  this.tokenManager = tokenManager;
1752
2241
  this.pluginId = pluginId;
1753
2242
  this.disableDefaultAuthPolicy = disableDefaultAuthPolicy;
1754
- this.publicKeyStore = publicKeyStore;
2243
+ this.pluginKeySource = pluginKeySource;
1755
2244
  }
1756
- // allowLimitedAccess is currently ignored, since we currently always use the full user tokens
1757
- async authenticate(token) {
2245
+ async authenticate(token, options) {
1758
2246
  const pluginResult = await this.pluginTokenHandler.verifyToken(token);
1759
2247
  if (pluginResult) {
1760
2248
  if (pluginResult.limitedUserToken) {
@@ -1776,6 +2264,9 @@ class DefaultAuthService {
1776
2264
  }
1777
2265
  const userResult = await this.userTokenHandler.verifyToken(token);
1778
2266
  if (userResult) {
2267
+ if (!options?.allowLimitedAccess && this.userTokenHandler.isLimitedUserToken(token)) {
2268
+ throw new errors.AuthenticationError("Illegal limited user token");
2269
+ }
1779
2270
  return createCredentialsWithUserPrincipal(
1780
2271
  userResult.userEntityRef,
1781
2272
  token,
@@ -1788,920 +2279,972 @@ class DefaultAuthService {
1788
2279
  externalResult.subject,
1789
2280
  void 0,
1790
2281
  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 };
2282
+ );
2283
+ }
2284
+ throw new errors.AuthenticationError("Illegal token");
2004
2285
  }
2005
- async isTargetPluginSupported(targetPluginId) {
2006
- if (this.supportedTargetPlugins.has(targetPluginId)) {
2286
+ isPrincipal(credentials, type) {
2287
+ const principal = credentials.principal;
2288
+ if (type === "unknown") {
2007
2289
  return true;
2008
2290
  }
2009
- const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
2010
- if (inFlight) {
2011
- return inFlight;
2291
+ if (principal.type !== type) {
2292
+ return false;
2012
2293
  }
2013
- const doCheck = async () => {
2014
- try {
2015
- const res = await fetch(
2016
- `${await this.discovery.getBaseUrl(
2294
+ return true;
2295
+ }
2296
+ async getNoneCredentials() {
2297
+ return createCredentialsWithNonePrincipal();
2298
+ }
2299
+ async getOwnServiceCredentials() {
2300
+ return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`);
2301
+ }
2302
+ async getPluginRequestToken(options) {
2303
+ const { targetPluginId } = options;
2304
+ const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
2305
+ const { type } = internalForward.principal;
2306
+ if (type === "none" && this.disableDefaultAuthPolicy) {
2307
+ return { token: "" };
2308
+ }
2309
+ const targetSupportsNewAuth = await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId);
2310
+ switch (type) {
2311
+ case "service":
2312
+ if (targetSupportsNewAuth) {
2313
+ return this.pluginTokenHandler.issueToken({
2314
+ pluginId: this.pluginId,
2017
2315
  targetPluginId
2018
- )}/.backstage/auth/v1/jwks.json`
2019
- );
2020
- if (res.status === 404) {
2021
- return false;
2316
+ });
2022
2317
  }
2023
- if (!res.ok) {
2024
- throw new Error(`Failed to fetch jwks.json, ${res.status}`);
2318
+ return this.tokenManager.getToken().catch((error) => {
2319
+ throw new errors.ForwardedError(
2320
+ `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`,
2321
+ error
2322
+ );
2323
+ });
2324
+ case "user": {
2325
+ const { token } = internalForward;
2326
+ if (!token) {
2327
+ throw new Error("User credentials is unexpectedly missing token");
2025
2328
  }
2026
- const data = await res.json();
2027
- if (!data.keys) {
2028
- throw new Error(`Invalid jwks.json response, missing keys`);
2329
+ if (targetSupportsNewAuth) {
2330
+ const onBehalfOf = await this.userTokenHandler.createLimitedUserToken(
2331
+ token
2332
+ );
2333
+ return this.pluginTokenHandler.issueToken({
2334
+ pluginId: this.pluginId,
2335
+ targetPluginId,
2336
+ onBehalfOf
2337
+ });
2029
2338
  }
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);
2339
+ if (this.userTokenHandler.isLimitedUserToken(token)) {
2340
+ throw new errors.AuthenticationError(
2341
+ `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`
2342
+ );
2343
+ }
2344
+ return { token };
2037
2345
  }
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;
2346
+ default:
2347
+ throw new errors.AuthenticationError(
2348
+ `Refused to issue service token for credential type '${type}'`
2349
+ );
2047
2350
  }
2048
- if (!await this.isTargetPluginSupported(pluginId)) {
2351
+ }
2352
+ async getLimitedUserToken(credentials) {
2353
+ const { token: backstageToken } = toInternalBackstageCredentials(credentials);
2354
+ if (!backstageToken) {
2049
2355
  throw new errors.AuthenticationError(
2050
- `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`
2356
+ "User credentials is unexpectedly missing token"
2051
2357
  );
2052
2358
  }
2053
- const newClient = new JwksClient(async () => {
2054
- return new URL(
2055
- `${await this.discovery.getBaseUrl(
2056
- pluginId
2057
- )}/.backstage/auth/v1/jwks.json`
2359
+ return this.userTokenHandler.createLimitedUserToken(backstageToken);
2360
+ }
2361
+ async listPublicServiceKeys() {
2362
+ const { keys } = await this.pluginKeySource.listKeys();
2363
+ return { keys: keys.map(({ key }) => key) };
2364
+ }
2365
+ #getJwtExpiration(token) {
2366
+ const { exp } = jose.decodeJwt(token);
2367
+ if (!exp) {
2368
+ throw new errors.AuthenticationError("User token is missing expiration");
2369
+ }
2370
+ return new Date(exp * 1e3);
2371
+ }
2372
+ }
2373
+
2374
+ function readAccessRestrictionsFromConfig(externalAccessEntryConfig) {
2375
+ const configs = externalAccessEntryConfig.getOptionalConfigArray("accessRestrictions") ?? [];
2376
+ const result = /* @__PURE__ */ new Map();
2377
+ for (const config of configs) {
2378
+ const validKeys = ["plugin", "permission", "permissionAttribute"];
2379
+ for (const key of config.keys()) {
2380
+ if (!validKeys.includes(key)) {
2381
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
2382
+ throw new Error(
2383
+ `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
2384
+ );
2385
+ }
2386
+ }
2387
+ const pluginId = config.getString("plugin");
2388
+ const permissionNames = readPermissionNames(config);
2389
+ const permissionAttributes = readPermissionAttributes(config);
2390
+ if (result.has(pluginId)) {
2391
+ throw new Error(
2392
+ `Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`
2058
2393
  );
2394
+ }
2395
+ result.set(pluginId, {
2396
+ ...permissionNames ? { permissionNames } : {},
2397
+ ...permissionAttributes ? { permissionAttributes } : {}
2059
2398
  });
2060
- this.jwksMap.set(pluginId, newClient);
2061
- return newClient;
2062
2399
  }
2063
- async getKey() {
2064
- if (this.privateKeyPromise) {
2065
- if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
2066
- return this.privateKeyPromise;
2400
+ return result.size ? result : void 0;
2401
+ }
2402
+ function readStringOrStringArrayFromConfig(root, key, validValues) {
2403
+ if (!root.has(key)) {
2404
+ return void 0;
2405
+ }
2406
+ const rawValues = Array.isArray(root.get(key)) ? root.getStringArray(key) : [root.getString(key)];
2407
+ const values = [
2408
+ ...new Set(
2409
+ rawValues.map((v) => v.split(/[ ,]/)).flat().filter(Boolean)
2410
+ )
2411
+ ];
2412
+ if (!values.length) {
2413
+ return void 0;
2414
+ }
2415
+ if (validValues?.length) {
2416
+ for (const value of values) {
2417
+ if (!validValues.includes(value)) {
2418
+ const valid = validValues.map((k) => `'${k}'`).join(", ");
2419
+ throw new Error(
2420
+ `Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`
2421
+ );
2067
2422
  }
2068
- this.logger.info(`Signing key has expired, generating new key`);
2069
- delete this.privateKeyPromise;
2070
2423
  }
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;
2424
+ }
2425
+ return values;
2426
+ }
2427
+ function readPermissionNames(externalAccessEntryConfig) {
2428
+ return readStringOrStringArrayFromConfig(
2429
+ externalAccessEntryConfig,
2430
+ "permission"
2431
+ );
2432
+ }
2433
+ function readPermissionAttributes(externalAccessEntryConfig) {
2434
+ const config = externalAccessEntryConfig.getOptionalConfig(
2435
+ "permissionAttribute"
2436
+ );
2437
+ if (!config) {
2438
+ return void 0;
2439
+ }
2440
+ const validKeys = ["action"];
2441
+ for (const key of config.keys()) {
2442
+ if (!validKeys.includes(key)) {
2443
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
2444
+ throw new Error(
2445
+ `Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`
2446
+ );
2098
2447
  }
2099
- return promise;
2100
2448
  }
2449
+ const action = readStringOrStringArrayFromConfig(config, "action", [
2450
+ "create",
2451
+ "read",
2452
+ "update",
2453
+ "delete"
2454
+ ]);
2455
+ const result = {
2456
+ ...action ? { action } : {}
2457
+ };
2458
+ return Object.keys(result).length ? result : void 0;
2101
2459
  }
2102
2460
 
2103
- class UserTokenHandler {
2104
- constructor(jwksClient) {
2105
- this.jwksClient = jwksClient;
2461
+ class LegacyTokenHandler {
2462
+ #entries = new Array();
2463
+ add(config) {
2464
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2465
+ this.#doAdd(
2466
+ config.getString("options.secret"),
2467
+ config.getString("options.subject"),
2468
+ allAccessRestrictions
2469
+ );
2106
2470
  }
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);
2471
+ // used only for the old backend.auth.keys array
2472
+ addOld(config) {
2473
+ this.#doAdd(config.getString("secret"), "external:backstage-plugin");
2113
2474
  }
2114
- async verifyToken(token) {
2115
- const verifyOpts = this.#getTokenVerificationOptions(token);
2116
- if (!verifyOpts) {
2117
- return void 0;
2475
+ #doAdd(secret, subject, allAccessRestrictions) {
2476
+ if (!secret.match(/^\S+$/)) {
2477
+ throw new Error("Illegal secret, must be a valid base64 string");
2478
+ } else if (!subject.match(/^\S+$/)) {
2479
+ throw new Error("Illegal subject, must be a set of non-space characters");
2118
2480
  }
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");
2481
+ let key;
2482
+ try {
2483
+ key = jose.base64url.decode(secret);
2484
+ } catch {
2485
+ throw new Error("Illegal secret, must be a valid base64 string");
2130
2486
  }
2131
- return { userEntityRef };
2487
+ if (this.#entries.some((e) => e.key === key)) {
2488
+ throw new Error(
2489
+ "Legacy externalAccess token was declared more than once"
2490
+ );
2491
+ }
2492
+ this.#entries.push({
2493
+ key,
2494
+ result: {
2495
+ subject,
2496
+ allAccessRestrictions
2497
+ }
2498
+ });
2132
2499
  }
2133
- #getTokenVerificationOptions(token) {
2500
+ async verifyToken(token) {
2134
2501
  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
- };
2502
+ const { alg } = jose.decodeProtectedHeader(token);
2503
+ if (alg !== "HS256") {
2504
+ return void 0;
2141
2505
  }
2142
- if (typ === pluginAuthNode.tokenTypes.limitedUser.typParam) {
2143
- return {
2144
- requiredClaims: ["iat", "exp", "sub"],
2145
- typ: pluginAuthNode.tokenTypes.limitedUser.typParam
2146
- };
2506
+ const { sub, aud } = jose.decodeJwt(token);
2507
+ if (sub !== "backstage-server" || aud) {
2508
+ return void 0;
2147
2509
  }
2148
- const { aud } = jose.decodeJwt(token);
2149
- if (aud === pluginAuthNode.tokenTypes.user.audClaim) {
2150
- return {
2151
- audience: pluginAuthNode.tokenTypes.user.audClaim
2152
- };
2510
+ } catch (e) {
2511
+ return void 0;
2512
+ }
2513
+ for (const { key, result } of this.#entries) {
2514
+ try {
2515
+ await jose.jwtVerify(token, key);
2516
+ return result;
2517
+ } catch (e) {
2518
+ if (e.code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
2519
+ throw e;
2520
+ }
2153
2521
  }
2154
- } catch {
2155
2522
  }
2156
2523
  return void 0;
2157
2524
  }
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) };
2525
+ }
2526
+
2527
+ const MIN_TOKEN_LENGTH = 8;
2528
+ class StaticTokenHandler {
2529
+ #entries = /* @__PURE__ */ new Map();
2530
+ add(config) {
2531
+ const token = config.getString("options.token");
2532
+ const subject = config.getString("options.subject");
2533
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2534
+ if (!token.match(/^\S+$/)) {
2535
+ throw new Error("Illegal token, must be a set of non-space characters");
2536
+ } else if (token.length < MIN_TOKEN_LENGTH) {
2537
+ throw new Error(
2538
+ `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`
2539
+ );
2540
+ } else if (!subject.match(/^\S+$/)) {
2541
+ throw new Error("Illegal subject, must be a set of non-space characters");
2542
+ } else if (this.#entries.has(token)) {
2543
+ throw new Error(
2544
+ "Static externalAccess token was declared more than once"
2545
+ );
2169
2546
  }
2170
- if (tokenType !== pluginAuthNode.tokenTypes.user.typParam) {
2171
- throw new errors.AuthenticationError(
2172
- "Failed to create limited user token, invalid token type"
2547
+ this.#entries.set(token, { subject, allAccessRestrictions });
2548
+ }
2549
+ async verifyToken(token) {
2550
+ return this.#entries.get(token);
2551
+ }
2552
+ }
2553
+
2554
+ class JWKSHandler {
2555
+ #entries = [];
2556
+ add(config) {
2557
+ if (!config.getString("options.url").match(/^\S+$/)) {
2558
+ throw new Error(
2559
+ "Illegal JWKS URL, must be a set of non-space characters"
2173
2560
  );
2174
2561
  }
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) };
2562
+ const algorithms = readStringOrStringArrayFromConfig(
2563
+ config,
2564
+ "options.algorithm"
2565
+ );
2566
+ const issuers = readStringOrStringArrayFromConfig(config, "options.issuer");
2567
+ const audiences = readStringOrStringArrayFromConfig(
2568
+ config,
2569
+ "options.audience"
2570
+ );
2571
+ const subjectPrefix = config.getOptionalString("options.subjectPrefix");
2572
+ const url = new URL(config.getString("options.url"));
2573
+ const jwks = jose.createRemoteJWKSet(url);
2574
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2575
+ this.#entries.push({
2576
+ algorithms,
2577
+ audiences,
2578
+ issuers,
2579
+ jwks,
2580
+ subjectPrefix,
2581
+ url,
2582
+ allAccessRestrictions
2583
+ });
2194
2584
  }
2195
- isLimitedUserToken(token) {
2196
- try {
2197
- const { typ } = jose.decodeProtectedHeader(token);
2198
- return typ === pluginAuthNode.tokenTypes.limitedUser.typParam;
2199
- } catch {
2200
- return false;
2585
+ async verifyToken(token) {
2586
+ for (const entry of this.#entries) {
2587
+ try {
2588
+ const {
2589
+ payload: { sub }
2590
+ } = await jose.jwtVerify(token, entry.jwks, {
2591
+ algorithms: entry.algorithms,
2592
+ issuer: entry.issuers,
2593
+ audience: entry.audiences
2594
+ });
2595
+ if (sub) {
2596
+ const prefix = entry.subjectPrefix ? `external:${entry.subjectPrefix}:` : "external:";
2597
+ return {
2598
+ subject: `${prefix}${sub}`,
2599
+ allAccessRestrictions: entry.allAccessRestrictions
2600
+ };
2601
+ }
2602
+ } catch {
2603
+ continue;
2604
+ }
2201
2605
  }
2606
+ return void 0;
2202
2607
  }
2203
2608
  }
2204
2609
 
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(", ");
2610
+ const NEW_CONFIG_KEY = "backend.auth.externalAccess";
2611
+ const OLD_CONFIG_KEY = "backend.auth.keys";
2612
+ let loggedDeprecationWarning = false;
2613
+ class ExternalTokenHandler {
2614
+ constructor(ownPluginId, handlers) {
2615
+ this.ownPluginId = ownPluginId;
2616
+ this.handlers = handlers;
2617
+ }
2618
+ static create(options) {
2619
+ const { ownPluginId, config, logger } = options;
2620
+ const staticHandler = new StaticTokenHandler();
2621
+ const legacyHandler = new LegacyTokenHandler();
2622
+ const jwksHandler = new JWKSHandler();
2623
+ const handlers = {
2624
+ static: staticHandler,
2625
+ legacy: legacyHandler,
2626
+ jwks: jwksHandler
2627
+ };
2628
+ const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
2629
+ for (const handlerConfig of handlerConfigs) {
2630
+ const type = handlerConfig.getString("type");
2631
+ const handler = handlers[type];
2632
+ if (!handler) {
2633
+ const valid = Object.keys(handlers).map((k) => `'${k}'`).join(", ");
2213
2634
  throw new Error(
2214
- `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
2635
+ `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`
2215
2636
  );
2216
2637
  }
2638
+ handler.add(handlerConfig);
2217
2639
  }
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`
2640
+ const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
2641
+ if (legacyConfigs.length && !loggedDeprecationWarning) {
2642
+ loggedDeprecationWarning = true;
2643
+ logger.warn(
2644
+ `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
2645
  );
2225
2646
  }
2226
- result.set(pluginId, {
2227
- ...permissionNames ? { permissionNames } : {},
2228
- ...permissionAttributes ? { permissionAttributes } : {}
2229
- });
2647
+ for (const handlerConfig of legacyConfigs) {
2648
+ legacyHandler.addOld(handlerConfig);
2649
+ }
2650
+ return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
2651
+ }
2652
+ async verifyToken(token) {
2653
+ for (const handler of this.handlers) {
2654
+ const result = await handler.verifyToken(token);
2655
+ if (result) {
2656
+ const { allAccessRestrictions, ...rest } = result;
2657
+ if (allAccessRestrictions) {
2658
+ const accessRestrictions = allAccessRestrictions.get(
2659
+ this.ownPluginId
2660
+ );
2661
+ if (!accessRestrictions) {
2662
+ const valid = [...allAccessRestrictions.keys()].map((k) => `'${k}'`).join(", ");
2663
+ throw new errors.NotAllowedError(
2664
+ `This token's access is restricted to plugin(s) ${valid}`
2665
+ );
2666
+ }
2667
+ return {
2668
+ ...rest,
2669
+ accessRestrictions
2670
+ };
2671
+ }
2672
+ return rest;
2673
+ }
2674
+ }
2675
+ return void 0;
2230
2676
  }
2231
- return result.size ? result : void 0;
2232
2677
  }
2233
- function readStringOrStringArrayFromConfig(root, key, validValues) {
2234
- if (!root.has(key)) {
2235
- return void 0;
2678
+
2679
+ const CLOCK_MARGIN_S = 10;
2680
+ class JwksClient {
2681
+ constructor(getEndpoint) {
2682
+ this.getEndpoint = getEndpoint;
2236
2683
  }
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;
2684
+ #keyStore;
2685
+ #keyStoreUpdated = 0;
2686
+ get getKey() {
2687
+ if (!this.#keyStore) {
2688
+ throw new errors.AuthenticationError(
2689
+ "refreshKeyStore must be called before jwksClient.getKey"
2690
+ );
2691
+ }
2692
+ return this.#keyStore;
2245
2693
  }
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
- );
2694
+ /**
2695
+ * If the last keystore refresh is stale, update the keystore URL to the latest
2696
+ */
2697
+ async refreshKeyStore(rawJwtToken) {
2698
+ const payload = await jose.decodeJwt(rawJwtToken);
2699
+ const header = await jose.decodeProtectedHeader(rawJwtToken);
2700
+ let keyStoreHasKey;
2701
+ try {
2702
+ if (this.#keyStore) {
2703
+ const [_, rawPayload, rawSignature] = rawJwtToken.split(".");
2704
+ keyStoreHasKey = await this.#keyStore(header, {
2705
+ payload: rawPayload,
2706
+ signature: rawSignature
2707
+ });
2253
2708
  }
2709
+ } catch (error) {
2710
+ keyStoreHasKey = false;
2254
2711
  }
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
- );
2712
+ const issuedAfterLastRefresh = payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S;
2713
+ if (!this.#keyStore || !keyStoreHasKey && issuedAfterLastRefresh) {
2714
+ const endpoint = await this.getEndpoint();
2715
+ this.#keyStore = jose.createRemoteJWKSet(endpoint);
2716
+ this.#keyStoreUpdated = Date.now() / 1e3;
2278
2717
  }
2279
2718
  }
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
2719
  }
2291
2720
 
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
- );
2721
+ const SECONDS_IN_MS$2 = 1e3;
2722
+ const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
2723
+ class PluginTokenHandler {
2724
+ constructor(logger, ownPluginId, keySource, algorithm, keyDurationSeconds, discovery) {
2725
+ this.logger = logger;
2726
+ this.ownPluginId = ownPluginId;
2727
+ this.keySource = keySource;
2728
+ this.algorithm = algorithm;
2729
+ this.keyDurationSeconds = keyDurationSeconds;
2730
+ this.discovery = discovery;
2301
2731
  }
2302
- // used only for the old backend.auth.keys array
2303
- addOld(config) {
2304
- this.#doAdd(config.getString("secret"), "external:backstage-plugin");
2732
+ jwksMap = /* @__PURE__ */ new Map();
2733
+ // Tracking state for isTargetPluginSupported
2734
+ supportedTargetPlugins = /* @__PURE__ */ new Set();
2735
+ targetPluginInflightChecks = /* @__PURE__ */ new Map();
2736
+ static create(options) {
2737
+ return new PluginTokenHandler(
2738
+ options.logger,
2739
+ options.ownPluginId,
2740
+ options.keySource,
2741
+ options.algorithm ?? "ES256",
2742
+ Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
2743
+ options.discovery
2744
+ );
2305
2745
  }
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;
2746
+ async verifyToken(token) {
2313
2747
  try {
2314
- key = jose.base64url.decode(secret);
2748
+ const { typ } = jose.decodeProtectedHeader(token);
2749
+ if (typ !== pluginAuthNode.tokenTypes.plugin.typParam) {
2750
+ return void 0;
2751
+ }
2315
2752
  } catch {
2316
- throw new Error("Illegal secret, must be a valid base64 string");
2753
+ return void 0;
2317
2754
  }
2318
- if (this.#entries.some((e) => e.key === key)) {
2319
- throw new Error(
2320
- "Legacy externalAccess token was declared more than once"
2755
+ const pluginId = String(jose.decodeJwt(token).sub);
2756
+ if (!pluginId) {
2757
+ throw new errors.AuthenticationError("Invalid plugin token: missing subject");
2758
+ }
2759
+ if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
2760
+ throw new errors.AuthenticationError(
2761
+ "Invalid plugin token: forbidden subject format"
2321
2762
  );
2322
2763
  }
2323
- this.#entries.push({
2324
- key,
2325
- result: {
2326
- subject,
2327
- allAccessRestrictions
2764
+ const jwksClient = await this.getJwksClient(pluginId);
2765
+ await jwksClient.refreshKeyStore(token);
2766
+ const { payload } = await jose.jwtVerify(
2767
+ token,
2768
+ jwksClient.getKey,
2769
+ {
2770
+ typ: pluginAuthNode.tokenTypes.plugin.typParam,
2771
+ audience: this.ownPluginId,
2772
+ requiredClaims: ["iat", "exp", "sub", "aud"]
2328
2773
  }
2774
+ ).catch((e) => {
2775
+ throw new errors.AuthenticationError("Invalid plugin token", e);
2329
2776
  });
2777
+ return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
2330
2778
  }
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;
2779
+ async issueToken(options) {
2780
+ const { pluginId, targetPluginId, onBehalfOf } = options;
2781
+ const key = await this.keySource.getPrivateSigningKey();
2782
+ const sub = pluginId;
2783
+ const aud = targetPluginId;
2784
+ const iat = Math.floor(Date.now() / SECONDS_IN_MS$2);
2785
+ const ourExp = iat + this.keyDurationSeconds;
2786
+ const exp = onBehalfOf ? Math.min(
2787
+ ourExp,
2788
+ Math.floor(onBehalfOf.expiresAt.getTime() / SECONDS_IN_MS$2)
2789
+ ) : ourExp;
2790
+ const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
2791
+ const token = await new jose.SignJWT(claims).setProtectedHeader({
2792
+ typ: pluginAuthNode.tokenTypes.plugin.typParam,
2793
+ alg: this.algorithm,
2794
+ kid: key.kid
2795
+ }).setAudience(aud).setSubject(sub).setIssuedAt(iat).setExpirationTime(exp).sign(await jose.importJWK(key));
2796
+ return { token };
2797
+ }
2798
+ async isTargetPluginSupported(targetPluginId) {
2799
+ if (this.supportedTargetPlugins.has(targetPluginId)) {
2800
+ return true;
2343
2801
  }
2344
- for (const { key, result } of this.#entries) {
2802
+ const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
2803
+ if (inFlight) {
2804
+ return inFlight;
2805
+ }
2806
+ const doCheck = async () => {
2345
2807
  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;
2808
+ const res = await fetch(
2809
+ `${await this.discovery.getBaseUrl(
2810
+ targetPluginId
2811
+ )}/.backstage/auth/v1/jwks.json`
2812
+ );
2813
+ if (res.status === 404) {
2814
+ return false;
2815
+ }
2816
+ if (!res.ok) {
2817
+ throw new Error(`Failed to fetch jwks.json, ${res.status}`);
2818
+ }
2819
+ const data = await res.json();
2820
+ if (!data.keys) {
2821
+ throw new Error(`Invalid jwks.json response, missing keys`);
2351
2822
  }
2823
+ this.supportedTargetPlugins.add(targetPluginId);
2824
+ return true;
2825
+ } catch (error) {
2826
+ this.logger.error("Unexpected failure for target JWKS check", error);
2827
+ return false;
2828
+ } finally {
2829
+ this.targetPluginInflightChecks.delete(targetPluginId);
2352
2830
  }
2353
- }
2354
- return void 0;
2831
+ };
2832
+ const check = doCheck();
2833
+ this.targetPluginInflightChecks.set(targetPluginId, check);
2834
+ return check;
2355
2835
  }
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"
2836
+ async getJwksClient(pluginId) {
2837
+ const client = this.jwksMap.get(pluginId);
2838
+ if (client) {
2839
+ return client;
2840
+ }
2841
+ if (!await this.isTargetPluginSupported(pluginId)) {
2842
+ throw new errors.AuthenticationError(
2843
+ `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`
2376
2844
  );
2377
2845
  }
2378
- this.#entries.set(token, { subject, allAccessRestrictions });
2379
- }
2380
- async verifyToken(token) {
2381
- return this.#entries.get(token);
2846
+ const newClient = new JwksClient(async () => {
2847
+ return new URL(
2848
+ `${await this.discovery.getBaseUrl(
2849
+ pluginId
2850
+ )}/.backstage/auth/v1/jwks.json`
2851
+ );
2852
+ });
2853
+ this.jwksMap.set(pluginId, newClient);
2854
+ return newClient;
2382
2855
  }
2383
2856
  }
2384
2857
 
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
- });
2858
+ const MIGRATIONS_TABLE = "backstage_backend_public_keys__knex_migrations";
2859
+ const TABLE = "backstage_backend_public_keys__keys";
2860
+ function applyDatabaseMigrations(knex) {
2861
+ const migrationsDir = backendPluginApi.resolvePackagePath(
2862
+ "@backstage/backend-defaults",
2863
+ "migrations/auth"
2864
+ );
2865
+ return knex.migrate.latest({
2866
+ directory: migrationsDir,
2867
+ tableName: MIGRATIONS_TABLE
2868
+ });
2869
+ }
2870
+ class DatabaseKeyStore {
2871
+ constructor(client, logger) {
2872
+ this.client = client;
2873
+ this.logger = logger;
2415
2874
  }
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
- }
2875
+ static async create(options) {
2876
+ const { database, logger } = options;
2877
+ const client = await database.getClient();
2878
+ if (!database.migrations?.skip) {
2879
+ await applyDatabaseMigrations(client);
2436
2880
  }
2437
- return void 0;
2881
+ return new DatabaseKeyStore(client, logger);
2438
2882
  }
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;
2883
+ async addKey(options) {
2884
+ await this.client(TABLE).insert({
2885
+ id: options.key.kid,
2886
+ key: JSON.stringify(options.key),
2887
+ expires_at: options.expiresAt.toISOString()
2888
+ });
2448
2889
  }
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
- );
2890
+ async listKeys() {
2891
+ const rows = await this.client(TABLE).select();
2892
+ const keys = rows.map((row) => ({
2893
+ id: row.id,
2894
+ key: JSON.parse(row.key),
2895
+ expiresAt: new Date(row.expires_at)
2896
+ }));
2897
+ const validKeys = [];
2898
+ const expiredKeys = [];
2899
+ for (const key of keys) {
2900
+ if (luxon.DateTime.fromJSDate(key.expiresAt) < luxon.DateTime.local()) {
2901
+ expiredKeys.push(key);
2902
+ } else {
2903
+ validKeys.push(key);
2468
2904
  }
2469
- handler.add(handlerConfig);
2470
2905
  }
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`
2906
+ if (expiredKeys.length > 0) {
2907
+ const kids = expiredKeys.map(({ key }) => key.kid);
2908
+ this.logger.info(
2909
+ `Removing expired plugin service keys, '${kids.join("', '")}'`
2476
2910
  );
2911
+ this.client(TABLE).delete().whereIn("id", kids).catch((error) => {
2912
+ this.logger.error(
2913
+ "Failed to remove expired plugin service keys",
2914
+ error
2915
+ );
2916
+ });
2477
2917
  }
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;
2918
+ return { keys: validKeys };
2507
2919
  }
2508
2920
  }
2509
2921
 
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
2922
+ const SECONDS_IN_MS$1 = 1e3;
2923
+ const KEY_EXPIRATION_MARGIN_FACTOR = 3;
2924
+ class DatabasePluginKeySource {
2925
+ constructor(keyStore, logger, keyDurationSeconds, algorithm) {
2926
+ this.keyStore = keyStore;
2927
+ this.logger = logger;
2928
+ this.keyDurationSeconds = keyDurationSeconds;
2929
+ this.algorithm = algorithm;
2930
+ }
2931
+ privateKeyPromise;
2932
+ keyExpiry;
2933
+ static async create(options) {
2934
+ const keyStore = await DatabaseKeyStore.create({
2935
+ database: options.database,
2936
+ logger: options.logger
2548
2937
  });
2549
- return new DefaultAuthService(
2550
- userTokens,
2551
- pluginTokens,
2552
- externalTokens,
2553
- tokenManager,
2554
- plugin.getId(),
2555
- disableDefaultAuthPolicy,
2556
- publicKeyStore
2938
+ return new DatabasePluginKeySource(
2939
+ keyStore,
2940
+ options.logger,
2941
+ Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
2942
+ options.algorithm ?? "ES256"
2943
+ );
2944
+ }
2945
+ async getPrivateSigningKey() {
2946
+ if (this.privateKeyPromise) {
2947
+ if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
2948
+ return this.privateKeyPromise;
2949
+ }
2950
+ this.logger.info(`Signing key has expired, generating new key`);
2951
+ delete this.privateKeyPromise;
2952
+ }
2953
+ this.keyExpiry = new Date(
2954
+ Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1
2557
2955
  );
2956
+ const promise = (async () => {
2957
+ const kid = uuid.v4();
2958
+ const key = await jose.generateKeyPair(this.algorithm);
2959
+ const publicKey = await jose.exportJWK(key.publicKey);
2960
+ const privateKey = await jose.exportJWK(key.privateKey);
2961
+ publicKey.kid = privateKey.kid = kid;
2962
+ publicKey.alg = privateKey.alg = this.algorithm;
2963
+ this.logger.info(`Created new signing key ${kid}`);
2964
+ await this.keyStore.addKey({
2965
+ id: kid,
2966
+ key: publicKey,
2967
+ expiresAt: new Date(
2968
+ Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1 * KEY_EXPIRATION_MARGIN_FACTOR
2969
+ )
2970
+ });
2971
+ return privateKey;
2972
+ })();
2973
+ this.privateKeyPromise = promise;
2974
+ try {
2975
+ await promise;
2976
+ } catch (error) {
2977
+ this.logger.error(`Failed to generate new signing key, ${error}`);
2978
+ delete this.keyExpiry;
2979
+ delete this.privateKeyPromise;
2980
+ }
2981
+ return promise;
2558
2982
  }
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();
2983
+ listKeys() {
2984
+ return this.keyStore.listKeys();
2573
2985
  }
2574
- });
2986
+ }
2575
2987
 
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);
2988
+ const DEFAULT_ALGORITHM = "ES256";
2989
+ const SECONDS_IN_MS = 1e3;
2990
+ class StaticConfigPluginKeySource {
2991
+ constructor(keyPairs, keyDurationSeconds) {
2992
+ this.keyPairs = keyPairs;
2993
+ this.keyDurationSeconds = keyDurationSeconds;
2994
+ }
2995
+ static async create(options) {
2996
+ const keyConfigs = options.sourceConfig.getConfigArray("static.keys").map((c) => {
2997
+ const staticKeyConfig = {
2998
+ publicKeyFile: c.getString("publicKeyFile"),
2999
+ privateKeyFile: c.getOptionalString("privateKeyFile"),
3000
+ keyId: c.getString("keyId"),
3001
+ algorithm: c.getOptionalString("algorithm") ?? DEFAULT_ALGORITHM
3002
+ };
3003
+ return staticKeyConfig;
3004
+ });
3005
+ const keyPairs = await Promise.all(
3006
+ keyConfigs.map(async (k) => await this.loadKeyPair(k))
3007
+ );
3008
+ if (keyPairs.length < 1) {
3009
+ throw new Error(
3010
+ "At least one key pair must be provided in static.keys, when the static key store type is used"
3011
+ );
3012
+ } else if (!keyPairs[0].privateKey) {
3013
+ throw new Error(
3014
+ "Private key for signing must be provided in the first key pair in static.keys, when the static key store type is used"
3015
+ );
2588
3016
  }
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
- })
3017
+ return new StaticConfigPluginKeySource(
3018
+ keyPairs,
3019
+ types.durationToMilliseconds(options.keyDuration) / SECONDS_IN_MS
2606
3020
  );
2607
- },
2608
- async factory({ pluginMetadata, lifecycle }, databaseManager) {
2609
- return databaseManager.forPlugin(pluginMetadata.getId(), {
2610
- pluginMetadata,
2611
- lifecycle
3021
+ }
3022
+ async getPrivateSigningKey() {
3023
+ return this.keyPairs[0].privateKey;
3024
+ }
3025
+ async listKeys() {
3026
+ const keys = this.keyPairs.map((k) => this.keyPairToStoredKey(k));
3027
+ return { keys };
3028
+ }
3029
+ static async loadKeyPair(options) {
3030
+ const algorithm = options.algorithm;
3031
+ const keyId = options.keyId;
3032
+ const publicKey = await this.loadPublicKeyFromFile(
3033
+ options.publicKeyFile,
3034
+ keyId,
3035
+ algorithm
3036
+ );
3037
+ const privateKey = options.privateKeyFile ? await this.loadPrivateKeyFromFile(
3038
+ options.privateKeyFile,
3039
+ keyId,
3040
+ algorithm
3041
+ ) : void 0;
3042
+ return { publicKey, privateKey, keyId };
3043
+ }
3044
+ static async loadPublicKeyFromFile(path, keyId, algorithm) {
3045
+ return this.loadKeyFromFile(path, keyId, algorithm, jose.importSPKI);
3046
+ }
3047
+ static async loadPrivateKeyFromFile(path, keyId, algorithm) {
3048
+ return this.loadKeyFromFile(path, keyId, algorithm, jose.importPKCS8);
3049
+ }
3050
+ static async loadKeyFromFile(path, keyId, algorithm, importer) {
3051
+ const content = await fs$1.promises.readFile(path, { encoding: "utf8", flag: "r" });
3052
+ const key = await importer(content, algorithm);
3053
+ const jwk = await jose.exportJWK(key);
3054
+ jwk.kid = keyId;
3055
+ jwk.alg = algorithm;
3056
+ return jwk;
3057
+ }
3058
+ keyPairToStoredKey(keyPair) {
3059
+ const publicKey = {
3060
+ ...keyPair.publicKey,
3061
+ kid: keyPair.keyId
3062
+ };
3063
+ return {
3064
+ key: publicKey,
3065
+ id: keyPair.keyId,
3066
+ expiresAt: new Date(Date.now() + this.keyDurationSeconds * SECONDS_IN_MS)
3067
+ };
3068
+ }
3069
+ }
3070
+
3071
+ const CONFIG_ROOT_KEY = "backend.auth.pluginKeyStore";
3072
+ async function createPluginKeySource(options) {
3073
+ const keyStoreConfig = options.config.getOptionalConfig(CONFIG_ROOT_KEY);
3074
+ const type = keyStoreConfig?.getOptionalString("type") ?? "database";
3075
+ if (!keyStoreConfig || type === "database") {
3076
+ return DatabasePluginKeySource.create({
3077
+ database: options.database,
3078
+ logger: options.logger,
3079
+ keyDuration: options.keyDuration,
3080
+ algorithm: options.algorithm
3081
+ });
3082
+ } else if (type === "static") {
3083
+ return StaticConfigPluginKeySource.create({
3084
+ sourceConfig: keyStoreConfig,
3085
+ keyDuration: options.keyDuration
2612
3086
  });
2613
3087
  }
2614
- });
3088
+ throw new Error(
3089
+ `Unsupported config value ${CONFIG_ROOT_KEY}.type '${type}'; expected one of 'database', 'static'`
3090
+ );
3091
+ }
2615
3092
 
2616
- class HostDiscovery {
2617
- constructor(internalBaseUrl, externalBaseUrl, discoveryConfig) {
2618
- this.internalBaseUrl = internalBaseUrl;
2619
- this.externalBaseUrl = externalBaseUrl;
2620
- this.discoveryConfig = discoveryConfig;
3093
+ class UserTokenHandler {
3094
+ constructor(jwksClient) {
3095
+ this.jwksClient = jwksClient;
2621
3096
  }
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";
3097
+ static create(options) {
3098
+ const jwksClient = new JwksClient(async () => {
3099
+ const url = await options.discovery.getBaseUrl("auth");
3100
+ return new URL(`${url}/.well-known/jwks.json`);
3101
+ });
3102
+ return new UserTokenHandler(jwksClient);
3103
+ }
3104
+ async verifyToken(token) {
3105
+ const verifyOpts = this.#getTokenVerificationOptions(token);
3106
+ if (!verifyOpts) {
3107
+ return void 0;
2658
3108
  }
2659
- if (host.includes(":")) {
2660
- host = `[${host}]`;
3109
+ await this.jwksClient.refreshKeyStore(token);
3110
+ const { payload } = await jose.jwtVerify(
3111
+ token,
3112
+ this.jwksClient.getKey,
3113
+ verifyOpts
3114
+ ).catch((e) => {
3115
+ throw new errors.AuthenticationError("Invalid token", e);
3116
+ });
3117
+ const userEntityRef = payload.sub;
3118
+ if (!userEntityRef) {
3119
+ throw new errors.AuthenticationError("No user sub found in token");
2661
3120
  }
2662
- const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
2663
- return new HostDiscovery(
2664
- internalBaseUrl + basePath,
2665
- externalBaseUrl + basePath,
2666
- config.getOptionalConfig("discovery")
2667
- );
3121
+ return { userEntityRef };
2668
3122
  }
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)}`;
3123
+ #getTokenVerificationOptions(token) {
3124
+ try {
3125
+ const { typ } = jose.decodeProtectedHeader(token);
3126
+ if (typ === pluginAuthNode.tokenTypes.user.typParam) {
3127
+ return {
3128
+ requiredClaims: ["iat", "exp", "sub"],
3129
+ typ: pluginAuthNode.tokenTypes.user.typParam
3130
+ };
3131
+ }
3132
+ if (typ === pluginAuthNode.tokenTypes.limitedUser.typParam) {
3133
+ return {
3134
+ requiredClaims: ["iat", "exp", "sub"],
3135
+ typ: pluginAuthNode.tokenTypes.limitedUser.typParam
3136
+ };
3137
+ }
3138
+ const { aud } = jose.decodeJwt(token);
3139
+ if (aud === pluginAuthNode.tokenTypes.user.audClaim) {
3140
+ return {
3141
+ audience: pluginAuthNode.tokenTypes.user.audClaim
3142
+ };
3143
+ }
3144
+ } catch {
2675
3145
  }
2676
- if (typeof target === "string") {
2677
- return target.replace(
2678
- /\{\{\s*pluginId\s*\}\}/g,
2679
- encodeURIComponent(pluginId)
3146
+ return void 0;
3147
+ }
3148
+ createLimitedUserToken(backstageToken) {
3149
+ const [headerRaw, payloadRaw] = backstageToken.split(".");
3150
+ const header = JSON.parse(
3151
+ new TextDecoder().decode(jose.base64url.decode(headerRaw))
3152
+ );
3153
+ const payload = JSON.parse(
3154
+ new TextDecoder().decode(jose.base64url.decode(payloadRaw))
3155
+ );
3156
+ const tokenType = header.typ;
3157
+ if (!tokenType || tokenType === pluginAuthNode.tokenTypes.limitedUser.typParam) {
3158
+ return { token: backstageToken, expiresAt: new Date(payload.exp * 1e3) };
3159
+ }
3160
+ if (tokenType !== pluginAuthNode.tokenTypes.user.typParam) {
3161
+ throw new errors.AuthenticationError(
3162
+ "Failed to create limited user token, invalid token type"
2680
3163
  );
2681
3164
  }
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");
3165
+ const limitedUserToken = [
3166
+ jose.base64url.encode(
3167
+ JSON.stringify({
3168
+ typ: pluginAuthNode.tokenTypes.limitedUser.typParam,
3169
+ alg: header.alg,
3170
+ kid: header.kid
3171
+ })
3172
+ ),
3173
+ jose.base64url.encode(
3174
+ JSON.stringify({
3175
+ sub: payload.sub,
3176
+ iat: payload.iat,
3177
+ exp: payload.exp
3178
+ })
3179
+ ),
3180
+ payload.uip
3181
+ ].join(".");
3182
+ return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1e3) };
2689
3183
  }
2690
- async getExternalBaseUrl(pluginId) {
2691
- return this.getTargetFromConfig(pluginId, "external");
3184
+ isLimitedUserToken(token) {
3185
+ try {
3186
+ const { typ } = jose.decodeProtectedHeader(token);
3187
+ return typ === pluginAuthNode.tokenTypes.limitedUser.typParam;
3188
+ } catch {
3189
+ return false;
3190
+ }
2692
3191
  }
2693
3192
  }
2694
3193
 
2695
- const discoveryServiceFactory = backendPluginApi.createServiceFactory({
2696
- service: backendPluginApi.coreServices.discovery,
3194
+ const authServiceFactory$1 = backendPluginApi.createServiceFactory({
3195
+ service: backendPluginApi.coreServices.auth,
2697
3196
  deps: {
2698
- config: backendPluginApi.coreServices.rootConfig
3197
+ config: backendPluginApi.coreServices.rootConfig,
3198
+ logger: backendPluginApi.coreServices.rootLogger,
3199
+ discovery: backendPluginApi.coreServices.discovery,
3200
+ plugin: backendPluginApi.coreServices.pluginMetadata,
3201
+ database: backendPluginApi.coreServices.database,
3202
+ // Re-using the token manager makes sure that we use the same generated keys for
3203
+ // development as plugins that have not yet been migrated. It's important that this
3204
+ // keeps working as long as there are plugins that have not been migrated to the
3205
+ // new auth services in the new backend system.
3206
+ tokenManager: backendPluginApi.coreServices.tokenManager
2699
3207
  },
2700
- async factory({ config }) {
2701
- return HostDiscovery.fromConfig(config);
3208
+ async factory({ config, discovery, plugin, tokenManager, logger, database }) {
3209
+ const disableDefaultAuthPolicy = config.getOptionalBoolean(
3210
+ "backend.auth.dangerouslyDisableDefaultAuthPolicy"
3211
+ ) ?? false;
3212
+ const keyDuration = { hours: 1 };
3213
+ const keySource = await createPluginKeySource({
3214
+ config,
3215
+ database,
3216
+ logger,
3217
+ keyDuration
3218
+ });
3219
+ const userTokens = UserTokenHandler.create({
3220
+ discovery
3221
+ });
3222
+ const pluginTokens = PluginTokenHandler.create({
3223
+ ownPluginId: plugin.getId(),
3224
+ logger,
3225
+ keySource,
3226
+ keyDuration,
3227
+ discovery
3228
+ });
3229
+ const externalTokens = ExternalTokenHandler.create({
3230
+ ownPluginId: plugin.getId(),
3231
+ config,
3232
+ logger
3233
+ });
3234
+ return new DefaultAuthService(
3235
+ userTokens,
3236
+ pluginTokens,
3237
+ externalTokens,
3238
+ tokenManager,
3239
+ plugin.getId(),
3240
+ disableDefaultAuthPolicy,
3241
+ keySource
3242
+ );
2702
3243
  }
2703
3244
  });
2704
3245
 
3246
+ const authServiceFactory = authServiceFactory$1;
3247
+
2705
3248
  const FIVE_MINUTES_MS = 5 * 60 * 1e3;
2706
3249
  const BACKSTAGE_AUTH_COOKIE = "backstage-auth";
2707
3250
  function getTokenFromRequest(req) {
@@ -2874,7 +3417,7 @@ class DefaultHttpAuthService {
2874
3417
  }
2875
3418
  }
2876
3419
  }
2877
- const httpAuthServiceFactory = backendPluginApi.createServiceFactory({
3420
+ const httpAuthServiceFactory$1 = backendPluginApi.createServiceFactory({
2878
3421
  service: backendPluginApi.coreServices.httpAuth,
2879
3422
  deps: {
2880
3423
  auth: backendPluginApi.coreServices.auth,
@@ -2886,8 +3429,10 @@ const httpAuthServiceFactory = backendPluginApi.createServiceFactory({
2886
3429
  }
2887
3430
  });
2888
3431
 
3432
+ const httpAuthServiceFactory = httpAuthServiceFactory$1;
3433
+
2889
3434
  const DEFAULT_TIMEOUT = { seconds: 5 };
2890
- function createLifecycleMiddleware(options) {
3435
+ function createLifecycleMiddleware$1(options) {
2891
3436
  const { lifecycle, startupRequestPauseTimeout = DEFAULT_TIMEOUT } = options;
2892
3437
  let state = "init";
2893
3438
  const waiting = /* @__PURE__ */ new Set();
@@ -3011,7 +3556,7 @@ function createCookieAuthRefreshMiddleware(options) {
3011
3556
  return router;
3012
3557
  }
3013
3558
 
3014
- const httpRouterServiceFactory = backendPluginApi.createServiceFactory(
3559
+ const httpRouterServiceFactory$1 = backendPluginApi.createServiceFactory(
3015
3560
  (options) => ({
3016
3561
  service: backendPluginApi.coreServices.httpRouter,
3017
3562
  initialization: "always",
@@ -3047,7 +3592,7 @@ const httpRouterServiceFactory = backendPluginApi.createServiceFactory(
3047
3592
  config
3048
3593
  });
3049
3594
  router.use(createAuthIntegrationRouter({ auth }));
3050
- router.use(createLifecycleMiddleware({ lifecycle }));
3595
+ router.use(createLifecycleMiddleware$1({ lifecycle }));
3051
3596
  router.use(credentialsBarrier.middleware);
3052
3597
  router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
3053
3598
  return {
@@ -3062,76 +3607,11 @@ const httpRouterServiceFactory = backendPluginApi.createServiceFactory(
3062
3607
  })
3063
3608
  );
3064
3609
 
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
- );
3610
+ const httpRouterServiceFactory = httpRouterServiceFactory$1;
3076
3611
 
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
- });
3612
+ const createLifecycleMiddleware = createLifecycleMiddleware$1;
3133
3613
 
3134
- const loggerServiceFactory = backendPluginApi.createServiceFactory({
3614
+ const loggerServiceFactory$1 = backendPluginApi.createServiceFactory({
3135
3615
  service: backendPluginApi.coreServices.logger,
3136
3616
  deps: {
3137
3617
  rootLogger: backendPluginApi.coreServices.rootLogger,
@@ -3142,27 +3622,12 @@ const loggerServiceFactory = backendPluginApi.createServiceFactory({
3142
3622
  }
3143
3623
  });
3144
3624
 
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
- });
3625
+ const loggerServiceFactory = loggerServiceFactory$1;
3161
3626
 
3162
3627
  function normalizePath(path) {
3163
3628
  return `${trimEnd__default.default(path, "/")}/`;
3164
3629
  }
3165
- class DefaultRootHttpRouter {
3630
+ let DefaultRootHttpRouter$1 = class DefaultRootHttpRouter {
3166
3631
  #indexPath;
3167
3632
  #router = express.Router();
3168
3633
  #namedRoutes = express.Router();
@@ -3223,12 +3688,12 @@ class DefaultRootHttpRouter {
3223
3688
  }
3224
3689
  return void 0;
3225
3690
  }
3226
- }
3691
+ };
3227
3692
 
3228
3693
  function defaultConfigure({ applyDefaults }) {
3229
3694
  applyDefaults();
3230
3695
  }
3231
- const rootHttpRouterServiceFactory = backendPluginApi.createServiceFactory(
3696
+ const rootHttpRouterServiceFactory$1 = backendPluginApi.createServiceFactory(
3232
3697
  (options) => ({
3233
3698
  service: backendPluginApi.coreServices.rootHttpRouter,
3234
3699
  deps: {
@@ -3240,12 +3705,12 @@ const rootHttpRouterServiceFactory = backendPluginApi.createServiceFactory(
3240
3705
  const { indexPath, configure = defaultConfigure } = options ?? {};
3241
3706
  const logger = rootLogger.child({ service: "rootHttpRouter" });
3242
3707
  const app = express__default.default();
3243
- const router = DefaultRootHttpRouter.create({ indexPath });
3244
- const middleware = MiddlewareFactory.create({ config, logger });
3708
+ const router = DefaultRootHttpRouter$1.create({ indexPath });
3709
+ const middleware = MiddlewareFactory$1.create({ config, logger });
3245
3710
  const routes = router.handler();
3246
- const server = await createHttpServer(
3711
+ const server = await createHttpServer$1(
3247
3712
  app,
3248
- readHttpServerOptions(config.getOptionalConfig("backend")),
3713
+ readHttpServerOptions$1(config.getOptionalConfig("backend")),
3249
3714
  { logger }
3250
3715
  );
3251
3716
  configure({
@@ -3273,128 +3738,46 @@ const rootHttpRouterServiceFactory = backendPluginApi.createServiceFactory(
3273
3738
  })
3274
3739
  );
3275
3740
 
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 });
3741
+ const rootHttpRouterServiceFactory = rootHttpRouterServiceFactory$1;
3742
+
3743
+ class DefaultRootHttpRouter {
3744
+ constructor(impl) {
3745
+ this.impl = impl;
3287
3746
  }
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
- );
3747
+ static create(options) {
3748
+ return new DefaultRootHttpRouter(DefaultRootHttpRouter$1.create(options));
3305
3749
  }
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 });
3750
+ use(path, handler) {
3751
+ this.impl.use(path, handler);
3313
3752
  }
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
- );
3753
+ handler() {
3754
+ return this.impl.handler();
3333
3755
  }
3334
3756
  }
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
3757
 
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
- });
3758
+ const rootLoggerServiceFactory = rootLoggerServiceFactory$1;
3382
3759
 
3383
- const urlReaderServiceFactory = backendPluginApi.createServiceFactory({
3384
- service: backendPluginApi.coreServices.urlReader,
3760
+ const schedulerServiceFactory = backendPluginApi.createServiceFactory({
3761
+ service: backendPluginApi.coreServices.scheduler,
3385
3762
  deps: {
3386
- config: backendPluginApi.coreServices.rootConfig,
3763
+ plugin: backendPluginApi.coreServices.pluginMetadata,
3764
+ databaseManager: backendPluginApi.coreServices.database,
3387
3765
  logger: backendPluginApi.coreServices.logger
3388
3766
  },
3389
- async factory({ config, logger }) {
3390
- return backendCommon.UrlReaders.default({
3391
- config,
3767
+ async factory({ plugin, databaseManager, logger }) {
3768
+ return backendTasks.TaskScheduler.forPlugin({
3769
+ pluginId: plugin.getId(),
3770
+ databaseManager,
3392
3771
  logger
3393
3772
  });
3394
3773
  }
3395
3774
  });
3396
3775
 
3397
3776
  class DefaultUserInfoService {
3777
+ discovery;
3778
+ constructor(options) {
3779
+ this.discovery = options.discovery;
3780
+ }
3398
3781
  async getUserInfo(credentials) {
3399
3782
  const internalCredentials = toInternalBackstageCredentials(credentials);
3400
3783
  if (internalCredentials.principal.type !== "user") {
@@ -3403,42 +3786,51 @@ class DefaultUserInfoService {
3403
3786
  if (!internalCredentials.token) {
3404
3787
  throw new Error("User credentials is unexpectedly missing token");
3405
3788
  }
3406
- const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = jose.decodeJwt(
3789
+ const { sub: userEntityRef, ent: tokenEnt } = jose.decodeJwt(
3407
3790
  internalCredentials.token
3408
3791
  );
3409
3792
  if (typeof userEntityRef !== "string") {
3410
3793
  throw new Error("User entity ref must be a string");
3411
3794
  }
3412
- if (!Array.isArray(ownershipEntityRefs) || ownershipEntityRefs.some((ref) => typeof ref !== "string")) {
3795
+ let ownershipEntityRefs = tokenEnt;
3796
+ if (!ownershipEntityRefs) {
3797
+ const userInfoResp = await fetch__default.default(
3798
+ `${await this.discovery.getBaseUrl("auth")}/v1/userinfo`,
3799
+ {
3800
+ headers: {
3801
+ Authorization: `Bearer ${internalCredentials.token}`
3802
+ }
3803
+ }
3804
+ );
3805
+ if (!userInfoResp.ok) {
3806
+ throw await errors.ResponseError.fromResponse(userInfoResp);
3807
+ }
3808
+ const {
3809
+ claims: { ent }
3810
+ } = await userInfoResp.json();
3811
+ ownershipEntityRefs = ent;
3812
+ }
3813
+ if (!ownershipEntityRefs) {
3814
+ throw new Error("Ownership entity refs can not be determined");
3815
+ } else if (!Array.isArray(ownershipEntityRefs) || ownershipEntityRefs.some((ref) => typeof ref !== "string")) {
3413
3816
  throw new Error("Ownership entity refs must be an array of strings");
3414
3817
  }
3415
3818
  return { userEntityRef, ownershipEntityRefs };
3416
3819
  }
3417
3820
  }
3418
- const userInfoServiceFactory = backendPluginApi.createServiceFactory({
3419
- service: backendPluginApi.coreServices.userInfo,
3420
- deps: {},
3421
- async factory() {
3422
- return new DefaultUserInfoService();
3423
- }
3424
- });
3425
3821
 
3426
- const schedulerServiceFactory = backendPluginApi.createServiceFactory({
3427
- service: backendPluginApi.coreServices.scheduler,
3822
+ const userInfoServiceFactory$1 = backendPluginApi.createServiceFactory({
3823
+ service: backendPluginApi.coreServices.userInfo,
3428
3824
  deps: {
3429
- plugin: backendPluginApi.coreServices.pluginMetadata,
3430
- databaseManager: backendPluginApi.coreServices.database,
3431
- logger: backendPluginApi.coreServices.logger
3825
+ discovery: backendPluginApi.coreServices.discovery
3432
3826
  },
3433
- async factory({ plugin, databaseManager, logger }) {
3434
- return backendTasks.TaskScheduler.forPlugin({
3435
- pluginId: plugin.getId(),
3436
- databaseManager,
3437
- logger
3438
- });
3827
+ async factory({ discovery }) {
3828
+ return new DefaultUserInfoService({ discovery });
3439
3829
  }
3440
3830
  });
3441
3831
 
3832
+ const userInfoServiceFactory = userInfoServiceFactory$1;
3833
+
3442
3834
  exports.DefaultRootHttpRouter = DefaultRootHttpRouter;
3443
3835
  exports.HostDiscovery = HostDiscovery;
3444
3836
  exports.MiddlewareFactory = MiddlewareFactory;