diff --git a/.changeset/empty-countries-run.md b/.changeset/empty-countries-run.md new file mode 100644 index 00000000000..ec4b804b612 --- /dev/null +++ b/.changeset/empty-countries-run.md @@ -0,0 +1,7 @@ +--- +'@firebase/app-check': minor +'@firebase/app-check-types': minor +'firebase': minor +--- + +Added `getToken()` and `onTokenChanged` methods to App Check. diff --git a/packages/app-check-types/index.d.ts b/packages/app-check-types/index.d.ts index 16fc46f373f..aa5e49d039b 100644 --- a/packages/app-check-types/index.d.ts +++ b/packages/app-check-types/index.d.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import { PartialObserver, Unsubscribe } from '@firebase/util'; + export interface FirebaseAppCheck { /** * Activate AppCheck @@ -36,6 +38,40 @@ export interface FirebaseAppCheck { * during `activate()`. */ setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void; + + /** + * Get the current App Check token. Attaches to the most recent + * in-flight request if one is present. Returns null if no token + * is present and no token requests are in flight. + * + * @param forceRefresh - If true, will always try to fetch a fresh token. + * If false, will use a cached token if found in storage. + */ + getToken(forceRefresh?: boolean): Promise; + + /** + * Registers a listener to changes in the token state. There can be more + * than one listener registered at the same time for one or more + * App Check instances. The listeners call back on the UI thread whenever + * the current token associated with this App Check instance changes. + * + * @returns A function that unsubscribes this listener. + */ + onTokenChanged(observer: PartialObserver): Unsubscribe; + + /** + * Registers a listener to changes in the token state. There can be more + * than one listener registered at the same time for one or more + * App Check instances. The listeners call back on the UI thread whenever + * the current token associated with this App Check instance changes. + * + * @returns A function that unsubscribes this listener. + */ + onTokenChanged( + onNext: (tokenResult: AppCheckTokenResult) => void, + onError?: (error: Error) => void, + onCompletion?: () => void + ): Unsubscribe; } /** @@ -64,6 +100,16 @@ interface AppCheckToken { readonly expireTimeMillis: number; } +/** + * Result returned by `getToken()`. + */ +interface AppCheckTokenResult { + /** + * The token string in JWT format. + */ + readonly token: string; +} + export type AppCheckComponentName = 'appCheck'; declare module '@firebase/component' { interface NameServiceMapping { diff --git a/packages/app-check/package.json b/packages/app-check/package.json index 5a607c96de7..a3f4b53158f 100644 --- a/packages/app-check/package.json +++ b/packages/app-check/package.json @@ -16,7 +16,7 @@ "build": "rollup -c", "build:deps": "lerna run --scope @firebase/app-check --include-dependencies build", "dev": "rollup -c -w", - "test": "yarn type-check && yarn test:browser", + "test": "yarn lint && yarn type-check && yarn test:browser", "test:ci": "node ../../scripts/run_tests_in_ci.js", "test:browser": "karma start --single-run", "test:browser:debug": "karma start --browsers Chrome --auto-watch", diff --git a/packages/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts index 89cc8fe06c4..14fb1d86305 100644 --- a/packages/app-check/src/api.test.ts +++ b/packages/app-check/src/api.test.ts @@ -16,16 +16,27 @@ */ import '../test/setup'; import { expect } from 'chai'; -import { stub } from 'sinon'; -import { activate, setTokenAutoRefreshEnabled } from './api'; +import { stub, spy } from 'sinon'; +import { + activate, + setTokenAutoRefreshEnabled, + getToken, + onTokenChanged +} from './api'; import { FAKE_SITE_KEY, getFakeApp, - getFakeCustomTokenProvider + getFakeCustomTokenProvider, + getFakePlatformLoggingProvider, + removegreCAPTCHAScriptsOnPage } from '../test/util'; -import { getState } from './state'; +import { clearState, getState } from './state'; import * as reCAPTCHA from './recaptcha'; import { FirebaseApp } from '@firebase/app-types'; +import * as internalApi from './internal-api'; +import * as client from './client'; +import * as storage from './storage'; +import * as logger from './logger'; describe('api', () => { describe('activate()', () => { @@ -86,4 +97,173 @@ describe('api', () => { expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true); }); }); + describe('getToken()', () => { + it('getToken() calls the internal getToken() function', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + const internalGetToken = stub(internalApi, 'getToken').resolves({ + token: 'a-token-string' + }); + await getToken(app, fakePlatformLoggingProvider, true); + expect(internalGetToken).to.be.calledWith( + app, + fakePlatformLoggingProvider, + true + ); + }); + it('getToken() throws errors returned with token', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + // If getToken() errors, it returns a dummy token with an error field + // instead of throwing. + stub(internalApi, 'getToken').resolves({ + token: 'a-dummy-token', + error: Error('there was an error') + }); + await expect( + getToken(app, fakePlatformLoggingProvider, true) + ).to.be.rejectedWith('there was an error'); + }); + }); + describe('onTokenChanged()', () => { + afterEach(() => { + clearState(); + removegreCAPTCHAScriptsOnPage(); + }); + it('Listeners work when using top-level parameters pattern', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + activate(app, FAKE_SITE_KEY, true); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + const fakeRecaptchaToken = 'fake-recaptcha-token'; + const fakeRecaptchaAppCheckToken = { + token: 'fake-recaptcha-app-check-token', + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }; + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); + + const listener1 = (): void => { + throw new Error(); + }; + const listener2 = spy(); + + const errorFn1 = spy(); + const errorFn2 = spy(); + + const unsubscribe1 = onTokenChanged( + app, + fakePlatformLoggingProvider, + listener1, + errorFn1 + ); + const unsubscribe2 = onTokenChanged( + app, + fakePlatformLoggingProvider, + listener2, + errorFn2 + ); + + expect(getState(app).tokenObservers.length).to.equal(2); + + await internalApi.getToken(app, fakePlatformLoggingProvider); + + expect(listener2).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + // onError should not be called on listener errors. + expect(errorFn1).to.not.be.called; + expect(errorFn2).to.not.be.called; + unsubscribe1(); + unsubscribe2(); + expect(getState(app).tokenObservers.length).to.equal(0); + }); + + it('Listeners work when using Observer pattern', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + activate(app, FAKE_SITE_KEY, true); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + const fakeRecaptchaToken = 'fake-recaptcha-token'; + const fakeRecaptchaAppCheckToken = { + token: 'fake-recaptcha-app-check-token', + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }; + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); + + const listener1 = (): void => { + throw new Error(); + }; + const listener2 = spy(); + + const errorFn1 = spy(); + const errorFn2 = spy(); + + /** + * Reverse the order of adding the failed and successful handler, for extra + * testing. + */ + const unsubscribe2 = onTokenChanged(app, fakePlatformLoggingProvider, { + next: listener2, + error: errorFn2 + }); + const unsubscribe1 = onTokenChanged(app, fakePlatformLoggingProvider, { + next: listener1, + error: errorFn1 + }); + + expect(getState(app).tokenObservers.length).to.equal(2); + + await internalApi.getToken(app, fakePlatformLoggingProvider); + + expect(listener2).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + // onError should not be called on listener errors. + expect(errorFn1).to.not.be.called; + expect(errorFn2).to.not.be.called; + unsubscribe1(); + unsubscribe2(); + expect(getState(app).tokenObservers.length).to.equal(0); + }); + + it('onError() catches token errors', async () => { + stub(logger.logger, 'error'); + const app = getFakeApp(); + activate(app, FAKE_SITE_KEY, false); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + const fakeRecaptchaToken = 'fake-recaptcha-token'; + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').rejects('exchange error'); + stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); + + const listener1 = spy(); + + const errorFn1 = spy(); + + const unsubscribe1 = onTokenChanged( + app, + fakePlatformLoggingProvider, + listener1, + errorFn1 + ); + + await internalApi.getToken(app, fakePlatformLoggingProvider); + + expect(getState(app).tokenObservers.length).to.equal(1); + + expect(errorFn1).to.be.calledOnce; + expect(errorFn1.args[0][0].name).to.include('exchange error'); + + unsubscribe1(); + expect(getState(app).tokenObservers.length).to.equal(0); + }); + }); }); diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts index 7913a114127..d0499bc5711 100644 --- a/packages/app-check/src/api.ts +++ b/packages/app-check/src/api.ts @@ -15,11 +15,21 @@ * limitations under the License. */ -import { AppCheckProvider } from '@firebase/app-check-types'; +import { + AppCheckProvider, + AppCheckTokenResult +} 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 } from './state'; +import { + getToken as getTokenInternal, + addTokenListener, + removeTokenListener +} from './internal-api'; +import { Provider } from '@firebase/component'; +import { ErrorFn, NextFn, PartialObserver, Unsubscribe } from '@firebase/util'; /** * @@ -82,3 +92,76 @@ export function setTokenAutoRefreshEnabled( } setState(app, { ...state, isTokenAutoRefreshEnabled }); } + +/** + * Differs from internal getToken in that it throws the error. + */ +export async function getToken( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'>, + forceRefresh?: boolean +): Promise { + const result = await getTokenInternal( + app, + platformLoggerProvider, + forceRefresh + ); + if (result.error) { + throw result.error; + } + return { token: result.token }; +} + +/** + * Wraps addTokenListener/removeTokenListener methods in an Observer + * pattern for public use. + */ +export function onTokenChanged( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'>, + observer: PartialObserver +): Unsubscribe; +export function onTokenChanged( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'>, + onNext: (tokenResult: AppCheckTokenResult) => void, + onError?: (error: Error) => void, + onCompletion?: () => void +): Unsubscribe; +export function onTokenChanged( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'>, + onNextOrObserver: + | ((tokenResult: AppCheckTokenResult) => void) + | PartialObserver, + onError?: (error: Error) => void, + /** + * NOTE: Although an `onCompletion` callback can be provided, it will + * never be called because the token stream is never-ending. + * It is added only for API consistency with the observer pattern, which + * we follow in JS APIs. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onCompletion?: () => void +): Unsubscribe { + let nextFn: NextFn = () => {}; + let errorFn: ErrorFn = () => {}; + if ((onNextOrObserver as PartialObserver).next != null) { + nextFn = (onNextOrObserver as PartialObserver).next!.bind( + onNextOrObserver + ); + } else { + nextFn = onNextOrObserver as NextFn; + } + if ( + (onNextOrObserver as PartialObserver).error != null + ) { + errorFn = (onNextOrObserver as PartialObserver).error!.bind( + onNextOrObserver + ); + } else if (onError) { + errorFn = onError; + } + addTokenListener(app, platformLoggerProvider, nextFn, errorFn); + return () => removeTokenListener(app, nextFn); +} diff --git a/packages/app-check/src/factory.ts b/packages/app-check/src/factory.ts index 4789036b881..ee27b9a04cf 100644 --- a/packages/app-check/src/factory.ts +++ b/packages/app-check/src/factory.ts @@ -15,25 +15,60 @@ * limitations under the License. */ -import { FirebaseAppCheck, AppCheckProvider } from '@firebase/app-check-types'; -import { activate, setTokenAutoRefreshEnabled } from './api'; +import { + FirebaseAppCheck, + AppCheckProvider, + AppCheckTokenResult +} from '@firebase/app-check-types'; +import { + activate, + setTokenAutoRefreshEnabled, + getToken, + onTokenChanged +} from './api'; import { FirebaseApp } from '@firebase/app-types'; import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types'; import { - getToken, + getToken as getTokenInternal, addTokenListener, removeTokenListener } from './internal-api'; import { Provider } from '@firebase/component'; +import { PartialObserver } from '@firebase/util'; -export function factory(app: FirebaseApp): FirebaseAppCheck { +export function factory( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'> +): FirebaseAppCheck { return { activate: ( siteKeyOrProvider: string | AppCheckProvider, isTokenAutoRefreshEnabled?: boolean ) => activate(app, siteKeyOrProvider, isTokenAutoRefreshEnabled), setTokenAutoRefreshEnabled: (isTokenAutoRefreshEnabled: boolean) => - setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled) + setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled), + + getToken: forceRefresh => + getToken(app, platformLoggerProvider, forceRefresh), + onTokenChanged: ( + onNextOrObserver: + | ((tokenResult: AppCheckTokenResult) => void) + | PartialObserver, + onError?: (error: Error) => void, + onCompletion?: () => void + ) => + onTokenChanged( + app, + platformLoggerProvider, + /** + * This can still be an observer. Need to do this casting because + * according to Typescript: "Implementation signatures of overloads + * are not externally visible" + */ + onNextOrObserver as (tokenResult: AppCheckTokenResult) => void, + onError, + onCompletion + ) }; } @@ -43,7 +78,7 @@ export function internalFactory( ): FirebaseAppCheckInternal { return { getToken: forceRefresh => - getToken(app, platformLoggerProvider, forceRefresh), + getTokenInternal(app, platformLoggerProvider, forceRefresh), addTokenListener: listener => addTokenListener(app, platformLoggerProvider, listener), removeTokenListener: listener => removeTokenListener(app, listener) diff --git a/packages/app-check/src/index.ts b/packages/app-check/src/index.ts index 9ab87738571..047c89d066e 100644 --- a/packages/app-check/src/index.ts +++ b/packages/app-check/src/index.ts @@ -41,7 +41,8 @@ function registerAppCheck(firebase: _FirebaseNamespace): void { container => { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); - return factory(app); + const platformLoggerProvider = container.getProvider('platform-logger'); + return factory(app, platformLoggerProvider); }, ComponentType.PUBLIC ) diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts index 3d4abce1019..02c9148901a 100644 --- a/packages/app-check/src/internal-api.test.ts +++ b/packages/app-check/src/internal-api.test.ts @@ -35,10 +35,11 @@ import { defaultTokenErrorData } from './internal-api'; import * as reCAPTCHA from './recaptcha'; +import * as logger from './logger'; import * as client from './client'; import * as storage from './storage'; import { getState, clearState, setState, getDebugState } from './state'; -import { AppCheckTokenListener } from '@firebase/app-check-interop-types'; +import { AppCheckTokenResult } from '@firebase/app-check-interop-types'; import { Deferred } from '@firebase/util'; const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); @@ -108,7 +109,7 @@ 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); + activate(app, FAKE_SITE_KEY, true); const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns( Promise.resolve(fakeRecaptchaToken) @@ -131,7 +132,7 @@ describe('internal api', () => { }); it('notifies listeners using cached token', async () => { - activate(app, FAKE_SITE_KEY); + activate(app, FAKE_SITE_KEY, true); const clock = useFakeTimers(); stub(storage, 'readTokenFromStorage').returns( @@ -156,7 +157,7 @@ describe('internal api', () => { }); it('notifies listeners using new token', async () => { - activate(app, FAKE_SITE_KEY); + activate(app, FAKE_SITE_KEY, true); stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined)); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); @@ -179,25 +180,21 @@ describe('internal api', () => { }); }); - it('ignores listeners that throw', async () => { - activate(app, FAKE_SITE_KEY); + it('calls optional error handler if there is an error getting a token', async () => { + stub(logger.logger, 'error'); + activate(app, FAKE_SITE_KEY, true); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); - stub(client, 'exchangeToken').returns( - Promise.resolve(fakeRecaptchaAppCheckToken) - ); - const listener1 = (): void => { - throw new Error(); - }; - const listener2 = spy(); + stub(client, 'exchangeToken').rejects('exchange error'); + const listener1 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1); - addTokenListener(app, fakePlatformLoggingProvider, listener2); + const errorFn1 = spy(); + + addTokenListener(app, fakePlatformLoggingProvider, listener1, errorFn1); await getToken(app, fakePlatformLoggingProvider); - expect(listener2).to.be.calledWith({ - token: fakeRecaptchaAppCheckToken.token - }); + expect(errorFn1).to.be.calledOnce; + expect(errorFn1.args[0][0].name).to.include('exchange error'); }); it('loads persisted token to memory and returns it', async () => { @@ -292,13 +289,13 @@ describe('internal api', () => { addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners[0]).to.equal(listener); + expect(getState(app).tokenObservers[0].next).to.equal(listener); }); 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).tokenObservers.length).to.equal(0); expect(getState(app).tokenRefresher).to.equal(undefined); addTokenListener(app, fakePlatformLoggingProvider, listener); @@ -308,7 +305,7 @@ describe('internal api', () => { it('notifies the listener with the valid token in memory immediately', done => { const clock = useFakeTimers(); - const fakeListener: AppCheckTokenListener = token => { + const fakeListener = (token: AppCheckTokenResult): void => { expect(token).to.deep.equal({ token: `fake-memory-app-check-token` }); @@ -330,7 +327,7 @@ describe('internal api', () => { it('notifies the listener with the valid token in storage', done => { const clock = useFakeTimers(); - activate(app, FAKE_SITE_KEY); + activate(app, FAKE_SITE_KEY, true); stub(storage, 'readTokenFromStorage').returns( Promise.resolve({ token: `fake-cached-app-check-token`, @@ -339,7 +336,7 @@ describe('internal api', () => { }) ); - const fakeListener: AppCheckTokenListener = token => { + const fakeListener = (token: AppCheckTokenResult): void => { expect(token).to.deep.equal({ token: `fake-cached-app-check-token` }); @@ -352,7 +349,7 @@ describe('internal api', () => { }); it('notifies the listener with the debug token immediately', done => { - const fakeListener: AppCheckTokenListener = token => { + const fakeListener = (token: AppCheckTokenResult): void => { expect(token).to.deep.equal({ token: `my-debug-token` }); @@ -364,7 +361,7 @@ describe('internal api', () => { debugState.token = new Deferred(); debugState.token.resolve('my-debug-token'); - activate(app, FAKE_SITE_KEY); + activate(app, FAKE_SITE_KEY, true); addTokenListener(app, fakePlatformLoggingProvider, fakeListener); }); @@ -374,7 +371,7 @@ describe('internal api', () => { debugState.token = new Deferred(); debugState.token.resolve('my-debug-token'); - activate(app, FAKE_SITE_KEY); + activate(app, FAKE_SITE_KEY, true); addTokenListener(app, fakePlatformLoggingProvider, () => {}); const state = getState(app); @@ -386,10 +383,10 @@ describe('internal api', () => { it('should remove token listeners', () => { const listener = (): void => {}; addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners.length).to.equal(1); + expect(getState(app).tokenObservers.length).to.equal(1); removeTokenListener(app, listener); - expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(0); }); it('should stop proactively refreshing token after deleting the last listener', () => { @@ -397,11 +394,11 @@ describe('internal api', () => { setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners.length).to.equal(1); + expect(getState(app).tokenObservers.length).to.equal(1); expect(getState(app).tokenRefresher?.isRunning()).to.be.true; removeTokenListener(app, listener); - expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(0); expect(getState(app).tokenRefresher?.isRunning()).to.be.false; }); }); diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts index 4e4b687edcf..f7d0767ee9b 100644 --- a/packages/app-check/src/internal-api.ts +++ b/packages/app-check/src/internal-api.ts @@ -18,11 +18,12 @@ import { getToken as getReCAPTCHAToken } from './recaptcha'; import { FirebaseApp } from '@firebase/app-types'; import { - AppCheckTokenResult, - AppCheckTokenListener + AppCheckTokenListener, + AppCheckTokenResult } from '@firebase/app-check-interop-types'; import { AppCheckTokenInternal, + AppCheckTokenObserver, getDebugState, getState, setState @@ -167,12 +168,17 @@ export async function getToken( export function addTokenListener( app: FirebaseApp, platformLoggerProvider: Provider<'platform-logger'>, - listener: AppCheckTokenListener + listener: AppCheckTokenListener, + onError?: (error: Error) => void ): void { const state = getState(app); + const tokenListener: AppCheckTokenObserver = { + next: listener, + error: onError + }; const newState = { ...state, - tokenListeners: [...state.tokenListeners, listener] + tokenObservers: [...state.tokenObservers, tokenListener] }; /** @@ -186,7 +192,7 @@ export function addTokenListener( debugState.token.promise .then(token => listener({ token })) .catch(() => { - /* we don't care about exceptions thrown in listeners */ + /** Ignore errors in listeners. */ }); } } else { @@ -215,7 +221,7 @@ export function addTokenListener( Promise.resolve() .then(() => listener({ token: validToken.token })) .catch(() => { - /* we don't care about exceptions thrown in listeners */ + /** Ignore errors in listeners. */ }); } } @@ -225,13 +231,15 @@ export function addTokenListener( export function removeTokenListener( app: FirebaseApp, - listener: AppCheckTokenListener + listener: (token: AppCheckTokenResult) => void ): void { const state = getState(app); - const newListeners = state.tokenListeners.filter(l => l !== listener); + const newObservers = state.tokenObservers.filter( + tokenObserver => tokenObserver.next !== listener + ); if ( - newListeners.length === 0 && + newObservers.length === 0 && state.tokenRefresher && state.tokenRefresher.isRunning() ) { @@ -240,7 +248,7 @@ export function removeTokenListener( setState(app, { ...state, - tokenListeners: newListeners + tokenObservers: newObservers }); } @@ -302,12 +310,23 @@ function notifyTokenListeners( app: FirebaseApp, token: AppCheckTokenResult ): void { - const listeners = getState(app).tokenListeners; + const observers = getState(app).tokenObservers; - for (const listener of listeners) { + for (const observer of observers) { try { - listener(token); - } catch (e) { + if (observer.error) { + // If this listener has an error handler, handle errors differently + // from successes. + if (token.error) { + observer.error(token.error); + } else { + observer.next(token); + } + } else { + // Otherwise return the token, whether or not it has an error field. + observer.next(token); + } + } catch (ignored) { // If any handler fails, ignore and run next handler. } } diff --git a/packages/app-check/src/state.ts b/packages/app-check/src/state.ts index 5d041ec82a4..4c983c835ba 100644 --- a/packages/app-check/src/state.ts +++ b/packages/app-check/src/state.ts @@ -16,18 +16,29 @@ */ import { FirebaseApp } from '@firebase/app-types'; -import { AppCheckProvider, AppCheckToken } from '@firebase/app-check-types'; +import { + AppCheckProvider, + AppCheckToken, + AppCheckTokenResult +} from '@firebase/app-check-types'; import { AppCheckTokenListener } from '@firebase/app-check-interop-types'; import { Refresher } from './proactive-refresh'; -import { Deferred } from '@firebase/util'; +import { Deferred, PartialObserver } from '@firebase/util'; import { GreCAPTCHA } from './recaptcha'; export interface AppCheckTokenInternal extends AppCheckToken { issuedAtTimeMillis: number; } + +export interface AppCheckTokenObserver + extends PartialObserver { + // required + next: AppCheckTokenListener; +} + export interface AppCheckState { activated: boolean; - tokenListeners: AppCheckTokenListener[]; + tokenObservers: AppCheckTokenObserver[]; customProvider?: AppCheckProvider; siteKey?: string; token?: AppCheckTokenInternal; @@ -49,7 +60,7 @@ export interface DebugState { const APP_CHECK_STATES = new Map(); export const DEFAULT_STATE: AppCheckState = { activated: false, - tokenListeners: [] + tokenObservers: [] }; const DEBUG_STATE: DebugState = { diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 85f612bc039..18c4e7e1b47 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -1535,6 +1535,13 @@ declare namespace firebase.app { * @webonly */ declare namespace firebase.appCheck { + /** + * Result returned by + * {@link firebase.appCheck.AppCheck.getToken `firebase.appCheck().getToken()`}. + */ + interface AppCheckTokenResult { + token: string; + } /** * The Firebase AppCheck service interface. * @@ -1563,6 +1570,57 @@ declare namespace firebase.appCheck { * during `activate()`. */ setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void; + /** + * Get the current App Check token. Attaches to the most recent + * in-flight request if one is present. Returns null if no token + * is present and no token requests are in-flight. + * + * @param forceRefresh - If true, will always try to fetch a fresh token. + * If false, will use a cached token if found in storage. + */ + getToken( + forceRefresh?: boolean + ): Promise; + + /** + * Registers a listener to changes in the token state. There can be more + * than one listener registered at the same time for one or more + * App Check instances. The listeners call back on the UI thread whenever + * the current token associated with this App Check instance changes. + * + * @param observer An object with `next`, `error`, and `complete` + * properties. `next` is called with an + * {@link firebase.appCheck.AppCheckTokenResult `AppCheckTokenResult`} + * whenever the token changes. `error` is optional and is called if an + * error is thrown by the listener (the `next` function). `complete` + * is unused, as the token stream is unending. + * + * @returns A function that unsubscribes this listener. + */ + onTokenChanged(observer: { + next: (tokenResult: firebase.appCheck.AppCheckTokenResult) => void; + error?: (error: Error) => void; + complete?: () => void; + }): Unsubscribe; + + /** + * Registers a listener to changes in the token state. There can be more + * than one listener registered at the same time for one or more + * App Check instances. The listeners call back on the UI thread whenever + * the current token associated with this App Check instance changes. + * + * @param onNext When the token changes, this function is called with aa + * {@link firebase.appCheck.AppCheckTokenResult `AppCheckTokenResult`}. + * @param onError Optional. Called if there is an error thrown by the + * listener (the `onNext` function). + * @param onCompletion Currently unused, as the token stream is unending. + * @returns A function that unsubscribes this listener. + */ + onTokenChanged( + onNext: (tokenResult: firebase.appCheck.AppCheckTokenResult) => void, + onError?: (error: Error) => void, + onCompletion?: () => void + ): Unsubscribe; } /**