diff --git a/.changeset/violet-dots-relate.md b/.changeset/violet-dots-relate.md index bc6b75caf9..3e22cdd1c7 100644 --- a/.changeset/violet-dots-relate.md +++ b/.changeset/violet-dots-relate.md @@ -2,29 +2,4 @@ '@backstage/core-components': patch --- -Added option to pass additional headers to `` - -The supports signing into Backstage with a Sign Page coming from an external provider e.g. Google IAP, AWS ALB, etc. The component makes requests to a single endpoint `/refresh` on the auth backend to fetch the logged in user session. - -If the provider in auth backend expects additional headers such as `x-provider-token`, there is now a way to configure that in `ProxiedSignInPage` using the optional `getHeaders` prop. - -Example - - -```tsx -const app = createApp({ - // ... - components: { - SignInPage: props => ( - { - const someValue = await someFn(); - return { 'x-some-key': someValue }; - }} - /> - ), - }, - // ... -}); -``` +Added option to pass additional headers to ``, which are passed along with the request to the underlying provider diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts index ed3a1d73a9..b8c2b84085 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts @@ -164,7 +164,21 @@ describe('ProxiedSignInIdentity', () => { expect(serverCalled).toHaveBeenCalledTimes(2); }); - it('handles optional headers correctly', async () => { + // dummy response for tests which are only testing the request behaviour + const dummySessionResponse = { + providerInfo: {}, + profile: {}, + backstageIdentity: { + token: '', + identity: { + ownershipEntityRefs: [''], + userEntityRef: '', + type: 'user', + }, + }, + }; + + it('handles headers passed as a promise', async () => { let req1: Request; const getBaseUrl = jest.fn(); const serverCalled = jest.fn().mockImplementation(req => { @@ -177,19 +191,7 @@ describe('ProxiedSignInIdentity', () => { res( ctx.status(200), ctx.set('Content-Type', 'application/json'), - // dummy response as we are only testing the request in this test - ctx.json({ - providerInfo: {}, - profile: {}, - backstageIdentity: { - token: '', - identity: { - ownershipEntityRefs: [''], - userEntityRef: '', - type: 'user', - }, - }, - }), + ctx.json(dummySessionResponse), ), ), ); @@ -198,7 +200,85 @@ describe('ProxiedSignInIdentity', () => { const identity = new ProxiedSignInIdentity({ provider: 'foo', discoveryApi: { getBaseUrl }, - getHeaders: getHeaders, + headers: getHeaders, + }); + + getBaseUrl.mockResolvedValue('http://example.com/api/auth'); + + await identity.start(); // should not throw + expect(getBaseUrl).toHaveBeenCalledTimes(1); + expect(getBaseUrl).toHaveBeenLastCalledWith('auth'); + expect(getHeaders).toHaveBeenCalledTimes(1); + expect(serverCalled).toHaveBeenCalledTimes(1); + + expect(req1!).not.toBeUndefined(); + // required header should be present + expect(req1!.headers.get('x-requested-with')).toEqual('XMLHttpRequest'); + // optional header should be present when passed + expect(req1!.headers.get('x-foo')).toEqual('bars'); + }); + + it('handles headers passed as an object', async () => { + let req1: Request; + const getBaseUrl = jest.fn(); + const serverCalled = jest.fn().mockImplementation(req => { + req1 = req; + }); + + 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(dummySessionResponse), + ), + ), + ); + + const identity = new ProxiedSignInIdentity({ + provider: 'foo', + discoveryApi: { getBaseUrl }, + headers: { 'x-foo': 'bars' }, + }); + + getBaseUrl.mockResolvedValue('http://example.com/api/auth'); + + await identity.start(); // should not throw + expect(getBaseUrl).toHaveBeenCalledTimes(1); + expect(getBaseUrl).toHaveBeenLastCalledWith('auth'); + expect(serverCalled).toHaveBeenCalledTimes(1); + + expect(req1!).not.toBeUndefined(); + // required header should be present + expect(req1!.headers.get('x-requested-with')).toEqual('XMLHttpRequest'); + // optional header should be present when passed + expect(req1!.headers.get('x-foo')).toEqual('bars'); + }); + + it('handles headers passed as a function', async () => { + let req1: Request; + const getBaseUrl = jest.fn(); + const serverCalled = jest.fn().mockImplementation(req => { + req1 = req; + }); + + 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(dummySessionResponse), + ), + ), + ); + + const getHeaders = jest.fn().mockReturnValue({ 'x-foo': 'bars' }); + const identity = new ProxiedSignInIdentity({ + provider: 'foo', + discoveryApi: { getBaseUrl }, + headers: getHeaders, }); getBaseUrl.mockResolvedValue('http://example.com/api/auth'); diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts index c8b4d66837..828e9ab1a0 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts @@ -48,10 +48,15 @@ export function tokenToExpiry(jwtToken: string | undefined): Date { return new Date(payload.exp * 1000 - DEFAULTS.tokenExpiryMarginMillis); } +export type RefreshHeaders = + | HeadersInit + | (() => HeadersInit) + | (() => Promise); + type ProxiedSignInIdentityOptions = { provider: string; discoveryApi: typeof discoveryApiRef.T; - getHeaders?: () => Promise; + headers?: RefreshHeaders; }; type State = @@ -193,7 +198,13 @@ export class ProxiedSignInIdentity implements IdentityApi { async fetchSession(): Promise { const baseUrl = await this.options.discoveryApi.getBaseUrl('auth'); - const headers = await this.options.getHeaders?.(); + + const headers = + typeof this.options.headers === 'function' + ? await this.options.headers() + : this.options.headers; + const mergedHeaders = new Headers(headers); + mergedHeaders.set('X-Requested-With', 'XMLHttpRequest'); // 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 @@ -202,7 +213,7 @@ export class ProxiedSignInIdentity implements IdentityApi { `${baseUrl}/${this.options.provider}/refresh`, { signal: this.abortController.signal, - headers: { ...headers, 'x-requested-with': 'XMLHttpRequest' }, + headers: mergedHeaders, credentials: 'include', }, ); diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx index 78ca5a8e84..9d91a31462 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx @@ -23,7 +23,7 @@ import React from 'react'; import { useAsync, useMountEffect } from '@react-hookz/web'; import { ErrorPanel } from '../../components/ErrorPanel'; import { Progress } from '../../components/Progress'; -import { ProxiedSignInIdentity } from './ProxiedSignInIdentity'; +import { ProxiedSignInIdentity, RefreshHeaders } from './ProxiedSignInIdentity'; /** * Props for {@link ProxiedSignInPage}. @@ -38,10 +38,10 @@ export type ProxiedSignInPageProps = SignInPageProps & { provider: string; /** - * An optional function which returns a promise resolving with any headers - * that need to be added to the call made to /refresh endpoint. + * Optional headers which are passed along with the request to the + * underlying provider */ - getHeaders?: () => Promise; + headers?: RefreshHeaders; }; /** @@ -66,7 +66,7 @@ export const ProxiedSignInPage = (props: ProxiedSignInPageProps) => { const identity = new ProxiedSignInIdentity({ provider: props.provider, discoveryApi, - getHeaders: props.getHeaders, + headers: props.headers, }); await identity.start();