Merge pull request #28942 from Sarabadu/externalTokenDecorator

RFC add external token decorator service
This commit is contained in:
Fredrik Adelöw
2025-09-25 15:40:02 +02:00
committed by GitHub
21 changed files with 1400 additions and 846 deletions
+111 -1
View File
@@ -414,9 +414,13 @@ Each entry has one or more of the following fields:
## Adding custom or logic for validation and issuing of tokens
The `pluginTokenHandlerDecoratorServiceRef` can be used to decorate the existing token handler without having to re-implement the entire `AuthService` implementation.
The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenHandlersServiceRef` can be used to extend the existing token handler without having to re-implement the entire `AuthService` implementation.
This is particularly useful when you want to add additional logic to the handler, such as logging or metrics or custom token validation.
### PluginTokenHandler decoration
The `pluginTokenHandlerDecoratorServiceRef` can be used to decorate the default PluginTokenHandler used for create and verify tokens from plugins.
The `PluginTokenHandler` interface has two methods:
- `issueToken`: This method is used to issue a token for a plugin. It takes in the `pluginId` and `targetPluginId` as arguments, and an optional `limitedUserToken` object which can be used to issue a token on behalf of another user. The method returns a promise that resolves to an object containing the issued token.
@@ -439,3 +443,109 @@ const decoratedPluginTokenHandler = createServiceFactory({
},
});
```
### Adding custom ExternalTokenHandler
The `externalTokenHandlersServiceRef` can be used to add custom external token handlers to the default implementation.
Your service factory must return an object with a `type` property that matches the token type in your configuration (e.g., 'custom', 'api-key'). When Backstage encounters tokens of this type, it calls your `initialize` method with all the configuration entries that match this type. Your factory can return either a single token handler or an array of handlers to process and validate these tokens.
:::note Note
During token verification, all the token handlers are tested. Consider this when adding many token handlers, as it may impact performance.
:::
For example, if we want to add a custom external token handler for the `custom` type:
our config would look like this:
```yaml title="in e.g. app-config.production.yaml"
backend:
auth:
externalAccess:
- type: custom
options:
customOptions: additional-value
accessRestrictions:
- plugin: events
- type: custom
options:
customOptions: another-value
accessRestrictions:
- plugin: events
```
And we can implement the custom token handler like this:
```ts
import {
ExternalTokenHandler,
externalTokenHandlersServiceRef,
createExternalTokenHandler,
} from '@backstage/backend-defaults/auth';
import { createServiceFactory } from '@backstage/backend-plugin-api';
const customExternalTokenHandlers = createServiceFactory({
service: externalTokenHandlersServiceRef,
deps: {},
async factory() {
return createExternalTokenHandler({
type: 'custom',
initialize({ options }) {
// Initialize your handler context from config
const customOptions = options.getString('customOptions');
return { customOptions };
},
async verifyToken(token, context) {
// Your custom token validation logic here
// Return undefined if token is invalid
// Return { subject: 'your-subject' } if token is valid
if (token === 'valid-token') {
return { subject: `custom:${context.customOptions}` };
}
return undefined;
},
});
},
});
```
The `createExternalTokenHandler` helper simplifies creating external token handlers with the new API:
- **`type`**: A string identifier for your token handler type that matches the config
- **`initialize`**: Called once for each config entry of this type, receives the config options and returns a context object that will be passed to `verifyToken`
- **`verifyToken`**: Called for each token verification with the token and context, returns the subject if valid or `undefined` if not
```ts
// Example of a more complex handler with external API call
const apiTokenHandler = createExternalTokenHandler({
type: 'api-validation',
initialize({ options }) {
const apiBaseUrl = options.getString('apiBaseUrl');
const apiKey = options.getString('apiKey');
return { apiBaseUrl, apiKey };
},
async verifyToken(token, { apiBaseUrl, apiKey }) {
try {
const response = await fetch(`${apiBaseUrl}/validate-token`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
if (response.ok) {
const { userId } = await response.json();
return { subject: `api:${userId}` };
}
} catch (error) {
// Log error but don't throw - return undefined for invalid tokens
}
return undefined;
},
});
```
@@ -223,6 +223,35 @@ export const customFooServiceFactory = createServiceFactory({
This allows you to provide more advanced options for the service implementation that couldn't be expressed through static configuration. It also gives users of the service implementation access to other services through dependency injection, which can be useful for their customizations.
## Multiton
By default the service reference will point to a singleton instance of the service. This mean if a new service factory uses this reference it will override the previous one. This is the most common use-case, but in some cases you may want to have multiple instances of the same service.
For some services, it is desirable to extend the functionality instead of overriding it. For example, some services could have many handlers to address specific events, and you may want to add a new handler instead of overriding the previous one. In this case, you can use the `multiton` option when creating the service reference:
```ts
// example-service-ref.ts
import { createServiceRef } from '@backstage/backend-plugin-api';
export interface FooService {
foo(options: FooOptions): Promise<FooResult>;
}
export const fooServiceRef = createServiceRef<FooService>({
id: 'example.foo',
multiton: true, // this service ref will be an array of instances
});
```
When adding this `serviceRef` as a dependency to a factory, the factory will receive an array of instances instead of a single instance:
```ts
deps: {fooServices: fooServiceRef},
factory(fooServices) {
// fooServices is an array of instances
return new Bar(fooServices);
},
```
## Service Factory Options Pattern
:::note Note