feat: add gcp-iap first draft

Signed-off-by: Fabian Hippmann <fabian.hippmann@moonshiner.at>
This commit is contained in:
Fabian Hippmann
2021-06-12 02:42:53 +02:00
committed by Fredrik Adelöw
parent 1fa18f2ab6
commit 9fa77c4b91
4 changed files with 461 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# Using AWS Application Load Balancer with Azure Active Directory to authenticate requests
Backstage allows offloading the responsibility of authenticating users to an AWS Application Load Balancer (**ALB**), leveraging the authentication support on ALB.
This tutorial shows how to use authentication on an ALB sitting in front of Backstage.
Azure Active Directory (**AAD**) is used as identity provider but any identity provider supporting OpenID Connect (OIDC) can be used.
It is assumed an ALB is already serving traffic in front of a Backstage instance configured to serve the frontend app from the backend.
## Infrastructure setup
## Backstage changes
### Frontend
The Backstage App needs a SignInPage when authentication is required.
When using ALB authentication Backstage will only be loaded once the user has successfully authenticated; we won't need to display a SignIn page, however we will need to create a dummy SignIn component that can refresh the token.
- edit `packages/app/src/App.tsx`
- import the following two additional definitions from `@backstage/core`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB
- add the following definition just before the app is created (`const app = createApp`):
```ts
const refreshToken = async ({ props, discoveryApiConfig, config }) => {
const baseUrl = await discoveryApiConfig.getBaseUrl("auth");
const shouldAuth = !!config.getOptionalConfig('auth.providers.gcp-iap');
if (!shouldAuth) {
props.onResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
picture: '',
},
});
return;
}
try {
const request = await fetch(`${baseUrl}/gcp-iap/refresh`, {
headers: {
"x-requested-with": "XMLHttpRequest"
},
credentials: "include"
});
const data = await request.json()
props.onResult({
userId: data.backstageIdentity.id ?? "nouser@ms.at",
profile: data.profile ?? "nouser@ms.at",
});
} catch (e) {
props.onResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
picture: '',
},
});
}
};
const DummySignInComponent: any = (props: any) => {
try {
const config = useApi(configApiRef);
const discoveryApiConfig = useApi(discoveryApiRef);
refreshToken({ props, discoveryApiConfig, config })
return <div />;
} catch (err) {
return <div>{err.message}</div>;
}
};
```
### Backend
When using ALB auth it is not possible to leverage the built-in auth config discovery mechanism implemented in the app created by default; bespoke logic needs to be implemented.
- replace the content of `packages/backend/plugin/auth.ts` with the below
```ts
// imports are relative - as this was tested out in repo directly
import { createGcpIAPProvider } from './../providers/gcp-iap/provider';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import {
createRouter,
AuthResponse,
AuthProviderFactoryOptions,
} from '@backstage/plugin-auth-backend';
export default async function createPlugin({
logger,
database,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
const identityResolver = (payload: any): Promise<AuthResponse<any>> => {
return Promise.resolve({
providerInfo: {},
profile: {
email: payload.email,
displayName: payload.name,
picture: payload.picture,
},
backstageIdentity: {
id: payload.email,
},
});
};
return await createRouter({
logger, config, database, discovery, providerFactories: {
"gcp-iap": (options: AuthProviderFactoryOptions) => {
return createGcpIAPProvider({ ...options, identityResolver })({ ...options, identityResolver })
}
},
});
}
```
### Configuration
Use the following `auth` configuration when running Backstage on AWS:
```yaml
auth:
providers:
gcp-iap:
audience: "/projects/0123456/global/backendServices/1242345678765434567"
```
## Conclusion
Once it's deployed, after going through the AAD authentication flow, Backstage should display the AAD user details.
<!-- links -->
[monorepo-app-setup-with-auth-ms]: https://backstage.io/docs/auth/microsoft/provider
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
export { createGcpIAPProvider } from './provider';
export type { GcpIAPProviderOptions } from './provider';
@@ -0,0 +1,182 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import { JWT } from 'jose';
import { AwsAlbAuthProvider } from './provider';
import { AuthResponse } from '../types';
const jwtMock = JWT as jest.Mocked<any>;
const mockKey = async () => {
return `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I
yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
jest.mock('jose');
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
return {
text: async () => {
return mockKey();
},
};
},
}));
const identityResolutionCallbackMock = async (): Promise<AuthResponse<any>> => {
return {
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
};
};
const identityResolutionCallbackRejectedMock = async (): Promise<
AuthResponse<any>
> => {
throw new Error('failed');
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('AwsALBAuthProvider', () => {
const catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const mockRequest = ({
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
}),
} as unknown) as express.Request;
const mockRequestWithoutJwt = ({
header: jest.fn(() => {
return undefined;
}),
} as unknown) as express.Request;
const mockResponse = ({
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn().mockReturnThis(),
status: jest.fn(),
} as unknown) as express.Response;
describe('should transform to type OAuthResponse', () => {
it('when JWT is valid and identity is resolved successfully', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
jwtMock.verify.mockImplementationOnce(() => ({
sub: 'foo',
}));
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith({
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
});
});
});
describe('should fail when', () => {
it('JWT is missing', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
await provider.refresh(mockRequestWithoutJwt, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('JWT is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
});
jwtMock.verify.mockImplementationOnce(() => {
throw new Error('bad JWT');
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foobar',
});
jwtMock.verify.mockReturnValueOnce({});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('identity resolution callback rejects', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
});
jwtMock.verify.mockReturnValueOnce({});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,123 @@
/*
* Copyright 2021 Spotify AB
*
* 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 {
AuthProviderFactoryOptions,
AuthProviderRouteHandlers,
AuthResponse
} from '@backstage/plugin-auth-backend';
import express from 'express';
import { Logger } from 'winston';
import { CatalogApi } from '@backstage/catalog-client';
const { OAuth2Client } = require('google-auth-library');
const IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
export type ExperimentalIdentityResolver = (
/**
* An object containing information specific to the auth provider.
*/
payload: object,
catalogApi: CatalogApi,
) => Promise<AuthResponse<any>>;
export type GcpIAPProviderOptions = {
audience: string;
identityResolutionCallback: ExperimentalIdentityResolver;
};
export class GcpIAPProvider implements AuthProviderRouteHandlers {
private logger: Logger;
private options: GcpIAPProviderOptions;
private readonly catalogClient: CatalogApi;
constructor(
logger: Logger,
catalogClient: CatalogApi,
options: GcpIAPProviderOptions,
) {
this.logger = logger;
this.catalogClient = catalogClient;
this.options = options;
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const expectedAudience = this.options.audience;
const jwtToken = req.header(IAP_JWT_HEADER);
const oAuth2Client = new OAuth2Client();
const verify = async () => {
const response = await oAuth2Client.getIapPublicKeys();
const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync(
jwtToken,
response.pubkeys,
expectedAudience,
['https://cloud.google.com/iap']
);
return ticket.payload;
}
try {
const user = await verify();
const resolvedEntity = await this.options.identityResolutionCallback(
{
email: user.email
},
this.catalogClient,
);
res.json(resolvedEntity);
} catch (e) {
const resolvedEntity = await this.options.identityResolutionCallback(
{},
this.catalogClient,
);
res.json(resolvedEntity);
this.logger.error('Verification failed with', e);
res.status(401);
res.end();
}
res.status(200);
res.end();
}
start(): Promise<void> {
return Promise.resolve(undefined);
}
}
export const createGcpIAPProvider = (_options?: GcpIAPProviderOptions) => {
return ({
logger,
catalogApi,
config,
identityResolver,
}: AuthProviderFactoryOptions) => {
const audience = config.getString('audience');
if (identityResolver !== undefined) {
return new GcpIAPProvider(logger, catalogApi, {
audience,
identityResolutionCallback: identityResolver,
});
}
throw new Error(
'Identity resolver is required to use this authentication provider',
);
};
};