Skip to content

Add public token API to AppCheck #5033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
Loading