bring the legacy config back
Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
@@ -3,24 +3,3 @@
|
||||
---
|
||||
|
||||
Add a new `externalTokenHandlersServiceRef` to allow custom external token validations
|
||||
|
||||
BREAKING CHANGE: The `backend.auth.keys` config has been removed. Please migrate to the new `backend.auth.externalAccess` config as described in the documentation: https://backstage.io/docs/auth/service-to-service-auth
|
||||
|
||||
**Migration Example:**
|
||||
|
||||
```yaml
|
||||
# ❌ Old format (no longer supported)
|
||||
backend:
|
||||
auth:
|
||||
keys:
|
||||
- secret: your-secret-key
|
||||
|
||||
# ✅ New format
|
||||
backend:
|
||||
auth:
|
||||
externalAccess:
|
||||
- type: static
|
||||
options:
|
||||
token: your-secret-key
|
||||
subject: external:backstage-plugin # this is the current default for old keys
|
||||
```
|
||||
|
||||
@@ -255,16 +255,10 @@ backend:
|
||||
subject: legacy-scaffolder
|
||||
```
|
||||
|
||||
:::warning
|
||||
The old style `backend.auth.keys` config is **no longer supported** and has been removed.
|
||||
If you are still using this configuration, you must migrate to the new `backend.auth.externalAccess` format above.
|
||||
The old style keys config is also supported as an alternative, but please
|
||||
consider using the new style above instead:
|
||||
|
||||
You'll see an error like `"Unknown key 'backend.auth.keys'"` during Backstage startup if you haven't migrated yet.
|
||||
:::
|
||||
|
||||
To migrate from the old config format:
|
||||
|
||||
```yaml title="❌ Old format (no longer supported)"
|
||||
```yaml title="in e.g. app-config.production.yaml"
|
||||
backend:
|
||||
auth:
|
||||
keys:
|
||||
@@ -272,22 +266,6 @@ backend:
|
||||
- secret: my-secret-key-scaffolder
|
||||
```
|
||||
|
||||
Convert to:
|
||||
|
||||
```yaml title="✅ New format"
|
||||
backend:
|
||||
auth:
|
||||
externalAccess:
|
||||
- type: legacy
|
||||
options:
|
||||
secret: my-secret-key-catalog
|
||||
subject: external:backstage-plugin
|
||||
- type: legacy
|
||||
options:
|
||||
secret: my-secret-key-scaffolder
|
||||
subject: external:backstage-plugin
|
||||
```
|
||||
|
||||
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:
|
||||
@@ -436,7 +414,7 @@ Each entry has one or more of the following fields:
|
||||
|
||||
## Adding custom or logic for validation and issuing of tokens
|
||||
|
||||
The `pluginTokenHandlerDecoratorServiceRef` and `externalTokenTypeHandlersRef` can be used to extend 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
|
||||
@@ -468,7 +446,7 @@ const decoratedPluginTokenHandler = createServiceFactory({
|
||||
|
||||
### Adding custom ExternalTokenHandler
|
||||
|
||||
The `externalTokenTypeHandlersRef` can be used to add custom external token handlers to the default implementation.
|
||||
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.
|
||||
|
||||
@@ -503,13 +481,13 @@ And we can implement the custom token handler like this:
|
||||
```ts
|
||||
import {
|
||||
ExternalTokenHandler,
|
||||
externalTokenTypeHandlersRef,
|
||||
externalTokenHandlersServiceRef,
|
||||
createExternalTokenHandler,
|
||||
} from '@backstage/backend-defaults/auth';
|
||||
import { createServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
const customExternalTokenHandlers = createServiceFactory({
|
||||
service: externalTokenTypeHandlersRef,
|
||||
service: externalTokenHandlersServiceRef,
|
||||
deps: {},
|
||||
async factory() {
|
||||
return createExternalTokenHandler({
|
||||
|
||||
@@ -4,17 +4,10 @@
|
||||
|
||||
```ts
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import type { Config } from '@backstage/config';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export type AccessRestrictionsMap = Map<
|
||||
string, // plugin ID
|
||||
BackstagePrincipalAccessRestrictions
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const authServiceFactory: ServiceFactory<
|
||||
AuthService,
|
||||
@@ -46,7 +39,7 @@ export interface ExternalTokenHandler<TContext> {
|
||||
}
|
||||
|
||||
// @public
|
||||
export const externalTokenTypeHandlersRef: ServiceRef<
|
||||
export const externalTokenHandlersServiceRef: ServiceRef<
|
||||
ExternalTokenHandler<unknown>,
|
||||
'plugin',
|
||||
'multiton'
|
||||
|
||||
@@ -31,7 +31,7 @@ import { toInternalBackstageCredentials } from './helpers';
|
||||
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
|
||||
import { createServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
import { externalTokenTypeHandlersRef } from './external/ExternalAuthTokenHandler';
|
||||
import { externalTokenHandlersServiceRef } from './external/ExternalAuthTokenHandler';
|
||||
import { createExternalTokenHandler } from './external/helpers';
|
||||
|
||||
const server = setupServer();
|
||||
@@ -44,7 +44,7 @@ const mockDeps = [
|
||||
backend: {
|
||||
baseUrl: 'http://localhost',
|
||||
auth: {
|
||||
// keys: [{ secret: 'abc' }],
|
||||
keys: [{ secret: 'abc' }],
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
@@ -468,7 +468,7 @@ describe('authServiceFactory', () => {
|
||||
backend: {
|
||||
baseUrl: 'http://localhost',
|
||||
auth: {
|
||||
// keys: [{ secret: 'abc' }w],
|
||||
keys: [{ secret: 'abc' }],
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
@@ -521,7 +521,7 @@ describe('authServiceFactory', () => {
|
||||
});
|
||||
|
||||
const customPluginTokenHandler = createServiceFactory({
|
||||
service: externalTokenTypeHandlersRef,
|
||||
service: externalTokenHandlersServiceRef,
|
||||
deps: {},
|
||||
async factory() {
|
||||
return customExternalTokenHandler;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { DefaultAuthService } from './DefaultAuthService';
|
||||
import {
|
||||
ExternalAuthTokenHandler,
|
||||
externalTokenTypeHandlersRef,
|
||||
externalTokenHandlersServiceRef,
|
||||
} from './external/ExternalAuthTokenHandler';
|
||||
import {
|
||||
DefaultPluginTokenHandler,
|
||||
@@ -67,7 +67,7 @@ export const authServiceFactory = createServiceFactory({
|
||||
plugin: coreServices.pluginMetadata,
|
||||
database: coreServices.database,
|
||||
pluginTokenHandlerDecorator: pluginTokenHandlerDecoratorServiceRef,
|
||||
externalTokenHandlers: externalTokenTypeHandlersRef,
|
||||
externalTokenHandlers: externalTokenHandlersServiceRef,
|
||||
},
|
||||
async factory({
|
||||
config,
|
||||
|
||||
+51
@@ -233,6 +233,57 @@ describe('ExternalTokenHandler', () => {
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
});
|
||||
it('successfully uses legacy configs', async () => {
|
||||
const legacyKey = randomBytes(24);
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'my-company',
|
||||
keyDurationSeconds: 100,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://example.com/.well-known/jwks.json',
|
||||
async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const logger = mockServices.logger.mock();
|
||||
const handler = ExternalAuthTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger,
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [
|
||||
{
|
||||
secret: legacyKey.toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`DEPRECATION WARNING: The backend.auth.keys config has been replaced by backend.auth.externalAccess, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
|
||||
const legacyToken = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(legacyKey);
|
||||
|
||||
await expect(handler.verifyToken(legacyToken)).resolves.toEqual({
|
||||
subject: 'external:backstage-plugin',
|
||||
});
|
||||
});
|
||||
it('successfully uses custom token handlers', async () => {
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'my-company',
|
||||
|
||||
+18
-6
@@ -33,14 +33,13 @@ let loggedDeprecationWarning = false;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* This service is used to decorate the default plugin token handler with custom logic.
|
||||
* This service is used to add custom handlers for external token.
|
||||
*/
|
||||
export const externalTokenTypeHandlersRef = createServiceRef<
|
||||
export const externalTokenHandlersServiceRef = createServiceRef<
|
||||
ExternalTokenHandler<unknown>
|
||||
>({
|
||||
id: 'core.auth.externalTokenHandlers',
|
||||
multiton: true,
|
||||
// defaultFactory // :pepe-think: seems like is not possible to use defaultFactory with multiton
|
||||
});
|
||||
|
||||
const defaultHandlers: ExternalTokenHandler<unknown>[] = [
|
||||
@@ -54,6 +53,7 @@ type ContextMapEntry<T> = {
|
||||
handler: ExternalTokenHandler<T>;
|
||||
allAccessRestrictions?: AccessRestrictionsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles all types of external caller token types (i.e. not Backstage user
|
||||
* tokens, nor Backstage backend plugin tokens).
|
||||
@@ -71,6 +71,7 @@ export class ExternalAuthTokenHandler {
|
||||
ownPluginId,
|
||||
config,
|
||||
externalTokenHandlers: customHandlers,
|
||||
logger,
|
||||
} = options;
|
||||
|
||||
const handlersTypes = [...defaultHandlers, ...(customHandlers ?? [])];
|
||||
@@ -104,12 +105,23 @@ export class ExternalAuthTokenHandler {
|
||||
|
||||
if (legacyConfigs.length && !loggedDeprecationWarning) {
|
||||
loggedDeprecationWarning = true;
|
||||
// :pepe-think: this message was here for more than a year, and replacing this simplifies things a lot
|
||||
throw new Error(
|
||||
`The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
|
||||
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 legacyConfig of legacyConfigs) {
|
||||
contexts.push({
|
||||
context: legacyTokenHandler.initialize({
|
||||
legacy: true,
|
||||
options: legacyConfig,
|
||||
}),
|
||||
handler: legacyTokenHandler,
|
||||
allAccessRestrictions: readAccessRestrictionsFromConfig(legacyConfig),
|
||||
});
|
||||
}
|
||||
|
||||
return new ExternalAuthTokenHandler(ownPluginId, contexts);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { legacyTokenHandler } from './legacy';
|
||||
describe('LegacyTokenHandler', () => {
|
||||
const key1 = randomBytes(24);
|
||||
const key2 = randomBytes(24);
|
||||
const key3 = randomBytes(24);
|
||||
|
||||
const tokenHandler = legacyTokenHandler;
|
||||
|
||||
@@ -40,6 +41,13 @@ describe('LegacyTokenHandler', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const contextWithLegacy = tokenHandler.initialize({
|
||||
options: new ConfigReader({
|
||||
secret: key3.toString('base64'),
|
||||
}),
|
||||
legacy: true,
|
||||
});
|
||||
|
||||
it('should verify valid tokens', async () => {
|
||||
const token1 = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
@@ -62,6 +70,19 @@ describe('LegacyTokenHandler', () => {
|
||||
await expect(tokenHandler.verifyToken(token2, context2)).resolves.toEqual({
|
||||
subject: 'key2',
|
||||
});
|
||||
|
||||
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, contextWithLegacy),
|
||||
).resolves.toEqual({
|
||||
subject: 'external:backstage-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined if the token is not a valid legacy token', async () => {
|
||||
@@ -235,5 +256,54 @@ describe('LegacyTokenHandler', () => {
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// old style add
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'b2s=' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ _missingsecret: true }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: '' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'has spaces' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 'hasnewline\n' }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.initialize({
|
||||
options: new ConfigReader({ secret: 3 }),
|
||||
legacy: true,
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { Config } from '@backstage/config';
|
||||
import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
|
||||
import { createExternalTokenHandler } from './helpers';
|
||||
import { ExternalTokenHandler } from './types';
|
||||
|
||||
export type LegacyConfigWrapper = {
|
||||
legacy: boolean;
|
||||
@@ -30,64 +30,74 @@ type LegacyTokenHandlerContext = {
|
||||
subject: string;
|
||||
};
|
||||
|
||||
export const legacyTokenHandler =
|
||||
createExternalTokenHandler<LegacyTokenHandlerContext>({
|
||||
type: 'legacy',
|
||||
initialize(ctx: { options: Config }): LegacyTokenHandlerContext {
|
||||
const secret = ctx.options.getString('secret');
|
||||
const subject = ctx.options.getString('subject');
|
||||
type LegacyTokenHandlerOverloaded =
|
||||
ExternalTokenHandler<LegacyTokenHandlerContext> & {
|
||||
initialize(ctx: {
|
||||
options: Config;
|
||||
legacy: true;
|
||||
}): LegacyTokenHandlerContext;
|
||||
};
|
||||
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error(
|
||||
'Illegal subject, must be a set of non-space characters',
|
||||
);
|
||||
}
|
||||
export const legacyTokenHandler: LegacyTokenHandlerOverloaded = {
|
||||
type: 'legacy',
|
||||
initialize(ctx: {
|
||||
options: Config;
|
||||
legacy?: true;
|
||||
}): LegacyTokenHandlerContext {
|
||||
const secret = ctx.options.getString('secret');
|
||||
const subject = ctx.legacy
|
||||
? 'external:backstage-plugin'
|
||||
: ctx.options.getString('subject');
|
||||
|
||||
try {
|
||||
return {
|
||||
key: base64url.decode(secret),
|
||||
subject,
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
},
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
async verifyToken(token: string, context: LegacyTokenHandlerContext) {
|
||||
// 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
|
||||
try {
|
||||
return {
|
||||
key: base64url.decode(secret),
|
||||
subject,
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
},
|
||||
|
||||
async verifyToken(token: string, context: LegacyTokenHandlerContext) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(token, context.key);
|
||||
return {
|
||||
subject: context.subject,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw error;
|
||||
}
|
||||
const { sub, aud } = decodeJwt(token);
|
||||
if (sub !== 'backstage-server' || aud) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// None of the signing keys matched
|
||||
} catch (e) {
|
||||
// Doesn't look like a jwt at all
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(token, context.key);
|
||||
return {
|
||||
subject: context.subject,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// None of the signing keys matched
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import type { Config } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type AccessRestrictionsMap = Map<
|
||||
string, // plugin ID
|
||||
BackstagePrincipalAccessRestrictions
|
||||
@@ -38,16 +35,3 @@ export interface ExternalTokenHandler<TContext> {
|
||||
ctx: TContext,
|
||||
): Promise<{ subject: string } | undefined>;
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @public
|
||||
// * This interface is used to handle external tokens.
|
||||
// */
|
||||
// export interface TokenTypeHandler {
|
||||
// type: string;
|
||||
// /**
|
||||
// * A factory function that takes all token configuration for a given type
|
||||
// * and returns a TokenHandler or an array of TokenHandlers.
|
||||
// */
|
||||
// factory: (configs: Config[]) => TokenHandler | TokenHandler[];
|
||||
// }
|
||||
|
||||
@@ -21,9 +21,8 @@ export {
|
||||
|
||||
export { createExternalTokenHandler } from './external/helpers';
|
||||
|
||||
export { externalTokenTypeHandlersRef } from './external/ExternalAuthTokenHandler';
|
||||
export { externalTokenHandlersServiceRef } from './external/ExternalAuthTokenHandler';
|
||||
|
||||
export type { ExternalTokenHandler } from './external/types';
|
||||
export type { AccessRestrictionsMap } from './external/types';
|
||||
|
||||
export type { PluginTokenHandler } from './plugin/PluginTokenHandler';
|
||||
|
||||
Reference in New Issue
Block a user