@@ -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"
|
||||
|
||||
+195
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-20
@@ -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<void>(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user