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;
};