implement external token access

Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-04-12 16:59:26 +02:00
parent 99305a09da
commit 00fca28b41
21 changed files with 1080 additions and 184 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Implemented support for external access using both the legacy token form and static tokens.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Ensure that `ServerTokenMnanager` also reads the new `backend.auth.externalAccess` settings
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+177
View File
@@ -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: <the string returned by the above crypto command>
# - 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
```
+139 -140
View File
@@ -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: <the string returned by the above crypto command>
# - 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
```
+98
View File
@@ -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": <epoch seconds one hour in the future>
* }
* ```
*
* 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;
};
}
>;
};
};
@@ -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<TType extends keyof BackstagePrincipalTypes>(
@@ -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,
}),
);
},
});
@@ -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<string, TokenHandler> = {
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;
}
}
@@ -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/,
);
});
});
@@ -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;
}
}
@@ -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/);
});
});
@@ -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,
};
}
}
@@ -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>;
}
@@ -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
@@ -69,7 +69,7 @@ describe('UserTokenHandler', () => {
beforeEach(() => {
jest.useRealTimers();
userTokenHandler = new UserTokenHandler({
userTokenHandler = UserTokenHandler.create({
discovery: mockServices.discovery(),
});
@@ -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,
};
}
@@ -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', () => {
@@ -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') {