diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index d20f568e98..32d0df1148 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -491,13 +491,14 @@ export class ServerTokenManager implements TokenManager { // (undocumented) authenticate(token: string): Promise; // (undocumented) - static fromConfig(config: Config): ServerTokenManager; + static default(options: { + config: Config; + logger: Logger_2; + }): ServerTokenManager; // (undocumented) getToken(): Promise<{ token: string; }>; - // (undocumented) - static noop(): TokenManager; } // @public (undocumented) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index fe724a0a4b..2bdd02c9f5 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -13,49 +13,68 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '../logging/voidLogger'; import { ConfigReader } from '@backstage/config'; -import { TokenManager } from './types'; import { ServerTokenManager } from './ServerTokenManager'; +import { Logger } from 'winston'; +import { JWK } from 'jose'; const emptyConfig = new ConfigReader({}); const configWithSecret = new ConfigReader({ backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, }); +const env = process.env; +let logger: Logger; describe('ServerTokenManager', () => { - it('should throw if secret in config does not exist', () => { - expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError(); + beforeEach(() => { + process.env = { ...env }; + logger = getVoidLogger(); + }); + + afterEach(() => { + process.env = env; }); describe('getToken', () => { - it('should return a token if secret in config exists', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect((await tokenManager.getToken()).token).toBeDefined(); - }); - - it('should return a token string if using a noop TokenManager', async () => { - const tokenManager = ServerTokenManager.noop(); + it('should return a token', async () => { + const tokenManager = ServerTokenManager.default({ + config: configWithSecret, + logger, + }); expect((await tokenManager.getToken()).token).toBeDefined(); }); }); describe('authenticate', () => { it('should not throw if token is valid', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager = ServerTokenManager.default({ + config: configWithSecret, + logger, + }); const { token } = await tokenManager.getToken(); await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); }); it('should throw if token is invalid', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager = ServerTokenManager.default({ + config: configWithSecret, + logger, + }); await expect( tokenManager.authenticate('random-string'), ).rejects.toThrowError(/invalid server token/i); }); it('should validate server tokens created by a different instance using the same secret', async () => { - const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); - const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager1 = ServerTokenManager.default({ + config: configWithSecret, + logger, + }); + const tokenManager2 = ServerTokenManager.default({ + config: configWithSecret, + logger, + }); const { token } = await tokenManager1.getToken(); @@ -63,23 +82,26 @@ describe('ServerTokenManager', () => { }); it('should validate server tokens created using any of the secrets', async () => { - const tokenManager1 = ServerTokenManager.fromConfig( - new ConfigReader({ + const tokenManager1 = ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, }), - ); - const tokenManager2 = ServerTokenManager.fromConfig( - new ConfigReader({ + logger, + }); + const tokenManager2 = ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, }), - ); - const tokenManager3 = ServerTokenManager.fromConfig( - new ConfigReader({ + logger, + }); + const tokenManager3 = ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] }, }, }), - ); + logger, + }); const { token: token1 } = await tokenManager1.getToken(); await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow(); @@ -89,16 +111,18 @@ describe('ServerTokenManager', () => { }); it('should throw for server tokens created using a different secret', async () => { - const tokenManager1 = ServerTokenManager.fromConfig( - new ConfigReader({ + const tokenManager1 = ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, }), - ); - const tokenManager2 = ServerTokenManager.fromConfig( - new ConfigReader({ + logger, + }); + const tokenManager2 = ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, }), - ); + logger, + }); const { token } = await tokenManager1.getToken(); @@ -107,83 +131,97 @@ describe('ServerTokenManager', () => { ); }); - it('should throw for server tokens created using a noop TokenManager', async () => { - const noopTokenManager = ServerTokenManager.noop(); - const tokenManager = ServerTokenManager.fromConfig( - new ConfigReader({ + it('should throw for server tokens created by a different generated secret', async () => { + (process.env as any).NODE_ENV = 'development'; + const tokenManager1 = ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, }), - ); + logger, + }); + const tokenManager2 = ServerTokenManager.default({ + config: emptyConfig, + logger, + }); - const { token } = await noopTokenManager.getToken(); + const { token } = await tokenManager2.getToken(); - await expect(tokenManager.authenticate(token)).rejects.toThrowError( + await expect(tokenManager1.authenticate(token)).rejects.toThrowError( /invalid server token/i, ); }); }); - describe('ServerTokenManager.fromConfig', () => { - it('should throw if backend auth configuration is missing', () => { - expect(() => - ServerTokenManager.fromConfig(new ConfigReader({})), - ).toThrow(); + describe('default', () => { + describe('NOVE_ENV === production', () => { + it('should throw if backend auth configuration is missing', () => { + expect(() => + ServerTokenManager.default({ config: emptyConfig, logger }), + ).toThrow(); + }); + + it('should throw if no keys are included in the configuration', () => { + expect(() => + ServerTokenManager.default({ + config: new ConfigReader({ + backend: { auth: { keys: [] } }, + }), + logger, + }), + ).toThrow(); + }); + + it('should throw if any key is missing a secret property', () => { + expect(() => + ServerTokenManager.default({ + config: new ConfigReader({ + backend: { + auth: { + keys: [{ secret: '123' }, {}, { secret: '789' }], + }, + }, + }), + logger, + }), + ).toThrow(); + }); }); - it('should throw if no keys are included in the configuration', () => { - expect(() => - ServerTokenManager.fromConfig( - new ConfigReader({ + describe('NOVE_ENV === development', () => { + const generateSyncSpy = jest.spyOn(JWK, 'generateSync'); + + beforeEach(() => { + (process.env as any).NODE_ENV = 'development'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should generate a key if no config is provided', () => { + ServerTokenManager.default({ + config: emptyConfig, + logger, + }); + + expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192); + }); + + it('should generate a key if no keys are provided in the configuration', () => { + ServerTokenManager.default({ + config: new ConfigReader({ backend: { auth: { keys: [] } }, }), - ), - ).toThrow(); - }); + logger, + }); - it('should throw if any key is missing a secret property', () => { - expect(() => - ServerTokenManager.fromConfig( - new ConfigReader({ - backend: { - auth: { - keys: [{ secret: '123' }, {}, { secret: '789' }], - }, - }, - }), - ), - ).toThrow(); - }); - }); + expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192); + }); - describe('ServerTokenManager.noop', () => { - let noopTokenManager: TokenManager; - - beforeEach(() => { - noopTokenManager = ServerTokenManager.noop(); - }); - - it('should accept tokens it generates', async () => { - const { token } = await noopTokenManager.getToken(); - - await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow(); - }); - - it('should accept tokens generated by other noop token managers', async () => { - const noopTokenManager2 = ServerTokenManager.noop(); - await expect( - noopTokenManager.authenticate( - ( - await noopTokenManager2.getToken() - ).token, - ), - ).resolves.not.toThrow(); - }); - - it('should accept signed tokens', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - await expect( - noopTokenManager.authenticate((await tokenManager.getToken()).token), - ).resolves.not.toThrow(); + it('should use provided secrets if config is provided', () => { + ServerTokenManager.default({ config: configWithSecret, logger }); + expect(generateSyncSpy).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 35a07509b6..a9633c7f77 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -18,14 +18,7 @@ import { JWKS, JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; - -class NoopTokenManager implements TokenManager { - async getToken() { - return { token: '' }; - } - - async authenticate() {} -} +import { Logger } from 'winston'; /** * Creates and validates tokens for use during backend-to-backend @@ -37,20 +30,42 @@ export class ServerTokenManager implements TokenManager { private readonly verificationKeys: JWKS.KeyStore; private readonly signingKey: JWK.Key; - static noop(): TokenManager { - return new NoopTokenManager(); + static default(options: { config: Config; logger: Logger }) { + const { config, logger } = options; + + if (process.env.NODE_ENV === 'development') { + let secrets: string[] = []; + try { + secrets = this.getSecrets(config); + } catch { + // For development, if a secret has not been configured, we auto generate a secret instead of throwing. + } + + if (!secrets.length) { + const generatedDevOnlyKey = JWK.generateSync('oct', 24 * 8); + if (generatedDevOnlyKey.k === undefined) { + throw new Error('No key generated'); + } + logger.warn( + 'Generated a secret for backend-to-backend authentication: DEVELOPMENT USE ONLY. You must configure a secret before deploying to production.', + ); + return new ServerTokenManager([generatedDevOnlyKey.k]); + } + return new ServerTokenManager(secrets); + } + + const secrets = this.getSecrets(config); + return new ServerTokenManager(secrets); } - static fromConfig(config: Config) { - return new ServerTokenManager( - config - .getConfigArray('backend.auth.keys') - .map(key => key.getString('secret')), - ); + private static getSecrets(config: Config) { + return config + .getConfigArray('backend.auth.keys') + .map(key => key.getString('secret')); } - private constructor(secrets?: string[]) { - if (!secrets?.length) { + private constructor(secrets: string[]) { + if (!secrets.length) { throw new Error( 'No secrets provided when constructing ServerTokenManager', ); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ba38f8c427..78b48e581f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -63,7 +63,7 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = ServerTokenManager.fromConfig(config); + const tokenManager = ServerTokenManager.default({ config, logger: root }); const permissions = new ServerPermissionClient({ discoveryApi: discovery, configApi: config, diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 3df1386d4d..5e254d4a36 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -4,11 +4,17 @@ ```ts import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common'; +import { AuthorizeResponse } from '@backstage/plugin-permission-common'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { Config } from '@backstage/config'; +import { DiscoveryApi } from '@backstage/plugin-permission-common'; +import { PermissionClient } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; import { Router } from 'express'; +import { ServerTokenManager } from '@backstage/backend-common'; // @public export type ApplyConditionsRequest = { @@ -119,4 +125,20 @@ export type PolicyDecision = result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; } | ConditionalPolicyDecision; + +// Warning: (ae-missing-release-tag) "ServerPermissionClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class ServerPermissionClient extends PermissionClient { + constructor(options: { + discoveryApi: DiscoveryApi; + configApi: Config; + serverTokenManager: ServerTokenManager; + }); + // (undocumented) + authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise; +} ```