feat: add gcp-iap first draft
Signed-off-by: Fabian Hippmann <fabian.hippmann@moonshiner.at>
This commit is contained in:
committed by
Fredrik Adelöw
parent
1fa18f2ab6
commit
9fa77c4b91
@@ -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',
|
||||
);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user