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 7 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
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
186 changes: 182 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,171 @@ 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();
stub(internalApi, 'getToken').resolves({
token: 'a-token-string',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be the dummy token in this case, right? Can you add a comment here to clarify?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified.

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