Skip to content

Commit 44b8c46

Browse files
committed
Add public token API
1 parent 725ab46 commit 44b8c46

File tree

9 files changed

+433
-40
lines changed

9 files changed

+433
-40
lines changed

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ export interface FirebaseAppCheckInternal {
2424
// registered at the same time for one or more FirebaseAppAttestation instances. The
2525
// listeners call back on the UI thread whenever the current token associated with this
2626
// FirebaseAppAttestation changes.
27-
addTokenListener(listener: AppCheckTokenListener): void;
27+
addTokenListener(listener: (token: AppCheckTokenResult) => void): void;
2828

2929
// Unregisters a listener to changes in the token state.
30-
removeTokenListener(listener: AppCheckTokenListener): void;
30+
removeTokenListener(listener: (token: AppCheckTokenResult) => void): void;
3131
}
3232

33-
type AppCheckTokenListener = (token: AppCheckTokenResult) => void;
33+
interface AppCheckTokenListener {
34+
listener: (token: AppCheckTokenResult) => void;
35+
onError?: (error: Error) => void;
36+
}
3437

3538
// If the error field is defined, the token field will be populated with a dummy token
3639
interface AppCheckTokenResult {

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

Lines changed: 46 additions & 0 deletions
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/src/api.test.ts

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,26 @@
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, restore } 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';
2939

3040
describe('api', () => {
3141
describe('activate()', () => {
@@ -86,4 +96,137 @@ describe('api', () => {
8696
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
8797
});
8898
});
99+
describe('getToken()', () => {
100+
it('getToken() calls the internal getToken() function', async () => {
101+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
102+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
103+
const internalGetToken = stub(internalApi, 'getToken').resolves({
104+
token: 'a-token-string'
105+
});
106+
await getToken(app, fakePlatformLoggingProvider, true);
107+
expect(internalGetToken).to.be.calledWith(
108+
app,
109+
fakePlatformLoggingProvider,
110+
true
111+
);
112+
});
113+
it('getToken() throws errors returned with token', async () => {
114+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
115+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
116+
stub(internalApi, 'getToken').resolves({
117+
token: 'a-token-string',
118+
error: Error('there was an error')
119+
});
120+
await expect(
121+
getToken(app, fakePlatformLoggingProvider, true)
122+
).to.be.rejectedWith('there was an error');
123+
});
124+
});
125+
describe('onTokenChanged()', () => {
126+
afterEach(() => {
127+
clearState();
128+
removegreCAPTCHAScriptsOnPage();
129+
});
130+
it('Listeners work when using top-level parameters pattern', async () => {
131+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
132+
activate(app, FAKE_SITE_KEY, true);
133+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
134+
const fakeRecaptchaToken = 'fake-recaptcha-token';
135+
const fakeRecaptchaAppCheckToken = {
136+
token: 'fake-recaptcha-app-check-token',
137+
expireTimeMillis: 123,
138+
issuedAtTimeMillis: 0
139+
};
140+
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
141+
stub(client, 'exchangeToken').returns(
142+
Promise.resolve(fakeRecaptchaAppCheckToken)
143+
);
144+
stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined));
145+
146+
const listener1 = (): void => {
147+
throw new Error();
148+
};
149+
const listener2 = spy();
150+
151+
const errorFn1 = spy();
152+
const errorFn2 = spy();
153+
154+
const unSubscribe1 = onTokenChanged(
155+
app,
156+
fakePlatformLoggingProvider,
157+
listener1,
158+
errorFn1
159+
);
160+
const unSubscribe2 = onTokenChanged(
161+
app,
162+
fakePlatformLoggingProvider,
163+
listener2,
164+
errorFn2
165+
);
166+
167+
expect(getState(app).tokenListeners.length).to.equal(2);
168+
169+
await getToken(app, fakePlatformLoggingProvider);
170+
171+
expect(listener2).to.be.calledWith({
172+
token: fakeRecaptchaAppCheckToken.token
173+
});
174+
expect(errorFn1).to.be.calledOnce;
175+
expect(errorFn2).to.not.be.called;
176+
unSubscribe1();
177+
unSubscribe2();
178+
expect(getState(app).tokenListeners.length).to.equal(0);
179+
});
180+
181+
it('Listeners work when using Observer pattern', async () => {
182+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
183+
activate(app, FAKE_SITE_KEY, true);
184+
const fakePlatformLoggingProvider = getFakePlatformLoggingProvider();
185+
const fakeRecaptchaToken = 'fake-recaptcha-token';
186+
const fakeRecaptchaAppCheckToken = {
187+
token: 'fake-recaptcha-app-check-token',
188+
expireTimeMillis: 123,
189+
issuedAtTimeMillis: 0
190+
};
191+
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
192+
stub(client, 'exchangeToken').returns(
193+
Promise.resolve(fakeRecaptchaAppCheckToken)
194+
);
195+
stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined));
196+
197+
const listener1 = (): void => {
198+
throw new Error();
199+
};
200+
const listener2 = spy();
201+
202+
const errorFn1 = spy();
203+
const errorFn2 = spy();
204+
205+
/**
206+
* Reverse the order of adding the failed and successful handler, for extra
207+
* testing.
208+
*/
209+
const unSubscribe2 = onTokenChanged(app, fakePlatformLoggingProvider, {
210+
next: listener2,
211+
error: errorFn2
212+
});
213+
const unSubscribe1 = onTokenChanged(app, fakePlatformLoggingProvider, {
214+
next: listener1,
215+
error: errorFn1
216+
});
217+
218+
expect(getState(app).tokenListeners.length).to.equal(2);
219+
220+
await getToken(app, fakePlatformLoggingProvider);
221+
222+
expect(listener2).to.be.calledWith({
223+
token: fakeRecaptchaAppCheckToken.token
224+
});
225+
expect(errorFn1).to.be.calledOnce;
226+
expect(errorFn2).to.not.be.called;
227+
unSubscribe1();
228+
unSubscribe2();
229+
expect(getState(app).tokenListeners.length).to.equal(0);
230+
});
231+
});
89232
});

packages/app-check/src/api.ts

Lines changed: 80 additions & 1 deletion
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,72 @@ 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!;
151+
} else {
152+
nextFn = onNextOrObserver as NextFn<AppCheckTokenResult>;
153+
}
154+
if (
155+
(onNextOrObserver as PartialObserver<AppCheckTokenResult>).error != null
156+
) {
157+
errorFn = (onNextOrObserver as PartialObserver<AppCheckTokenResult>).error!;
158+
} else if (onError) {
159+
errorFn = onError;
160+
}
161+
addTokenListener(app, platformLoggerProvider, nextFn, errorFn);
162+
return () => removeTokenListener(app, nextFn);
163+
}

0 commit comments

Comments
 (0)