Skip to content

Implement App Check auto refresh timing and opt out flag #4847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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