Merge pull request #8695 from backstage/feature/add-gcp-iap-auth-provider-frontend

auth: implement frontend parts of iap-like sign-in
This commit is contained in:
Fredrik Adelöw
2022-01-13 12:17:18 +01:00
committed by GitHub
12 changed files with 697 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Add a `ProxiedSignInPage` that can be used e.g. for GCP IAP and AWS ALB
+125
View File
@@ -0,0 +1,125 @@
---
id: provider
title: Google Identity-Aware Proxy Provider
sidebar_label: Google IAP
# prettier-ignore
description: Adding Google Identity-Aware Proxy as an authentication provider in Backstage
---
Backstage allows offloading the responsibility of authenticating users to the
Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the
authentication support on the latter.
This tutorial shows how to use authentication on an IAP 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.
## Configuration
Let's start by adding the following `auth` configuration in your
`app-config.yaml` or `app-config.production.yaml` or similar:
```yaml
auth:
providers:
gcp-iap:
audience:
'/projects/<project number>/global/backendServices/<backend service id>'
```
You can find the project number and service ID in the Google Cloud Console.
This config section must be in place for the provider to load at all. Now let's
add the provider itself.
## Backend Changes
This provider is not enabled by default in the auth backend code, because
besides the config section above, it also needs to be given one or more
callbacks in actual code as well as described below.
Add a `providerFactories` entry to the router in
`packages/backend/plugin/auth.ts`.
```ts
import { createGcpIapProvider } from '@backstage/plugin-auth-backend';
export default async function createPlugin({
logger,
database,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
config,
database,
discovery,
providerFactories: {
'gcp-iap': createGcpIapProvider({
// Replace the auth handler if you want to customize the returned user
// profile info (can be left out; the default implementation is shown
// below which only returns the email). You may want to amend this code
// with something that loads additional user profile data out of e.g.
// GSuite or LDAP or similar.
async authHandler({ iapToken }) {
return { profile: { email: iapToken.email } };
},
signIn: {
// You need to supply an identity resolver, that takes the profile
// and the IAP token and produces the Backstage token with the
// relevant user info.
async resolver({ profile, result: { iapToken } }, ctx) {
// Somehow compute the Backstage token claims. Just some dummy code
// shown here, but you may want to query your LDAP server, or
// GSuite or similar, based on the IAP token sub/email claims
const id = `user:default/${iapToken.email.split('@')[0]}`;
const fullEnt = ['group:default/team-name'];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: fullEnt },
});
return { id, token };
},
},
}),
},
});
}
```
Now the backend is ready to serve auth requests on the
`/api/auth/gcp-iap/refresh` endpoint. All that's left is to update the frontend
sign-in mechanism to poll that endpoint through the IAP, on the user's behalf.
## Frontend Changes
All Backstage apps need a `SignInPage` to be configured. Its purpose is to
establish who the user is and what their identifying credentials are, blocking
rendering the rest of the UI until that's complete, and then keeping those
credentials fresh.
When using IAP Proxy authentication, the Backstage UI will only be loaded once
the user has already successfully completely authenticated themselves with the
IAP and has an active session, so we don't want to make the user have to go
through a _second_ layer of authentication flows after that.
As such, we want to not display a sign-in page visually at all. Instead, we will
pick a `SignInPage` implementation component which knows how to silently make
requests to the backend provider we configured above, and just trusting its
output to properly represent the current user. Luckily, Backstage comes with a
component for this purpose out of the box.
Update your `createApp` call in `packages/app/src/App.tsx`, as follows.
```diff
+import { ProxiedSignInPage } from '@backstage/core-components';
const app = createApp({
components: {
+ SignInPage: props => <ProxiedSignInPage {...props} provider="gcp-iap" />,
```
After this, your app should be ready to leverage the Identity-Aware Proxy for
authentication!
+10
View File
@@ -752,6 +752,16 @@ export function Progress(
props: PropsWithChildren<LinearProgressProps>,
): JSX.Element;
// @public
export const ProxiedSignInPage: (
props: ProxiedSignInPageProps,
) => JSX.Element | null;
// @public
export type ProxiedSignInPageProps = SignInPageProps & {
provider: string;
};
// Warning: (ae-missing-release-tag) "Ranker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
+5 -2
View File
@@ -64,7 +64,8 @@
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"remark-gfm": "^2.0.0",
"zen-observable": "^0.8.15"
"zen-observable": "^0.8.15",
"zod": "^3.11.6"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -92,7 +93,9 @@
"@types/react-syntax-highlighter": "^13.5.2",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/zen-observable": "^0.8.0"
"@types/zen-observable": "^0.8.0",
"cross-fetch": "^3.0.6",
"msw": "^0.35.0"
},
"files": [
"dist"
@@ -0,0 +1,145 @@
/*
* Copyright 2022 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 { setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
DEFAULTS,
ProxiedSignInIdentity,
tokenToExpiry,
} from './ProxiedSignInIdentity';
const validBackstageTokenExpClaim = 1641216199;
const validBackstageToken =
'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJmcmViZW4iLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE2NDEyMTI1OTksImV4cCI6MTY0MTIxNjE5OSwiZW50IjpbInVzZXI6ZGVmYXVsdC9mcmViZW4iXX0.4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ';
describe('ProxiedSignInIdentity', () => {
describe('tokenToExpiry', () => {
beforeEach(() => jest.useFakeTimers('modern'));
afterEach(() => jest.useRealTimers());
it('handles undefined', async () => {
expect(tokenToExpiry(undefined)).toEqual(
new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis),
);
});
it('handles a valid token', async () => {
expect(tokenToExpiry(validBackstageToken)).toEqual(
new Date(
validBackstageTokenExpClaim * 1000 - DEFAULTS.tokenExpiryMarginMillis,
),
);
});
it('handles a token that has no exp', async () => {
const [a, _b, c] = validBackstageToken.split('.');
const botched = `${a}.${btoa(JSON.stringify({}))}.${c}`;
expect(tokenToExpiry(botched)).toEqual(
new Date(new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis)),
);
});
});
describe('ProxiedSignInIdentity', () => {
beforeEach(() => jest.useFakeTimers('modern'));
afterEach(() => jest.useRealTimers());
const worker = setupServer();
setupRequestMockHandlers(worker);
it('runs the happy path', async () => {
const getBaseUrl = jest.fn();
const serverCalled = jest.fn();
function makeToken() {
const iat = Math.floor(Date.now() / 1000);
const exp = iat + 3600;
return {
providerInfo: {
stuff: 1,
},
profile: {
email: 'e',
displayName: 'd',
picture: 'p',
},
backstageIdentity: {
id: 'i',
token: [
'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9',
btoa(
JSON.stringify({
iss: 'http://localhost:7007/api/auth',
sub: 'user:default/freben',
aud: 'backstage',
iat,
exp,
ent: ['group:default/my-team'],
}),
).replace(/=/g, ''),
'4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ',
].join('.'),
identity: {
type: 'user',
userEntityRef: 'ue',
ownershipEntityRefs: ['oe'],
},
},
};
}
worker.events.on('request:match', serverCalled);
worker.use(
rest.get('http://example.com/api/auth/foo/refresh', (_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(makeToken()),
),
),
);
const identity = new ProxiedSignInIdentity({
provider: 'foo',
discoveryApi: { getBaseUrl },
});
getBaseUrl.mockResolvedValue('http://example.com/api/auth');
await identity.start(); // should not throw
expect(getBaseUrl).toBeCalledTimes(1);
expect(getBaseUrl).lastCalledWith('auth');
expect(serverCalled).toBeCalledTimes(1);
await identity.getSessionAsync(); // no need to fetch again just yet
expect(serverCalled).toBeCalledTimes(1);
// Use a fairly large margin (1000) since the iat and exp are clamped to
// full seconds, but the "local current time" isn't
jest.advanceTimersByTime(
3600 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000,
);
await identity.getSessionAsync(); // still no need to fetch again
expect(serverCalled).toBeCalledTimes(1);
jest.advanceTimersByTime(1001);
await identity.getSessionAsync(); // now the expiry has passed
expect(serverCalled).toBeCalledTimes(2);
});
});
});
@@ -0,0 +1,208 @@
/*
* 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 {
BackstageUserIdentity,
discoveryApiRef,
IdentityApi,
ProfileInfo,
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { ProxiedSession, proxiedSessionSchema } from './types';
export const DEFAULTS = {
// The amount of time between token refreshes, if we fail to get an actual
// value out of the exp claim
defaultTokenExpiryMillis: 5 * 60 * 1000,
// The amount of time before the actual expiry of the Backstage token, that we
// shall start trying to get a new one
tokenExpiryMarginMillis: 5 * 60 * 1000,
} as const;
// When the token expires, with some margin
export function tokenToExpiry(jwtToken: string | undefined): Date {
const fallback = new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis);
if (!jwtToken) {
return fallback;
}
const [_header, rawPayload, _signature] = jwtToken.split('.');
const payload = JSON.parse(atob(rawPayload));
if (typeof payload.exp !== 'number') {
return fallback;
}
return new Date(payload.exp * 1000 - DEFAULTS.tokenExpiryMarginMillis);
}
type ProxiedSignInIdentityOptions = {
provider: string;
discoveryApi: typeof discoveryApiRef.T;
};
type State =
| {
type: 'empty';
}
| {
type: 'fetching';
promise: Promise<ProxiedSession>;
previous: ProxiedSession | undefined;
}
| {
type: 'active';
session: ProxiedSession;
expiresAt: Date;
}
| {
type: 'failed';
error: Error;
};
/**
* An identity API that gets the user auth information solely based on a
* provider's `/refresh` endpoint.
*/
export class ProxiedSignInIdentity implements IdentityApi {
private readonly options: ProxiedSignInIdentityOptions;
private readonly abortController: AbortController;
private state: State;
constructor(options: ProxiedSignInIdentityOptions) {
this.options = options;
this.abortController = new AbortController();
this.state = { type: 'empty' };
}
async start() {
// Try to make a first fetch, bubble up any errors to the caller
await this.getSessionAsync();
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
getUserId(): string {
const session = this.getSessionSync();
return session.backstageIdentity.id;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
async getIdToken(): Promise<string | undefined> {
const session = await this.getSessionAsync();
return session.backstageIdentity.token;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
getProfile(): ProfileInfo {
const session = this.getSessionSync();
return session.profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
async getProfileInfo(): Promise<ProfileInfo> {
const session = await this.getSessionAsync();
return session.profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
const session = await this.getSessionAsync();
return session.backstageIdentity.identity;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
async getCredentials(): Promise<{ token?: string | undefined }> {
const session = await this.getSessionAsync();
return {
token: session.backstageIdentity.token,
};
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
async signOut(): Promise<void> {
this.abortController.abort();
}
getSessionSync(): ProxiedSession {
if (this.state.type === 'active') {
return this.state.session;
} else if (this.state.type === 'fetching' && this.state.previous) {
return this.state.previous;
}
throw new Error('No session available. Try reloading your browser page.');
}
async getSessionAsync(): Promise<ProxiedSession> {
if (this.state.type === 'fetching') {
return this.state.promise;
} else if (
this.state.type === 'active' &&
new Date() < this.state.expiresAt
) {
return this.state.session;
}
const previous =
this.state.type === 'active' ? this.state.session : undefined;
const promise = this.fetchSession().then(
session => {
this.state = {
type: 'active',
session,
expiresAt: tokenToExpiry(session.backstageIdentity.token),
};
return session;
},
error => {
this.state = {
type: 'failed',
error,
};
throw error;
},
);
this.state = {
type: 'fetching',
promise,
previous,
};
return promise;
}
async fetchSession(): Promise<ProxiedSession> {
const baseUrl = await this.options.discoveryApi.getBaseUrl('auth');
// Note that we do not use the fetchApi here, since this all happens before
// sign-in completes so there can be no automatic token injection and
// similar.
const response = await fetch(
`${baseUrl}/${this.options.provider}/refresh`,
{
signal: this.abortController.signal,
headers: { 'x-requested-with': 'XMLHttpRequest' },
credentials: 'include',
},
);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return proxiedSessionSchema.parse(await response.json());
}
}
@@ -0,0 +1,82 @@
/*
* 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 {
discoveryApiRef,
SignInPageProps,
useApi,
} from '@backstage/core-plugin-api';
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { ErrorPanel } from '../../components/ErrorPanel';
import { Progress } from '../../components/Progress';
import { ProxiedSignInIdentity } from './ProxiedSignInIdentity';
/**
* Props for {@link ProxiedSignInPage}.
*
* @public
*/
export type ProxiedSignInPageProps = SignInPageProps & {
/**
* The provider to use, e.g. "gcp-iap" or "aws-alb". This must correspond to
* a properly configured auth provider ID in the auth backend.
*/
provider: string;
};
/**
* A sign-in page that has no user interface of its own. Instead, it relies on
* sign-in being performed by a reverse authenticating proxy that Backstage is
* deployed behind, and leverages its session handling.
*
* @remarks
*
* This sign-in page is useful when you are using products such as Google
* Identity-Aware Proxy or AWS Application Load Balancer or similar, to front
* your Backstage installation. This sign-in page implementation will silently
* and regularly punch through the proxy to the auth backend to refresh your
* frontend session information, without requiring user interaction.
*
* @public
*/
export const ProxiedSignInPage = (props: ProxiedSignInPageProps) => {
const discoveryApi = useApi(discoveryApiRef);
const { loading, error } = useAsync(async () => {
const identity = new ProxiedSignInIdentity({
provider: props.provider,
discoveryApi,
});
await identity.start();
props.onSignInSuccess(identity);
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return (
<ErrorPanel
title="You do not appear to be signed in. Please try reloading the browser page."
error={error}
/>
);
}
return null;
};
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { ProxiedSignInPage } from './ProxiedSignInPage';
export type { ProxiedSignInPageProps } from './ProxiedSignInPage';
@@ -0,0 +1,46 @@
/*
* 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 { TypeOf } from 'zod';
import { ProxiedSession, proxiedSessionSchema } from './types';
describe('types', () => {
const responseData: ProxiedSession = {
providerInfo: {
stuff: 1,
},
profile: {
email: 'e',
displayName: 'd',
picture: 'p',
},
backstageIdentity: {
id: 'i',
token: 't',
identity: {
type: 'user',
userEntityRef: 'ue',
ownershipEntityRefs: ['oe'],
},
},
};
it('has a compatible schema type', () => {
function f(_b: TypeOf<typeof proxiedSessionSchema>) {}
f(responseData); // no tsc errors
expect(proxiedSessionSchema.parse(responseData)).toEqual(responseData);
});
});
@@ -0,0 +1,51 @@
/*
* 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 {
BackstageIdentityResponse,
ProfileInfo,
} from '@backstage/core-plugin-api';
import { z } from 'zod';
export const proxiedSessionSchema = z.object({
providerInfo: z.object({}).catchall(z.unknown()).optional(),
profile: z.object({
email: z.string().optional(),
displayName: z.string().optional(),
picture: z.string().optional(),
}),
backstageIdentity: z.object({
id: z.string(),
token: z.string(),
identity: z.object({
type: z.literal('user'),
userEntityRef: z.string(),
ownershipEntityRefs: z.array(z.string()),
}),
}),
});
/**
* Generic session information for proxied sign-in providers, e.g. common
* reverse authenticating proxy implementations.
*
* @public
*/
export type ProxiedSession = {
providerInfo?: { [key: string]: unknown };
profile: ProfileInfo;
backstageIdentity: BackstageIdentityResponse;
};
@@ -26,6 +26,7 @@ export * from './HomepageTimer';
export * from './InfoCard';
export * from './ItemCard';
export * from './Page';
export * from './ProxiedSignInPage';
export * from './Sidebar';
export * from './SignInPage';
export * from './TabbedCard';
@@ -15,3 +15,4 @@
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';