diff --git a/.changeset/great-tigers-doubt.md b/.changeset/great-tigers-doubt.md new file mode 100644 index 00000000000..e05385a2cf0 --- /dev/null +++ b/.changeset/great-tigers-doubt.md @@ -0,0 +1,7 @@ +--- +'@firebase/app-check': minor +'@firebase/app-check-types': minor +'firebase': minor +--- + +Add `RecaptchaV3Provider` and `CustomProvider` classes that can be supplied to `firebase.appCheck().activate()`. diff --git a/packages/app-check-types/index.d.ts b/packages/app-check-types/index.d.ts index 4a46a685796..b91ac5ac820 100644 --- a/packages/app-check-types/index.d.ts +++ b/packages/app-check-types/index.d.ts @@ -17,6 +17,7 @@ import { PartialObserver, Unsubscribe } from '@firebase/util'; import { FirebaseApp } from '@firebase/app-types'; +import { Provider } from '@firebase/component'; export interface FirebaseAppCheck { /** The `FirebaseApp` associated with this instance. */ @@ -90,6 +91,29 @@ interface AppCheckProvider { getToken(): Promise; } +export class ReCaptchaV3Provider { + /** + * @param siteKey - ReCAPTCHA v3 site key (public key). + */ + constructor(siteKey: string); +} +/* + * Custom token provider. + */ +export class CustomProvider { + /** + * @param options - Options for creating the custom provider. + */ + constructor(options: CustomProviderOptions); +} +interface CustomProviderOptions { + /** + * Function to get an App Check token through a custom provider + * service. + */ + getToken: () => Promise; +} + /** * The token returned from an `AppCheckProvider`. */ diff --git a/packages/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts index 894fbde65dd..2271c35cf69 100644 --- a/packages/app-check/src/api.test.ts +++ b/packages/app-check/src/api.test.ts @@ -39,6 +39,7 @@ import * as client from './client'; import * as storage from './storage'; import * as logger from './logger'; import * as util from './util'; +import { ReCaptchaV3Provider } from './providers'; describe('api', () => { beforeEach(() => { @@ -53,47 +54,92 @@ describe('api', () => { it('sets activated to true', () => { expect(getState(app).activated).to.equal(false); - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() + ); expect(getState(app).activated).to.equal(true); }); it('isTokenAutoRefreshEnabled value defaults to global setting', () => { app = getFakeApp({ automaticDataCollectionEnabled: false }); - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() + ); expect(getState(app).isTokenAutoRefreshEnabled).to.equal(false); }); it('sets isTokenAutoRefreshEnabled correctly, overriding global setting', () => { app = getFakeApp({ automaticDataCollectionEnabled: false }); - activate(app, FAKE_SITE_KEY, true); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider(), + 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( - /AppCheck can only be activated once/ + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() ); + expect(() => + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() + ) + ).to.throw(/AppCheck can only be activated once/); }); - it('initialize reCAPTCHA when a sitekey is provided', () => { + it('initialize reCAPTCHA when a sitekey string is provided', () => { const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns( Promise.resolve({} as any) ); - activate(app, FAKE_SITE_KEY); + activate(app, FAKE_SITE_KEY, getFakePlatformLoggingProvider()); expect(initReCAPTCHAStub).to.have.been.calledWithExactly( app, FAKE_SITE_KEY ); }); - it('does NOT initialize reCAPTCHA when a custom token provider is provided', () => { - const fakeCustomTokenProvider = getFakeCustomTokenProvider(); - const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize'); - activate(app, fakeCustomTokenProvider); - expect(getState(app).customProvider).to.equal(fakeCustomTokenProvider); - expect(initReCAPTCHAStub).to.have.not.been.called; + it('initialize reCAPTCHA when a ReCaptchaV3Provider instance is provided', () => { + const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns( + Promise.resolve({} as any) + ); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() + ); + expect(initReCAPTCHAStub).to.have.been.calledWithExactly( + app, + FAKE_SITE_KEY + ); }); + + it( + 'creates CustomProvider instance if user provides an object containing' + + ' a getToken() method', + async () => { + const fakeCustomTokenProvider = getFakeCustomTokenProvider(); + const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize'); + activate( + app, + fakeCustomTokenProvider, + getFakePlatformLoggingProvider() + ); + const result = await getState(app).provider?.getToken(); + expect(result?.token).to.equal('fake-custom-app-check-token'); + expect(initReCAPTCHAStub).to.have.not.been.called; + } + ); }); describe('setTokenAutoRefreshEnabled()', () => { it('sets isTokenAutoRefreshEnabled correctly', () => { @@ -149,7 +195,12 @@ describe('api', () => { }); it('Listeners work when using top-level parameters pattern', async () => { const app = getFakeApp(); - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider, + false + ); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').returns( Promise.resolve(fakeRecaptchaAppCheckToken) @@ -193,7 +244,12 @@ describe('api', () => { it('Listeners work when using Observer pattern', async () => { const app = getFakeApp(); - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider, + false + ); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').returns( Promise.resolve(fakeRecaptchaAppCheckToken) @@ -238,7 +294,12 @@ describe('api', () => { it('onError() catches token errors', async () => { stub(logger.logger, 'error'); const app = getFakeApp(); - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider, + false + ); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').rejects('exchange error'); diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts index 05ebd9cdea2..27bb6c5730b 100644 --- a/packages/app-check/src/api.ts +++ b/packages/app-check/src/api.ts @@ -21,27 +21,35 @@ import { } from '@firebase/app-check-types'; import { FirebaseApp } from '@firebase/app-types'; import { ERROR_FACTORY, AppCheckError } from './errors'; -import { initialize as initializeRecaptcha } from './recaptcha'; import { getState, setState, AppCheckState, ListenerType } from './state'; import { getToken as getTokenInternal, addTokenListener, - removeTokenListener + removeTokenListener, + isValid } from './internal-api'; import { Provider } from '@firebase/component'; import { ErrorFn, NextFn, PartialObserver, Unsubscribe } from '@firebase/util'; +import { CustomProvider, ReCaptchaV3Provider } from './providers'; +import { readTokenFromStorage } from './storage'; /** * * @param app * @param siteKeyOrProvider - optional custom attestation provider - * or reCAPTCHA siteKey + * or reCAPTCHA provider * @param isTokenAutoRefreshEnabled - if true, enables auto refresh * of appCheck token. */ export function activate( app: FirebaseApp, - siteKeyOrProvider: string | AppCheckProvider, + siteKeyOrProvider: + | ReCaptchaV3Provider + | CustomProvider + // This is the old interface for users to supply a custom provider. + | AppCheckProvider + | string, + platformLoggerProvider: Provider<'platform-logger'>, isTokenAutoRefreshEnabled?: boolean ): void { const state = getState(app); @@ -52,10 +60,29 @@ export function activate( } const newState: AppCheckState = { ...state, activated: true }; + + // Read cached token from storage if it exists and store it in memory. + newState.cachedTokenPromise = readTokenFromStorage(app).then(cachedToken => { + if (cachedToken && isValid(cachedToken)) { + setState(app, { ...getState(app), token: cachedToken }); + } + return cachedToken; + }); + if (typeof siteKeyOrProvider === 'string') { - newState.siteKey = siteKeyOrProvider; + newState.provider = new ReCaptchaV3Provider(siteKeyOrProvider); + } else if ( + siteKeyOrProvider instanceof ReCaptchaV3Provider || + siteKeyOrProvider instanceof CustomProvider + ) { + newState.provider = siteKeyOrProvider; } else { - newState.customProvider = siteKeyOrProvider; + // Process "old" custom provider to avoid breaking previous users. + // This was defined at beta release as simply an object with a + // getToken() method. + newState.provider = new CustomProvider({ + getToken: siteKeyOrProvider.getToken + }); } // Use value of global `automaticDataCollectionEnabled` (which @@ -68,12 +95,7 @@ export function activate( setState(app, newState); - // initialize reCAPTCHA if siteKey is provided - if (newState.siteKey) { - initializeRecaptcha(app, newState.siteKey).catch(() => { - /* we don't care about the initialization result in activate() */ - }); - } + newState.provider.initialize(app, platformLoggerProvider); } export function setTokenAutoRefreshEnabled( diff --git a/packages/app-check/src/factory.ts b/packages/app-check/src/factory.ts index 240b69b8ee7..8c0995afbcc 100644 --- a/packages/app-check/src/factory.ts +++ b/packages/app-check/src/factory.ts @@ -46,9 +46,15 @@ export function factory( return { app, activate: ( - siteKeyOrProvider: string | AppCheckProvider, + siteKeyOrProvider: AppCheckProvider | string, isTokenAutoRefreshEnabled?: boolean - ) => activate(app, siteKeyOrProvider, isTokenAutoRefreshEnabled), + ) => + activate( + app, + siteKeyOrProvider, + platformLoggerProvider, + isTokenAutoRefreshEnabled + ), setTokenAutoRefreshEnabled: (isTokenAutoRefreshEnabled: boolean) => setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled), getToken: forceRefresh => diff --git a/packages/app-check/src/index.ts b/packages/app-check/src/index.ts index 047c89d066e..5c3acbfa7f0 100644 --- a/packages/app-check/src/index.ts +++ b/packages/app-check/src/index.ts @@ -23,9 +23,15 @@ import { } from '@firebase/component'; import { FirebaseAppCheck, - AppCheckComponentName + AppCheckComponentName, + ReCaptchaV3Provider, + CustomProvider } from '@firebase/app-check-types'; import { factory, internalFactory } from './factory'; +import { + ReCaptchaV3Provider as ReCaptchaV3ProviderImpl, + CustomProvider as CustomProviderImpl +} from './providers'; import { initializeDebugMode } from './debug'; import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { name, version } from '../package.json'; @@ -46,6 +52,10 @@ function registerAppCheck(firebase: _FirebaseNamespace): void { }, ComponentType.PUBLIC ) + .setServiceProps({ + ReCaptchaV3Provider: ReCaptchaV3ProviderImpl, + CustomProvider: CustomProviderImpl + }) /** * AppCheck can only be initialized by explicitly calling firebase.appCheck() * We don't want firebase products that consume AppCheck to gate on AppCheck @@ -94,6 +104,8 @@ initializeDebugMode(); declare module '@firebase/app-types' { interface FirebaseNamespace { appCheck(app?: FirebaseApp): FirebaseAppCheck; + ReCaptchaV3Provider: typeof ReCaptchaV3Provider; + CustomProvider: typeof CustomProvider; } interface FirebaseApp { appCheck(): FirebaseAppCheck; diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts index bd512d86707..1fbc53507e9 100644 --- a/packages/app-check/src/internal-api.test.ts +++ b/packages/app-check/src/internal-api.test.ts @@ -32,7 +32,6 @@ import { getToken, addTokenListener, removeTokenListener, - formatDummyToken, defaultTokenErrorData } from './internal-api'; import * as reCAPTCHA from './recaptcha'; @@ -48,7 +47,8 @@ import { ListenerType } from './state'; import { Deferred } from '@firebase/util'; -import { AppCheckTokenResult } from '../../app-check-interop-types'; +import { CustomProvider, ReCaptchaV3Provider } from './providers'; +import { formatDummyToken } from './util'; const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); @@ -85,11 +85,11 @@ describe('internal api', () => { issuedAtTimeMillis: 0 }; - it('uses customTokenProvider to get an AppCheck token', async () => { + it('uses user-provided custom getToken() method to get an AppCheck token', async () => { const customTokenProvider = getFakeCustomTokenProvider(); const customProviderSpy = spy(customTokenProvider, 'getToken'); - activate(app, customTokenProvider); + activate(app, customTokenProvider, fakePlatformLoggingProvider); const token = await getToken(app, fakePlatformLoggingProvider); expect(customProviderSpy).to.be.called; @@ -98,8 +98,30 @@ describe('internal api', () => { }); }); - it('uses reCAPTCHA token to exchange for AppCheck token if no customTokenProvider is provided', async () => { - activate(app, FAKE_SITE_KEY); + it('uses user-provided CustomProvider instance to get an AppCheck token', async () => { + const getTokenStub = stub().resolves({ + token: 'custom-token-using-class', + expireTimeMillis: 1000 + }); + const fakeCustomProvider = new CustomProvider({ + getToken: getTokenStub + }); + + activate(app, fakeCustomProvider, fakePlatformLoggingProvider); + const token = await getToken(app, fakePlatformLoggingProvider); + + expect(getTokenStub).to.be.called; + expect(token).to.deep.equal({ + token: 'custom-token-using-class' + }); + }); + + it('uses reCAPTCHA token to exchange for AppCheck token if ReCAPTCHAProvider is provided', async () => { + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').resolves( fakeRecaptchaToken @@ -121,7 +143,11 @@ describe('internal api', () => { it('resolves with a dummy token and an error if failed to get a token', async () => { const errorStub = stub(console, 'error'); - activate(app, FAKE_SITE_KEY, true); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').resolves( fakeRecaptchaToken @@ -144,8 +170,12 @@ describe('internal api', () => { }); it('notifies listeners using cached token', async () => { - activate(app, FAKE_SITE_KEY, false); storageReadStub.resolves(fakeCachedAppCheckToken); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); const listener1 = spy(); const listener2 = spy(); @@ -173,7 +203,11 @@ describe('internal api', () => { }); it('notifies listeners using new token', async () => { - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); stub(reCAPTCHA, 'getToken').resolves(fakeRecaptchaToken); stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); @@ -205,7 +239,11 @@ describe('internal api', () => { it('calls 3P error handler if there is an error getting a token', async () => { stub(logger.logger, 'error'); - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); stub(reCAPTCHA, 'getToken').resolves(fakeRecaptchaToken); stub(client, 'exchangeToken').rejects('exchange error'); const listener1 = spy(); @@ -227,7 +265,11 @@ describe('internal api', () => { }); it('ignores listeners that throw', async () => { - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); stub(reCAPTCHA, 'getToken').resolves(fakeRecaptchaToken); stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); const listener1 = stub().throws(new Error()); @@ -257,10 +299,14 @@ describe('internal api', () => { }); it('loads persisted token to memory and returns it', async () => { - activate(app, FAKE_SITE_KEY); - storageReadStub.resolves(fakeCachedAppCheckToken); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); + const clientStub = stub(client, 'exchangeToken'); expect(getState(app).token).to.equal(undefined); @@ -273,7 +319,11 @@ describe('internal api', () => { }); it('persists token to storage', async () => { - activate(app, FAKE_SITE_KEY, false); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); stub(reCAPTCHA, 'getToken').resolves(fakeRecaptchaToken); stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); @@ -286,7 +336,11 @@ describe('internal api', () => { }); it('returns the valid token in memory without making network request', async () => { - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken }); const clientStub = stub(client, 'exchangeToken'); @@ -298,7 +352,11 @@ describe('internal api', () => { }); it('force to get new token when forceRefresh is true', async () => { - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken }); stub(reCAPTCHA, 'getToken').resolves(fakeRecaptchaToken); @@ -320,7 +378,11 @@ describe('internal api', () => { debugState.enabled = true; debugState.token = new Deferred(); debugState.token.resolve('my-debug-token'); - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); const token = await getToken(app, fakePlatformLoggingProvider); expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal( @@ -340,6 +402,10 @@ describe('internal api', () => { it('adds token listeners', () => { const listener = (): void => {}; stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); + setState(app, { + ...getState(app), + cachedTokenPromise: Promise.resolve(undefined) + }); addTokenListener( app, @@ -354,7 +420,11 @@ describe('internal api', () => { it('starts proactively refreshing token after adding the first listener', () => { const listener = (): void => {}; stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); - setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); + setState(app, { + ...getState(app), + isTokenAutoRefreshEnabled: true, + cachedTokenPromise: Promise.resolve(undefined) + }); expect(getState(app).tokenObservers.length).to.equal(0); expect(getState(app).tokenRefresher).to.equal(undefined); @@ -396,29 +466,31 @@ describe('internal api', () => { clock.restore(); }); - it('notifies the listener with the valid token in storage', done => { - activate(app, FAKE_SITE_KEY); + it('notifies the listener with the valid token in storage', async () => { + const clock = useFakeTimers(); + const listener = stub(); storageReadStub.resolves({ token: `fake-cached-app-check-token`, expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 }); - - // Need to use done() if the callback will be called by the - // refresher. - const fakeListener = (token: AppCheckTokenResult): void => { - expect(token).to.deep.equal({ - token: `fake-cached-app-check-token` - }); - done(); - }; + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + fakePlatformLoggingProvider + ); addTokenListener( app, fakePlatformLoggingProvider, ListenerType.INTERNAL, - fakeListener + listener ); + await clock.runAllAsync(); + expect(listener).to.be.calledWith({ + token: 'fake-cached-app-check-token' + }); + clock.restore(); }); }); @@ -431,6 +503,10 @@ describe('internal api', () => { }; it('should remove token listeners', () => { stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); + setState(app, { + ...getState(app), + cachedTokenPromise: Promise.resolve(undefined) + }); const listener = (): void => {}; addTokenListener( app, @@ -446,6 +522,10 @@ describe('internal api', () => { it('should stop proactively refreshing token after deleting the last listener', () => { stub(client, 'exchangeToken').resolves(fakeRecaptchaAppCheckToken); + setState(app, { + ...getState(app), + cachedTokenPromise: Promise.resolve(undefined) + }); const listener = (): void => {}; setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts index 9c2dac052f8..5ed9ebee504 100644 --- a/packages/app-check/src/internal-api.ts +++ b/packages/app-check/src/internal-api.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { getToken as getReCAPTCHAToken } from './recaptcha'; import { FirebaseApp } from '@firebase/app-types'; import { AppCheckTokenListener, @@ -30,16 +29,10 @@ import { } from './state'; import { TOKEN_REFRESH_TIME } from './constants'; import { Refresher } from './proactive-refresh'; -import { ensureActivated } from './util'; -import { - exchangeToken, - getExchangeDebugTokenRequest, - getExchangeRecaptchaTokenRequest -} from './client'; -import { writeTokenToStorage, readTokenFromStorage } from './storage'; +import { ensureActivated, formatDummyToken } from './util'; +import { exchangeToken, getExchangeDebugTokenRequest } from './client'; +import { writeTokenToStorage } from './storage'; import { getDebugToken, isDebugMode } from './debug'; -import { base64, issuedAtTime } from '@firebase/util'; -import { ERROR_FACTORY, AppCheckError } from './errors'; import { logger } from './logger'; import { Provider } from '@firebase/component'; @@ -47,20 +40,6 @@ import { Provider } from '@firebase/component'; // Format left open for possible dynamic error values and other fields in the future. export const defaultTokenErrorData = { error: 'UNKNOWN_ERROR' }; -/** - * Stringify and base64 encode token error data. - * - * @param tokenError Error data, currently hardcoded. - */ -export function formatDummyToken( - tokenErrorData: Record -): string { - return base64.encodeString( - JSON.stringify(tokenErrorData), - /* webSafe= */ false - ); -} - /** * This function will always resolve. * The result will contain an error field if there is any error. @@ -85,8 +64,8 @@ export async function getToken( * If there is no token in memory, try to load token from indexedDB. */ if (!token) { - // readTokenFromStorage() always resolves. In case of an error, it resolves with `undefined`. - const cachedToken = await readTokenFromStorage(app); + // cachedTokenPromise contains the token found in IndexedDB or undefined if not found. + const cachedToken = await state.cachedTokenPromise; if (cachedToken && isValid(cachedToken)) { token = cachedToken; @@ -124,31 +103,10 @@ export async function getToken( * request a new token */ try { - if (state.customProvider) { - 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. - throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); - }); - token = await exchangeToken( - getExchangeRecaptchaTokenRequest(app, attestedClaimsToken), - platformLoggerProvider - ); - } + // state.provider is populated in initializeAppCheck() + // ensureActivated() at the top of this function checks that + // initializeAppCheck() has been called. + token = await state.provider!.getToken(); } catch (e) { // `getToken()` should never throw, but logging error text to console will aid debugging. logger.error(e); @@ -202,14 +160,12 @@ export function addTokenListener( // Create the refresher but don't start it if `isTokenAutoRefreshEnabled` // is not true. - if ( - !newState.tokenRefresher.isRunning() && - state.isTokenAutoRefreshEnabled === true - ) { + if (!newState.tokenRefresher.isRunning() && state.isTokenAutoRefreshEnabled) { newState.tokenRefresher.start(); } - // invoke the listener async immediately if there is a valid token + // Invoke the listener async immediately if there is a valid token + // in memory. if (state.token && isValid(state.token)) { const validToken = state.token; Promise.resolve() @@ -217,6 +173,19 @@ export function addTokenListener( .catch(() => { /** Ignore errors in listeners. */ }); + } else if (state.token == null) { + // Only check cache if there was no token. If the token was invalid, + // skip this and rely on exchange endpoint. + void state + .cachedTokenPromise! // Storage token promise. Always populated in `activate()`. + .then(cachedToken => { + if (cachedToken && isValid(cachedToken)) { + listener({ token: cachedToken.token }); + } + }) + .catch(() => { + /** Ignore errors in listeners. */ + }); } setState(app, newState); @@ -324,7 +293,7 @@ function notifyTokenListeners( } } -function isValid(token: AppCheckTokenInternal): boolean { +export function isValid(token: AppCheckTokenInternal): boolean { return token.expireTimeMillis - Date.now() > 0; } diff --git a/packages/app-check/src/providers.ts b/packages/app-check/src/providers.ts new file mode 100644 index 00000000000..74c935fdfc4 --- /dev/null +++ b/packages/app-check/src/providers.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * 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 { FirebaseApp } from '@firebase/app-types'; +import { Provider } from '@firebase/component'; +import { issuedAtTime } from '@firebase/util'; +import { CustomProviderOptions } from '../../app-check-types'; +import { exchangeToken, getExchangeRecaptchaTokenRequest } from './client'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { + getToken as getReCAPTCHAToken, + initialize as initializeRecaptcha +} from './recaptcha'; +import { AppCheckTokenInternal } from './state'; + +export interface AppCheckProviderInternal { + /** + * Returns an AppCheck token. + */ + getToken(): Promise; + /** + * Initialize the class once app and platformLoggerProvider are available. + */ + initialize( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'> + ): void; +} + +/** + * App Check provider that can obtain a reCAPTCHA V3 token and exchange it + * for an App Check token. + */ +export class ReCaptchaV3Provider implements AppCheckProviderInternal { + private _app?: FirebaseApp; + private _platformLoggerProvider?: Provider<'platform-logger'>; + /** + * Create a ReCaptchaV3Provider instance. + * @param siteKey - ReCAPTCHA V3 siteKey. + */ + constructor(private _siteKey: string) {} + /** + * Returns an App Check token. + * @internal + */ + async getToken(): Promise { + if (!this._app || !this._platformLoggerProvider) { + // This should only occur if user has not called initializeAppCheck(). + // We don't have an appName to provide if so. + // This should already be caught in the top level `getToken()` function. + throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, { + appName: '' + }); + } + let attestedClaimsToken; + try { + attestedClaimsToken = await getReCAPTCHAToken(this._app); + } catch (e) { + // reCaptcha.execute() throws null which is not very descriptive. + throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); + } + return exchangeToken( + getExchangeRecaptchaTokenRequest(this._app, attestedClaimsToken), + this._platformLoggerProvider + ); + } + + initialize( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'> + ): void { + this._app = app; + this._platformLoggerProvider = platformLoggerProvider; + initializeRecaptcha(app, this._siteKey).catch(() => { + /* we don't care about the initialization result */ + }); + } +} + +/** + * Custom provider class. + */ +export class CustomProvider implements AppCheckProviderInternal { + private _app?: FirebaseApp; + + constructor(private _customProviderOptions: CustomProviderOptions) {} + + /** + * @internal + */ + async getToken(): Promise { + if (!this._app) { + // This should only occur if user has not called initializeAppCheck(). + // We don't have an appName to provide if so. + // This should already be caught in the top level `getToken()` function. + throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, { + appName: '' + }); + } + // custom provider + const customToken = await this._customProviderOptions.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(); + + return { ...customToken, issuedAtTimeMillis }; + } + + /** + * @internal + */ + initialize(app: FirebaseApp): void { + this._app = app; + } +} diff --git a/packages/app-check/src/recaptcha.test.ts b/packages/app-check/src/recaptcha.test.ts index 581d564862b..42dd81a5254 100644 --- a/packages/app-check/src/recaptcha.test.ts +++ b/packages/app-check/src/recaptcha.test.ts @@ -24,13 +24,15 @@ import { getFakeGreCAPTCHA, removegreCAPTCHAScriptsOnPage, findgreCAPTCHAScriptsOnPage, - FAKE_SITE_KEY + FAKE_SITE_KEY, + getFakePlatformLoggingProvider } from '../test/util'; import { initialize, getToken } from './recaptcha'; import * as utils from './util'; import { getState } from './state'; import { Deferred } from '@firebase/util'; import { activate } from './api'; +import { ReCaptchaV3Provider } from './providers'; describe('recaptcha', () => { let app: FirebaseApp; @@ -99,7 +101,11 @@ describe('recaptcha', () => { Promise.resolve('fake-recaptcha-token') ); self.grecaptcha = grecaptchaFake; - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() + ); await getToken(app); expect(executeStub).to.have.been.calledWith('fake_widget_1', { @@ -113,7 +119,11 @@ describe('recaptcha', () => { Promise.resolve('fake-recaptcha-token') ); self.grecaptcha = grecaptchaFake; - activate(app, FAKE_SITE_KEY); + activate( + app, + new ReCaptchaV3Provider(FAKE_SITE_KEY), + getFakePlatformLoggingProvider() + ); const token = await getToken(app); expect(token).to.equal('fake-recaptcha-token'); diff --git a/packages/app-check/src/state.ts b/packages/app-check/src/state.ts index 51bdec2b363..64ce07ed79e 100644 --- a/packages/app-check/src/state.ts +++ b/packages/app-check/src/state.ts @@ -16,15 +16,12 @@ */ import { FirebaseApp } from '@firebase/app-types'; -import { - AppCheckProvider, - AppCheckToken, - AppCheckTokenResult -} from '@firebase/app-check-types'; +import { AppCheckToken, AppCheckTokenResult } from '@firebase/app-check-types'; import { AppCheckTokenListener } from '@firebase/app-check-interop-types'; import { Refresher } from './proactive-refresh'; import { Deferred, PartialObserver } from '@firebase/util'; import { GreCAPTCHA } from './recaptcha'; +import { AppCheckProviderInternal } from './providers'; export interface AppCheckTokenInternal extends AppCheckToken { issuedAtTimeMillis: number; @@ -45,9 +42,10 @@ export const enum ListenerType { export interface AppCheckState { activated: boolean; tokenObservers: AppCheckTokenObserver[]; - customProvider?: AppCheckProvider; + provider?: AppCheckProviderInternal; siteKey?: string; token?: AppCheckTokenInternal; + cachedTokenPromise?: Promise; tokenRefresher?: Refresher; reCAPTCHAState?: ReCAPTCHAState; isTokenAutoRefreshEnabled?: boolean; diff --git a/packages/app-check/src/util.ts b/packages/app-check/src/util.ts index 341cb550ee3..12271e5f748 100644 --- a/packages/app-check/src/util.ts +++ b/packages/app-check/src/util.ts @@ -19,6 +19,7 @@ import { GreCAPTCHA } from './recaptcha'; import { getState } from './state'; import { ERROR_FACTORY, AppCheckError } from './errors'; import { FirebaseApp } from '@firebase/app-types'; +import { base64 } from '@firebase/util'; export function getRecaptcha(): GreCAPTCHA | undefined { return self.grecaptcha; @@ -42,3 +43,17 @@ export function uuidv4(): string { return v.toString(16); }); } + +/** + * Stringify and base64 encode token error data. + * + * @param tokenError Error data, currently hardcoded. + */ +export function formatDummyToken( + tokenErrorData: Record +): string { + return base64.encodeString( + JSON.stringify(tokenErrorData), + /* webSafe= */ false + ); +} diff --git a/packages/app-check/test/util.ts b/packages/app-check/test/util.ts index b348128a971..41cd0f29d5e 100644 --- a/packages/app-check/test/util.ts +++ b/packages/app-check/test/util.ts @@ -39,7 +39,7 @@ export function getFakeApp(overrides: Record = {}): FirebaseApp { storageBucket: 'storageBucket', appId: '1:777777777777:web:d93b5ca1475efe57' } as any, - automaticDataCollectionEnabled: true, + automaticDataCollectionEnabled: false, delete: async () => {}, // This won't be used in tests. // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index df26b6e19c3..0c5f1aa2000 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -1542,6 +1542,35 @@ declare namespace firebase.appCheck { interface AppCheckTokenResult { token: string; } + /* + * ReCAPTCHA v3 token provider. + */ + class ReCaptchaV3Provider { + /** + * @param siteKey - ReCAPTCHA v3 site key (public key). + */ + constructor(siteKey: string); + } + /* + * Custom token provider. + */ + class CustomProvider { + /** + * @param options - Options for creating the custom provider. + */ + constructor(options: CustomProviderOptions); + } + /** + * Options when creating a CustomProvider. + */ + interface CustomProviderOptions { + /** + * Function to get an App Check token through a custom provider + * service. + */ + getToken: () => Promise; + } + /** * The Firebase AppCheck service interface. * @@ -1551,15 +1580,14 @@ declare namespace firebase.appCheck { export interface AppCheck { /** * Activate AppCheck - * @param siteKeyOrProvider reCAPTCHA v3 site key (public key) or - * custom token provider. + * @param provider reCAPTCHA or custom token provider. * @param isTokenAutoRefreshEnabled If true, the SDK automatically * refreshes App Check tokens as needed. If undefined, defaults to the * value of `app.automaticDataCollectionEnabled`, which defaults to * false and can be set in the app config. */ activate( - siteKeyOrProvider: string | AppCheckProvider, + provider: AppCheckProvider, isTokenAutoRefreshEnabled?: boolean ): void;