update tests, add dependencies

Signed-off-by: Fabian Hippmann <fabian.hippmann@moonshiner.at>
This commit is contained in:
Fabian Hippmann
2021-07-06 15:13:25 +02:00
committed by Fredrik Adelöw
parent 876a5351d8
commit 1756d252ce
4 changed files with 74 additions and 110 deletions
+42 -24
View File
@@ -2,16 +2,21 @@
id: provider
title: Google Identity Aware Proxy Provider
sidebar_label: Google IAP
description: Adding Google Identity Aware Proxy as an authentication provider in Backstage
description:
Adding Google Identity Aware Proxy as an authentication provider in Backstage
---
# Using Google Identity Aware Proxy to authenticate requests
Backstage allows offloading the responsibility of authenticating users to an Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the authentication support on IAP.
Backstage allows offloading the responsibility of authenticating users to an
Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the
authentication support on IAP.
This tutorial shows how to use authentication on an ALB sitting in front of Backstage.
This tutorial shows how to use authentication on an ALB sitting in front of
Backstage.
It is assumed an IAP is already serving traffic in front of a Backstage instance configured to serve the frontend app from the backend.
It is assumed an IAP is already serving traffic in front of a Backstage instance
configured to serve the frontend app from the backend.
## Infrastructure setup
@@ -19,16 +24,21 @@ It is assumed an IAP is already serving traffic in front of a Backstage instance
### 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.
The Backstage App needs a SignInPage when authentication is required. When using
IAP Proxy 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`):
- 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 baseUrl = await discoveryApiConfig.getBaseUrl('auth');
const shouldAuth = !!config.getOptionalConfig('auth.providers.gcp-iap');
if (!shouldAuth) {
@@ -43,18 +53,17 @@ const refreshToken = async ({ props, discoveryApiConfig, config }) => {
return;
}
try {
const request = await fetch(`${baseUrl}/gcp-iap/refresh`, {
headers: {
"x-requested-with": "XMLHttpRequest"
'x-requested-with': 'XMLHttpRequest',
},
credentials: "include"
credentials: 'include',
});
const data = await request.json()
const data = await request.json();
props.onResult({
userId: data.backstageIdentity.id ?? "nouser@ms.at",
profile: data.profile ?? "nouser@ms.at",
userId: data.backstageIdentity.id ?? 'nouser@ms.at',
profile: data.profile ?? 'nouser@ms.at',
});
} catch (e) {
props.onResult({
@@ -71,7 +80,7 @@ const DummySignInComponent: any = (props: any) => {
try {
const config = useApi(configApiRef);
const discoveryApiConfig = useApi(discoveryApiRef);
refreshToken({ props, discoveryApiConfig, config })
refreshToken({ props, discoveryApiConfig, config });
return <div />;
} catch (err) {
return <div>{err.message}</div>;
@@ -81,7 +90,9 @@ const DummySignInComponent: any = (props: any) => {
### 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.
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
@@ -116,10 +127,17 @@ export default async function createPlugin({
});
};
return await createRouter({
logger, config, database, discovery, providerFactories: {
"gcp-iap": (options: AuthProviderFactoryOptions) => {
return createGcpIAPProvider({ ...options, identityResolver })({ ...options, identityResolver })
}
logger,
config,
database,
discovery,
providerFactories: {
'gcp-iap': (options: AuthProviderFactoryOptions) => {
return createGcpIAPProvider({ ...options, identityResolver })({
...options,
identityResolver,
});
},
},
});
}
@@ -133,10 +151,10 @@ Use the following `auth` configuration when running Backstage on AWS:
auth:
providers:
gcp-iap:
audience: "/projects/0123456/global/backendServices/1242345678765434567"
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.
Once it's deployed, after going through the AAD authentication flow, Backstage
should display the AAD user details.
+1 -1
View File
@@ -45,7 +45,7 @@
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-session": "^1.17.1",
"fs-extra": "9.1.0",
"google-auth-library": "^7.2.0",
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jwt-decode": "^3.1.0",
@@ -15,33 +15,14 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import { JWT } from 'jose';
import { AwsAlbAuthProvider } from './provider';
import { GcpIAPProvider } from './provider';
import { AuthResponse } from '../types';
const jwtMock = JWT as jest.Mocked<any>;
jest.mock('google-auth-library');
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 setCredentialsMock = jest.fn();
const getAccessTokenMock = jest.fn();
const identityResolutionCallbackMock = async (): Promise<AuthResponse<any>> => {
return {
@@ -66,7 +47,7 @@ beforeEach(() => {
jest.clearAllMocks();
});
describe('AwsALBAuthProvider', () => {
describe('GcpIAPProvider', () => {
const catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(),
@@ -98,16 +79,11 @@ describe('AwsALBAuthProvider', () => {
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',
const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, {
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
audience: 'foo',
});
jwtMock.verify.mockImplementationOnce(() => ({
sub: 'foo',
}));
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith({
@@ -124,10 +100,9 @@ describe('AwsALBAuthProvider', () => {
});
describe('should fail when', () => {
it('JWT is missing', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, {
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
audience: 'foo',
});
await provider.refresh(mockRequestWithoutJwt, mockResponse);
@@ -135,44 +110,12 @@ describe('AwsALBAuthProvider', () => {
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',
const provider = new GcpIAPProvider(getVoidLogger(), catalogApi, {
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
audience: 'foo',
});
jwtMock.verify.mockReturnValueOnce({});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
@@ -13,16 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AuthProviderFactoryOptions,
AuthProviderRouteHandlers,
AuthResponse
} from '@backstage/plugin-auth-backend';
import {
AuthProviderRouteHandlers,
AuthProviderFactoryOptions,
ExperimentalIdentityResolver,
} from '../types';
import express from 'express';
import { Logger } from 'winston';
import { CatalogApi } from '@backstage/catalog-client';
@@ -54,9 +53,12 @@ export class GcpIAPProvider implements AuthProviderRouteHandlers {
async refresh(req: express.Request, res: express.Response): Promise<void> {
const expectedAudience = this.options.audience;
const jwtToken = req.header(IAP_JWT_HEADER);
if (jwtToken === undefined) {
res.status(401);
res.end();
return;
}
const oAuth2Client = new OAuth2Client();
const verify = async () => {
const response = await oAuth2Client.getIapPublicKeys();
@@ -64,30 +66,32 @@ export class GcpIAPProvider implements AuthProviderRouteHandlers {
jwtToken,
response.pubkeys,
expectedAudience,
['https://cloud.google.com/iap']
['https://cloud.google.com/iap'],
);
return ticket.payload;
}
return ticket.getPayload();
};
try {
const user = await verify();
if (user === undefined) {
this.logger.error('gcp iap proxy user returned undefined');
res.status(401);
res.end();
return;
}
const resolvedEntity = await this.options.identityResolutionCallback(
{
email: user.email
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();
return;
}
res.status(200);
res.end();
@@ -98,7 +102,6 @@ export class GcpIAPProvider implements AuthProviderRouteHandlers {
}
}
export const createGcpIAPProvider = (_options?: GcpIAPProviderOptions) => {
return ({
logger,