Skip to content

Commit

Permalink
Implement App Check auto refresh timing and opt out flag (#4847)
Browse files Browse the repository at this point in the history
  • Loading branch information
hsubox76 authored May 4, 2021
1 parent 97f61e6 commit f3a1a3f
Show file tree
Hide file tree
Showing 13 changed files with 146 additions and 40 deletions.
12 changes: 10 additions & 2 deletions packages/app-check-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@
export interface FirebaseAppCheck {
/**
* Activate AppCheck
* @param siteKeyOrOrovider - reCAPTCHA sitekey or custom token provider
* @param siteKeyOrProvider - reCAPTCHA sitekey or custom token provider
* @param isTokenAutoRefreshEnabled - If true, enables SDK to automatically
* refresh AppCheck token as needed. If undefined, the value will default
* to the value of `app.automaticDataCollectionEnabled`. That property
* defaults to false and can be set in the app config.
*/
activate(siteKeyOrProvider: string | AppCheckProvider): void;
activate(
siteKeyOrProvider: string | AppCheckProvider,
isTokenAutoRefreshEnabled?: boolean
): void;
setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void;
}

interface AppCheckProvider {
Expand Down
21 changes: 20 additions & 1 deletion packages/app-check/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import '../test/setup';
import { expect } from 'chai';
import { stub } from 'sinon';
import { activate } from './api';
import { activate, setTokenAutoRefreshEnabled } from './api';
import {
FAKE_SITE_KEY,
getFakeApp,
Expand All @@ -41,6 +41,18 @@ describe('api', () => {
expect(getState(app).activated).to.equal(true);
});

it('isTokenAutoRefreshEnabled value defaults to global setting', () => {
app = getFakeApp({ automaticDataCollectionEnabled: false });
activate(app, FAKE_SITE_KEY);
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(false);
});

it('sets isTokenAutoRefreshEnabled correctly, overriding global setting', () => {
app = getFakeApp({ automaticDataCollectionEnabled: false });
activate(app, FAKE_SITE_KEY, true);
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
});

it('can only be called once', () => {
activate(app, FAKE_SITE_KEY);
expect(() => activate(app, FAKE_SITE_KEY)).to.throw(
Expand All @@ -67,4 +79,11 @@ describe('api', () => {
expect(initReCAPTCHAStub).to.have.not.been.called;
});
});
describe('setTokenAutoRefreshEnabled()', () => {
it('sets isTokenAutoRefreshEnabled correctly', () => {
const app = getFakeApp({ automaticDataCollectionEnabled: false });
setTokenAutoRefreshEnabled(app, true);
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
});
});
});
33 changes: 31 additions & 2 deletions packages/app-check/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ import { getState, setState, AppCheckState } from './state';
/**
*
* @param app
* @param provider - optional custom attestation provider
* @param siteKeyOrProvider - optional custom attestation provider
* or reCAPTCHA siteKey
* @param isTokenAutoRefreshEnabled - if true, enables auto refresh
* of appCheck token.
*/
export function activate(
app: FirebaseApp,
siteKeyOrProvider: string | AppCheckProvider
siteKeyOrProvider: string | AppCheckProvider,
isTokenAutoRefreshEnabled?: boolean
): void {
const state = getState(app);
if (state.activated) {
Expand All @@ -44,6 +48,14 @@ export function activate(
newState.customProvider = siteKeyOrProvider;
}

// Use value of global `automaticDataCollectionEnabled` (which
// itself defaults to false if not specified in config) if
// `isTokenAutoRefreshEnabled` param was not provided by user.
newState.isTokenAutoRefreshEnabled =
isTokenAutoRefreshEnabled === undefined
? app.automaticDataCollectionEnabled
: isTokenAutoRefreshEnabled;

setState(app, newState);

// initialize reCAPTCHA if siteKey is provided
Expand All @@ -53,3 +65,20 @@ export function activate(
});
}
}

export function setTokenAutoRefreshEnabled(
app: FirebaseApp,
isTokenAutoRefreshEnabled: boolean
): void {
const state = getState(app);
// This will exist if any product libraries have called
// `addTokenListener()`
if (state.tokenRefresher) {
if (isTokenAutoRefreshEnabled === true) {
state.tokenRefresher.start();
} else {
state.tokenRefresher.stop();
}
}
setState(app, { ...state, isTokenAutoRefreshEnabled });
}
3 changes: 2 additions & 1 deletion packages/app-check/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ describe('client', () => {

expect(response).to.deep.equal({
token: 'fake-appcheck-token',
expireTimeMillis: 3600
expireTimeMillis: 3600,
issuedAtTimeMillis: 0
});
});

Expand Down
8 changes: 5 additions & 3 deletions packages/app-check/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
} from './constants';
import { FirebaseApp } from '@firebase/app-types';
import { ERROR_FACTORY, AppCheckError } from './errors';
import { AppCheckToken } from '@firebase/app-check-types';
import { Provider } from '@firebase/component';
import { AppCheckTokenInternal } from './state';

/**
* Response JSON returned from AppCheck server endpoint.
Expand All @@ -42,7 +42,7 @@ interface AppCheckRequest {
export async function exchangeToken(
{ url, body }: AppCheckRequest,
platformLoggerProvider: Provider<'platform-logger'>
): Promise<AppCheckToken> {
): Promise<AppCheckTokenInternal> {
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
Expand Down Expand Up @@ -95,9 +95,11 @@ export async function exchangeToken(
}
const timeToLiveAsNumber = Number(match[1]) * 1000;

const now = Date.now();
return {
token: responseBody.attestationToken,
expireTimeMillis: Date.now() + timeToLiveAsNumber
expireTimeMillis: now + timeToLiveAsNumber,
issuedAtTimeMillis: now
};
}

Expand Down
10 changes: 7 additions & 3 deletions packages/app-check/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/

import { FirebaseAppCheck, AppCheckProvider } from '@firebase/app-check-types';
import { activate } from './api';
import { activate, setTokenAutoRefreshEnabled } from './api';
import { FirebaseApp } from '@firebase/app-types';
import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types';
import {
Expand All @@ -28,8 +28,12 @@ import { Provider } from '@firebase/component';

export function factory(app: FirebaseApp): FirebaseAppCheck {
return {
activate: (siteKeyOrProvider: string | AppCheckProvider) =>
activate(app, siteKeyOrProvider)
activate: (
siteKeyOrProvider: string | AppCheckProvider,
isTokenAutoRefreshEnabled?: boolean
) => activate(app, siteKeyOrProvider, isTokenAutoRefreshEnabled),
setTokenAutoRefreshEnabled: (isTokenAutoRefreshEnabled: boolean) =>
setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled)
};
}

Expand Down
8 changes: 4 additions & 4 deletions packages/app-check/src/indexeddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
* limitations under the License.
*/

import { AppCheckToken } from '@firebase/app-check-types';
import { FirebaseApp } from '@firebase/app-types';
import { ERROR_FACTORY, AppCheckError } from './errors';
import { AppCheckTokenInternal } from './state';
const DB_NAME = 'firebase-app-check-database';
const DB_VERSION = 1;
const STORE_NAME = 'firebase-app-check-store';
Expand Down Expand Up @@ -74,13 +74,13 @@ function getDBPromise(): Promise<IDBDatabase> {

export function readTokenFromIndexedDB(
app: FirebaseApp
): Promise<AppCheckToken | undefined> {
return read(computeKey(app)) as Promise<AppCheckToken | undefined>;
): Promise<AppCheckTokenInternal | undefined> {
return read(computeKey(app)) as Promise<AppCheckTokenInternal | undefined>;
}

export function writeTokenToIndexedDB(
app: FirebaseApp,
token: AppCheckToken
token: AppCheckTokenInternal
): Promise<void> {
return write(computeKey(app), token);
}
Expand Down
14 changes: 10 additions & 4 deletions packages/app-check/src/internal-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ describe('internal api', () => {
const fakeRecaptchaToken = 'fake-recaptcha-token';
const fakeRecaptchaAppCheckToken = {
token: 'fake-recaptcha-app-check-token',
expireTimeMillis: 123
expireTimeMillis: 123,
issuedAtTimeMillis: 0
};

const fakeCachedAppCheckToken = {
token: 'fake-cached-app-check-token',
expireTimeMillis: 123
expireTimeMillis: 123,
issuedAtTimeMillis: 0
};

it('uses customTokenProvider to get an AppCheck token', async () => {
Expand Down Expand Up @@ -295,6 +297,7 @@ describe('internal api', () => {

it('starts proactively refreshing token after adding the first listener', () => {
const listener = (): void => {};
setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true });
expect(getState(app).tokenListeners.length).to.equal(0);
expect(getState(app).tokenRefresher).to.equal(undefined);

Expand All @@ -317,7 +320,8 @@ describe('internal api', () => {
...getState(app),
token: {
token: `fake-memory-app-check-token`,
expireTimeMillis: 123
expireTimeMillis: 123,
issuedAtTimeMillis: 0
}
});

Expand All @@ -330,7 +334,8 @@ describe('internal api', () => {
stub(storage, 'readTokenFromStorage').returns(
Promise.resolve({
token: `fake-cached-app-check-token`,
expireTimeMillis: 123
expireTimeMillis: 123,
issuedAtTimeMillis: 0
})
);

Expand Down Expand Up @@ -389,6 +394,7 @@ describe('internal api', () => {

it('should stop proactively refreshing token after deleting the last listener', () => {
const listener = (): void => {};
setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true });

addTokenListener(app, fakePlatformLoggingProvider, listener);
expect(getState(app).tokenListeners.length).to.equal(1);
Expand Down
56 changes: 43 additions & 13 deletions packages/app-check/src/internal-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ import {
AppCheckTokenResult,
AppCheckTokenListener
} from '@firebase/app-check-interop-types';
import { AppCheckToken } from '@firebase/app-check-types';
import { getDebugState, getState, setState } from './state';
import {
AppCheckTokenInternal,
getDebugState,
getState,
setState
} from './state';
import { TOKEN_REFRESH_TIME } from './constants';
import { Refresher } from './proactive-refresh';
import { ensureActivated } from './util';
Expand All @@ -33,7 +37,7 @@ import {
} from './client';
import { writeTokenToStorage, readTokenFromStorage } from './storage';
import { getDebugToken, isDebugMode } from './debug';
import { base64 } from '@firebase/util';
import { base64, issuedAtTime } from '@firebase/util';
import { ERROR_FACTORY, AppCheckError } from './errors';
import { logger } from './logger';
import { Provider } from '@firebase/component';
Expand Down Expand Up @@ -72,7 +76,7 @@ export async function getToken(
* return the debug token directly
*/
if (isDebugMode()) {
const tokenFromDebugExchange: AppCheckToken = await exchangeToken(
const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken(
getExchangeDebugTokenRequest(app, await getDebugToken()),
platformLoggerProvider
);
Expand All @@ -81,7 +85,7 @@ export async function getToken(

const state = getState(app);

let token: AppCheckToken | undefined = state.token;
let token: AppCheckTokenInternal | undefined = state.token;
let error: Error | undefined = undefined;

/**
Expand Down Expand Up @@ -111,7 +115,20 @@ export async function getToken(
*/
try {
if (state.customProvider) {
token = await state.customProvider.getToken();
const customToken = await state.customProvider.getToken();
// Try to extract IAT from custom token, in case this token is not
// being newly issued. JWT timestamps are in seconds since epoch.
const issuedAtTimeSeconds = issuedAtTime(customToken.token);
// Very basic validation, use current timestamp as IAT if JWT
// has no `iat` field or value is out of bounds.
const issuedAtTimeMillis =
issuedAtTimeSeconds !== null &&
issuedAtTimeSeconds < Date.now() &&
issuedAtTimeSeconds > 0
? issuedAtTimeSeconds * 1000
: Date.now();

token = { ...customToken, issuedAtTimeMillis };
} else {
const attestedClaimsToken = await getReCAPTCHAToken(app).catch(_e => {
// reCaptcha.execute() throws null which is not very descriptive.
Expand Down Expand Up @@ -183,7 +200,12 @@ export function addTokenListener(
newState.tokenRefresher = tokenRefresher;
}

if (!newState.tokenRefresher.isRunning()) {
// Create the refresher but don't start it if `isTokenAutoRefreshEnabled`
// is not true.
if (
!newState.tokenRefresher.isRunning() &&
state.isTokenAutoRefreshEnabled === true
) {
newState.tokenRefresher.start();
}

Expand Down Expand Up @@ -253,12 +275,20 @@ function createTokenRefresher(
const state = getState(app);

if (state.token) {
return Math.max(
0,
state.token.expireTimeMillis -
Date.now() -
TOKEN_REFRESH_TIME.OFFSET_DURATION
// issuedAtTime + (50% * total TTL) + 5 minutes
let nextRefreshTimeMillis =
state.token.issuedAtTimeMillis +
(state.token.expireTimeMillis - state.token.issuedAtTimeMillis) *
0.5 +
5 * 60 * 1000;
// Do not allow refresh time to be past (expireTime - 5 minutes)
const latestAllowableRefresh =
state.token.expireTimeMillis - 5 * 60 * 1000;
nextRefreshTimeMillis = Math.min(
nextRefreshTimeMillis,
latestAllowableRefresh
);
return Math.max(0, nextRefreshTimeMillis - Date.now());
} else {
return 0;
}
Expand All @@ -283,7 +313,7 @@ function notifyTokenListeners(
}
}

function isValid(token: AppCheckToken): boolean {
function isValid(token: AppCheckTokenInternal): boolean {
return token.expireTimeMillis - Date.now() > 0;
}

Expand Down
7 changes: 6 additions & 1 deletion packages/app-check/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,19 @@ import { AppCheckTokenListener } from '@firebase/app-check-interop-types';
import { Refresher } from './proactive-refresh';
import { Deferred } from '@firebase/util';
import { GreCAPTCHA } from './recaptcha';

export interface AppCheckTokenInternal extends AppCheckToken {
issuedAtTimeMillis: number;
}
export interface AppCheckState {
activated: boolean;
tokenListeners: AppCheckTokenListener[];
customProvider?: AppCheckProvider;
siteKey?: string;
token?: AppCheckToken;
token?: AppCheckTokenInternal;
tokenRefresher?: Refresher;
reCAPTCHAState?: ReCAPTCHAState;
isTokenAutoRefreshEnabled?: boolean;
}

export interface ReCAPTCHAState {
Expand Down
Loading

0 comments on commit f3a1a3f

Please sign in to comment.