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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Implemented support for external access using both the legacy token form and static tokens.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Ensure that `ServerTokenMnanager` also reads the new `backend.auth.externalAccess` settings
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Vendored
+98
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Vendored
+91
@@ -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;
|
||||
}
|
||||
}
|
||||
+207
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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/);
|
||||
});
|
||||
});
|
||||
+55
@@ -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>;
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -69,7 +69,7 @@ describe('UserTokenHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
|
||||
userTokenHandler = new UserTokenHandler({
|
||||
userTokenHandler = UserTokenHandler.create({
|
||||
discovery: mockServices.discovery(),
|
||||
});
|
||||
|
||||
+15
-12
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user