From 6f5a1e74f55def5c25af380b7389cf0285391b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 26 Dec 2021 21:57:07 +0100 Subject: [PATCH 1/9] use only a sign-in resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/core-app-api/api-report.md | 37 ++++++++++ .../auth/gcp-iap/GcpIapIdentity.ts | 74 +++++++++++++++++++ .../implementations/auth/gcp-iap/index.ts | 18 +++++ .../auth/gcp-iap/types.test.ts | 50 +++++++++++++ .../implementations/auth/gcp-iap/types.ts | 63 ++++++++++++++++ .../src/apis/implementations/auth/index.ts | 1 + 6 files changed, 243 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/auth/gcp-iap/GcpIapIdentity.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/gcp-iap/index.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index f94979f975..ece34959de 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -21,7 +21,9 @@ 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'; @@ -388,6 +390,41 @@ 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 new file mode 100644 index 0000000000..0df14e1d99 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/GcpIapIdentity.ts @@ -0,0 +1,74 @@ +/* + * 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/gcp-iap/index.ts b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/index.ts new file mode 100644 index 0000000000..8133061f20 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/index.ts @@ -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 { GcpIapIdentity } from './GcpIapIdentity'; +export type { GcpIapSession } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts new file mode 100644 index 0000000000..cb185103e2 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { GcpIapSession, gcpIapSessionSchema } from './types'; + +describe('types', () => { + const responseData: GcpIapSession = { + providerInfo: { + iapToken: { + sub: 's', + email: 'e', + other: 7, + }, + }, + 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) {} + f(responseData); // no tsc errors + expect(gcpIapSessionSchema.parse(responseData)).toEqual(responseData); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts new file mode 100644 index 0000000000..98c728785d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/gcp-iap/types.ts @@ -0,0 +1,63 @@ +/* + * 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 gcpIapSessionSchema = z.object({ + providerInfo: z.object({ + iapToken: z + .object({ + sub: z.string(), + email: z.string(), + }) + .catchall(z.unknown()), + }), + 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()), + }), + }), +}); + +/** + * Session information for Google Identity-Aware Proxy auth. + * + * @public + */ +export type GcpIapSession = { + providerInfo: { + iapToken: { + sub: string; + email: string; + [key: string]: unknown; + }; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; 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 50333f07a0..c53c9b7c76 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -25,4 +25,5 @@ export * from './microsoft'; export * from './onelogin'; export * from './bitbucket'; export * from './atlassian'; +export * from './gcp-iap'; export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; From 6415189d9946a9790cac72b1849eb03b1447af19 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 28 Dec 2021 17:30:00 +0100 Subject: [PATCH 2/9] 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; }; From 0bc142f54526fe3113556e59f71c09ea1eada2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 3 Jan 2022 12:00:58 +0100 Subject: [PATCH 3/9] moved around the docs sections to make things clearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- docs/auth/google/gcp-iap-auth.md | 83 +++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md index 2281d7654e..88077080da 100644 --- a/docs/auth/google/gcp-iap-auth.md +++ b/docs/auth/google/gcp-iap-auth.md @@ -16,30 +16,32 @@ 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 +## Configuration -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. +Let's start by adding the following `auth` configuration in your +`app-config.yaml` or `app-config.production.yaml` or similar: -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 => , +```yaml +auth: + providers: + gcp-iap: + audience: + '/projects//global/backendServices/' ``` +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 -- Add a `providerFactories` entry to the router in - `packages/backend/plugin/auth.ts`. +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'; @@ -59,7 +61,9 @@ export default async function createPlugin({ '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. + // 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 } }; }, @@ -68,7 +72,7 @@ export default async function createPlugin({ // 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 + // 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]}`; @@ -85,14 +89,37 @@ export default async function createPlugin({ } ``` -## Configuration +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. -Use the following `auth` configuration: +## Frontend Changes -```yaml -auth: - providers: - gcp-iap: - audience: - '/projects//global/backendServices/' +Any Backstage app needs 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 { DelegatedSignInPage } from '@backstage/core-components'; + + const app = createApp({ + components: { ++ SignInPage: props => , ``` + +After this, your app should be ready to leverage the Identity-Aware Proxy for +authentication! From b254d60924aa05b2d4734e00a8bddc69068ac487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 3 Jan 2022 14:34:15 +0100 Subject: [PATCH 4/9] proper token expiry handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../DelegatedSignInIdentity.ts | 58 ++++++++++++++++--- .../DelegatedSignInPage.tsx | 2 - 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts index ca39c802b6..2d24796a17 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts @@ -25,10 +25,50 @@ import { import { ResponseError } from '@backstage/errors'; import { DelegatedSession, delegatedSessionSchema } from './types'; +const DEFAULTS = { + // How often we retry a failed auth refresh call + retryRefreshFrequencyMillis: 5_000, + // The amount of time between token refreshes, if we fail to get an actual + // value out of the exp claim + defaultTokenExpiryMillis: 30_000, + // The amount of time before the actual expiry of the Backstage token, that we + // shall start trying to get a new one + tokenExpiryMarginMillis: 30_000, +}; + +// The amount of time to wait until trying to refresh the auth session +function tokenToExpiryMillis(token: string | undefined): number { + if (typeof token !== 'string') { + return DEFAULTS.retryRefreshFrequencyMillis; + } + + const [_header, rawPayload, _signature] = token.split('.'); + const payload = JSON.parse(atob(rawPayload)); + const expiresInMillis = payload.exp * 1000 - Date.now(); + + return Math.max( + expiresInMillis - DEFAULTS.tokenExpiryMarginMillis, + DEFAULTS.defaultTokenExpiryMillis, + ); +} + +// Sleeps for a given duration, unless interrupted by signal +async function sleep(durationMillis: number, signal: AbortSignal) { + if (!signal.aborted) { + await new Promise(resolve => { + const timeoutHandle = setTimeout(done, durationMillis); + signal.addEventListener('abort', done); + function done() { + clearTimeout(timeoutHandle); + signal.removeEventListener('abort', done); + resolve(); + } + }); + } +} + type DelegatedSignInIdentityOptions = { provider: string; - refreshFrequencyMillis: number; - retryRefreshFrequencyMillis: number; discoveryApi: typeof discoveryApiRef.T; fetchApi: typeof fetchApiRef.T; errorApi: typeof errorApiRef.T; @@ -50,23 +90,27 @@ export class DelegatedSignInIdentity implements IdentityApi { } async start() { + // Try first refresh and bubble up any errors to the caller await this.refresh(false); let signalErrors = true; + let pause: number; + const refreshLoop = async () => { - if (!this.abortController.signal.aborted) { + while (!this.abortController.signal.aborted) { try { await this.refresh(signalErrors); signalErrors = true; - setTimeout(refreshLoop, this.options.refreshFrequencyMillis); + pause = tokenToExpiryMillis(this.session?.backstageIdentity.token); } catch { - signalErrors = false; - setTimeout(refreshLoop, this.options.retryRefreshFrequencyMillis); + signalErrors = false; // Only signal first failure in a row + pause = DEFAULTS.retryRefreshFrequencyMillis; } + await sleep(pause, this.abortController.signal); } }; - setTimeout(refreshLoop, this.options.refreshFrequencyMillis); + refreshLoop(); } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */ diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx index f8ecc8c2a5..54cc0495fd 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx @@ -63,8 +63,6 @@ export const DelegatedSignInPage = (props: DelegatedSignInPageProps) => { const { loading, error } = useAsync(async () => { const identity = new DelegatedSignInIdentity({ provider: props.provider, - refreshFrequencyMillis: 60_000, - retryRefreshFrequencyMillis: 10_000, errorApi, discoveryApi, fetchApi, From f0a743ecaec7c8acd6053adfe5e864c8ddc7c6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 3 Jan 2022 19:06:31 +0100 Subject: [PATCH 5/9] more tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/core-components/package.json | 4 +- .../DelegatedSignInIdentity.test.ts | 195 ++++++++++++++++++ .../DelegatedSignInIdentity.ts | 46 +++-- 3 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts diff --git a/packages/core-components/package.json b/packages/core-components/package.json index a15bec3eac..83b6b47ad6 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -93,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" diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts new file mode 100644 index 0000000000..11fdaf257e --- /dev/null +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts @@ -0,0 +1,195 @@ +/* + * 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 fetch from 'cross-fetch'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { + DEFAULTS, + DelegatedSignInIdentity, + sleep, + tokenToExpiryMillis, +} from './DelegatedSignInIdentity'; + +const flushPromises = () => new Promise(setImmediate); + +const validBackstageTokenExpClaim = 1641216199; +const validBackstageToken = + 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJmcmViZW4iLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE2NDEyMTI1OTksImV4cCI6MTY0MTIxNjE5OSwiZW50IjpbInVzZXI6ZGVmYXVsdC9mcmViZW4iXX0.4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ'; + +describe('DelegatedSignInIdentity', () => { + describe('tokenToExpiryMillis', () => { + beforeEach(() => jest.useFakeTimers('modern')); + afterEach(() => jest.useRealTimers()); + + it('handles undefined', async () => { + expect(tokenToExpiryMillis(undefined)).toEqual( + DEFAULTS.defaultTokenExpiryMillis, + ); + }); + + it('handles a valid token', async () => { + jest.setSystemTime( + validBackstageTokenExpClaim * 1000 - + (3600 + DEFAULTS.tokenExpiryMarginMillis), + ); + expect(tokenToExpiryMillis(validBackstageToken)).toEqual(3600); + }); + }); + + describe('sleep', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('normally sleeps', async () => { + const fn = jest.fn(); + sleep(100, new AbortController().signal).then(fn); + + await flushPromises(); + expect(fn).toBeCalledTimes(0); + + jest.advanceTimersByTime(99); + await flushPromises(); + expect(fn).toBeCalledTimes(0); + + jest.advanceTimersByTime(2); + await flushPromises(); + expect(fn).toBeCalledTimes(1); + }); + + it('can be aborted', async () => { + const controller = new AbortController(); + const fn = jest.fn(); + sleep(100, controller.signal).then(fn); + + jest.advanceTimersByTime(50); + await flushPromises(); + expect(fn).toBeCalledTimes(0); + + controller.abort(); + await flushPromises(); + expect(fn).toBeCalledTimes(1); + }); + + it('reuses the same controller nicely', async () => { + const controller = new AbortController(); + + for (let i = 0; i < 50; ++i) { + const fn = jest.fn(); + sleep(100, controller.signal).then(fn); + + jest.advanceTimersByTime(99); + await flushPromises(); + expect(fn).toBeCalledTimes(0); + + jest.advanceTimersByTime(2); + await flushPromises(); + expect(fn).toBeCalledTimes(1); + } + }); + }); + + describe('DelegatedSignInIdentity', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('runs the happy path', async () => { + const getBaseUrl = jest.fn(); + const postError = jest.fn(); + const serverCalled = jest.fn(); + + function makeToken() { + const iat = Math.floor(Date.now() / 1000); + const exp = iat + 100; + 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 DelegatedSignInIdentity({ + provider: 'foo', + discoveryApi: { getBaseUrl }, + errorApi: { post: postError } as any, + fetchApi: { fetch }, + }); + + getBaseUrl.mockResolvedValue('http://example.com/api/auth'); + + await identity.start(); // should not throw + + expect(getBaseUrl).toBeCalledTimes(1); + expect(getBaseUrl).lastCalledWith('auth'); + expect(postError).toBeCalledTimes(0); + 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( + 100 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000, + ); + await flushPromises(); + expect(serverCalled).toBeCalledTimes(1); + + jest.advanceTimersByTime(1001); + await flushPromises(); + expect(serverCalled).toBeCalledTimes(2); + }); + }); +}); diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts index 2d24796a17..92c8b34c50 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts @@ -25,35 +25,32 @@ import { import { ResponseError } from '@backstage/errors'; import { DelegatedSession, delegatedSessionSchema } from './types'; -const DEFAULTS = { +export const DEFAULTS = { // How often we retry a failed auth refresh call retryRefreshFrequencyMillis: 5_000, // The amount of time between token refreshes, if we fail to get an actual // value out of the exp claim - defaultTokenExpiryMillis: 30_000, + defaultTokenExpiryMillis: 60_000, // The amount of time before the actual expiry of the Backstage token, that we // shall start trying to get a new one tokenExpiryMarginMillis: 30_000, -}; +} as const; // The amount of time to wait until trying to refresh the auth session -function tokenToExpiryMillis(token: string | undefined): number { - if (typeof token !== 'string') { - return DEFAULTS.retryRefreshFrequencyMillis; +export function tokenToExpiryMillis(jwtToken: string | undefined): number { + if (typeof jwtToken !== 'string') { + return DEFAULTS.defaultTokenExpiryMillis; } - const [_header, rawPayload, _signature] = token.split('.'); + const [_header, rawPayload, _signature] = jwtToken.split('.'); const payload = JSON.parse(atob(rawPayload)); const expiresInMillis = payload.exp * 1000 - Date.now(); - return Math.max( - expiresInMillis - DEFAULTS.tokenExpiryMarginMillis, - DEFAULTS.defaultTokenExpiryMillis, - ); + return Math.max(expiresInMillis - DEFAULTS.tokenExpiryMarginMillis, 0); } // Sleeps for a given duration, unless interrupted by signal -async function sleep(durationMillis: number, signal: AbortSignal) { +export async function sleep(durationMillis: number, signal: AbortSignal) { if (!signal.aborted) { await new Promise(resolve => { const timeoutHandle = setTimeout(done, durationMillis); @@ -90,26 +87,35 @@ export class DelegatedSignInIdentity implements IdentityApi { } async start() { - // Try first refresh and bubble up any errors to the caller - await this.refresh(false); - let signalErrors = true; - let pause: number; + let pause = 0; - const refreshLoop = async () => { - while (!this.abortController.signal.aborted) { + const refreshOnce = async () => { + if (!this.abortController.signal.aborted) { try { await this.refresh(signalErrors); signalErrors = true; pause = tokenToExpiryMillis(this.session?.backstageIdentity.token); + if (pause <= 0) { + this.session = undefined; + } } catch { signalErrors = false; // Only signal first failure in a row pause = DEFAULTS.retryRefreshFrequencyMillis; } - await sleep(pause, this.abortController.signal); } }; + const refreshLoop = async () => { + while (!this.abortController.signal.aborted) { + await sleep(pause, this.abortController.signal); + await refreshOnce(); + } + }; + + // Try first refresh and bubble up any errors to the caller, then get the + // loop going behind the scenes and return + await refreshOnce(); refreshLoop(); } @@ -150,7 +156,7 @@ export class DelegatedSignInIdentity implements IdentityApi { getSession(): DelegatedSession { if (!this.session) { - throw new Error('No session available'); + throw new Error('No session available. Try reloading your browser page.'); } return this.session; } From 742c7a9929dc038c0f9cb77c640eecd4313a8e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 4 Jan 2022 10:49:43 +0100 Subject: [PATCH 6/9] api report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/core-components/api-report.md | 10 ++++++++++ packages/core-components/src/layout/index.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index e84982e31a..bdd6f0e030 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -202,6 +202,16 @@ export type CustomProviderClassKey = 'form' | 'button'; // @public (undocumented) export function DashboardIcon(props: IconComponentProps): JSX.Element; +// @public +export const DelegatedSignInPage: ( + props: DelegatedSignInPageProps, +) => JSX.Element | null; + +// @public +export type DelegatedSignInPageProps = SignInPageProps & { + provider: string; +}; + // @public type DependencyEdge = T & { from: string; diff --git a/packages/core-components/src/layout/index.ts b/packages/core-components/src/layout/index.ts index 19cd6dfb78..f726481535 100644 --- a/packages/core-components/src/layout/index.ts +++ b/packages/core-components/src/layout/index.ts @@ -17,6 +17,7 @@ export * from './BottomLink'; export * from './Content'; export * from './ContentHeader'; +export * from './DelegatedSignInPage'; export * from './ErrorBoundary'; export * from './ErrorPage'; export * from './Header'; From 28b61f32b4d8d5ee9324c9ae801504593c2ab561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 4 Jan 2022 13:02:55 +0100 Subject: [PATCH 7/9] slim down and fetch on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../DelegatedSignInIdentity.test.ts | 84 ++----- .../DelegatedSignInIdentity.ts | 208 +++++++++--------- .../DelegatedSignInPage.tsx | 3 - 3 files changed, 120 insertions(+), 175 deletions(-) diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts index 11fdaf257e..16ce71980a 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts @@ -21,90 +21,35 @@ import { setupServer } from 'msw/node'; import { DEFAULTS, DelegatedSignInIdentity, - sleep, - tokenToExpiryMillis, + tokenToExpiry, } from './DelegatedSignInIdentity'; -const flushPromises = () => new Promise(setImmediate); - const validBackstageTokenExpClaim = 1641216199; const validBackstageToken = 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJmcmViZW4iLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE2NDEyMTI1OTksImV4cCI6MTY0MTIxNjE5OSwiZW50IjpbInVzZXI6ZGVmYXVsdC9mcmViZW4iXX0.4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ'; describe('DelegatedSignInIdentity', () => { - describe('tokenToExpiryMillis', () => { + describe('tokenToExpiry', () => { beforeEach(() => jest.useFakeTimers('modern')); afterEach(() => jest.useRealTimers()); it('handles undefined', async () => { - expect(tokenToExpiryMillis(undefined)).toEqual( - DEFAULTS.defaultTokenExpiryMillis, + expect(tokenToExpiry(undefined)).toEqual( + new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis), ); }); it('handles a valid token', async () => { - jest.setSystemTime( - validBackstageTokenExpClaim * 1000 - - (3600 + DEFAULTS.tokenExpiryMarginMillis), + expect(tokenToExpiry(validBackstageToken)).toEqual( + new Date( + validBackstageTokenExpClaim * 1000 - DEFAULTS.tokenExpiryMarginMillis, + ), ); - expect(tokenToExpiryMillis(validBackstageToken)).toEqual(3600); - }); - }); - - describe('sleep', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('normally sleeps', async () => { - const fn = jest.fn(); - sleep(100, new AbortController().signal).then(fn); - - await flushPromises(); - expect(fn).toBeCalledTimes(0); - - jest.advanceTimersByTime(99); - await flushPromises(); - expect(fn).toBeCalledTimes(0); - - jest.advanceTimersByTime(2); - await flushPromises(); - expect(fn).toBeCalledTimes(1); - }); - - it('can be aborted', async () => { - const controller = new AbortController(); - const fn = jest.fn(); - sleep(100, controller.signal).then(fn); - - jest.advanceTimersByTime(50); - await flushPromises(); - expect(fn).toBeCalledTimes(0); - - controller.abort(); - await flushPromises(); - expect(fn).toBeCalledTimes(1); - }); - - it('reuses the same controller nicely', async () => { - const controller = new AbortController(); - - for (let i = 0; i < 50; ++i) { - const fn = jest.fn(); - sleep(100, controller.signal).then(fn); - - jest.advanceTimersByTime(99); - await flushPromises(); - expect(fn).toBeCalledTimes(0); - - jest.advanceTimersByTime(2); - await flushPromises(); - expect(fn).toBeCalledTimes(1); - } }); }); describe('DelegatedSignInIdentity', () => { - beforeEach(() => jest.useFakeTimers()); + beforeEach(() => jest.useFakeTimers('modern')); afterEach(() => jest.useRealTimers()); const worker = setupServer(); @@ -112,7 +57,6 @@ describe('DelegatedSignInIdentity', () => { it('runs the happy path', async () => { const getBaseUrl = jest.fn(); - const postError = jest.fn(); const serverCalled = jest.fn(); function makeToken() { @@ -166,17 +110,17 @@ describe('DelegatedSignInIdentity', () => { const identity = new DelegatedSignInIdentity({ provider: 'foo', discoveryApi: { getBaseUrl }, - errorApi: { post: postError } as any, fetchApi: { fetch }, }); getBaseUrl.mockResolvedValue('http://example.com/api/auth'); await identity.start(); // should not throw - expect(getBaseUrl).toBeCalledTimes(1); expect(getBaseUrl).lastCalledWith('auth'); - expect(postError).toBeCalledTimes(0); + 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 @@ -184,11 +128,11 @@ describe('DelegatedSignInIdentity', () => { jest.advanceTimersByTime( 100 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000, ); - await flushPromises(); + await identity.getSessionAsync(); // still no need to fetch again expect(serverCalled).toBeCalledTimes(1); jest.advanceTimersByTime(1001); - await flushPromises(); + await identity.getSessionAsync(); // now the expiry has passed expect(serverCalled).toBeCalledTimes(2); }); }); diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts index 92c8b34c50..750f9ef027 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts @@ -17,7 +17,6 @@ import { BackstageUserIdentity, discoveryApiRef, - errorApiRef, fetchApiRef, IdentityApi, ProfileInfo, @@ -26,8 +25,6 @@ import { ResponseError } from '@backstage/errors'; import { DelegatedSession, delegatedSessionSchema } from './types'; export const DEFAULTS = { - // How often we retry a failed auth refresh call - retryRefreshFrequencyMillis: 5_000, // The amount of time between token refreshes, if we fail to get an actual // value out of the exp claim defaultTokenExpiryMillis: 60_000, @@ -36,117 +33,99 @@ export const DEFAULTS = { tokenExpiryMarginMillis: 30_000, } as const; -// The amount of time to wait until trying to refresh the auth session -export function tokenToExpiryMillis(jwtToken: string | undefined): number { - if (typeof jwtToken !== 'string') { - return DEFAULTS.defaultTokenExpiryMillis; +// When the token expires, with some margin +export function tokenToExpiry(jwtToken: string | undefined): Date { + if (!jwtToken) { + return new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis); } const [_header, rawPayload, _signature] = jwtToken.split('.'); const payload = JSON.parse(atob(rawPayload)); - const expiresInMillis = payload.exp * 1000 - Date.now(); - return Math.max(expiresInMillis - DEFAULTS.tokenExpiryMarginMillis, 0); -} - -// Sleeps for a given duration, unless interrupted by signal -export async function sleep(durationMillis: number, signal: AbortSignal) { - if (!signal.aborted) { - await new Promise(resolve => { - const timeoutHandle = setTimeout(done, durationMillis); - signal.addEventListener('abort', done); - function done() { - clearTimeout(timeoutHandle); - signal.removeEventListener('abort', done); - resolve(); - } - }); - } + return new Date(payload.exp * 1000 - DEFAULTS.tokenExpiryMarginMillis); } type DelegatedSignInIdentityOptions = { provider: string; discoveryApi: typeof discoveryApiRef.T; fetchApi: typeof fetchApiRef.T; - errorApi: typeof errorApiRef.T; }; +type State = + | { + type: 'empty'; + } + | { + type: 'fetching'; + promise: Promise; + previous: DelegatedSession | undefined; + } + | { + type: 'active'; + session: DelegatedSession; + expiresAt: Date; + } + | { + type: 'failed'; + error: Error; + }; + /** - * An identity API that keeps itself up to date solely based on getting session - * information from a `/refresh` endpoint. + * An identity API that gets the user auth information solely based on a + * provider's `/refresh` endpoint. */ export class DelegatedSignInIdentity implements IdentityApi { private readonly options: DelegatedSignInIdentityOptions; private readonly abortController: AbortController; - private session: DelegatedSession | undefined; + private state: State; constructor(options: DelegatedSignInIdentityOptions) { this.options = options; this.abortController = new AbortController(); - this.session = undefined; + this.state = { type: 'empty' }; } async start() { - let signalErrors = true; - let pause = 0; - - const refreshOnce = async () => { - if (!this.abortController.signal.aborted) { - try { - await this.refresh(signalErrors); - signalErrors = true; - pause = tokenToExpiryMillis(this.session?.backstageIdentity.token); - if (pause <= 0) { - this.session = undefined; - } - } catch { - signalErrors = false; // Only signal first failure in a row - pause = DEFAULTS.retryRefreshFrequencyMillis; - } - } - }; - - const refreshLoop = async () => { - while (!this.abortController.signal.aborted) { - await sleep(pause, this.abortController.signal); - await refreshOnce(); - } - }; - - // Try first refresh and bubble up any errors to the caller, then get the - // loop going behind the scenes and return - await refreshOnce(); - refreshLoop(); + // 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 { - return this.getSession().backstageIdentity.id; + const session = this.getSessionSync(); + return session.backstageIdentity.id; } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */ async getIdToken(): Promise { - return this.getSession().backstageIdentity.token; + const session = await this.getSessionAsync(); + return session.backstageIdentity.token; } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */ getProfile(): ProfileInfo { - return this.getSession().profile; + const session = this.getSessionSync(); + return session.profile; } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */ async getProfileInfo(): Promise { - return this.getSession().profile; + const session = await this.getSessionAsync(); + return session.profile; } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */ async getBackstageIdentity(): Promise { - return this.getSession().backstageIdentity.identity; + const session = await this.getSessionAsync(); + return session.backstageIdentity.identity; } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */ async getCredentials(): Promise<{ token?: string | undefined }> { - return { token: this.getSession().backstageIdentity.token }; + const session = await this.getSessionAsync(); + return { + token: session.backstageIdentity.token, + }; } /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */ @@ -154,46 +133,71 @@ export class DelegatedSignInIdentity implements IdentityApi { this.abortController.abort(); } - getSession(): DelegatedSession { - if (!this.session) { - throw new Error('No session available. Try reloading your browser page.'); + getSessionSync(): DelegatedSession { + if (this.state.type === 'active') { + return this.state.session; + } else if (this.state.type === 'fetching' && this.state.previous) { + return this.state.previous; } - return this.session; + throw new Error('No session available. Try reloading your browser page.'); } - 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; + async getSessionAsync(): Promise { + 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 { + 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); + } + + return delegatedSessionSchema.parse(await response.json()); } } diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx index 54cc0495fd..0337e3122d 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx +++ b/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx @@ -16,7 +16,6 @@ import { discoveryApiRef, - errorApiRef, fetchApiRef, SignInPageProps, useApi, @@ -58,12 +57,10 @@ export type DelegatedSignInPageProps = SignInPageProps & { 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, - errorApi, discoveryApi, fetchApi, }); From be610c5744141dc6a358a46cecef7c990e9db253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 13 Jan 2022 11:16:16 +0100 Subject: [PATCH 8/9] address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- docs/auth/google/gcp-iap-auth.md | 6 +-- packages/core-components/api-report.md | 20 ++++----- .../ProxiedSignInIdentity.test.ts} | 24 +++++++---- .../ProxiedSignInIdentity.ts} | 41 +++++++++++-------- .../ProxiedSignInPage.tsx} | 19 ++++----- .../index.ts | 4 +- .../types.test.ts | 8 ++-- .../types.ts | 6 +-- packages/core-components/src/layout/index.ts | 2 +- packages/core-components/src/setupTests.ts | 1 + 10 files changed, 70 insertions(+), 61 deletions(-) rename packages/core-components/src/layout/{DelegatedSignInPage/DelegatedSignInIdentity.test.ts => ProxiedSignInPage/ProxiedSignInIdentity.test.ts} (87%) rename packages/core-components/src/layout/{DelegatedSignInPage/DelegatedSignInIdentity.ts => ProxiedSignInPage/ProxiedSignInIdentity.ts} (82%) rename packages/core-components/src/layout/{DelegatedSignInPage/DelegatedSignInPage.tsx => ProxiedSignInPage/ProxiedSignInPage.tsx} (80%) rename packages/core-components/src/layout/{DelegatedSignInPage => ProxiedSignInPage}/index.ts (82%) rename packages/core-components/src/layout/{DelegatedSignInPage => ProxiedSignInPage}/types.test.ts (81%) rename packages/core-components/src/layout/{DelegatedSignInPage => ProxiedSignInPage}/types.ts (89%) diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md index 88077080da..29951951d1 100644 --- a/docs/auth/google/gcp-iap-auth.md +++ b/docs/auth/google/gcp-iap-auth.md @@ -95,7 +95,7 @@ sign-in mechanism to poll that endpoint through the IAP, on the user's behalf. ## Frontend Changes -Any Backstage app needs a `SignInPage` to be configured. Its purpose is to +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. @@ -114,11 +114,11 @@ component for this purpose out of the box. Update your `createApp` call in `packages/app/src/App.tsx`, as follows. ```diff -+import { DelegatedSignInPage } from '@backstage/core-components'; ++import { ProxiedSignInPage } from '@backstage/core-components'; const app = createApp({ components: { -+ SignInPage: props => , ++ SignInPage: props => , ``` After this, your app should be ready to leverage the Identity-Aware Proxy for diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index bdd6f0e030..6af2746683 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -202,16 +202,6 @@ export type CustomProviderClassKey = 'form' | 'button'; // @public (undocumented) export function DashboardIcon(props: IconComponentProps): JSX.Element; -// @public -export const DelegatedSignInPage: ( - props: DelegatedSignInPageProps, -) => JSX.Element | null; - -// @public -export type DelegatedSignInPageProps = SignInPageProps & { - provider: string; -}; - // @public type DependencyEdge = T & { from: string; @@ -765,6 +755,16 @@ export function Progress( props: PropsWithChildren, ): 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 diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts similarity index 87% rename from packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts rename to packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts index 16ce71980a..cc23678606 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.test.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts @@ -15,20 +15,19 @@ */ import { setupRequestMockHandlers } from '@backstage/test-utils'; -import fetch from 'cross-fetch'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { DEFAULTS, - DelegatedSignInIdentity, + ProxiedSignInIdentity, tokenToExpiry, -} from './DelegatedSignInIdentity'; +} from './ProxiedSignInIdentity'; const validBackstageTokenExpClaim = 1641216199; const validBackstageToken = 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJmcmViZW4iLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE2NDEyMTI1OTksImV4cCI6MTY0MTIxNjE5OSwiZW50IjpbInVzZXI6ZGVmYXVsdC9mcmViZW4iXX0.4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ'; -describe('DelegatedSignInIdentity', () => { +describe('ProxiedSignInIdentity', () => { describe('tokenToExpiry', () => { beforeEach(() => jest.useFakeTimers('modern')); afterEach(() => jest.useRealTimers()); @@ -46,9 +45,17 @@ describe('DelegatedSignInIdentity', () => { ), ); }); + + 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('DelegatedSignInIdentity', () => { + describe('ProxiedSignInIdentity', () => { beforeEach(() => jest.useFakeTimers('modern')); afterEach(() => jest.useRealTimers()); @@ -61,7 +68,7 @@ describe('DelegatedSignInIdentity', () => { function makeToken() { const iat = Math.floor(Date.now() / 1000); - const exp = iat + 100; + const exp = iat + 3600; return { providerInfo: { stuff: 1, @@ -107,10 +114,9 @@ describe('DelegatedSignInIdentity', () => { ), ); - const identity = new DelegatedSignInIdentity({ + const identity = new ProxiedSignInIdentity({ provider: 'foo', discoveryApi: { getBaseUrl }, - fetchApi: { fetch }, }); getBaseUrl.mockResolvedValue('http://example.com/api/auth'); @@ -126,7 +132,7 @@ describe('DelegatedSignInIdentity', () => { // 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( - 100 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000, + 3600 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000, ); await identity.getSessionAsync(); // still no need to fetch again expect(serverCalled).toBeCalledTimes(1); diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts similarity index 82% rename from packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts rename to packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts index 750f9ef027..1f8737b200 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInIdentity.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts @@ -17,38 +17,40 @@ import { BackstageUserIdentity, discoveryApiRef, - fetchApiRef, IdentityApi, ProfileInfo, } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; -import { DelegatedSession, delegatedSessionSchema } from './types'; +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: 60_000, + 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: 30_000, + 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 new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis); + 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 DelegatedSignInIdentityOptions = { +type ProxiedSignInIdentityOptions = { provider: string; discoveryApi: typeof discoveryApiRef.T; - fetchApi: typeof fetchApiRef.T; }; type State = @@ -57,12 +59,12 @@ type State = } | { type: 'fetching'; - promise: Promise; - previous: DelegatedSession | undefined; + promise: Promise; + previous: ProxiedSession | undefined; } | { type: 'active'; - session: DelegatedSession; + session: ProxiedSession; expiresAt: Date; } | { @@ -74,12 +76,12 @@ type State = * An identity API that gets the user auth information solely based on a * provider's `/refresh` endpoint. */ -export class DelegatedSignInIdentity implements IdentityApi { - private readonly options: DelegatedSignInIdentityOptions; +export class ProxiedSignInIdentity implements IdentityApi { + private readonly options: ProxiedSignInIdentityOptions; private readonly abortController: AbortController; private state: State; - constructor(options: DelegatedSignInIdentityOptions) { + constructor(options: ProxiedSignInIdentityOptions) { this.options = options; this.abortController = new AbortController(); this.state = { type: 'empty' }; @@ -133,7 +135,7 @@ export class DelegatedSignInIdentity implements IdentityApi { this.abortController.abort(); } - getSessionSync(): DelegatedSession { + getSessionSync(): ProxiedSession { if (this.state.type === 'active') { return this.state.session; } else if (this.state.type === 'fetching' && this.state.previous) { @@ -142,7 +144,7 @@ export class DelegatedSignInIdentity implements IdentityApi { throw new Error('No session available. Try reloading your browser page.'); } - async getSessionAsync(): Promise { + async getSessionAsync(): Promise { if (this.state.type === 'fetching') { return this.state.promise; } else if ( @@ -182,10 +184,13 @@ export class DelegatedSignInIdentity implements IdentityApi { return promise; } - async fetchSession(): Promise { + async fetchSession(): Promise { const baseUrl = await this.options.discoveryApi.getBaseUrl('auth'); - const response = await this.options.fetchApi.fetch( + // 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, @@ -198,6 +203,6 @@ export class DelegatedSignInIdentity implements IdentityApi { throw await ResponseError.fromResponse(response); } - return delegatedSessionSchema.parse(await response.json()); + return proxiedSessionSchema.parse(await response.json()); } } diff --git a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx similarity index 80% rename from packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx rename to packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx index 0337e3122d..adcd6ced8f 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/DelegatedSignInPage.tsx +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx @@ -16,7 +16,6 @@ import { discoveryApiRef, - fetchApiRef, SignInPageProps, useApi, } from '@backstage/core-plugin-api'; @@ -24,14 +23,14 @@ 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'; +import { ProxiedSignInIdentity } from './ProxiedSignInIdentity'; /** - * Props for {@link DelegatedSignInPage}. + * Props for {@link ProxiedSignInPage}. * * @public */ -export type DelegatedSignInPageProps = SignInPageProps & { +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. @@ -40,9 +39,9 @@ export type DelegatedSignInPageProps = SignInPageProps & { }; /** - * 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. + * 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 * @@ -54,15 +53,13 @@ export type DelegatedSignInPageProps = SignInPageProps & { * * @public */ -export const DelegatedSignInPage = (props: DelegatedSignInPageProps) => { +export const ProxiedSignInPage = (props: ProxiedSignInPageProps) => { const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); const { loading, error } = useAsync(async () => { - const identity = new DelegatedSignInIdentity({ + const identity = new ProxiedSignInIdentity({ provider: props.provider, discoveryApi, - fetchApi, }); await identity.start(); diff --git a/packages/core-components/src/layout/DelegatedSignInPage/index.ts b/packages/core-components/src/layout/ProxiedSignInPage/index.ts similarity index 82% rename from packages/core-components/src/layout/DelegatedSignInPage/index.ts rename to packages/core-components/src/layout/ProxiedSignInPage/index.ts index bf3081e630..f7bab5f46c 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/index.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { DelegatedSignInPage } from './DelegatedSignInPage'; -export type { DelegatedSignInPageProps } from './DelegatedSignInPage'; +export { ProxiedSignInPage } from './ProxiedSignInPage'; +export type { ProxiedSignInPageProps } from './ProxiedSignInPage'; diff --git a/packages/core-components/src/layout/DelegatedSignInPage/types.test.ts b/packages/core-components/src/layout/ProxiedSignInPage/types.test.ts similarity index 81% rename from packages/core-components/src/layout/DelegatedSignInPage/types.test.ts rename to packages/core-components/src/layout/ProxiedSignInPage/types.test.ts index c3eeff4eec..5da8203cbd 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/types.test.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/types.test.ts @@ -15,10 +15,10 @@ */ import { TypeOf } from 'zod'; -import { DelegatedSession, delegatedSessionSchema } from './types'; +import { ProxiedSession, proxiedSessionSchema } from './types'; describe('types', () => { - const responseData: DelegatedSession = { + const responseData: ProxiedSession = { providerInfo: { stuff: 1, }, @@ -39,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(delegatedSessionSchema.parse(responseData)).toEqual(responseData); + expect(proxiedSessionSchema.parse(responseData)).toEqual(responseData); }); }); diff --git a/packages/core-components/src/layout/DelegatedSignInPage/types.ts b/packages/core-components/src/layout/ProxiedSignInPage/types.ts similarity index 89% rename from packages/core-components/src/layout/DelegatedSignInPage/types.ts rename to packages/core-components/src/layout/ProxiedSignInPage/types.ts index 0ef65344a3..f5b54f306a 100644 --- a/packages/core-components/src/layout/DelegatedSignInPage/types.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/types.ts @@ -20,7 +20,7 @@ import { } from '@backstage/core-plugin-api'; import { z } from 'zod'; -export const delegatedSessionSchema = z.object({ +export const proxiedSessionSchema = z.object({ providerInfo: z.object({}).catchall(z.unknown()).optional(), profile: z.object({ email: z.string().optional(), @@ -39,12 +39,12 @@ export const delegatedSessionSchema = z.object({ }); /** - * Generic session information for delegated sign-in providers, e.g. common + * Generic session information for proxied sign-in providers, e.g. common * reverse authenticating proxy implementations. * * @public */ -export type DelegatedSession = { +export type ProxiedSession = { providerInfo?: { [key: string]: unknown }; profile: ProfileInfo; backstageIdentity: BackstageIdentityResponse; diff --git a/packages/core-components/src/layout/index.ts b/packages/core-components/src/layout/index.ts index f726481535..9bb7fbaa26 100644 --- a/packages/core-components/src/layout/index.ts +++ b/packages/core-components/src/layout/index.ts @@ -17,7 +17,6 @@ export * from './BottomLink'; export * from './Content'; export * from './ContentHeader'; -export * from './DelegatedSignInPage'; export * from './ErrorBoundary'; export * from './ErrorPage'; export * from './Header'; @@ -27,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'; diff --git a/packages/core-components/src/setupTests.ts b/packages/core-components/src/setupTests.ts index 963c0f188b..c1d649f2ad 100644 --- a/packages/core-components/src/setupTests.ts +++ b/packages/core-components/src/setupTests.ts @@ -15,3 +15,4 @@ */ import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; From 7a35aa9377ef1963b3b159fcf6a77921a205f788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 13 Jan 2022 11:39:42 +0100 Subject: [PATCH 9/9] typo in changeset 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cool-baboons-switch.md b/.changeset/cool-baboons-switch.md index 88752502c8..9bf09bab74 100644 --- a/.changeset/cool-baboons-switch.md +++ b/.changeset/cool-baboons-switch.md @@ -2,4 +2,4 @@ '@backstage/core-components': patch --- -Add a `DelegatedSignInPage` that can be used e.g. for GCP IAP and AWS ALB +Add a `ProxiedSignInPage` that can be used e.g. for GCP IAP and AWS ALB