diff --git a/.changeset/hip-rabbits-sort.md b/.changeset/hip-rabbits-sort.md new file mode 100644 index 0000000000..198115045e --- /dev/null +++ b/.changeset/hip-rabbits-sort.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Implemented support for external access using both the legacy token form and static tokens. diff --git a/.changeset/mean-gifts-study.md b/.changeset/mean-gifts-study.md new file mode 100644 index 0000000000..32e4d33da1 --- /dev/null +++ b/.changeset/mean-gifts-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Ensure that `ServerTokenMnanager` also reads the new `backend.auth.externalAccess` settings diff --git a/docs/auth/identity-resolver--old.md b/docs/auth/identity-resolver--old.md index 267f3e9d3c..c1659f46d8 100644 --- a/docs/auth/identity-resolver--old.md +++ b/docs/auth/identity-resolver--old.md @@ -9,7 +9,7 @@ This documentation is written for the old backend which has been replaced by [the new backend system](../backend-system/index.md), being the default since Backstage [version 1.24](../releases/v1.24.0.md). If have migrated to the new backend system, you may want to read [its own article](./identity-resolver.md) -instead. Also, check out [the migration docs](../backend-system/building-backends/08-migrating.md)! +instead. Otherwise, [consider migrating](../backend-system/building-backends/08-migrating.md)! ::: By default, every Backstage auth provider is configured only for the use-case of diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 39fc13fcb2..406f789630 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -9,7 +9,7 @@ This documentation is written for [the new backend system](../backend-system/index.md) which is the default since Backstage [version 1.24](../releases/v1.24.0.md). If you are still on the old backend system, you may want to read [its own article](./identity-resolver--old.md) -instead. +instead, and [consider migrating](../backend-system/building-backends/08-migrating.md)! ::: By default, every Backstage auth provider is configured only for the use-case of diff --git a/docs/auth/service-to-service-auth--old.md b/docs/auth/service-to-service-auth--old.md new file mode 100644 index 0000000000..7cd5cf4ca0 --- /dev/null +++ b/docs/auth/service-to-service-auth--old.md @@ -0,0 +1,177 @@ +--- +id: service-to-service-auth +title: Service to Service Auth +# prettier-ignore +description: This section describes how to use service to service authentication, both internally within Backstage plugins and towards external services. +--- + +:::info +This documentation is written for the old backend which has been replaced by +[the new backend system](../backend-system/index.md), being the default since +Backstage [version 1.24](../releases/v1.24.0.md). If have migrated to the new +backend system, you may want to read [its own article](./identity-resolver.md) +instead. Otherwise, [consider migrating](../backend-system/building-backends/08-migrating.md)! +::: + +This article describes the steps needed to introduce _service-to-service auth_ (formerly _backend-to-backend_ auth). +This allows plugin backends to determine whether a given request originates from +a legitimate Backstage plugin (or other external caller), by requiring a special +type of service-to-service token which is signed with a shared secret. + +When enabling this protection on your Backstage backend plugins, for example the +catalog, other callers in the ecosystem such as the search indexer and +scaffolder would need to present a valid token to the catalog to be able to +request its contents. + +## Setup + +In a newly created Backstage app, the backend is setup up to not require any +auth at all. This means that generated service-to-service tokens are empty, and +that incoming requests are not validated. If you want to enable +service-to-service auth, the first step is to switch out the following line in +your backend setup at `packages/backend/src/index.ts`: + +```ts title="packages/backend/src/index.ts" +/* highlight-remove-next-line */ +const tokenManager = ServerTokenManager.noop(); +/* highlight-add-next-line */ +const tokenManager = ServerTokenManager.fromConfig(config, { logger: root }); +``` + +By switching from the no-op `ServiceTokenManager` to one created from config, +you enable service-to-service auth for any plugin that implements it. The local +development setup will generally not be impacted by this, as temporary keys are +generated under the hood. But for the production setup, this means you must now +provide a shared secret that enables your backend plugins to communicate with +each other. + +Backstage service-to-service tokens are currently always signed with a single +secret key. It needs to be shared across all backend plugins and services that +ones wishes to communicate across. The key can be any base64 encoded secret. +The following command can be used to generate such a key in a terminal: + +```bash +node -p 'require("crypto").randomBytes(24).toString("base64")' +``` + +Then place it in the backend configuration, either as a direct value or +injected as an env variable. + +```yaml +# commonly in your app-config.production.yaml +backend: + auth: + keys: + - secret: + # - secret: ${BACKEND_SECRET} - if you want to use an env variable instead +``` + +**NOTE**: For ease of development, we auto-generate a key for you if you haven't +configured a secret in dev mode. You _must set your own secret_ in order for +service-to-service auth to work in production; the `ServiceTokenManager` will +throw an exception in production if it has no keys to work with, which will lead +to the backend failing to start up. + +## Usage in Backend Plugins + +There are a few steps if you want to make use of the service-to-service auth in +your own backend plugin. First you need to add the `TokenManager` dependency to +the `createRouter` options. Typically as `tokenManager: TokenManager`. Along +with this you'll need to ask users to start providing this new dependency in +their backend setup code. + +Once the `TokenManager` is available, you use the `.getToken()` method to generate +a new token for any outgoing requests towards other Backstage backend plugins. +This method should be called for every request that you make; do not store the +token for later use. The `TokenManager` implementations should already cache +tokens as needed. The returned token should then be added as a `Bearer` token +for the upstream request, for example: + +```ts +const { token } = await this.tokenManager.getToken(); + +const response = await fetch(pluginBackendApiUrl, { + method: 'GET', + headers: { + ...headers, + Authorization: `Bearer ${token}`, + }, +}); +``` + +To authenticate an incoming request you use the `.authenticate(token)` method. +At the time of writing this method doesn't return anything, it will simply +throw if the token is invalid. + +```ts +await tokenManager.authenticate(token); // throws if token is invalid +``` + +## Usage in External Callers + +If you have enabled server-to-server auth, you may be interested in generating +tokens in code that is external to Backstage itself. External callers may even +be written in other languages than Node.js. This section explains how to generate +a valid token yourself. + +The token must be a JWT with a `HS256` signature, using the raw base64 decoded +value of the configured key as the secret. It must also have the following payload: + +- `sub`: "backstage-server" (only this value supported currently) +- `exp`: one hour from the time it was generated, in epoch seconds + +> NOTE: The JWT must encode the `alg` header as a protected header, such as with +> [setProtectedHeader](https://github.com/panva/jose/blob/main/docs/classes/jwt_sign.SignJWT.md#setprotectedheader). + +## Granular Access Control + +We plan to build out the service-to-service auth to be much more powerful in the +future, but before that is done there are a few tricks you can use with the +current system to harden your deployments. This section assumes that you have +already split your backend plugins into more than one backend deployment, in +order to scale or isolate them. + +The backend auth configuration has support for providing multiple keys, for +example: + +```yaml +backend: + auth: + keys: + - secret: my-secret-key-1 + - secret: my-secret-key-2 + - secret: my-secret-key-3 +``` + +The first key will be used for signing requests, while all of the keys will be +used for validation. This means that you can set up an asymmetric configuration +where some backend deployments do not have access to each other. + +For example, consider the case where we have split up the catalog, scaffolder, +and search plugin into three separate backend deployments. We can use the +following configurations to allow both the scaffolder and search plugin to speak +to the +catalog, but not the other way around, and to not allow any communication between +the scaffolder and search plugins. + +```yaml +# catalog config +backend: + auth: + keys: + - secret: my-secret-key-catalog + - secret: my-secret-key-scaffolder + - secret: my-secret-key-search + +# scaffolder config +backend: + auth: + keys: + - secret: my-secret-key-scaffolder + +# search config +backend: + auth: + keys: + - secret: my-secret-key-search +``` diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index aac61be563..15c2567d47 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -2,168 +2,167 @@ id: service-to-service-auth title: Service to Service Auth # prettier-ignore -description: This section describes how to use service to service authentication, both internally within Backstage plugins and towards external services. +description: This section describes service to service authentication works, both internally within Backstage plugins and when external callers want to make requests. --- -This article describes the steps needed to introduce _service-to-service auth_ (formerly _backend-to-backend_ auth). -This allows plugin backends to determine whether a given request originates from -a legitimate Backstage plugin (or other external caller), by requiring a special -type of service-to-service token which is signed with a shared secret. +:::info +This documentation is written for [the new backend +system](../backend-system/index.md) which is the default since Backstage +[version 1.24](../releases/v1.24.0.md). If you are still on the old backend +system, you may want to read [its own article](./service-to-service-auth--old.md) +instead, and [consider migrating](../backend-system/building-backends/08-migrating.md)! +::: -When enabling this protection on your Backstage backend plugins, for example the -catalog, other callers in the ecosystem such as the search indexer and -scaffolder would need to present a valid token to the catalog to be able to -request its contents. +This article describes how _service-to-service auth_ works in Backstage, both +between Backstage backend plugins and for external callers who want to make +requests to them. This is in contrast to _user and user-to-service auth_ which +use different flows. -## Setup +Each section describes one distinct type of auth flow. -In a newly created Backstage app, the backend is setup up to not require any -auth at all. This means that generated service-to-service tokens are empty, and -that incoming requests are not validated. If you want to enable -service-to-service auth, the first step is to switch out the following line in -your backend setup at `packages/backend/src/index.ts`: +## Standard Plugin-to-Plugin Auth -```ts title="packages/backend/src/index.ts" -/* highlight-remove-next-line */ -const tokenManager = ServerTokenManager.noop(); -/* highlight-add-next-line */ -const tokenManager = ServerTokenManager.fromConfig(config, { logger: root }); +Backstage plugins that use the new backend system and handle credentials using +the `auth` and `httpAuth` service APIs are secure by default, without requiring +any configuration. They generate self-signed tokens automatically for making +requests to other Backstage backend plugins, and the receivers use the caller's +public key set endpoint to be able to perform verification. + +This flow has only one configuration option to set in your app-config: +`backend.auth.dangerouslyDisableDefaultAuthPolicy`, which can be set to `true` +if you for some reason need to completely disable both the issuing and +verification of tokens between backend plugins. This makes your backends +insecure and callable by anyone without auth, so only use this as a last resort +and when your deployment is behind a secure ingress like a VPN. + +External callers cannot leverage this flow; it's only used internally by backend +plugins calling other backend plugins. + +## Static Tokens + +This access method consists of random static tokens that can be handed out to +external callers who want to make requests to Backstage backend plugins. This is +useful for the most basic callers such as command line scripts, web hooks and +similar. + +You configure this access method by adding one or more entries of type `static` +to the `backend.auth.externalAccess` app-config key: + +```yaml title="in e.g. app-config.production.yaml" +backend: + auth: + externalAccess: + - type: static + config: + token: ${CICD_TOKEN} + subject: cicd-system-completion-events + - type: static + config: + token: ${ADMIN_CURL_TOKEN} + subject: admin-curl-access ``` -By switching from the no-op `ServiceTokenManager` to one created from config, -you enable service-to-service auth for any plugin that implements it. The local -development setup will generally not be impacted by this, as temporary keys are -generated under the hood. But for the production setup, this means you must now -provide a shared secret that enables your backend plugins to communicate with -each other. +The tokens can be any string without whitespace, but for security reasons should +be sufficiently long so as not to be easy to guess by brute force. You can for +example generate them on the command line: -Backstage service-to-service tokens are currently always signed with a single -secret key. It needs to be shared across all backend plugins and services that -ones wishes to communicate across. The key can be any base64 encoded secret. -The following command can be used to generate such a key in a terminal: - -```bash +```shell node -p 'require("crypto").randomBytes(24).toString("base64")' ``` -Then place it in the backend configuration, either as a direct value or -injected as an env variable. +The subjects must be strings without whitespace. They are used for identifying +each caller, and become part of the credentials object that request recipient +plugins get. + +Callers pass along the tokens verbatim with requests in the `Authorization` +header: ```yaml -# commonly in your app-config.production.yaml +Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW +``` + +## Legacy Tokens + +Plugins and backends that are _not_ on the new backend system use a legacy token +flow, where shared static secrets in your app-config are used for signing and +verification. If you are on the new backend system and are not using legacy +plugins using the compatibility wrapper, you can skip this section. + +### Configuration (legacy) + +In local development, there is no need to configure anything for this auth +method. But in production, you must configure at least one legacy type external +access method: + +```yaml title="in e.g. app-config.production.yaml" backend: auth: - keys: - - secret: - # - secret: ${BACKEND_SECRET} - if you want to use an env variable instead + externalAccess: + - type: legacy + config: + secret: my-secret-key-catalog + subject: legacy-catalog + - type: legacy + config: + secret: my-secret-key-scaffolder + subject: legacy-scaffolder ``` -**NOTE**: For ease of development, we auto-generate a key for you if you haven't -configured a secret in dev mode. You _must set your own secret_ in order for -service-to-service auth to work in production; the `ServiceTokenManager` will -throw an exception in production if it has no keys to work with, which will lead -to the backend failing to start up. +The old style keys config is also supported as an alternative, but please +consider using the new style above instead: -## Usage in Backend Plugins - -There are a few steps if you want to make use of the service-to-service auth in -your own backend plugin. First you need to add the `TokenManager` dependency to -the `createRouter` options. Typically as `tokenManager: TokenManager`. Along -with this you'll need to ask users to start providing this new dependency in -their backend setup code. - -Once the `TokenManager` is available, you use the `.getToken()` method to generate -a new token for any outgoing requests towards other Backstage backend plugins. -This method should be called for every request that you make; do not store the -token for later use. The `TokenManager` implementations should already cache -tokens as needed. The returned token should then be added as a `Bearer` token -for the upstream request, for example: - -```ts -const { token } = await this.tokenManager.getToken(); - -const response = await fetch(pluginBackendApiUrl, { - method: 'GET', - headers: { - ...headers, - Authorization: `Bearer ${token}`, - }, -}); -``` - -To authenticate an incoming request you use the `.authenticate(token)` method. -At the time of writing this method doesn't return anything, it will simply -throw if the token is invalid. - -```ts -await tokenManager.authenticate(token); // throws if token is invalid -``` - -## Usage in External Callers - -If you have enabled server-to-server auth, you may be interested in generating -tokens in code that is external to Backstage itself. External callers may even -be written in other languages than Node.js. This section explains how to generate -a valid token yourself. - -The token must be a JWT with a `HS256` signature, using the raw base64 decoded -value of the configured key as the secret. It must also have the following payload: - -- `sub`: "backstage-server" (only this value supported currently) -- `exp`: one hour from the time it was generated, in epoch seconds - -> NOTE: The JWT must encode the `alg` header as a protected header, such as with -> [setProtectedHeader](https://github.com/panva/jose/blob/main/docs/classes/jwt_sign.SignJWT.md#setprotectedheader). - -## Granular Access Control - -We plan to build out the service-to-service auth to be much more powerful in the -future, but before that is done there are a few tricks you can use with the -current system to harden your deployments. This section assumes that you have -already split your backend plugins into more than one backend deployment, in -order to scale or isolate them. - -The backend auth configuration has support for providing multiple keys, for -example: - -```yaml -backend: - auth: - keys: - - secret: my-secret-key-1 - - secret: my-secret-key-2 - - secret: my-secret-key-3 -``` - -The first key will be used for signing requests, while all of the keys will be -used for validation. This means that you can set up an asymmetric configuration -where some backend deployments do not have access to each other. - -For example, consider the case where we have split up the catalog, scaffolder, -and search plugin into three separate backend deployments. We can use the -following configurations to allow both the scaffolder and search plugin to speak -to the -catalog, but not the other way around, and to not allow any communication between -the scaffolder and search plugins. - -```yaml -# catalog config +```yaml title="in e.g. app-config.production.yaml" backend: auth: keys: - secret: my-secret-key-catalog - secret: my-secret-key-scaffolder - - secret: my-secret-key-search - -# scaffolder config -backend: - auth: - keys: - - secret: my-secret-key-scaffolder - -# search config -backend: - auth: - keys: - - secret: my-secret-key-search +``` + +The secrets must be any base64-encoded random data, but for security reasons +should be sufficiently long so as not to be easy to guess by brute force. You +can for example generate them on the command line: + +```shell +node -p 'require("crypto").randomBytes(24).toString("base64")' +``` + +The subjects must be strings without whitespace. They are used for identifying +each caller, and become part of the credentials object that request recipient +plugins get. + +In both of the examples we showed two secrets being specified, but the minimum +is one. The order is significant: the first one is always used for signing of +outgoing requests to other backend plugins, while all of the keys are used for +verification. This is useful if you want to be able to have unique keys per +deployment if you are using split deployments of Backstage. Then each deployment +lists its own signing secret at the top, and only adds the secrets for those +other deployments that it wants to permit to call it. + +For most organizations, we recommend leaving it at just one key and +[migrating](../backend-system/building-backends/08-migrating.md) to the new +backend system as soon as possible instead of experimenting with multiple legacy +secrets. + +### External Callers (legacy) + +For legacy Backstage backend plugins, the above configuration is enough. But +external callers who wish to make requests using this flow must generate tokens +according to the following rules. + +The token must be a JWT with a `HS256` signature, using the raw base64 decoded +value of the configured key as the secret. It must also have the following +payload: + +- `sub`: the exact string "backstage-server" +- `exp`: one hour from the time it was generated, in epoch seconds + +> NOTE: The JWT must encode the `alg` header as a protected header, such as with +> [setProtectedHeader](https://github.com/panva/jose/blob/main/docs/classes/jwt_sign.SignJWT.md#setprotectedheader). + +The caller then passes along the JWT token with requests in the `Authorization` +header: + +```yaml +Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW ``` diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts index 137615a152..4a54de1cb8 100644 --- a/packages/backend-app-api/config.d.ts +++ b/packages/backend-app-api/config.d.ts @@ -32,6 +32,104 @@ export interface Config { * unless you configure credentials for service calls. */ dangerouslyDisableDefaultAuthPolicy?: boolean; + + /** + * Configures methods of external access, ie ways for callers outside of + * the Backstage ecosystem to get authorized for access to APIs that do + * not permit unauthorized access. + * + * @deepVisibility secret + */ + externalAccess: Array< + | { + /** + * This is the legacy service-to-service access method, where a set + * of static keys were shared among plugins and used for symmetric + * signing and verification. These correspond to the old + * `backend.auth.keys` set and retain their behavior for backwards + * compatibility. Please migrate to other access methods when + * possible. + * + * Callers generate JWT tokens with the following payload: + * + * ```json + * { + * "sub": "backstage-plugin", + * "exp": + * } + * ``` + * + * And sign them with HS256, using the base64 decoded secret. The + * tokens are then passed along with requests in the Authorization + * header: + * + * ``` + * Authorization: Bearer eyJhbGciOiJIUzI... + * ``` + */ + type: 'legacy'; + options: { + /** + * Any set of base64 encoded random bytes to be used as both the + * signing and verification key. Should be sufficiently long so as + * not to be easy to guess by brute force. + * + * Can be generated eg using + * + * ```sh + * node -p 'require("crypto").randomBytes(24).toString("base64")' + * ``` + */ + secret: string; + + /** + * Sets the subject of the principal, when matching this token. + * Useful for debugging and tracking purposes. + */ + subject: string; + }; + } + | { + /** + * This access method consists of random static tokens that can be + * handed out to callers. + * + * The tokens are then passed along verbatim with requests in the + * Authorization header: + * + * ``` + * Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW + * ``` + */ + type: 'static'; + options: { + /** + * A raw token that can be any string, but for security reasons + * should be sufficiently long so as not to be easy to guess by + * brute force. + * + * Can be generated eg using + * + * ```sh + * node -p 'require("crypto").randomBytes(24).toString("base64")' + * ``` + * + * Since the tokens can be any string, you are free to add + * additional identifying data to them if you like. For example, + * adding a `freben-local-dev-` prefix for debugging purposes to a + * token that you know will be handed out for use as a personal + * access token during development. + */ + token: string; + + /** + * Sets the subject of the principal, when matching this token. + * Useful for debugging and tracking purposes. + */ + subject: string; + }; + } + >; }; }; diff --git a/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts b/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts index 8db3f033e2..eeb6100e75 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts @@ -26,8 +26,9 @@ import { import { AuthenticationError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; import { decodeJwt } from 'jose'; -import { PluginTokenHandler } from './PluginTokenHandler'; -import { UserTokenHandler } from './UserTokenHandler'; +import { ExternalTokenHandler } from './external/ExternalTokenHandler'; +import { PluginTokenHandler } from './plugin/PluginTokenHandler'; +import { UserTokenHandler } from './user/UserTokenHandler'; import { createCredentialsWithNonePrincipal, createCredentialsWithServicePrincipal, @@ -39,12 +40,13 @@ import { KeyStore } from './types'; /** @internal */ export class DefaultAuthService implements AuthService { constructor( - private readonly tokenManager: TokenManager, private readonly userTokenHandler: UserTokenHandler, + private readonly pluginTokenHandler: PluginTokenHandler, + private readonly externalTokenHandler: ExternalTokenHandler, + private readonly tokenManager: TokenManager, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, private readonly publicKeyStore: KeyStore, - private readonly pluginTokenHandler: PluginTokenHandler, ) {} // allowLimitedAccess is currently ignored, since we currently always use the full user tokens @@ -78,14 +80,15 @@ export class DefaultAuthService implements AuthService { ); } - // Legacy service-to-service token - const { sub, aud } = decodeJwt(token); - if (sub === 'backstage-server' && !aud) { - await this.tokenManager.authenticate(token); - return createCredentialsWithServicePrincipal('external:backstage-plugin'); + const externalResult = await this.externalTokenHandler.verifyToken(token); + if (externalResult) { + return createCredentialsWithServicePrincipal( + externalResult.subject, + externalResult.token, + ); } - throw new AuthenticationError('Unknown token'); + throw new AuthenticationError('Illegal token'); } isPrincipal( diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 226e9a6917..462aabfa21 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -20,8 +20,9 @@ import { } from '@backstage/backend-plugin-api'; import { DatabaseKeyStore } from './DatabaseKeyStore'; import { DefaultAuthService } from './DefaultAuthService'; -import { PluginTokenHandler } from './PluginTokenHandler'; -import { UserTokenHandler } from './UserTokenHandler'; +import { PluginTokenHandler } from './plugin/PluginTokenHandler'; +import { UserTokenHandler } from './user/UserTokenHandler'; +import { ExternalTokenHandler } from './external/ExternalTokenHandler'; /** @public */ export const authServiceFactory = createServiceFactory({ @@ -46,21 +47,34 @@ export const authServiceFactory = createServiceFactory({ ), ); - const publicKeyStore = await DatabaseKeyStore.create({ database, logger }); + const publicKeyStore = await DatabaseKeyStore.create({ + database, + logger, + }); + + const userTokens = UserTokenHandler.create({ + discovery, + }); + const pluginTokens = PluginTokenHandler.create({ + ownPluginId: plugin.getId(), + keyDurationSeconds: 60 * 60, + logger, + publicKeyStore, + discovery, + }); + const externalTokens = ExternalTokenHandler.create({ + config, + logger, + }); return new DefaultAuthService( + userTokens, + pluginTokens, + externalTokens, tokenManager, - new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, publicKeyStore, - PluginTokenHandler.create({ - ownPluginId: plugin.getId(), - keyDurationSeconds: 60 * 60, - logger, - publicKeyStore, - discovery, - }), ); }, }); diff --git a/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts new file mode 100644 index 0000000000..bb0dd02d46 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { LegacyTokenHandler } from './legacy'; +import { StaticTokenHandler } from './static'; +import { TokenHandler } from './types'; + +const NEW_CONFIG_KEY = 'backend.auth.externalAccess'; +const OLD_CONFIG_KEY = 'backend.auth.keys'; + +/** + * Handles all types of external caller token types (i.e. not Backstage user + * tokens, nor Backstage backend plugin tokens). + * + * @internal + */ +export class ExternalTokenHandler { + static create(options: { + config: RootConfigService; + logger: LoggerService; + }): ExternalTokenHandler { + const { config, logger } = options; + + const staticHandler = new StaticTokenHandler(); + const legacyHandler = new LegacyTokenHandler(); + const handlers: Record = { + static: staticHandler, + legacy: legacyHandler, + }; + + // Load the new-style handlers + const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? []; + for (const handlerConfig of handlerConfigs) { + const type = handlerConfig.getString('type'); + const handler = handlers[type]; + if (!handler) { + const valid = Object.keys(handlers) + .map(k => `'${k}'`) + .join(', '); + throw new Error( + `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`, + ); + } + handler.add(handlerConfig.getConfig('options')); + } + + // Load the old keys too + const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? []; + if (legacyConfigs.length) { + logger.warn( + `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`, + ); + } + for (const handlerConfig of legacyConfigs) { + legacyHandler.addOld(handlerConfig); + } + + return new ExternalTokenHandler(Object.values(handlers)); + } + + constructor(private readonly handlers: TokenHandler[]) {} + + async verifyToken( + token: string, + ): Promise<{ subject: string; token?: string } | undefined> { + for (const handler of this.handlers) { + const result = await handler.verifyToken(token); + if (result) { + return result; + } + } + return undefined; + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/external/legacy.test.ts b/packages/backend-app-api/src/services/implementations/auth/external/legacy.test.ts new file mode 100644 index 0000000000..6952449c9a --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/legacy.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { randomBytes } from 'crypto'; +import { SignJWT, importJWK } from 'jose'; +import { DateTime } from 'luxon'; +import { LegacyTokenHandler } from './legacy'; + +describe('LegacyTokenHandler', () => { + const tokenHandler = new LegacyTokenHandler(); + const key1 = randomBytes(24); + const key2 = randomBytes(24); + const key3 = randomBytes(24); + + tokenHandler.add( + new ConfigReader({ + secret: key1.toString('base64'), + subject: 'key1', + }), + ); + tokenHandler.add( + new ConfigReader({ + secret: key2.toString('base64'), + subject: 'key2', + }), + ); + tokenHandler.addOld( + new ConfigReader({ + secret: key3.toString('base64'), + }), + ); + + it('should verify valid tokens', async () => { + const token1 = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key1); + + await expect(tokenHandler.verifyToken(token1)).resolves.toEqual({ + subject: 'key1', + token: token1, + }); + + const token2 = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key2); + + await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({ + subject: 'key2', + token: token2, + }); + + const token3 = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key3); + + await expect(tokenHandler.verifyToken(token3)).resolves.toEqual({ + subject: 'external:backstage-plugin', + token: token3, + }); + }); + + it('should return undefined if the token is not a valid legacy token', async () => { + const validToken = await new SignJWT({ + sub: 'backstage-serverrr', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key1); + + await expect(tokenHandler.verifyToken(validToken)).resolves.toBeUndefined(); + + await expect( + tokenHandler.verifyToken('statickeyblaaa'), + ).resolves.toBeUndefined(); + + const randomToken = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(randomBytes(24)); + await expect( + tokenHandler.verifyToken(randomToken), + ).resolves.toBeUndefined(); + + const mockPublicKey = { + kty: 'EC', + x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI', + y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro', + crv: 'P-256', + kid: 'mock', + alg: 'ES256', + }; + const mockPrivateKey = { + ...mockPublicKey, + d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI', + }; + + const keyWithWrongAlg = await new SignJWT({ + sub: 'backstage-server', + exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(), + }) + .setProtectedHeader({ alg: 'ES256' }) + .sign(await importJWK(mockPrivateKey)); + + await expect( + tokenHandler.verifyToken(keyWithWrongAlg), + ).resolves.toBeUndefined(); + }); + + it('should throw in case key uses a different payload', async () => { + const keyWithWrongExp = await new SignJWT({ + sub: 'backstage-server', + // @ts-expect-error + exp: 'blaaah', + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key1); + + await expect(tokenHandler.verifyToken(keyWithWrongExp)).rejects.toThrow( + /\"exp\" claim must be a number/, + ); + }); + + it('rejects bad config', () => { + const handler = new LegacyTokenHandler(); + + // new style add, bad secrets + expect(() => + handler.add(new ConfigReader({ _missingsecret: true, subject: 'ok' })), + ).toThrow(/secret/); + expect(() => + handler.add(new ConfigReader({ secret: '', subject: 'ok' })), + ).toThrow(/secret/); + expect(() => + handler.add(new ConfigReader({ secret: 'has spaces', subject: 'ok' })), + ).toThrow(/secret/); + expect(() => + handler.add(new ConfigReader({ secret: 'hasnewline\n', subject: 'ok' })), + ).toThrow(/secret/); + expect(() => + handler.add(new ConfigReader({ secret: 3, subject: 'ok' })), + ).toThrow(/secret/); + + // new style add, bad subjects + expect(() => + handler.add(new ConfigReader({ secret: 'b2s=', _missingsubject: true })), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ secret: 'b2s=', subject: '' })), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ secret: 'b2s=', subject: 'has spaces' })), + ).toThrow(/subject/); + expect(() => + handler.add( + new ConfigReader({ secret: 'b2s=', subject: 'hasnewline\n' }), + ), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ secret: 'b2s=', subject: 3 })), + ).toThrow(/subject/); + + // old style add + expect(() => + handler.addOld(new ConfigReader({ secret: 'b2s=' })), + ).not.toThrow(); + expect(() => + handler.addOld(new ConfigReader({ _missingsecret: true })), + ).toThrow(/secret/); + expect(() => handler.addOld(new ConfigReader({ secret: '' }))).toThrow( + /secret/, + ); + expect(() => + handler.addOld(new ConfigReader({ secret: 'has spaces' })), + ).toThrow(/secret/); + expect(() => + handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })), + ).toThrow(/secret/); + expect(() => handler.addOld(new ConfigReader({ secret: 3 }))).toThrow( + /secret/, + ); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/external/legacy.ts b/packages/backend-app-api/src/services/implementations/auth/external/legacy.ts new file mode 100644 index 0000000000..bc7105fa88 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/legacy.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose'; +import { TokenHandler } from './types'; + +/** + * Handles `type: legacy` access. + * + * @internal + */ +export class LegacyTokenHandler implements TokenHandler { + #entries: Array<{ key: Uint8Array; subject: string }> = []; + + add(options: Config) { + this.#doAdd(options.getString('secret'), options.getString('subject')); + } + + // used only for the old backend.auth.keys array + addOld(options: Config) { + // This choice of subject is for compatibility reasons + this.#doAdd(options.getString('secret'), 'external:backstage-plugin'); + } + + #doAdd(secret: string, subject: string) { + if (!secret.match(/^\S+$/)) { + throw new Error('Illegal secret, must be a valid base64 string'); + } + + let key: Uint8Array; + try { + key = base64url.decode(secret); + } catch { + throw new Error('Illegal secret, must be a valid base64 string'); + } + + if (!subject.match(/^\S+$/)) { + throw new Error('Illegal subject, must be a set of non-space characters'); + } + + this.#entries.push({ key, subject }); + } + + async verifyToken( + token: string, + ): Promise<{ subject: string; token?: string } | undefined> { + // First do a duck typing check to see if it remotely looks like a legacy token + try { + // We do a fair amount of checking upfront here. Since we aren't certain + // that it's even the right type of key that we're looking at, we can't + // defer eg the alg check to jwtVerify, because it won't be possible to + // discern different reasons for key verification failures from each other + // easily + const { alg } = decodeProtectedHeader(token); + if (alg !== 'HS256') { + return undefined; + } + const { sub, aud } = decodeJwt(token); + if (sub !== 'backstage-server' || aud) { + return undefined; + } + } catch (e) { + // Doesn't look like a jwt at all + return undefined; + } + + for (const entry of this.#entries) { + try { + await jwtVerify(token, entry.key); + return { + subject: entry.subject, + token: token, + }; + } catch (e) { + if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { + throw e; + } + // Otherwise continue to try the next key + } + } + + // None of the signing keys matched + return undefined; + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/external/static.test.ts b/packages/backend-app-api/src/services/implementations/auth/external/static.test.ts new file mode 100644 index 0000000000..5e9c6b83dd --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/static.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { StaticTokenHandler } from './static'; + +describe('StaticTokenHandler', () => { + it('accepts any of the added list of tokens', async () => { + const handler = new StaticTokenHandler(); + handler.add(new ConfigReader({ token: 'abc', subject: 'one' })); + handler.add(new ConfigReader({ token: 'def', subject: 'two' })); + + await expect(handler.verifyToken('abc')).resolves.toEqual({ + subject: 'one', + token: 'abc', + }); + await expect(handler.verifyToken('def')).resolves.toEqual({ + subject: 'two', + token: 'def', + }); + await expect(handler.verifyToken('ghi')).resolves.toBeUndefined(); + }); + + it('gracefully handles no added tokens', async () => { + const handler = new StaticTokenHandler(); + await expect(handler.verifyToken('ghi')).resolves.toBeUndefined(); + }); + + it('rejects bad config', () => { + const handler = new StaticTokenHandler(); + + expect(() => + handler.add(new ConfigReader({ _missingtoken: true, subject: 'ok' })), + ).toThrow(/token/); + expect(() => + handler.add(new ConfigReader({ token: '', subject: 'ok' })), + ).toThrow(/token/); + expect(() => + handler.add(new ConfigReader({ token: 'has spaces', subject: 'ok' })), + ).toThrow(/token/); + expect(() => + handler.add(new ConfigReader({ token: 'hasnewline\n', subject: 'ok' })), + ).toThrow(/token/); + expect(() => + handler.add(new ConfigReader({ token: 3, subject: 'ok' })), + ).toThrow(/token/); + + expect(() => + handler.add(new ConfigReader({ token: 'ok', _missingsubject: true })), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ token: 'ok', subject: '' })), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ token: 'ok', subject: 'has spaces' })), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ token: 'ok', subject: 'hasnewline\n' })), + ).toThrow(/subject/); + expect(() => + handler.add(new ConfigReader({ token: 'ok', subject: 3 })), + ).toThrow(/subject/); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/external/static.ts b/packages/backend-app-api/src/services/implementations/auth/external/static.ts new file mode 100644 index 0000000000..6325253708 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/static.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { TokenHandler } from './types'; + +/** + * Handles `type: static` access. + * + * @internal + */ +export class StaticTokenHandler implements TokenHandler { + #entries: Array<{ token: string; subject: string }> = []; + + add(options: Config) { + const token = options.getString('token'); + if (!token.match(/^\S+$/)) { + throw new Error('Illegal token, must be a set of non-space characters'); + } + + const subject = options.getString('subject'); + if (!subject.match(/^\S+$/)) { + throw new Error('Illegal subject, must be a set of non-space characters'); + } + + this.#entries.push({ token, subject }); + } + + async verifyToken( + token: string, + ): Promise<{ subject: string; token: string } | undefined> { + const entry = this.#entries.find(e => e.token === token); + if (!entry) { + return undefined; + } + + return { + subject: entry.subject, + token: token, + }; + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/external/types.ts b/packages/backend-app-api/src/services/implementations/auth/external/types.ts new file mode 100644 index 0000000000..27b294824e --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; + +export interface TokenHandler { + add(options: Config): void; + verifyToken( + token: string, + ): Promise<{ subject: string; token?: string } | undefined>; +} diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.test.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.test.ts similarity index 100% rename from packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.test.ts rename to packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.test.ts diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts similarity index 98% rename from packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts rename to packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts index 15eaac6b1c..85cfa26285 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts @@ -25,11 +25,11 @@ import { decodeProtectedHeader, } from 'jose'; import { v4 as uuid } from 'uuid'; -import { InternalKey, KeyStore } from './types'; +import { InternalKey, KeyStore } from '../types'; import { AuthenticationError } from '@backstage/errors'; import { jwtVerify } from 'jose'; import { tokenTypes } from '@backstage/plugin-auth-node'; -import { JwksClient } from './JwksClient'; +import { JwksClient } from '../JwksClient'; /** * The margin for how many times longer we make the public key available diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts b/packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.test.ts similarity index 99% rename from packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts rename to packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.test.ts index 82b9b48d29..f2a86e4a20 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.test.ts @@ -69,7 +69,7 @@ describe('UserTokenHandler', () => { beforeEach(() => { jest.useRealTimers(); - userTokenHandler = new UserTokenHandler({ + userTokenHandler = UserTokenHandler.create({ discovery: mockServices.discovery(), }); diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.ts similarity index 87% rename from packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts rename to packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.ts index 10121fe450..af3faae22d 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/user/UserTokenHandler.ts @@ -24,7 +24,7 @@ import { jwtVerify, JWTVerifyOptions, } from 'jose'; -import { JwksClient } from './JwksClient'; +import { JwksClient } from '../JwksClient'; /** * An identity client to interact with auth-backend and authenticate Backstage @@ -33,29 +33,32 @@ import { JwksClient } from './JwksClient'; * @internal */ export class UserTokenHandler { - readonly #jwksClient: JwksClient; - readonly #algorithms?: string[]; - - constructor(options: { discovery: DiscoveryService }) { - this.#algorithms = ['ES256']; // TODO: configurable? - this.#jwksClient = new JwksClient(async () => { + static create(options: { discovery: DiscoveryService }): UserTokenHandler { + const algorithms = ['ES256']; // TODO: configurable? + const jwksClient = new JwksClient(async () => { const url = await options.discovery.getBaseUrl('auth'); return new URL(`${url}/.well-known/jwks.json`); }); + return new UserTokenHandler(algorithms, jwksClient); } + constructor( + private readonly algorithms: string[], + private readonly jwksClient: JwksClient, + ) {} + async verifyToken(token: string) { const verifyOpts = this.#getTokenVerificationOptions(token); if (!verifyOpts) { return undefined; } - await this.#jwksClient.refreshKeyStore(token); + await this.jwksClient.refreshKeyStore(token); // Verify a limited token, ensuring the necessarily claims are present and token type is correct const { payload } = await jwtVerify( token, - this.#jwksClient.getKey, + this.jwksClient.getKey, verifyOpts, ).catch(e => { throw new AuthenticationError('Invalid token', e); @@ -76,7 +79,7 @@ export class UserTokenHandler { if (typ === tokenTypes.user.typParam) { return { - algorithms: this.#algorithms, + algorithms: this.algorithms, requiredClaims: ['iat', 'exp', 'sub'], typ: tokenTypes.user.typParam, }; @@ -84,7 +87,7 @@ export class UserTokenHandler { if (typ === tokenTypes.limitedUser.typParam) { return { - algorithms: this.#algorithms, + algorithms: this.algorithms, requiredClaims: ['iat', 'exp', 'sub'], typ: tokenTypes.limitedUser.typParam, }; @@ -93,7 +96,7 @@ export class UserTokenHandler { const { aud } = decodeJwt(token); if (aud === tokenTypes.user.audClaim) { return { - algorithms: this.#algorithms, + algorithms: this.algorithms, audience: tokenTypes.user.audClaim, }; } diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 3c487e9b9b..b9b6a372d1 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -19,6 +19,7 @@ import * as jose from 'jose'; import { getVoidLogger } from '../logging'; import { ServerTokenManager } from './ServerTokenManager'; import { TokenManager } from './types'; +import { DateTime } from 'luxon'; const emptyConfig = new ConfigReader({}); const configWithSecret = new ConfigReader({ @@ -232,6 +233,39 @@ describe('ServerTokenManager', () => { 'Invalid server token; caused by AuthenticationError: Server-to-server token had no exp claim', ); }); + + it('loads both old and new config', async () => { + const oldSecret = jose.base64url.encode('old'); + const newSecret = jose.base64url.encode('new'); + + const tokenManager = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { + auth: { + keys: [{ secret: oldSecret }], + externalAccess: [ + { type: 'legacy', config: { secret: newSecret } }, + ], + }, + }, + }), + { logger }, + ); + + const oldToken = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject('backstage-server') + .setExpirationTime(DateTime.now().plus({ minutes: 1 }).toUnixInteger()) + .sign(jose.base64url.decode(oldSecret)); + const newToken = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject('backstage-server') + .setExpirationTime(DateTime.now().plus({ minutes: 1 }).toUnixInteger()) + .sign(jose.base64url.decode(newSecret)); + + await expect(tokenManager.authenticate(oldToken)).resolves.not.toThrow(); + await expect(tokenManager.authenticate(newToken)).resolves.not.toThrow(); + }); }); describe('fromConfig', () => { diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index cec6ff068f..3e4311f05e 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -74,12 +74,17 @@ export class ServerTokenManager implements TokenManager { } static fromConfig(config: Config, options: ServerTokenManagerOptions) { - const keys = config.getOptionalConfigArray('backend.auth.keys'); - if (keys?.length) { - return new ServerTokenManager( - keys.map(key => key.getString('secret')), - options, - ); + const oldSecrets = config + .getOptionalConfigArray('backend.auth.keys') + ?.map(c => c.getString('secret')); + const newSecrets = config + .getOptionalConfigArray('backend.auth.externalAccess') + ?.filter(c => c.getString('type') === 'legacy') + .map(c => c.getString('config.secret')); + const secrets = [...(oldSecrets ?? []), ...(newSecrets ?? [])]; + + if (secrets.length) { + return new ServerTokenManager(secrets, options); } if (process.env.NODE_ENV !== 'development') {