Skip to content

Commit f3a1a3f

Browse files
authored
Implement App Check auto refresh timing and opt out flag (#4847)
1 parent 97f61e6 commit f3a1a3f

File tree

13 files changed

+146
-40
lines changed

13 files changed

+146
-40
lines changed

packages/app-check-types/index.d.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@
1818
export interface FirebaseAppCheck {
1919
/**
2020
* Activate AppCheck
21-
* @param siteKeyOrOrovider - reCAPTCHA sitekey or custom token provider
21+
* @param siteKeyOrProvider - reCAPTCHA sitekey or custom token provider
22+
* @param isTokenAutoRefreshEnabled - If true, enables SDK to automatically
23+
* refresh AppCheck token as needed. If undefined, the value will default
24+
* to the value of `app.automaticDataCollectionEnabled`. That property
25+
* defaults to false and can be set in the app config.
2226
*/
23-
activate(siteKeyOrProvider: string | AppCheckProvider): void;
27+
activate(
28+
siteKeyOrProvider: string | AppCheckProvider,
29+
isTokenAutoRefreshEnabled?: boolean
30+
): void;
31+
setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void;
2432
}
2533

2634
interface AppCheckProvider {

packages/app-check/src/api.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import '../test/setup';
1818
import { expect } from 'chai';
1919
import { stub } from 'sinon';
20-
import { activate } from './api';
20+
import { activate, setTokenAutoRefreshEnabled } from './api';
2121
import {
2222
FAKE_SITE_KEY,
2323
getFakeApp,
@@ -41,6 +41,18 @@ describe('api', () => {
4141
expect(getState(app).activated).to.equal(true);
4242
});
4343

44+
it('isTokenAutoRefreshEnabled value defaults to global setting', () => {
45+
app = getFakeApp({ automaticDataCollectionEnabled: false });
46+
activate(app, FAKE_SITE_KEY);
47+
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(false);
48+
});
49+
50+
it('sets isTokenAutoRefreshEnabled correctly, overriding global setting', () => {
51+
app = getFakeApp({ automaticDataCollectionEnabled: false });
52+
activate(app, FAKE_SITE_KEY, true);
53+
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
54+
});
55+
4456
it('can only be called once', () => {
4557
activate(app, FAKE_SITE_KEY);
4658
expect(() => activate(app, FAKE_SITE_KEY)).to.throw(
@@ -67,4 +79,11 @@ describe('api', () => {
6779
expect(initReCAPTCHAStub).to.have.not.been.called;
6880
});
6981
});
82+
describe('setTokenAutoRefreshEnabled()', () => {
83+
it('sets isTokenAutoRefreshEnabled correctly', () => {
84+
const app = getFakeApp({ automaticDataCollectionEnabled: false });
85+
setTokenAutoRefreshEnabled(app, true);
86+
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
87+
});
88+
});
7089
});

packages/app-check/src/api.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ import { getState, setState, AppCheckState } from './state';
2424
/**
2525
*
2626
* @param app
27-
* @param provider - optional custom attestation provider
27+
* @param siteKeyOrProvider - optional custom attestation provider
28+
* or reCAPTCHA siteKey
29+
* @param isTokenAutoRefreshEnabled - if true, enables auto refresh
30+
* of appCheck token.
2831
*/
2932
export function activate(
3033
app: FirebaseApp,
31-
siteKeyOrProvider: string | AppCheckProvider
34+
siteKeyOrProvider: string | AppCheckProvider,
35+
isTokenAutoRefreshEnabled?: boolean
3236
): void {
3337
const state = getState(app);
3438
if (state.activated) {
@@ -44,6 +48,14 @@ export function activate(
4448
newState.customProvider = siteKeyOrProvider;
4549
}
4650

51+
// Use value of global `automaticDataCollectionEnabled` (which
52+
// itself defaults to false if not specified in config) if
53+
// `isTokenAutoRefreshEnabled` param was not provided by user.
54+
newState.isTokenAutoRefreshEnabled =
55+
isTokenAutoRefreshEnabled === undefined
56+
? app.automaticDataCollectionEnabled
57+
: isTokenAutoRefreshEnabled;
58+
4759
setState(app, newState);
4860

4961
// initialize reCAPTCHA if siteKey is provided
@@ -53,3 +65,20 @@ export function activate(
5365
});
5466
}
5567
}
68+
69+
export function setTokenAutoRefreshEnabled(
70+
app: FirebaseApp,
71+
isTokenAutoRefreshEnabled: boolean
72+
): void {
73+
const state = getState(app);
74+
// This will exist if any product libraries have called
75+
// `addTokenListener()`
76+
if (state.tokenRefresher) {
77+
if (isTokenAutoRefreshEnabled === true) {
78+
state.tokenRefresher.start();
79+
} else {
80+
state.tokenRefresher.stop();
81+
}
82+
}
83+
setState(app, { ...state, isTokenAutoRefreshEnabled });
84+
}

packages/app-check/src/client.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ describe('client', () => {
7474

7575
expect(response).to.deep.equal({
7676
token: 'fake-appcheck-token',
77-
expireTimeMillis: 3600
77+
expireTimeMillis: 3600,
78+
issuedAtTimeMillis: 0
7879
});
7980
});
8081

packages/app-check/src/client.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import {
2222
} from './constants';
2323
import { FirebaseApp } from '@firebase/app-types';
2424
import { ERROR_FACTORY, AppCheckError } from './errors';
25-
import { AppCheckToken } from '@firebase/app-check-types';
2625
import { Provider } from '@firebase/component';
26+
import { AppCheckTokenInternal } from './state';
2727

2828
/**
2929
* Response JSON returned from AppCheck server endpoint.
@@ -42,7 +42,7 @@ interface AppCheckRequest {
4242
export async function exchangeToken(
4343
{ url, body }: AppCheckRequest,
4444
platformLoggerProvider: Provider<'platform-logger'>
45-
): Promise<AppCheckToken> {
45+
): Promise<AppCheckTokenInternal> {
4646
const headers: HeadersInit = {
4747
'Content-Type': 'application/json'
4848
};
@@ -95,9 +95,11 @@ export async function exchangeToken(
9595
}
9696
const timeToLiveAsNumber = Number(match[1]) * 1000;
9797

98+
const now = Date.now();
9899
return {
99100
token: responseBody.attestationToken,
100-
expireTimeMillis: Date.now() + timeToLiveAsNumber
101+
expireTimeMillis: now + timeToLiveAsNumber,
102+
issuedAtTimeMillis: now
101103
};
102104
}
103105

packages/app-check/src/factory.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { FirebaseAppCheck, AppCheckProvider } from '@firebase/app-check-types';
19-
import { activate } from './api';
19+
import { activate, setTokenAutoRefreshEnabled } from './api';
2020
import { FirebaseApp } from '@firebase/app-types';
2121
import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types';
2222
import {
@@ -28,8 +28,12 @@ import { Provider } from '@firebase/component';
2828

2929
export function factory(app: FirebaseApp): FirebaseAppCheck {
3030
return {
31-
activate: (siteKeyOrProvider: string | AppCheckProvider) =>
32-
activate(app, siteKeyOrProvider)
31+
activate: (
32+
siteKeyOrProvider: string | AppCheckProvider,
33+
isTokenAutoRefreshEnabled?: boolean
34+
) => activate(app, siteKeyOrProvider, isTokenAutoRefreshEnabled),
35+
setTokenAutoRefreshEnabled: (isTokenAutoRefreshEnabled: boolean) =>
36+
setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled)
3337
};
3438
}
3539

packages/app-check/src/indexeddb.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { AppCheckToken } from '@firebase/app-check-types';
1918
import { FirebaseApp } from '@firebase/app-types';
2019
import { ERROR_FACTORY, AppCheckError } from './errors';
20+
import { AppCheckTokenInternal } from './state';
2121
const DB_NAME = 'firebase-app-check-database';
2222
const DB_VERSION = 1;
2323
const STORE_NAME = 'firebase-app-check-store';
@@ -74,13 +74,13 @@ function getDBPromise(): Promise<IDBDatabase> {
7474

7575
export function readTokenFromIndexedDB(
7676
app: FirebaseApp
77-
): Promise<AppCheckToken | undefined> {
78-
return read(computeKey(app)) as Promise<AppCheckToken | undefined>;
77+
): Promise<AppCheckTokenInternal | undefined> {
78+
return read(computeKey(app)) as Promise<AppCheckTokenInternal | undefined>;
7979
}
8080

8181
export function writeTokenToIndexedDB(
8282
app: FirebaseApp,
83-
token: AppCheckToken
83+
token: AppCheckTokenInternal
8484
): Promise<void> {
8585
return write(computeKey(app), token);
8686
}

packages/app-check/src/internal-api.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@ describe('internal api', () => {
5959
const fakeRecaptchaToken = 'fake-recaptcha-token';
6060
const fakeRecaptchaAppCheckToken = {
6161
token: 'fake-recaptcha-app-check-token',
62-
expireTimeMillis: 123
62+
expireTimeMillis: 123,
63+
issuedAtTimeMillis: 0
6364
};
6465

6566
const fakeCachedAppCheckToken = {
6667
token: 'fake-cached-app-check-token',
67-
expireTimeMillis: 123
68+
expireTimeMillis: 123,
69+
issuedAtTimeMillis: 0
6870
};
6971

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

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

@@ -317,7 +320,8 @@ describe('internal api', () => {
317320
...getState(app),
318321
token: {
319322
token: `fake-memory-app-check-token`,
320-
expireTimeMillis: 123
323+
expireTimeMillis: 123,
324+
issuedAtTimeMillis: 0
321325
}
322326
});
323327

@@ -330,7 +334,8 @@ describe('internal api', () => {
330334
stub(storage, 'readTokenFromStorage').returns(
331335
Promise.resolve({
332336
token: `fake-cached-app-check-token`,
333-
expireTimeMillis: 123
337+
expireTimeMillis: 123,
338+
issuedAtTimeMillis: 0
334339
})
335340
);
336341

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

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

393399
addTokenListener(app, fakePlatformLoggingProvider, listener);
394400
expect(getState(app).tokenListeners.length).to.equal(1);

packages/app-check/src/internal-api.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import {
2121
AppCheckTokenResult,
2222
AppCheckTokenListener
2323
} from '@firebase/app-check-interop-types';
24-
import { AppCheckToken } from '@firebase/app-check-types';
25-
import { getDebugState, getState, setState } from './state';
24+
import {
25+
AppCheckTokenInternal,
26+
getDebugState,
27+
getState,
28+
setState
29+
} from './state';
2630
import { TOKEN_REFRESH_TIME } from './constants';
2731
import { Refresher } from './proactive-refresh';
2832
import { ensureActivated } from './util';
@@ -33,7 +37,7 @@ import {
3337
} from './client';
3438
import { writeTokenToStorage, readTokenFromStorage } from './storage';
3539
import { getDebugToken, isDebugMode } from './debug';
36-
import { base64 } from '@firebase/util';
40+
import { base64, issuedAtTime } from '@firebase/util';
3741
import { ERROR_FACTORY, AppCheckError } from './errors';
3842
import { logger } from './logger';
3943
import { Provider } from '@firebase/component';
@@ -72,7 +76,7 @@ export async function getToken(
7276
* return the debug token directly
7377
*/
7478
if (isDebugMode()) {
75-
const tokenFromDebugExchange: AppCheckToken = await exchangeToken(
79+
const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken(
7680
getExchangeDebugTokenRequest(app, await getDebugToken()),
7781
platformLoggerProvider
7882
);
@@ -81,7 +85,7 @@ export async function getToken(
8185

8286
const state = getState(app);
8387

84-
let token: AppCheckToken | undefined = state.token;
88+
let token: AppCheckTokenInternal | undefined = state.token;
8589
let error: Error | undefined = undefined;
8690

8791
/**
@@ -111,7 +115,20 @@ export async function getToken(
111115
*/
112116
try {
113117
if (state.customProvider) {
114-
token = await state.customProvider.getToken();
118+
const customToken = await state.customProvider.getToken();
119+
// Try to extract IAT from custom token, in case this token is not
120+
// being newly issued. JWT timestamps are in seconds since epoch.
121+
const issuedAtTimeSeconds = issuedAtTime(customToken.token);
122+
// Very basic validation, use current timestamp as IAT if JWT
123+
// has no `iat` field or value is out of bounds.
124+
const issuedAtTimeMillis =
125+
issuedAtTimeSeconds !== null &&
126+
issuedAtTimeSeconds < Date.now() &&
127+
issuedAtTimeSeconds > 0
128+
? issuedAtTimeSeconds * 1000
129+
: Date.now();
130+
131+
token = { ...customToken, issuedAtTimeMillis };
115132
} else {
116133
const attestedClaimsToken = await getReCAPTCHAToken(app).catch(_e => {
117134
// reCaptcha.execute() throws null which is not very descriptive.
@@ -183,7 +200,12 @@ export function addTokenListener(
183200
newState.tokenRefresher = tokenRefresher;
184201
}
185202

186-
if (!newState.tokenRefresher.isRunning()) {
203+
// Create the refresher but don't start it if `isTokenAutoRefreshEnabled`
204+
// is not true.
205+
if (
206+
!newState.tokenRefresher.isRunning() &&
207+
state.isTokenAutoRefreshEnabled === true
208+
) {
187209
newState.tokenRefresher.start();
188210
}
189211

@@ -253,12 +275,20 @@ function createTokenRefresher(
253275
const state = getState(app);
254276

255277
if (state.token) {
256-
return Math.max(
257-
0,
258-
state.token.expireTimeMillis -
259-
Date.now() -
260-
TOKEN_REFRESH_TIME.OFFSET_DURATION
278+
// issuedAtTime + (50% * total TTL) + 5 minutes
279+
let nextRefreshTimeMillis =
280+
state.token.issuedAtTimeMillis +
281+
(state.token.expireTimeMillis - state.token.issuedAtTimeMillis) *
282+
0.5 +
283+
5 * 60 * 1000;
284+
// Do not allow refresh time to be past (expireTime - 5 minutes)
285+
const latestAllowableRefresh =
286+
state.token.expireTimeMillis - 5 * 60 * 1000;
287+
nextRefreshTimeMillis = Math.min(
288+
nextRefreshTimeMillis,
289+
latestAllowableRefresh
261290
);
291+
return Math.max(0, nextRefreshTimeMillis - Date.now());
262292
} else {
263293
return 0;
264294
}
@@ -283,7 +313,7 @@ function notifyTokenListeners(
283313
}
284314
}
285315

286-
function isValid(token: AppCheckToken): boolean {
316+
function isValid(token: AppCheckTokenInternal): boolean {
287317
return token.expireTimeMillis - Date.now() > 0;
288318
}
289319

packages/app-check/src/state.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,19 @@ import { AppCheckTokenListener } from '@firebase/app-check-interop-types';
2121
import { Refresher } from './proactive-refresh';
2222
import { Deferred } from '@firebase/util';
2323
import { GreCAPTCHA } from './recaptcha';
24+
25+
export interface AppCheckTokenInternal extends AppCheckToken {
26+
issuedAtTimeMillis: number;
27+
}
2428
export interface AppCheckState {
2529
activated: boolean;
2630
tokenListeners: AppCheckTokenListener[];
2731
customProvider?: AppCheckProvider;
2832
siteKey?: string;
29-
token?: AppCheckToken;
33+
token?: AppCheckTokenInternal;
3034
tokenRefresher?: Refresher;
3135
reCAPTCHAState?: ReCAPTCHAState;
36+
isTokenAutoRefreshEnabled?: boolean;
3237
}
3338

3439
export interface ReCAPTCHAState {

0 commit comments

Comments
 (0)