Skip to content

Commit 870dd5e

Browse files
authored
Add public token API to AppCheck (#5033)
1 parent d9dc89f commit 870dd5e

File tree

11 files changed

+498
-61
lines changed

11 files changed

+498
-61
lines changed

.changeset/empty-countries-run.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@firebase/app-check': minor
3+
'@firebase/app-check-types': minor
4+
'firebase': minor
5+
---
6+
7+
Added `getToken()` and `onTokenChanged` methods to App Check.

packages/app-check-types/index.d.ts

+46
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { PartialObserver, Unsubscribe } from '@firebase/util';
19+
1820
export interface FirebaseAppCheck {
1921
/**
2022
* Activate AppCheck
@@ -36,6 +38,40 @@ export interface FirebaseAppCheck {
3638
* during `activate()`.
3739
*/
3840
setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void;
41+
42+
/**
43+
* Get the current App Check token. Attaches to the most recent
44+
* in-flight request if one is present. Returns null if no token
45+
* is present and no token requests are in flight.
46+
*
47+
* @param forceRefresh - If true, will always try to fetch a fresh token.
48+
* If false, will use a cached token if found in storage.
49+
*/
50+
getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult>;
51+
52+
/**
53+
* Registers a listener to changes in the token state. There can be more
54+
* than one listener registered at the same time for one or more
55+
* App Check instances. The listeners call back on the UI thread whenever
56+
* the current token associated with this App Check instance changes.
57+
*
58+
* @returns A function that unsubscribes this listener.
59+
*/
60+
onTokenChanged(observer: PartialObserver<AppCheckTokenResult>): Unsubscribe;
61+
62+
/**
63+
* Registers a listener to changes in the token state. There can be more
64+
* than one listener registered at the same time for one or more
65+
* App Check instances. The listeners call back on the UI thread whenever
66+
* the current token associated with this App Check instance changes.
67+
*
68+
* @returns A function that unsubscribes this listener.
69+
*/
70+
onTokenChanged(
71+
onNext: (tokenResult: AppCheckTokenResult) => void,
72+
onError?: (error: Error) => void,
73+
onCompletion?: () => void
74+
): Unsubscribe;
3975
}
4076

4177
/**
@@ -64,6 +100,16 @@ interface AppCheckToken {
64100
readonly expireTimeMillis: number;
65101
}
66102

103+
/**
104+
* Result returned by `getToken()`.
105+
*/
106+
interface AppCheckTokenResult {
107+
/**
108+
* The token string in JWT format.
109+
*/
110+
readonly token: string;
111+
}
112+
67113
export type AppCheckComponentName = 'appCheck';
68114
declare module '@firebase/component' {
69115
interface NameServiceMapping {

packages/app-check/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"build": "rollup -c",
1717
"build:deps": "lerna run --scope @firebase/app-check --include-dependencies build",
1818
"dev": "rollup -c -w",
19-
"test": "yarn type-check && yarn test:browser",
19+
"test": "yarn lint && yarn type-check && yarn test:browser",
2020
"test:ci": "node ../../scripts/run_tests_in_ci.js",
2121
"test:browser": "karma start --single-run",
2222
"test:browser:debug": "karma start --browsers Chrome --auto-watch",

packages/app-check/src/api.test.ts

+184-4
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,27 @@
1616
*/
1717
import '../test/setup';
1818
import { expect } from 'chai';
19-
import { stub } from 'sinon';
20-
import { activate, setTokenAutoRefreshEnabled } from './api';
19+
import { stub, spy } from 'sinon';
20+
import {
21+
activate,
22+
setTokenAutoRefreshEnabled,
23+
getToken,
24+
onTokenChanged
25+
} from './api';
2126
import {
2227
FAKE_SITE_KEY,
2328
getFakeApp,
24-
getFakeCustomTokenProvider
29+
getFakeCustomTokenProvider,
30+
getFakePlatformLoggingProvider,
31+
removegreCAPTCHAScriptsOnPage
2532
} from '../test/util';
26-
import { getState } from './state';
33+
import { clearState, getState } from './state';
2734
import * as reCAPTCHA from './recaptcha';
2835
import { FirebaseApp } from '@firebase/app-types';
36+
import * as internalApi from './internal-api';
37+
import * as client from './client';
38+
import * as storage from './storage';
39+
import * as logger from './logger';
2940

3041
describe('api', () => {
3142
describe('activate()', () => {
@@ -86,4 +97,173 @@ describe('api', () => {
8697
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
8798
});
8899
});
100+
describe('getToken()', () => {
101+
it('getToken() calls the internal getToken() function', async () => {
102+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
103+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
104+
const internalGetToken = stub(internalApi, 'getToken').resolves({
105+
token: 'a-token-string'
106+
});
107+
await getToken(app, fakePlatformLoggingProvider, true);
108+
expect(internalGetToken).to.be.calledWith(
109+
app,
110+
fakePlatformLoggingProvider,
111+
true
112+
);
113+
});
114+
it('getToken() throws errors returned with token', async () => {
115+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
116+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
117+
// If getToken() errors, it returns a dummy token with an error field
118+
// instead of throwing.
119+
stub(internalApi, 'getToken').resolves({
120+
token: 'a-dummy-token',
121+
error: Error('there was an error')
122+
});
123+
await expect(
124+
getToken(app, fakePlatformLoggingProvider, true)
125+
).to.be.rejectedWith('there was an error');
126+
});
127+
});
128+
describe('onTokenChanged()', () => {
129+
afterEach(() => {
130+
clearState();
131+
removegreCAPTCHAScriptsOnPage();
132+
});
133+
it('Listeners work when using top-level parameters pattern', async () => {
134+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
135+
activate(app, FAKE_SITE_KEY, true);
136+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
137+
const fakeRecaptchaToken = 'fake-recaptcha-token';
138+
const fakeRecaptchaAppCheckToken = {
139+
token: 'fake-recaptcha-app-check-token',
140+
expireTimeMillis: 123,
141+
issuedAtTimeMillis: 0
142+
};
143+
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
144+
stub(client, 'exchangeToken').returns(
145+
Promise.resolve(fakeRecaptchaAppCheckToken)
146+
);
147+
stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined));
148+
149+
const listener1 = (): void => {
150+
throw new Error();
151+
};
152+
const listener2 = spy();
153+
154+
const errorFn1 = spy();
155+
const errorFn2 = spy();
156+
157+
const unsubscribe1 = onTokenChanged(
158+
app,
159+
fakePlatformLoggingProvider,
160+
listener1,
161+
errorFn1
162+
);
163+
const unsubscribe2 = onTokenChanged(
164+
app,
165+
fakePlatformLoggingProvider,
166+
listener2,
167+
errorFn2
168+
);
169+
170+
expect(getState(app).tokenObservers.length).to.equal(2);
171+
172+
await internalApi.getToken(app, fakePlatformLoggingProvider);
173+
174+
expect(listener2).to.be.calledWith({
175+
token: fakeRecaptchaAppCheckToken.token
176+
});
177+
// onError should not be called on listener errors.
178+
expect(errorFn1).to.not.be.called;
179+
expect(errorFn2).to.not.be.called;
180+
unsubscribe1();
181+
unsubscribe2();
182+
expect(getState(app).tokenObservers.length).to.equal(0);
183+
});
184+
185+
it('Listeners work when using Observer pattern', async () => {
186+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
187+
activate(app, FAKE_SITE_KEY, true);
188+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
189+
const fakeRecaptchaToken = 'fake-recaptcha-token';
190+
const fakeRecaptchaAppCheckToken = {
191+
token: 'fake-recaptcha-app-check-token',
192+
expireTimeMillis: 123,
193+
issuedAtTimeMillis: 0
194+
};
195+
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
196+
stub(client, 'exchangeToken').returns(
197+
Promise.resolve(fakeRecaptchaAppCheckToken)
198+
);
199+
stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined));
200+
201+
const listener1 = (): void => {
202+
throw new Error();
203+
};
204+
const listener2 = spy();
205+
206+
const errorFn1 = spy();
207+
const errorFn2 = spy();
208+
209+
/**
210+
* Reverse the order of adding the failed and successful handler, for extra
211+
* testing.
212+
*/
213+
const unsubscribe2 = onTokenChanged(app, fakePlatformLoggingProvider, {
214+
next: listener2,
215+
error: errorFn2
216+
});
217+
const unsubscribe1 = onTokenChanged(app, fakePlatformLoggingProvider, {
218+
next: listener1,
219+
error: errorFn1
220+
});
221+
222+
expect(getState(app).tokenObservers.length).to.equal(2);
223+
224+
await internalApi.getToken(app, fakePlatformLoggingProvider);
225+
226+
expect(listener2).to.be.calledWith({
227+
token: fakeRecaptchaAppCheckToken.token
228+
});
229+
// onError should not be called on listener errors.
230+
expect(errorFn1).to.not.be.called;
231+
expect(errorFn2).to.not.be.called;
232+
unsubscribe1();
233+
unsubscribe2();
234+
expect(getState(app).tokenObservers.length).to.equal(0);
235+
});
236+
237+
it('onError() catches token errors', async () => {
238+
stub(logger.logger, 'error');
239+
const app = getFakeApp();
240+
activate(app, FAKE_SITE_KEY, false);
241+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
242+
const fakeRecaptchaToken = 'fake-recaptcha-token';
243+
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
244+
stub(client, 'exchangeToken').rejects('exchange error');
245+
stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined));
246+
247+
const listener1 = spy();
248+
249+
const errorFn1 = spy();
250+
251+
const unsubscribe1 = onTokenChanged(
252+
app,
253+
fakePlatformLoggingProvider,
254+
listener1,
255+
errorFn1
256+
);
257+
258+
await internalApi.getToken(app, fakePlatformLoggingProvider);
259+
260+
expect(getState(app).tokenObservers.length).to.equal(1);
261+
262+
expect(errorFn1).to.be.calledOnce;
263+
expect(errorFn1.args[0][0].name).to.include('exchange error');
264+
265+
unsubscribe1();
266+
expect(getState(app).tokenObservers.length).to.equal(0);
267+
});
268+
});
89269
});

packages/app-check/src/api.ts

+84-1
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,21 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { AppCheckProvider } from '@firebase/app-check-types';
18+
import {
19+
AppCheckProvider,
20+
AppCheckTokenResult
21+
} from '@firebase/app-check-types';
1922
import { FirebaseApp } from '@firebase/app-types';
2023
import { ERROR_FACTORY, AppCheckError } from './errors';
2124
import { initialize as initializeRecaptcha } from './recaptcha';
2225
import { getState, setState, AppCheckState } from './state';
26+
import {
27+
getToken as getTokenInternal,
28+
addTokenListener,
29+
removeTokenListener
30+
} from './internal-api';
31+
import { Provider } from '@firebase/component';
32+
import { ErrorFn, NextFn, PartialObserver, Unsubscribe } from '@firebase/util';
2333

2434
/**
2535
*
@@ -82,3 +92,76 @@ export function setTokenAutoRefreshEnabled(
8292
}
8393
setState(app, { ...state, isTokenAutoRefreshEnabled });
8494
}
95+
96+
/**
97+
* Differs from internal getToken in that it throws the error.
98+
*/
99+
export async function getToken(
100+
app: FirebaseApp,
101+
platformLoggerProvider: Provider<'platform-logger'>,
102+
forceRefresh?: boolean
103+
): Promise<AppCheckTokenResult> {
104+
const result = await getTokenInternal(
105+
app,
106+
platformLoggerProvider,
107+
forceRefresh
108+
);
109+
if (result.error) {
110+
throw result.error;
111+
}
112+
return { token: result.token };
113+
}
114+
115+
/**
116+
* Wraps addTokenListener/removeTokenListener methods in an Observer
117+
* pattern for public use.
118+
*/
119+
export function onTokenChanged(
120+
app: FirebaseApp,
121+
platformLoggerProvider: Provider<'platform-logger'>,
122+
observer: PartialObserver<AppCheckTokenResult>
123+
): Unsubscribe;
124+
export function onTokenChanged(
125+
app: FirebaseApp,
126+
platformLoggerProvider: Provider<'platform-logger'>,
127+
onNext: (tokenResult: AppCheckTokenResult) => void,
128+
onError?: (error: Error) => void,
129+
onCompletion?: () => void
130+
): Unsubscribe;
131+
export function onTokenChanged(
132+
app: FirebaseApp,
133+
platformLoggerProvider: Provider<'platform-logger'>,
134+
onNextOrObserver:
135+
| ((tokenResult: AppCheckTokenResult) => void)
136+
| PartialObserver<AppCheckTokenResult>,
137+
onError?: (error: Error) => void,
138+
/**
139+
* NOTE: Although an `onCompletion` callback can be provided, it will
140+
* never be called because the token stream is never-ending.
141+
* It is added only for API consistency with the observer pattern, which
142+
* we follow in JS APIs.
143+
*/
144+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
145+
onCompletion?: () => void
146+
): Unsubscribe {
147+
let nextFn: NextFn<AppCheckTokenResult> = () => {};
148+
let errorFn: ErrorFn = () => {};
149+
if ((onNextOrObserver as PartialObserver<AppCheckTokenResult>).next != null) {
150+
nextFn = (onNextOrObserver as PartialObserver<AppCheckTokenResult>).next!.bind(
151+
onNextOrObserver
152+
);
153+
} else {
154+
nextFn = onNextOrObserver as NextFn<AppCheckTokenResult>;
155+
}
156+
if (
157+
(onNextOrObserver as PartialObserver<AppCheckTokenResult>).error != null
158+
) {
159+
errorFn = (onNextOrObserver as PartialObserver<AppCheckTokenResult>).error!.bind(
160+
onNextOrObserver
161+
);
162+
} else if (onError) {
163+
errorFn = onError;
164+
}
165+
addTokenListener(app, platformLoggerProvider, nextFn, errorFn);
166+
return () => removeTokenListener(app, nextFn);
167+
}

0 commit comments

Comments
 (0)