diff --git a/packages/app-check-types/index.d.ts b/packages/app-check-types/index.d.ts index 353edbeed34..db6acdaa417 100644 --- a/packages/app-check-types/index.d.ts +++ b/packages/app-check-types/index.d.ts @@ -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 { diff --git a/packages/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts index 27c8363e5ec..89cc8fe06c4 100644 --- a/packages/app-check/src/api.test.ts +++ b/packages/app-check/src/api.test.ts @@ -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, @@ -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( @@ -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); + }); + }); }); diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts index 716052ce237..7913a114127 100644 --- a/packages/app-check/src/api.ts +++ b/packages/app-check/src/api.ts @@ -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) { @@ -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 @@ -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 }); +} diff --git a/packages/app-check/src/client.test.ts b/packages/app-check/src/client.test.ts index 86f81bcc6e0..78a06f90799 100644 --- a/packages/app-check/src/client.test.ts +++ b/packages/app-check/src/client.test.ts @@ -74,7 +74,8 @@ describe('client', () => { expect(response).to.deep.equal({ token: 'fake-appcheck-token', - expireTimeMillis: 3600 + expireTimeMillis: 3600, + issuedAtTimeMillis: 0 }); }); diff --git a/packages/app-check/src/client.ts b/packages/app-check/src/client.ts index c1f94dbb38c..be9b0c52d49 100644 --- a/packages/app-check/src/client.ts +++ b/packages/app-check/src/client.ts @@ -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. @@ -42,7 +42,7 @@ interface AppCheckRequest { export async function exchangeToken( { url, body }: AppCheckRequest, platformLoggerProvider: Provider<'platform-logger'> -): Promise { +): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json' }; @@ -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 }; } diff --git a/packages/app-check/src/factory.ts b/packages/app-check/src/factory.ts index 802ea49e1d1..4789036b881 100644 --- a/packages/app-check/src/factory.ts +++ b/packages/app-check/src/factory.ts @@ -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 { @@ -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) }; } diff --git a/packages/app-check/src/indexeddb.ts b/packages/app-check/src/indexeddb.ts index 1261a500a5c..4ba72637393 100644 --- a/packages/app-check/src/indexeddb.ts +++ b/packages/app-check/src/indexeddb.ts @@ -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'; @@ -74,13 +74,13 @@ function getDBPromise(): Promise { export function readTokenFromIndexedDB( app: FirebaseApp -): Promise { - return read(computeKey(app)) as Promise; +): Promise { + return read(computeKey(app)) as Promise; } export function writeTokenToIndexedDB( app: FirebaseApp, - token: AppCheckToken + token: AppCheckTokenInternal ): Promise { return write(computeKey(app), token); } diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts index 7706a93dbad..3d4abce1019 100644 --- a/packages/app-check/src/internal-api.test.ts +++ b/packages/app-check/src/internal-api.test.ts @@ -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 () => { @@ -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); @@ -317,7 +320,8 @@ describe('internal api', () => { ...getState(app), token: { token: `fake-memory-app-check-token`, - expireTimeMillis: 123 + expireTimeMillis: 123, + issuedAtTimeMillis: 0 } }); @@ -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 }) ); @@ -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); diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts index 1e6a76dccaf..4e4b687edcf 100644 --- a/packages/app-check/src/internal-api.ts +++ b/packages/app-check/src/internal-api.ts @@ -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'; @@ -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'; @@ -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 ); @@ -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; /** @@ -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. @@ -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(); } @@ -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; } @@ -283,7 +313,7 @@ function notifyTokenListeners( } } -function isValid(token: AppCheckToken): boolean { +function isValid(token: AppCheckTokenInternal): boolean { return token.expireTimeMillis - Date.now() > 0; } diff --git a/packages/app-check/src/state.ts b/packages/app-check/src/state.ts index 17588bd5b17..5d041ec82a4 100644 --- a/packages/app-check/src/state.ts +++ b/packages/app-check/src/state.ts @@ -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 { diff --git a/packages/app-check/src/storage.test.ts b/packages/app-check/src/storage.test.ts index c7dc21eb59b..763dbf3e661 100644 --- a/packages/app-check/src/storage.test.ts +++ b/packages/app-check/src/storage.test.ts @@ -28,7 +28,8 @@ describe('Storage', () => { const app = getFakeApp(); const fakeToken = { token: 'fake-app-check-token', - expireTimeMillis: 345 + expireTimeMillis: 345, + issuedAtTimeMillis: 0 }; it('sets and gets appCheck token to indexeddb', async () => { diff --git a/packages/app-check/src/storage.ts b/packages/app-check/src/storage.ts index e893abd2aac..6baa8ce6735 100644 --- a/packages/app-check/src/storage.ts +++ b/packages/app-check/src/storage.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { AppCheckToken } from '@firebase/app-check-types'; import { uuidv4 } from './util'; import { FirebaseApp } from '@firebase/app-types'; import { isIndexedDBAvailable } from '@firebase/util'; @@ -26,13 +25,14 @@ import { writeTokenToIndexedDB } from './indexeddb'; import { logger } from './logger'; +import { AppCheckTokenInternal } from './state'; /** * Always resolves. In case of an error reading from indexeddb, resolve with undefined */ export async function readTokenFromStorage( app: FirebaseApp -): Promise { +): Promise { if (isIndexedDBAvailable()) { let token = undefined; try { @@ -52,7 +52,7 @@ export async function readTokenFromStorage( */ export function writeTokenToStorage( app: FirebaseApp, - token: AppCheckToken + token: AppCheckTokenInternal ): Promise { if (isIndexedDBAvailable()) { return writeTokenToIndexedDB(app, token).catch(e => { diff --git a/packages/app-check/test/util.ts b/packages/app-check/test/util.ts index 4b7100308a6..b348128a971 100644 --- a/packages/app-check/test/util.ts +++ b/packages/app-check/test/util.ts @@ -27,7 +27,7 @@ import { export const FAKE_SITE_KEY = 'fake-site-key'; -export function getFakeApp(): FirebaseApp { +export function getFakeApp(overrides: Record = {}): FirebaseApp { return { name: 'appName', options: { @@ -43,7 +43,8 @@ export function getFakeApp(): FirebaseApp { delete: async () => {}, // This won't be used in tests. // eslint-disable-next-line @typescript-eslint/no-explicit-any - appCheck: null as any + appCheck: null as any, + ...overrides }; }