Skip to content

Commit

Permalink
Add public token API to AppCheck (#5033)
Browse files Browse the repository at this point in the history
  • Loading branch information
hsubox76 committed Jun 23, 2021
1 parent d9dc89f commit 870dd5e
Show file tree
Hide file tree
Showing 11 changed files with 498 additions and 61 deletions.
7 changes: 7 additions & 0 deletions .changeset/empty-countries-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@firebase/app-check': minor
'@firebase/app-check-types': minor
'firebase': minor
---

Added `getToken()` and `onTokenChanged` methods to App Check.
46 changes: 46 additions & 0 deletions packages/app-check-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* limitations under the License.
*/

import { PartialObserver, Unsubscribe } from '@firebase/util';

export interface FirebaseAppCheck {
/**
* Activate AppCheck
Expand All @@ -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<AppCheckTokenResult>;

/**
* 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<AppCheckTokenResult>): 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;
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-check/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
188 changes: 184 additions & 4 deletions packages/app-check/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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);
});
});
});
85 changes: 84 additions & 1 deletion packages/app-check/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
*
Expand Down Expand Up @@ -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<AppCheckTokenResult> {
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<AppCheckTokenResult>
): 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<AppCheckTokenResult>,
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<AppCheckTokenResult> = () => {};
let errorFn: ErrorFn = () => {};
if ((onNextOrObserver as PartialObserver<AppCheckTokenResult>).next != null) {
nextFn = (onNextOrObserver as PartialObserver<AppCheckTokenResult>).next!.bind(
onNextOrObserver
);
} else {
nextFn = onNextOrObserver as NextFn<AppCheckTokenResult>;
}
if (
(onNextOrObserver as PartialObserver<AppCheckTokenResult>).error != null
) {
errorFn = (onNextOrObserver as PartialObserver<AppCheckTokenResult>).error!.bind(
onNextOrObserver
);
} else if (onError) {
errorFn = onError;
}
addTokenListener(app, platformLoggerProvider, nextFn, errorFn);
return () => removeTokenListener(app, nextFn);
}

0 comments on commit 870dd5e

Please sign in to comment.