From 6415189d9946a9790cac72b1849eb03b1447af19 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 28 Dec 2021 17:30:00 +0100 Subject: [PATCH] auth: implement frontend parts of iap-like sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/cool-baboons-switch.md | 5 + docs/auth/google/gcp-iap-auth.md | 98 ++++++++++++ packages/core-app-api/api-report.md | 37 ----- .../auth/gcp-iap/GcpIapIdentity.ts | 74 --------- .../src/apis/implementations/auth/index.ts | 1 - packages/core-components/package.json | 3 +- .../DelegatedSignInIdentity.ts | 149 ++++++++++++++++++ .../DelegatedSignInPage.tsx | 90 +++++++++++ .../src/layout/DelegatedSignInPage}/index.ts | 4 +- .../layout/DelegatedSignInPage}/types.test.ts | 14 +- .../src/layout/DelegatedSignInPage}/types.ts | 24 +-- 11 files changed, 357 insertions(+), 142 deletions(-) create mode 100644 .changeset/cool-baboons-switch.md create mode 100644 docs/auth/google/gcp-iap-auth.md delete mode 100644 packages/core-app-api/src/apis/implementations/auth/gcp-iap/GcpIapIdentity.ts create mode 100644 packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts create mode 100644 packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx rename packages/{core-app-api/src/apis/implementations/auth/gcp-iap => core-components/src/layout/DelegatedSignInPage}/index.ts (82%) rename packages/{core-app-api/src/apis/implementations/auth/gcp-iap => core-components/src/layout/DelegatedSignInPage}/types.test.ts (76%) rename packages/{core-app-api/src/apis/implementations/auth/gcp-iap => core-components/src/layout/DelegatedSignInPage}/types.ts (74%) diff --git a/.changeset/cool-baboons-switch.md b/.changeset/cool-baboons-switch.md new file mode 100644 index 0000000000..88752502c8 --- /dev/null +++ b/.changeset/cool-baboons-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Add a `DelegatedSignInPage` that can be used e.g. for GCP IAP and AWS ALB diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md new file mode 100644 index 0000000000..2281d7654e --- /dev/null +++ b/docs/auth/google/gcp-iap-auth.md @@ -0,0 +1,98 @@ +--- +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. + +## Frontend Changes + +Any Backstage app needs a `SignInPage` to be configured. When using IAP Proxy +authentication Backstage will only be loaded once the user has already +successfully authenticated. As such, we won't need to display a sign-in page +visually, however we will need to pick a `SignInPage` implementation which knows +how to silently fetch the IAP-injected token and other information via the +backend. + +Update your `createApp` call in `packages/app/src/App.tsx`, to pass in the +proper sign-in page implementation. + +```diff ++import { DelegatedSignInPage } from '@backstage/core-components'; + + const app = createApp({ + components: { ++ SignInPage: props => , +``` + +## Backend Changes + +- 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 { + 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. + 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 }; + }, + }, + }), + }, + }); +} +``` + +## Configuration + +Use the following `auth` configuration: + +```yaml +auth: + providers: + gcp-iap: + audience: + '/projects//global/backendServices/' +``` diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index ece34959de..f94979f975 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -21,9 +21,7 @@ import { AuthProviderInfo } from '@backstage/core-plugin-api'; import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentity } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; -import { BackstageIdentityResponse } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; -import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; @@ -390,41 +388,6 @@ export type FlatRoutesProps = { children: ReactNode; }; -// @public -export class GcpIapIdentity implements IdentityApi { - constructor(session: GcpIapSession); - static fromResponse(data: unknown): GcpIapIdentity; - // (undocumented) - getBackstageIdentity(): Promise; - // (undocumented) - getCredentials(): Promise<{ - token?: string | undefined; - }>; - // (undocumented) - getIdToken(): Promise; - // (undocumented) - getProfile(): ProfileInfo; - // (undocumented) - getProfileInfo(): Promise; - // (undocumented) - getUserId(): string; - // (undocumented) - signOut(): Promise; -} - -// @public -export type GcpIapSession = { - providerInfo: { - iapToken: { - sub: string; - email: string; - [key: string]: unknown; - }; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentityResponse; -}; - // @public export class GithubAuth implements OAuthApi, SessionApi { // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/GcpIapIdentity.ts b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/GcpIapIdentity.ts deleted file mode 100644 index 0df14e1d99..0000000000 --- a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/GcpIapIdentity.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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, - IdentityApi, - ProfileInfo, -} from '@backstage/core-plugin-api'; -import { GcpIapSession, gcpIapSessionSchema } from './types'; - -/** - * An identity API based on a Google Identity-Aware Proxy auth response. - * - * @public - */ -export class GcpIapIdentity implements IdentityApi { - /** - * Creates an identity instance based on the auth-backend API response. - * - * @param data - The raw JSON response from the auth-backend. - */ - static fromResponse(data: unknown): GcpIapIdentity { - const parsed = gcpIapSessionSchema.parse(data); - return new GcpIapIdentity(parsed); - } - - constructor(private readonly session: GcpIapSession) {} - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */ - getUserId(): string { - return this.session.backstageIdentity.id; - } - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */ - async getIdToken(): Promise { - return this.session.backstageIdentity.token; - } - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */ - getProfile(): ProfileInfo { - return this.session.profile; - } - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */ - async getProfileInfo(): Promise { - return this.session.profile; - } - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */ - async getBackstageIdentity(): Promise { - return this.session.backstageIdentity.identity; - } - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */ - async getCredentials(): Promise<{ token?: string | undefined }> { - return { token: this.session.backstageIdentity.token }; - } - - /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */ - async signOut(): Promise {} -} diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index c53c9b7c76..50333f07a0 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -25,5 +25,4 @@ export * from './microsoft'; export * from './onelogin'; export * from './bitbucket'; export * from './atlassian'; -export * from './gcp-iap'; export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 82c3857a81..a15bec3eac 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -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", diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts new file mode 100644 index 0000000000..ca39c802b6 --- /dev/null +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts @@ -0,0 +1,149 @@ +/* + * 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, + errorApiRef, + fetchApiRef, + IdentityApi, + ProfileInfo, +} from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { DelegatedSession, delegatedSessionSchema } from './types'; + +type DelegatedSignInIdentityOptions = { + provider: string; + refreshFrequencyMillis: number; + retryRefreshFrequencyMillis: number; + discoveryApi: typeof discoveryApiRef.T; + fetchApi: typeof fetchApiRef.T; + errorApi: typeof errorApiRef.T; +}; + +/** + * An identity API that keeps itself up to date solely based on getting session + * information from a `/refresh` endpoint. + */ +export class DelegatedSignInIdentity implements IdentityApi { + private readonly options: DelegatedSignInIdentityOptions; + private readonly abortController: AbortController; + private session: DelegatedSession | undefined; + + constructor(options: DelegatedSignInIdentityOptions) { + this.options = options; + this.abortController = new AbortController(); + this.session = undefined; + } + + async start() { + await this.refresh(false); + + let signalErrors = true; + const refreshLoop = async () => { + if (!this.abortController.signal.aborted) { + try { + await this.refresh(signalErrors); + signalErrors = true; + setTimeout(refreshLoop, this.options.refreshFrequencyMillis); + } catch { + signalErrors = false; + setTimeout(refreshLoop, this.options.retryRefreshFrequencyMillis); + } + } + }; + + setTimeout(refreshLoop, this.options.refreshFrequencyMillis); + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */ + getUserId(): string { + return this.getSession().backstageIdentity.id; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */ + async getIdToken(): Promise { + return this.getSession().backstageIdentity.token; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */ + getProfile(): ProfileInfo { + return this.getSession().profile; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */ + async getProfileInfo(): Promise { + return this.getSession().profile; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */ + async getBackstageIdentity(): Promise { + return this.getSession().backstageIdentity.identity; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */ + async getCredentials(): Promise<{ token?: string | undefined }> { + return { token: this.getSession().backstageIdentity.token }; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */ + async signOut(): Promise { + this.abortController.abort(); + } + + getSession(): DelegatedSession { + if (!this.session) { + throw new Error('No session available'); + } + return this.session; + } + + async refresh(notifyErrors: boolean) { + try { + const baseUrl = await this.options.discoveryApi.getBaseUrl('auth'); + + const response = await this.options.fetchApi.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); + } + + const session = delegatedSessionSchema.parse(await response.json()); + this.session = session; + } catch (e) { + if (this.abortController.signal.aborted) { + return; + } + + if (notifyErrors) { + this.options.errorApi.post( + new Error( + `Failed to refresh browser session, ${e}. Try reloading your browser page.`, + ), + ); + } + + throw e; + } + } +} diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx new file mode 100644 index 0000000000..f8ecc8c2a5 --- /dev/null +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx @@ -0,0 +1,90 @@ +/* + * 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, + errorApiRef, + fetchApiRef, + 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 { DelegatedSignInIdentity } from './DelegatedSignInIdentity'; + +/** + * Props for {@link DelegatedSignInPage}. + * + * @public + */ +export type DelegatedSignInPageProps = 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 delegates + * sign-in to some 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 DelegatedSignInPage = (props: DelegatedSignInPageProps) => { + const discoveryApi = useApi(discoveryApiRef); + const fetchApi = useApi(fetchApiRef); + const errorApi = useApi(errorApiRef); + + const { loading, error } = useAsync(async () => { + const identity = new DelegatedSignInIdentity({ + provider: props.provider, + refreshFrequencyMillis: 60_000, + retryRefreshFrequencyMillis: 10_000, + errorApi, + discoveryApi, + fetchApi, + }); + + await identity.start(); + + props.onSignInSuccess(identity); + }, []); + + if (loading) { + return ; + } else if (error) { + return ( + + ); + } + + return null; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/index.ts b/packages/core-components/src/layout/DelegatedSignInPage/index.ts similarity index 82% rename from packages/core-app-api/src/apis/implementations/auth/gcp-iap/index.ts rename to packages/core-components/src/layout/DelegatedSignInPage/index.ts index 8133061f20..bf3081e630 100644 --- a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/index.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { GcpIapIdentity } from './GcpIapIdentity'; -export type { GcpIapSession } from './types'; +export { DelegatedSignInPage } from './DelegatedSignInPage'; +export type { DelegatedSignInPageProps } from './DelegatedSignInPage'; diff --git a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts b/packages/core-components/src/layout/DelegatedSignInPage/types.test.ts similarity index 76% rename from packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts rename to packages/core-components/src/layout/DelegatedSignInPage/types.test.ts index cb185103e2..c3eeff4eec 100644 --- a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/types.test.ts @@ -15,16 +15,12 @@ */ import { TypeOf } from 'zod'; -import { GcpIapSession, gcpIapSessionSchema } from './types'; +import { DelegatedSession, delegatedSessionSchema } from './types'; describe('types', () => { - const responseData: GcpIapSession = { + const responseData: DelegatedSession = { providerInfo: { - iapToken: { - sub: 's', - email: 'e', - other: 7, - }, + stuff: 1, }, profile: { email: 'e', @@ -43,8 +39,8 @@ describe('types', () => { }; it('has a compatible schema type', () => { - function f(_b: TypeOf) {} + function f(_b: TypeOf) {} f(responseData); // no tsc errors - expect(gcpIapSessionSchema.parse(responseData)).toEqual(responseData); + expect(delegatedSessionSchema.parse(responseData)).toEqual(responseData); }); }); diff --git a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts b/packages/core-components/src/layout/DelegatedSignInPage/types.ts similarity index 74% rename from packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts rename to packages/core-components/src/layout/DelegatedSignInPage/types.ts index 98c728785d..0ef65344a3 100644 --- a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/types.ts @@ -20,15 +20,8 @@ import { } from '@backstage/core-plugin-api'; import { z } from 'zod'; -export const gcpIapSessionSchema = z.object({ - providerInfo: z.object({ - iapToken: z - .object({ - sub: z.string(), - email: z.string(), - }) - .catchall(z.unknown()), - }), +export const delegatedSessionSchema = z.object({ + providerInfo: z.object({}).catchall(z.unknown()).optional(), profile: z.object({ email: z.string().optional(), displayName: z.string().optional(), @@ -46,18 +39,13 @@ export const gcpIapSessionSchema = z.object({ }); /** - * Session information for Google Identity-Aware Proxy auth. + * Generic session information for delegated sign-in providers, e.g. common + * reverse authenticating proxy implementations. * * @public */ -export type GcpIapSession = { - providerInfo: { - iapToken: { - sub: string; - email: string; - [key: string]: unknown; - }; - }; +export type DelegatedSession = { + providerInfo?: { [key: string]: unknown }; profile: ProfileInfo; backstageIdentity: BackstageIdentityResponse; };