Change AuthIdentityTokenManager to ServerTokenManager
Co-authored-by: Joe Porpeglia <joeporpeglia@users.noreply.github.com> Co-authored-by: Tim Hansen <timbonicus@gmail.com> Signed-off-by: Nataliya Issayeva <nissayeva@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@ app:
|
||||
title: '#backstage'
|
||||
|
||||
backend:
|
||||
auth:
|
||||
authorization:
|
||||
secret: ${BACKEND_SECRET}
|
||||
baseUrl: http://localhost:7000
|
||||
listen:
|
||||
|
||||
@@ -28,8 +28,6 @@ const CLOCK_MARGIN_S = 10;
|
||||
* @experimental This is not a stable API yet
|
||||
*/
|
||||
|
||||
// TODO: (b2b-auth) move IdentityClient into tokens
|
||||
// perhaps also create an interface?
|
||||
export class IdentityClient {
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
private readonly issuer: string;
|
||||
@@ -93,7 +91,6 @@ export class IdentityClient {
|
||||
* Returns the public signing key matching the given jwt token,
|
||||
* or null if no matching key was found
|
||||
*/
|
||||
// TODO (b2b-auth): switch on type to identify server tokens?
|
||||
private async getKey(rawJwtToken: string): Promise<JWK.Key | null> {
|
||||
const { header, payload } = JWT.decode(rawJwtToken, {
|
||||
complete: true,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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/core-app-api';
|
||||
import { PluginEndpointDiscovery } from '..';
|
||||
import { IdentityClient } from '../identity';
|
||||
import { AuthIdentityTokenManager } from './AuthIdentityTokenManager';
|
||||
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
async getBaseUrl() {
|
||||
return 'url';
|
||||
},
|
||||
async getExternalBaseUrl() {
|
||||
return 'url';
|
||||
},
|
||||
};
|
||||
const config = new ConfigReader({
|
||||
backend: { auth: { secret: 'a-secret-key' } },
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(IdentityClient.prototype, 'authenticate')
|
||||
.mockImplementation(async (_token?: string) => {
|
||||
throw new Error('No');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthIdentityTokenManager', () => {
|
||||
it('should throw in getServerToken if there is no secret in the config', async () => {
|
||||
const emptyConfig = new ConfigReader({});
|
||||
const tokenManager = new AuthIdentityTokenManager(discovery, emptyConfig);
|
||||
await expect(tokenManager.getServerToken()).rejects.toThrow(
|
||||
'No server token defined in config',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate a valid server token', async () => {
|
||||
const tokenManager = new AuthIdentityTokenManager(discovery, config);
|
||||
const { token } = await tokenManager.getServerToken();
|
||||
await expect(tokenManager.validateToken(token)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject an invalid server token', async () => {
|
||||
const differentConfig = new ConfigReader({
|
||||
backend: { auth: { secret: 'a-different-key' } },
|
||||
});
|
||||
const tokenManager = new AuthIdentityTokenManager(discovery, config);
|
||||
const differentTokenManager = new AuthIdentityTokenManager(
|
||||
discovery,
|
||||
differentConfig,
|
||||
);
|
||||
const { token } = await tokenManager.getServerToken();
|
||||
await expect(differentTokenManager.validateToken(token)).rejects.toThrow(
|
||||
'Invalid token',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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 { JWK, JWT } from 'jose';
|
||||
import { Config } from '@backstage/config';
|
||||
import { TokenManager } from './types';
|
||||
import { IdentityClient } from '../identity';
|
||||
import { PluginEndpointDiscovery } from '../discovery';
|
||||
|
||||
// TODO: (b2b-auth) rename this class
|
||||
export class AuthIdentityTokenManager implements TokenManager {
|
||||
private identityClient: IdentityClient;
|
||||
private key?: JWK.OctKey;
|
||||
|
||||
constructor(discovery: PluginEndpointDiscovery, config: Config) {
|
||||
this.identityClient = new IdentityClient({
|
||||
discovery: discovery,
|
||||
issuer: 'auth-identity-token-manager',
|
||||
});
|
||||
|
||||
const secret = config.getOptionalString('backend.auth.secret');
|
||||
if (secret) {
|
||||
this.key = JWK.asKey({ kty: 'oct', k: secret });
|
||||
}
|
||||
}
|
||||
|
||||
async getServerToken(): Promise<{ token: string }> {
|
||||
if (!this.key) {
|
||||
throw new Error('No server token defined in config');
|
||||
}
|
||||
|
||||
const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, {
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
return { token: jwt };
|
||||
}
|
||||
|
||||
// TODO: (b2b-auth) authenticate returns a Backstage Identity
|
||||
// need to figure out what to return after validating a server token
|
||||
async validateToken(token: string): Promise<void> {
|
||||
let maybeUser;
|
||||
let maybeServer;
|
||||
try {
|
||||
maybeUser = await this.identityClient.authenticate(token);
|
||||
} catch (error) {
|
||||
// invalid token
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.key) {
|
||||
throw new Error('No server token defined in config');
|
||||
}
|
||||
maybeServer = JWT.verify(token, this.key);
|
||||
} catch (error) {
|
||||
// invalid token
|
||||
}
|
||||
|
||||
if (!maybeUser && !maybeServer) {
|
||||
throw new Error(`Invalid token`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2021 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/core-app-api';
|
||||
import { ServerTokenManager } from './ServerTokenManager';
|
||||
|
||||
const emptyConfig = new ConfigReader({});
|
||||
const configWithSecret = new ConfigReader({
|
||||
backend: { authorization: { secret: 'a-secret-key' } },
|
||||
});
|
||||
|
||||
describe('ServerTokenManager', () => {
|
||||
describe('getServerToken', () => {
|
||||
it('should always return a token', async () => {
|
||||
const tokenManager = new ServerTokenManager(configWithSecret);
|
||||
expect((await tokenManager.getServerToken()).token).toBeDefined();
|
||||
|
||||
const emptyTokenManager = new ServerTokenManager(emptyConfig);
|
||||
expect((await emptyTokenManager.getServerToken()).token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isServerToken', () => {
|
||||
it('should return true if token is valid', async () => {
|
||||
const tokenManager = new ServerTokenManager(configWithSecret);
|
||||
const { token } = await tokenManager.getServerToken();
|
||||
const isServerToken = await tokenManager.isServerToken(token);
|
||||
expect(isServerToken).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if token is invalid', async () => {
|
||||
const tokenManager = new ServerTokenManager(configWithSecret);
|
||||
const isServerToken = await tokenManager.isServerToken('random-string');
|
||||
expect(isServerToken).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2021 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 { JWK, JWT } from 'jose';
|
||||
import { Config } from '@backstage/config';
|
||||
import { TokenManager } from './types';
|
||||
|
||||
export class ServerTokenManager implements TokenManager {
|
||||
private key: JWK.OctKey;
|
||||
|
||||
constructor(config: Config) {
|
||||
const secret =
|
||||
config.getOptionalString('backend.authorization.secret') ?? 'no-secret';
|
||||
this.key = JWK.asKey({ kty: 'oct', k: secret });
|
||||
}
|
||||
|
||||
async isServerToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
JWT.verify(token, this.key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getServerToken(): Promise<{ token: string }> {
|
||||
const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, {
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
return { token: jwt };
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { AuthIdentityTokenManager } from './AuthIdentityTokenManager';
|
||||
export { ServerTokenManager } from './ServerTokenManager';
|
||||
export type { TokenManager } from './types';
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
|
||||
export interface TokenManager {
|
||||
isServerToken: (token: string) => Promise<boolean>;
|
||||
getServerToken: () => Promise<{ token: string }>;
|
||||
validateToken: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
SingleHostDiscovery,
|
||||
UrlReaders,
|
||||
useHotMemoize,
|
||||
AuthIdentityTokenManager,
|
||||
ServerTokenManager,
|
||||
IdentityClient,
|
||||
} from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
@@ -66,7 +66,7 @@ function makeCreateEnv(config: Config) {
|
||||
const root = getRootLogger();
|
||||
const reader = UrlReaders.default({ logger: root, config });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const tokenManager = new AuthIdentityTokenManager(discovery, config);
|
||||
const tokenManager = new ServerTokenManager(config);
|
||||
|
||||
root.info(`Created UrlReader ${reader}`);
|
||||
|
||||
@@ -158,7 +158,10 @@ async function main() {
|
||||
IdentityClient.getBearerToken(req.headers.authorization) ||
|
||||
req.cookies.token;
|
||||
|
||||
await authEnv.tokenManager.validateToken(token);
|
||||
const isServerToken = await authEnv.tokenManager.isServerToken(token);
|
||||
if (!isServerToken) {
|
||||
await identity.authenticate(token);
|
||||
}
|
||||
|
||||
if (!req.headers.authorization) {
|
||||
// Authorization header may be forwarded by plugin requests
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
AuthIdentityTokenManager,
|
||||
PluginCacheManager,
|
||||
PluginDatabaseManager,
|
||||
PluginEndpointDiscovery,
|
||||
TokenManager,
|
||||
UrlReader,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
@@ -31,5 +31,5 @@ export type PluginEnvironment = {
|
||||
config: Config;
|
||||
reader: UrlReader;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
tokenManager: AuthIdentityTokenManager;
|
||||
tokenManager: TokenManager;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DatabaseManager,
|
||||
SingleHostDiscovery,
|
||||
UrlReaders,
|
||||
AuthIdentityTokenManager,
|
||||
ServerTokenManager,
|
||||
} from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import app from './plugins/app';
|
||||
@@ -38,7 +38,7 @@ function makeCreateEnv(config: Config) {
|
||||
|
||||
const cacheManager = CacheManager.fromConfig(config);
|
||||
const databaseManager = DatabaseManager.fromConfig(config);
|
||||
const tokenManager = new AuthIdentityTokenManager(discovery, config);
|
||||
const tokenManager = new ServerTokenManager(config);
|
||||
|
||||
return (plugin: string): PluginEnvironment => {
|
||||
const logger = root.child({ type: 'plugin', plugin });
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
AuthIdentityTokenManager,
|
||||
PluginCacheManager,
|
||||
PluginDatabaseManager,
|
||||
PluginEndpointDiscovery,
|
||||
TokenManager,
|
||||
UrlReader,
|
||||
} from '@backstage/backend-common';
|
||||
|
||||
@@ -15,5 +15,5 @@ export type PluginEnvironment = {
|
||||
config: Config;
|
||||
reader: UrlReader;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
tokenManager: AuthIdentityTokenManager;
|
||||
tokenManager: TokenManager;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user