diff --git a/.changeset/cool-baboons-switch.md b/.changeset/cool-baboons-switch.md new file mode 100644 index 0000000000..9bf09bab74 --- /dev/null +++ b/.changeset/cool-baboons-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Add a `ProxiedSignInPage` 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..29951951d1 --- /dev/null +++ b/docs/auth/google/gcp-iap-auth.md @@ -0,0 +1,125 @@ +--- +id: provider +title: Google Identity-Aware Proxy Provider +sidebar_label: Google IAP +# prettier-ignore +description: Adding Google Identity-Aware Proxy as an authentication provider in Backstage +--- + +Backstage allows offloading the responsibility of authenticating users to the +Google HTTPS Load Balancer & [IAP](https://cloud.google.com/iap), leveraging the +authentication support on the latter. + +This tutorial shows how to use authentication on an IAP sitting in front of +Backstage. + +It is assumed an IAP is already serving traffic in front of a Backstage instance +configured to serve the frontend app from the backend. + +## Configuration + +Let's start by adding the following `auth` configuration in your +`app-config.yaml` or `app-config.production.yaml` or similar: + +```yaml +auth: + providers: + gcp-iap: + audience: + '/projects//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 + +This provider is not enabled by default in the auth backend code, because +besides the config section above, it also needs to be given one or more +callbacks in actual code as well as described below. + +Add a `providerFactories` entry to the router in +`packages/backend/plugin/auth.ts`. + +```ts +import { createGcpIapProvider } from '@backstage/plugin-auth-backend'; + +export default async function createPlugin({ + logger, + database, + config, + discovery, +}: PluginEnvironment): Promise { + return await createRouter({ + logger, + config, + database, + discovery, + providerFactories: { + 'gcp-iap': createGcpIapProvider({ + // Replace the auth handler if you want to customize the returned user + // profile info (can be left out; the default implementation is shown + // below which only returns the email). You may want to amend this code + // with something that loads additional user profile data out of e.g. + // GSuite or LDAP or similar. + async authHandler({ iapToken }) { + return { profile: { email: iapToken.email } }; + }, + signIn: { + // You need to supply an identity resolver, that takes the profile + // and the IAP token and produces the Backstage token with the + // relevant user info. + async resolver({ profile, result: { iapToken } }, ctx) { + // Somehow compute the Backstage token claims. Just some dummy code + // shown here, but you may want to query your LDAP server, or + // GSuite or similar, based on the IAP token sub/email claims + const id = `user:default/${iapToken.email.split('@')[0]}`; + const fullEnt = ['group:default/team-name']; + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id, ent: fullEnt }, + }); + return { id, token }; + }, + }, + }), + }, + }); +} +``` + +Now the backend is ready to serve auth requests on the +`/api/auth/gcp-iap/refresh` endpoint. All that's left is to update the frontend +sign-in mechanism to poll that endpoint through the IAP, on the user's behalf. + +## Frontend Changes + +All Backstage apps need a `SignInPage` to be configured. Its purpose is to +establish who the user is and what their identifying credentials are, blocking +rendering the rest of the UI until that's complete, and then keeping those +credentials fresh. + +When using IAP Proxy authentication, the Backstage UI will only be loaded once +the user has already successfully completely authenticated themselves with the +IAP and has an active session, so we don't want to make the user have to go +through a _second_ layer of authentication flows after that. + +As such, we want to not display a sign-in page visually at all. Instead, we will +pick a `SignInPage` implementation component which knows how to silently make +requests to the backend provider we configured above, and just trusting its +output to properly represent the current user. Luckily, Backstage comes with a +component for this purpose out of the box. + +Update your `createApp` call in `packages/app/src/App.tsx`, as follows. + +```diff ++import { ProxiedSignInPage } from '@backstage/core-components'; + + const app = createApp({ + components: { ++ SignInPage: props => , +``` + +After this, your app should be ready to leverage the Identity-Aware Proxy for +authentication! diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 87a9186d5b..29847b079e 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -752,6 +752,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/package.json b/packages/core-components/package.json index 337a759da8..cdce4497af 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", @@ -92,7 +93,9 @@ "@types/react-syntax-highlighter": "^13.5.2", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", - "@types/zen-observable": "^0.8.0" + "@types/zen-observable": "^0.8.0", + "cross-fetch": "^3.0.6", + "msw": "^0.35.0" }, "files": [ "dist" diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts new file mode 100644 index 0000000000..cc23678606 --- /dev/null +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { + DEFAULTS, + ProxiedSignInIdentity, + tokenToExpiry, +} from './ProxiedSignInIdentity'; + +const validBackstageTokenExpClaim = 1641216199; +const validBackstageToken = + 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjcwMDcvYXBpL2F1dGgiLCJzdWIiOiJmcmViZW4iLCJhdWQiOiJiYWNrc3RhZ2UiLCJpYXQiOjE2NDEyMTI1OTksImV4cCI6MTY0MTIxNjE5OSwiZW50IjpbInVzZXI6ZGVmYXVsdC9mcmViZW4iXX0.4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ'; + +describe('ProxiedSignInIdentity', () => { + describe('tokenToExpiry', () => { + beforeEach(() => jest.useFakeTimers('modern')); + afterEach(() => jest.useRealTimers()); + + it('handles undefined', async () => { + expect(tokenToExpiry(undefined)).toEqual( + new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis), + ); + }); + + it('handles a valid token', async () => { + expect(tokenToExpiry(validBackstageToken)).toEqual( + new Date( + validBackstageTokenExpClaim * 1000 - DEFAULTS.tokenExpiryMarginMillis, + ), + ); + }); + + it('handles a token that has no exp', async () => { + const [a, _b, c] = validBackstageToken.split('.'); + const botched = `${a}.${btoa(JSON.stringify({}))}.${c}`; + expect(tokenToExpiry(botched)).toEqual( + new Date(new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis)), + ); + }); + }); + + describe('ProxiedSignInIdentity', () => { + beforeEach(() => jest.useFakeTimers('modern')); + afterEach(() => jest.useRealTimers()); + + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('runs the happy path', async () => { + const getBaseUrl = jest.fn(); + const serverCalled = jest.fn(); + + function makeToken() { + const iat = Math.floor(Date.now() / 1000); + const exp = iat + 3600; + return { + providerInfo: { + stuff: 1, + }, + profile: { + email: 'e', + displayName: 'd', + picture: 'p', + }, + backstageIdentity: { + id: 'i', + token: [ + 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImMxNTMzNDRiLWZjYzktNGIwOS1iN2ZhLTU3ZmM5MDhjMjBiNiJ9', + btoa( + JSON.stringify({ + iss: 'http://localhost:7007/api/auth', + sub: 'user:default/freben', + aud: 'backstage', + iat, + exp, + ent: ['group:default/my-team'], + }), + ).replace(/=/g, ''), + '4nOTmPHPwhzaKTzikgUsHcszfcP-JamcojMnRfyfsKhyHCCEywe6uLFlvvmK5NbaX5Z7IIji-kg7bxKU58kwoQ', + ].join('.'), + identity: { + type: 'user', + userEntityRef: 'ue', + ownershipEntityRefs: ['oe'], + }, + }, + }; + } + + worker.events.on('request:match', serverCalled); + worker.use( + rest.get('http://example.com/api/auth/foo/refresh', (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(makeToken()), + ), + ), + ); + + const identity = new ProxiedSignInIdentity({ + provider: 'foo', + discoveryApi: { getBaseUrl }, + }); + + getBaseUrl.mockResolvedValue('http://example.com/api/auth'); + + await identity.start(); // should not throw + expect(getBaseUrl).toBeCalledTimes(1); + expect(getBaseUrl).lastCalledWith('auth'); + expect(serverCalled).toBeCalledTimes(1); + + await identity.getSessionAsync(); // no need to fetch again just yet + expect(serverCalled).toBeCalledTimes(1); + + // Use a fairly large margin (1000) since the iat and exp are clamped to + // full seconds, but the "local current time" isn't + jest.advanceTimersByTime( + 3600 * 1000 - DEFAULTS.tokenExpiryMarginMillis - 1000, + ); + await identity.getSessionAsync(); // still no need to fetch again + expect(serverCalled).toBeCalledTimes(1); + + jest.advanceTimersByTime(1001); + await identity.getSessionAsync(); // now the expiry has passed + expect(serverCalled).toBeCalledTimes(2); + }); + }); +}); diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts new file mode 100644 index 0000000000..1f8737b200 --- /dev/null +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + BackstageUserIdentity, + discoveryApiRef, + IdentityApi, + ProfileInfo, +} from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { ProxiedSession, proxiedSessionSchema } from './types'; + +export const DEFAULTS = { + // The amount of time between token refreshes, if we fail to get an actual + // value out of the exp claim + defaultTokenExpiryMillis: 5 * 60 * 1000, + // The amount of time before the actual expiry of the Backstage token, that we + // shall start trying to get a new one + tokenExpiryMarginMillis: 5 * 60 * 1000, +} as const; + +// When the token expires, with some margin +export function tokenToExpiry(jwtToken: string | undefined): Date { + const fallback = new Date(Date.now() + DEFAULTS.defaultTokenExpiryMillis); + if (!jwtToken) { + return fallback; + } + + const [_header, rawPayload, _signature] = jwtToken.split('.'); + const payload = JSON.parse(atob(rawPayload)); + if (typeof payload.exp !== 'number') { + return fallback; + } + + return new Date(payload.exp * 1000 - DEFAULTS.tokenExpiryMarginMillis); +} + +type ProxiedSignInIdentityOptions = { + provider: string; + discoveryApi: typeof discoveryApiRef.T; +}; + +type State = + | { + type: 'empty'; + } + | { + type: 'fetching'; + promise: Promise; + previous: ProxiedSession | undefined; + } + | { + type: 'active'; + session: ProxiedSession; + expiresAt: Date; + } + | { + type: 'failed'; + error: Error; + }; + +/** + * An identity API that gets the user auth information solely based on a + * provider's `/refresh` endpoint. + */ +export class ProxiedSignInIdentity implements IdentityApi { + private readonly options: ProxiedSignInIdentityOptions; + private readonly abortController: AbortController; + private state: State; + + constructor(options: ProxiedSignInIdentityOptions) { + this.options = options; + this.abortController = new AbortController(); + this.state = { type: 'empty' }; + } + + async start() { + // Try to make a first fetch, bubble up any errors to the caller + await this.getSessionAsync(); + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */ + getUserId(): string { + const session = this.getSessionSync(); + return session.backstageIdentity.id; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */ + async getIdToken(): Promise { + const session = await this.getSessionAsync(); + return session.backstageIdentity.token; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */ + getProfile(): ProfileInfo { + const session = this.getSessionSync(); + return session.profile; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */ + async getProfileInfo(): Promise { + const session = await this.getSessionAsync(); + return session.profile; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */ + async getBackstageIdentity(): Promise { + const session = await this.getSessionAsync(); + return session.backstageIdentity.identity; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */ + async getCredentials(): Promise<{ token?: string | undefined }> { + const session = await this.getSessionAsync(); + return { + token: session.backstageIdentity.token, + }; + } + + /** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */ + async signOut(): Promise { + this.abortController.abort(); + } + + getSessionSync(): ProxiedSession { + if (this.state.type === 'active') { + return this.state.session; + } else if (this.state.type === 'fetching' && this.state.previous) { + return this.state.previous; + } + throw new Error('No session available. Try reloading your browser page.'); + } + + async getSessionAsync(): Promise { + 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'); + + // Note that we do not use the fetchApi here, since this all happens before + // sign-in completes so there can be no automatic token injection and + // similar. + const response = await fetch( + `${baseUrl}/${this.options.provider}/refresh`, + { + signal: this.abortController.signal, + headers: { 'x-requested-with': 'XMLHttpRequest' }, + credentials: 'include', + }, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return proxiedSessionSchema.parse(await response.json()); + } +} diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx new file mode 100644 index 0000000000..adcd6ced8f --- /dev/null +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + discoveryApiRef, + SignInPageProps, + useApi, +} from '@backstage/core-plugin-api'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { ErrorPanel } from '../../components/ErrorPanel'; +import { Progress } from '../../components/Progress'; +import { ProxiedSignInIdentity } from './ProxiedSignInIdentity'; + +/** + * Props for {@link ProxiedSignInPage}. + * + * @public + */ +export type ProxiedSignInPageProps = SignInPageProps & { + /** + * The provider to use, e.g. "gcp-iap" or "aws-alb". This must correspond to + * a properly configured auth provider ID in the auth backend. + */ + provider: string; +}; + +/** + * A sign-in page that has no user interface of its own. Instead, it relies on + * sign-in being performed by a reverse authenticating proxy that Backstage is + * deployed behind, and leverages its session handling. + * + * @remarks + * + * This sign-in page is useful when you are using products such as Google + * Identity-Aware Proxy or AWS Application Load Balancer or similar, to front + * your Backstage installation. This sign-in page implementation will silently + * and regularly punch through the proxy to the auth backend to refresh your + * frontend session information, without requiring user interaction. + * + * @public + */ +export const ProxiedSignInPage = (props: ProxiedSignInPageProps) => { + const discoveryApi = useApi(discoveryApiRef); + + const { loading, error } = useAsync(async () => { + const identity = new ProxiedSignInIdentity({ + provider: props.provider, + discoveryApi, + }); + + await identity.start(); + + props.onSignInSuccess(identity); + }, []); + + if (loading) { + return ; + } else if (error) { + return ( + + ); + } + + return null; +}; diff --git a/packages/core-components/src/layout/ProxiedSignInPage/index.ts b/packages/core-components/src/layout/ProxiedSignInPage/index.ts new file mode 100644 index 0000000000..f7bab5f46c --- /dev/null +++ b/packages/core-components/src/layout/ProxiedSignInPage/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 { ProxiedSignInPage } from './ProxiedSignInPage'; +export type { ProxiedSignInPageProps } from './ProxiedSignInPage'; diff --git a/packages/core-components/src/layout/ProxiedSignInPage/types.test.ts b/packages/core-components/src/layout/ProxiedSignInPage/types.test.ts new file mode 100644 index 0000000000..5da8203cbd --- /dev/null +++ b/packages/core-components/src/layout/ProxiedSignInPage/types.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TypeOf } from 'zod'; +import { ProxiedSession, proxiedSessionSchema } from './types'; + +describe('types', () => { + const responseData: ProxiedSession = { + providerInfo: { + stuff: 1, + }, + profile: { + email: 'e', + displayName: 'd', + picture: 'p', + }, + backstageIdentity: { + id: 'i', + token: 't', + identity: { + type: 'user', + userEntityRef: 'ue', + ownershipEntityRefs: ['oe'], + }, + }, + }; + + it('has a compatible schema type', () => { + function f(_b: TypeOf) {} + f(responseData); // no tsc errors + expect(proxiedSessionSchema.parse(responseData)).toEqual(responseData); + }); +}); diff --git a/packages/core-components/src/layout/ProxiedSignInPage/types.ts b/packages/core-components/src/layout/ProxiedSignInPage/types.ts new file mode 100644 index 0000000000..f5b54f306a --- /dev/null +++ b/packages/core-components/src/layout/ProxiedSignInPage/types.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + BackstageIdentityResponse, + ProfileInfo, +} from '@backstage/core-plugin-api'; +import { z } from 'zod'; + +export const proxiedSessionSchema = z.object({ + providerInfo: z.object({}).catchall(z.unknown()).optional(), + profile: z.object({ + email: z.string().optional(), + displayName: z.string().optional(), + picture: z.string().optional(), + }), + backstageIdentity: z.object({ + id: z.string(), + token: z.string(), + identity: z.object({ + type: z.literal('user'), + userEntityRef: z.string(), + ownershipEntityRefs: z.array(z.string()), + }), + }), +}); + +/** + * Generic session information for proxied sign-in providers, e.g. common + * reverse authenticating proxy implementations. + * + * @public + */ +export type ProxiedSession = { + providerInfo?: { [key: string]: unknown }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; diff --git a/packages/core-components/src/layout/index.ts b/packages/core-components/src/layout/index.ts index 19cd6dfb78..9bb7fbaa26 100644 --- a/packages/core-components/src/layout/index.ts +++ b/packages/core-components/src/layout/index.ts @@ -26,6 +26,7 @@ export * from './HomepageTimer'; export * from './InfoCard'; export * from './ItemCard'; export * from './Page'; +export * from './ProxiedSignInPage'; export * from './Sidebar'; export * from './SignInPage'; export * from './TabbedCard'; 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';