Generate secret in development
Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
@@ -491,13 +491,14 @@ export class ServerTokenManager implements TokenManager {
|
||||
// (undocumented)
|
||||
authenticate(token: string): Promise<void>;
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config): ServerTokenManager;
|
||||
static default(options: {
|
||||
config: Config;
|
||||
logger: Logger_2;
|
||||
}): ServerTokenManager;
|
||||
// (undocumented)
|
||||
getToken(): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
// (undocumented)
|
||||
static noop(): TokenManager;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -13,49 +13,68 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { getVoidLogger } from '../logging/voidLogger';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TokenManager } from './types';
|
||||
import { ServerTokenManager } from './ServerTokenManager';
|
||||
import { Logger } from 'winston';
|
||||
import { JWK } from 'jose';
|
||||
|
||||
const emptyConfig = new ConfigReader({});
|
||||
const configWithSecret = new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a-secret-key' }] } },
|
||||
});
|
||||
const env = process.env;
|
||||
let logger: Logger;
|
||||
|
||||
describe('ServerTokenManager', () => {
|
||||
it('should throw if secret in config does not exist', () => {
|
||||
expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError();
|
||||
beforeEach(() => {
|
||||
process.env = { ...env };
|
||||
logger = getVoidLogger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
describe('getToken', () => {
|
||||
it('should return a token if secret in config exists', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
|
||||
expect((await tokenManager.getToken()).token).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return a token string if using a noop TokenManager', async () => {
|
||||
const tokenManager = ServerTokenManager.noop();
|
||||
it('should return a token', async () => {
|
||||
const tokenManager = ServerTokenManager.default({
|
||||
config: configWithSecret,
|
||||
logger,
|
||||
});
|
||||
expect((await tokenManager.getToken()).token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('should not throw if token is valid', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
|
||||
const tokenManager = ServerTokenManager.default({
|
||||
config: configWithSecret,
|
||||
logger,
|
||||
});
|
||||
const { token } = await tokenManager.getToken();
|
||||
await expect(tokenManager.authenticate(token)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw if token is invalid', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
|
||||
const tokenManager = ServerTokenManager.default({
|
||||
config: configWithSecret,
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
tokenManager.authenticate('random-string'),
|
||||
).rejects.toThrowError(/invalid server token/i);
|
||||
});
|
||||
|
||||
it('should validate server tokens created by a different instance using the same secret', async () => {
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret);
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret);
|
||||
const tokenManager1 = ServerTokenManager.default({
|
||||
config: configWithSecret,
|
||||
logger,
|
||||
});
|
||||
const tokenManager2 = ServerTokenManager.default({
|
||||
config: configWithSecret,
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token } = await tokenManager1.getToken();
|
||||
|
||||
@@ -63,23 +82,26 @@ describe('ServerTokenManager', () => {
|
||||
});
|
||||
|
||||
it('should validate server tokens created using any of the secrets', async () => {
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
const tokenManager1 = ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
);
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
logger,
|
||||
});
|
||||
const tokenManager2 = ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'd4e5f6' }] } },
|
||||
}),
|
||||
);
|
||||
const tokenManager3 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
logger,
|
||||
});
|
||||
const tokenManager3 = ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: {
|
||||
auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token: token1 } = await tokenManager1.getToken();
|
||||
await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow();
|
||||
@@ -89,16 +111,18 @@ describe('ServerTokenManager', () => {
|
||||
});
|
||||
|
||||
it('should throw for server tokens created using a different secret', async () => {
|
||||
const tokenManager1 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
const tokenManager1 = ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
);
|
||||
const tokenManager2 = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
logger,
|
||||
});
|
||||
const tokenManager2 = ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'd4e5f6' }] } },
|
||||
}),
|
||||
);
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token } = await tokenManager1.getToken();
|
||||
|
||||
@@ -107,83 +131,97 @@ describe('ServerTokenManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for server tokens created using a noop TokenManager', async () => {
|
||||
const noopTokenManager = ServerTokenManager.noop();
|
||||
const tokenManager = ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
it('should throw for server tokens created by a different generated secret', async () => {
|
||||
(process.env as any).NODE_ENV = 'development';
|
||||
const tokenManager1 = ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
|
||||
}),
|
||||
);
|
||||
logger,
|
||||
});
|
||||
const tokenManager2 = ServerTokenManager.default({
|
||||
config: emptyConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
const { token } = await noopTokenManager.getToken();
|
||||
const { token } = await tokenManager2.getToken();
|
||||
|
||||
await expect(tokenManager.authenticate(token)).rejects.toThrowError(
|
||||
await expect(tokenManager1.authenticate(token)).rejects.toThrowError(
|
||||
/invalid server token/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerTokenManager.fromConfig', () => {
|
||||
it('should throw if backend auth configuration is missing', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.fromConfig(new ConfigReader({})),
|
||||
).toThrow();
|
||||
describe('default', () => {
|
||||
describe('NOVE_ENV === production', () => {
|
||||
it('should throw if backend auth configuration is missing', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.default({ config: emptyConfig, logger }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if no keys are included in the configuration', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [] } },
|
||||
}),
|
||||
logger,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if any key is missing a secret property', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [{ secret: '123' }, {}, { secret: '789' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
logger,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if no keys are included in the configuration', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
describe('NOVE_ENV === development', () => {
|
||||
const generateSyncSpy = jest.spyOn(JWK, 'generateSync');
|
||||
|
||||
beforeEach(() => {
|
||||
(process.env as any).NODE_ENV = 'development';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should generate a key if no config is provided', () => {
|
||||
ServerTokenManager.default({
|
||||
config: emptyConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192);
|
||||
});
|
||||
|
||||
it('should generate a key if no keys are provided in the configuration', () => {
|
||||
ServerTokenManager.default({
|
||||
config: new ConfigReader({
|
||||
backend: { auth: { keys: [] } },
|
||||
}),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
logger,
|
||||
});
|
||||
|
||||
it('should throw if any key is missing a secret property', () => {
|
||||
expect(() =>
|
||||
ServerTokenManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
auth: {
|
||||
keys: [{ secret: '123' }, {}, { secret: '789' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192);
|
||||
});
|
||||
|
||||
describe('ServerTokenManager.noop', () => {
|
||||
let noopTokenManager: TokenManager;
|
||||
|
||||
beforeEach(() => {
|
||||
noopTokenManager = ServerTokenManager.noop();
|
||||
});
|
||||
|
||||
it('should accept tokens it generates', async () => {
|
||||
const { token } = await noopTokenManager.getToken();
|
||||
|
||||
await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept tokens generated by other noop token managers', async () => {
|
||||
const noopTokenManager2 = ServerTokenManager.noop();
|
||||
await expect(
|
||||
noopTokenManager.authenticate(
|
||||
(
|
||||
await noopTokenManager2.getToken()
|
||||
).token,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept signed tokens', async () => {
|
||||
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
|
||||
await expect(
|
||||
noopTokenManager.authenticate((await tokenManager.getToken()).token),
|
||||
).resolves.not.toThrow();
|
||||
it('should use provided secrets if config is provided', () => {
|
||||
ServerTokenManager.default({ config: configWithSecret, logger });
|
||||
expect(generateSyncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,14 +18,7 @@ import { JWKS, JWK, JWT } from 'jose';
|
||||
import { Config } from '@backstage/config';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { TokenManager } from './types';
|
||||
|
||||
class NoopTokenManager implements TokenManager {
|
||||
async getToken() {
|
||||
return { token: '' };
|
||||
}
|
||||
|
||||
async authenticate() {}
|
||||
}
|
||||
import { Logger } from 'winston';
|
||||
|
||||
/**
|
||||
* Creates and validates tokens for use during backend-to-backend
|
||||
@@ -37,20 +30,42 @@ export class ServerTokenManager implements TokenManager {
|
||||
private readonly verificationKeys: JWKS.KeyStore;
|
||||
private readonly signingKey: JWK.Key;
|
||||
|
||||
static noop(): TokenManager {
|
||||
return new NoopTokenManager();
|
||||
static default(options: { config: Config; logger: Logger }) {
|
||||
const { config, logger } = options;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
let secrets: string[] = [];
|
||||
try {
|
||||
secrets = this.getSecrets(config);
|
||||
} catch {
|
||||
// For development, if a secret has not been configured, we auto generate a secret instead of throwing.
|
||||
}
|
||||
|
||||
if (!secrets.length) {
|
||||
const generatedDevOnlyKey = JWK.generateSync('oct', 24 * 8);
|
||||
if (generatedDevOnlyKey.k === undefined) {
|
||||
throw new Error('No key generated');
|
||||
}
|
||||
logger.warn(
|
||||
'Generated a secret for backend-to-backend authentication: DEVELOPMENT USE ONLY. You must configure a secret before deploying to production.',
|
||||
);
|
||||
return new ServerTokenManager([generatedDevOnlyKey.k]);
|
||||
}
|
||||
return new ServerTokenManager(secrets);
|
||||
}
|
||||
|
||||
const secrets = this.getSecrets(config);
|
||||
return new ServerTokenManager(secrets);
|
||||
}
|
||||
|
||||
static fromConfig(config: Config) {
|
||||
return new ServerTokenManager(
|
||||
config
|
||||
.getConfigArray('backend.auth.keys')
|
||||
.map(key => key.getString('secret')),
|
||||
);
|
||||
private static getSecrets(config: Config) {
|
||||
return config
|
||||
.getConfigArray('backend.auth.keys')
|
||||
.map(key => key.getString('secret'));
|
||||
}
|
||||
|
||||
private constructor(secrets?: string[]) {
|
||||
if (!secrets?.length) {
|
||||
private constructor(secrets: string[]) {
|
||||
if (!secrets.length) {
|
||||
throw new Error(
|
||||
'No secrets provided when constructing ServerTokenManager',
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ function makeCreateEnv(config: Config) {
|
||||
const root = getRootLogger();
|
||||
const reader = UrlReaders.default({ logger: root, config });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const tokenManager = ServerTokenManager.fromConfig(config);
|
||||
const tokenManager = ServerTokenManager.default({ config, logger: root });
|
||||
const permissions = new ServerPermissionClient({
|
||||
discoveryApi: discovery,
|
||||
configApi: config,
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
|
||||
```ts
|
||||
import { AuthorizeRequest } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeResponse } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend';
|
||||
import { BackstageIdentity } from '@backstage/plugin-auth-backend';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryApi } from '@backstage/plugin-permission-common';
|
||||
import { PermissionClient } from '@backstage/plugin-permission-common';
|
||||
import { PermissionCondition } from '@backstage/plugin-permission-common';
|
||||
import { PermissionCriteria } from '@backstage/plugin-permission-common';
|
||||
import { Router } from 'express';
|
||||
import { ServerTokenManager } from '@backstage/backend-common';
|
||||
|
||||
// @public
|
||||
export type ApplyConditionsRequest = {
|
||||
@@ -119,4 +125,20 @@ export type PolicyDecision =
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
}
|
||||
| ConditionalPolicyDecision;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ServerPermissionClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export class ServerPermissionClient extends PermissionClient {
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
configApi: Config;
|
||||
serverTokenManager: ServerTokenManager;
|
||||
});
|
||||
// (undocumented)
|
||||
authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]>;
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user