Add identity token to api requests

This commit is contained in:
Erik Larsson
2021-01-05 11:15:13 +01:00
parent 2132233fc8
commit 6ed2b47d60
20 changed files with 317 additions and 68 deletions
@@ -14,35 +14,109 @@
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogIdentityClient } from './CatalogIdentityClient';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discovery: PluginEndpointDiscovery = {
async getBaseUrl(_pluginId) {
return mockBaseUrl;
},
async getExternalBaseUrl(_pluginId) {
return mockBaseUrl;
},
};
describe('CatalogIdentityClient', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
};
let client: CatalogIdentityClient;
afterEach(() => jest.resetAllMocks());
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
it('passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
const client = new CatalogIdentityClient({
catalogApi: catalogApi as CatalogApi,
beforeEach(() => {
client = new CatalogIdentityClient({ discovery });
});
describe('findUser', () => {
const defaultServiceResponse: UserEntity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'Test1',
namespace: 'test1',
annotations: {
key: 'value',
},
},
spec: {
memberOf: ['group1'],
},
},
];
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => {
return res(ctx.json(defaultServiceResponse));
}),
);
});
client.findUser({ annotations: { key: 'value' } });
it('should entities from correct endpoint', async () => {
const response = await client.findUser({ annotations: { key: 'value' } });
expect(response).toEqual(defaultServiceResponse[0]);
});
expect(catalogApi.getEntities).toBeCalledWith({
filter: {
kind: 'user',
'metadata.annotations.key': 'value',
},
it('builds entity search filters properly', async () => {
expect.assertions(2);
server.use(
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.url.search).toBe(
'?filter=kind=user,metadata.annotations.key=value',
);
return res(ctx.json(defaultServiceResponse));
}),
);
const response = await client.findUser({ annotations: { key: 'value' } });
expect(response).toEqual(defaultServiceResponse[0]);
});
it('omits authorization header if not available', async () => {
expect.assertions(1);
server.use(
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.headers.has('authorization')).toBe(false);
return res(ctx.json([]));
}),
);
client.findUser({ annotations: { key: 'value' } });
});
it('adds authorization header if available', async () => {
expect.assertions(1);
server.use(
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.headers.get('authorization')).toEqual('hello');
return res(ctx.json([]));
}),
);
client.findUser(
{ annotations: { key: 'value' } },
{ headers: { authorization: 'hello' } },
);
});
});
});
@@ -14,10 +14,13 @@
* limitations under the License.
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import fetch from 'cross-fetch';
import {
ConflictError,
NotFoundError,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { UserEntity } from '@backstage/catalog-model';
type UserQuery = {
annotations: Record<string, string>;
};
@@ -26,10 +29,10 @@ type UserQuery = {
* A catalog client tailored for reading out identity data from the catalog.
*/
export class CatalogIdentityClient {
private readonly catalogApi: CatalogApi;
private readonly discovery: PluginEndpointDiscovery;
constructor(options: { catalogApi: CatalogApi }) {
this.catalogApi = options.catalogApi;
constructor(options: { discovery: PluginEndpointDiscovery }) {
this.discovery = options.discovery;
}
/**
@@ -37,24 +40,54 @@ export class CatalogIdentityClient {
*
* Throws a NotFoundError or ConflictError if 0 or multiple users are found.
*/
async findUser(query: UserQuery): Promise<UserEntity> {
async findUser(
query: UserQuery,
options?: { headers?: Record<string, string> },
): Promise<UserEntity> {
const filter: Record<string, string> = {
kind: 'user',
};
for (const [key, value] of Object.entries(query.annotations)) {
filter[`metadata.annotations.${key}`] = value;
}
const params: string[] = [];
const { items } = await this.catalogApi.getEntities({ filter });
const filterParts: string[] = [];
for (const [key, value] of Object.entries(filter)) {
for (const v of [value].flat()) {
filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
}
if (filterParts.length) {
params.push(`filter=${filterParts.join(',')}`);
}
const queryPart = params.length ? `?${params.join('&')}` : '';
if (items.length !== 1) {
if (items.length > 1) {
const url = `${await this.discovery.getBaseUrl(
'catalog',
)}/entities${queryPart}`;
const response = await fetch(url, {
headers: {
...options?.headers,
},
});
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
const entities: UserEntity[] = await response.json();
if (entities.length !== 1) {
if (entities.length > 1) {
throw new ConflictError('User lookup resulted in multiple matches');
} else {
throw new NotFoundError('User not found');
}
}
return items[0] as UserEntity;
return entities[0] as UserEntity;
}
}
@@ -38,6 +38,7 @@ import {
PassportDoneCallback,
} from '../../lib/passport';
import { AuthProviderFactory, RedirectInfo } from '../types';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken: string;
@@ -46,16 +47,19 @@ type PrivateInfo = {
export type GoogleAuthProviderOptions = OAuthProviderOptions & {
logger: Logger;
identityClient: CatalogIdentityClient;
tokenIssuer: TokenIssuer;
};
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
private readonly logger: Logger;
private readonly identityClient: CatalogIdentityClient;
private readonly tokenIssuer: TokenIssuer;
constructor(options: GoogleAuthProviderOptions) {
this.logger = options.logger;
this.identityClient = options.identityClient;
this.tokenIssuer = options.tokenIssuer;
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
{
@@ -150,11 +154,21 @@ export class GoogleAuthProvider implements OAuthHandlers {
}
try {
const user = await this.identityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const user = await this.identityClient.findUser(
{
annotations: {
'google.com/email': profile.email,
},
},
{
headers: {
authorization: `Bearer ${token}`,
},
},
);
return {
...response,
@@ -180,7 +194,7 @@ export const createGoogleProvider: AuthProviderFactory = ({
config,
logger,
tokenIssuer,
catalogApi,
discovery,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
@@ -192,7 +206,8 @@ export const createGoogleProvider: AuthProviderFactory = ({
clientSecret,
callbackUrl,
logger,
identityClient: new CatalogIdentityClient({ catalogApi }),
tokenIssuer,
identityClient: new CatalogIdentityClient({ discovery }),
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -15,7 +15,6 @@
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
@@ -132,7 +131,6 @@ export type AuthProviderFactoryOptions = {
logger: Logger;
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
@@ -27,7 +27,6 @@ import {
PluginDatabaseManager,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import session from 'express-session';
@@ -66,7 +65,6 @@ export async function createRouter({
keyDurationSeconds,
logger: logger.child({ component: 'token-factory' }),
});
const catalogApi = new CatalogClient({ discoveryApi: discovery });
const secret = config.getOptionalString('auth.session.secret');
if (secret) {
@@ -103,7 +101,6 @@ export async function createRouter({
logger,
tokenIssuer,
discovery,
catalogApi,
});
const r = Router();