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