diff --git a/.changeset/violet-dots-relate.md b/.changeset/violet-dots-relate.md new file mode 100644 index 0000000000..3e22cdd1c7 --- /dev/null +++ b/.changeset/violet-dots-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Added option to pass additional headers to ``, which are passed along with the request to the underlying provider diff --git a/docs/auth/index.md b/docs/auth/index.md index 5702e96cee..fa185c0c23 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -150,6 +150,31 @@ const app = createApp({ }); ``` +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 `headers` prop. + +Example: + +```tsx + +``` + +Headers can also be returned in an async manner: + +```tsx + { + const someValue = await someFn(); + return { 'x-some-key': someValue }; + }} +/> +``` + A downside of this method is that it can be cumbersome to set up for local development. As a workaround for this, it's possible to dynamically select the sign-in page based on what environment the app is running in, and then use a different sign-in method for local diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index d8ea9d2bbe..c46b3637cb 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -807,6 +807,7 @@ export const ProxiedSignInPage: ( // @public export type ProxiedSignInPageProps = SignInPageProps & { provider: string; + headers?: HeadersInit | (() => HeadersInit) | (() => Promise); }; // Warning: (ae-missing-release-tag) "Ranker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts index 3a40f5bb4b..b8c2b84085 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.test.ts @@ -103,7 +103,6 @@ describe('ProxiedSignInIdentity', () => { }, }; } - worker.events.on('request:match', serverCalled); worker.use( rest.get('http://example.com/api/auth/foo/refresh', (_, res, ctx) => @@ -164,5 +163,137 @@ describe('ProxiedSignInIdentity', () => { await identity.getSessionAsync(); // now the expiry has passed expect(serverCalled).toHaveBeenCalledTimes(2); }); + + // 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 => { + 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().mockResolvedValue({ 'x-foo': 'bars' }); + const identity = new ProxiedSignInIdentity({ + provider: 'foo', + discoveryApi: { getBaseUrl }, + 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'); + + 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'); + }); }); }); diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts index 7633fc43c3..bd1fc1bf39 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInIdentity.ts @@ -51,6 +51,7 @@ export function tokenToExpiry(jwtToken: string | undefined): Date { type ProxiedSignInIdentityOptions = { provider: string; discoveryApi: typeof discoveryApiRef.T; + headers?: HeadersInit | (() => HeadersInit) | (() => Promise); }; type State = @@ -193,6 +194,13 @@ export class ProxiedSignInIdentity implements IdentityApi { async fetchSession(): Promise { const baseUrl = await this.options.discoveryApi.getBaseUrl('auth'); + 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 // similar. @@ -200,7 +208,7 @@ export class ProxiedSignInIdentity implements IdentityApi { `${baseUrl}/${this.options.provider}/refresh`, { signal: this.abortController.signal, - 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 965c603004..ea095cd8ab 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.tsx @@ -36,6 +36,12 @@ export type ProxiedSignInPageProps = SignInPageProps & { * a properly configured auth provider ID in the auth backend. */ provider: string; + + /** + * Optional headers which are passed along with the request to the + * underlying provider + */ + headers?: HeadersInit | (() => HeadersInit) | (() => Promise); }; /** @@ -60,6 +66,7 @@ export const ProxiedSignInPage = (props: ProxiedSignInPageProps) => { const identity = new ProxiedSignInIdentity({ provider: props.provider, discoveryApi, + headers: props.headers, }); await identity.start();