Skip to content

Commit 5dcd943

Browse files
committed
FAC scoped token support
1 parent 3fe7597 commit 5dcd943

File tree

5 files changed

+314
-2
lines changed

5 files changed

+314
-2
lines changed

common/api-review/app-check.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export interface CustomProviderOptions {
6060
getToken: () => Promise<AppCheckToken>;
6161
}
6262

63+
// @public
64+
export function getScopedToken(appCheckInstance: AppCheck): Promise<AppCheckTokenResult>;
65+
6366
// @public
6467
export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise<AppCheckTokenResult>;
6568

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
setTokenAutoRefreshEnabled,
2222
initializeAppCheck,
2323
getToken,
24-
onTokenChanged
24+
onTokenChanged,
25+
getScopedToken
2526
} from './api';
2627
import {
2728
FAKE_SITE_KEY,
@@ -284,6 +285,33 @@ describe('api', () => {
284285
);
285286
});
286287
});
288+
describe('getScopedToken()', () => {
289+
it('getScopedToken() calls the internal getScopedToken() function', async () => {
290+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
291+
const appCheck = getFakeAppCheck(app);
292+
const internalGetScopedToken = stub(
293+
internalApi,
294+
'getScopedToken'
295+
).resolves({
296+
token: 'a-token-string'
297+
});
298+
await getScopedToken(appCheck);
299+
expect(internalGetScopedToken).to.be.calledWith(appCheck);
300+
});
301+
it('getScopedToken() throws errors returned with token', async () => {
302+
const app = getFakeApp({ automaticDataCollectionEnabled: true });
303+
const appCheck = getFakeAppCheck(app);
304+
// If getScopedToken() errors, it returns a dummy token with an error field
305+
// instead of throwing.
306+
stub(internalApi, 'getScopedToken').resolves({
307+
token: 'a-dummy-token',
308+
error: Error('there was an error')
309+
});
310+
await expect(getScopedToken(appCheck)).to.be.rejectedWith(
311+
'there was an error'
312+
);
313+
});
314+
});
287315
describe('onTokenChanged()', () => {
288316
it('Listeners work when using top-level parameters pattern', async () => {
289317
const appCheck = initializeAppCheck(app, {

packages/app-check/src/api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AppCheckService } from './factory';
3535
import { AppCheckProvider, ListenerType } from './types';
3636
import {
3737
getToken as getTokenInternal,
38+
getScopedToken as getScopedTokenInternal,
3839
addTokenListener,
3940
removeTokenListener,
4041
isValid,
@@ -209,6 +210,28 @@ export async function getToken(
209210
return { token: result.token };
210211
}
211212

213+
/**
214+
* Requests a Firebase App Check token. This method should be used ONLY if you
215+
* need to authorize requests to a non-Firebase backend. Tokens from this
216+
* method are intended for use with the Admin SDKs when `consume` is set to
217+
* true. Tokens from this method do not interact with FirebaseAppCheck’s token
218+
* cache.
219+
*
220+
* @param appCheckInstance - The App Check service instance.
221+
* @public
222+
*/
223+
export async function getScopedToken(
224+
appCheckInstance: AppCheck
225+
): Promise<AppCheckTokenResult> {
226+
const result = await getScopedTokenInternal(
227+
appCheckInstance as AppCheckService
228+
);
229+
if (result.error) {
230+
throw result.error;
231+
}
232+
return { token: result.token };
233+
}
234+
212235
/**
213236
* Registers a listener to changes in the token state. There can be more
214237
* than one listener registered at the same time for one or more

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

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import {
3232
addTokenListener,
3333
removeTokenListener,
3434
formatDummyToken,
35-
defaultTokenErrorData
35+
defaultTokenErrorData,
36+
getScopedToken
3637
} from './internal-api';
3738
import * as reCAPTCHA from './recaptcha';
3839
import * as client from './client';
@@ -637,6 +638,180 @@ describe('internal api', () => {
637638
});
638639
});
639640

641+
describe('getScopedToken()', () => {
642+
it('uses customTokenProvider to get an AppCheck token', async () => {
643+
const customTokenProvider = getFakeCustomTokenProvider();
644+
const customProviderSpy = spy(customTokenProvider, 'getToken');
645+
646+
const appCheck = initializeAppCheck(app, {
647+
provider: customTokenProvider
648+
});
649+
const token = await getScopedToken(appCheck as AppCheckService);
650+
651+
expect(customProviderSpy).to.be.called;
652+
expect(token).to.deep.equal({
653+
token: 'fake-custom-app-check-token'
654+
});
655+
});
656+
657+
it('does not interact with state', async () => {
658+
const customTokenProvider = getFakeCustomTokenProvider();
659+
spy(customTokenProvider, 'getToken');
660+
661+
const appCheck = initializeAppCheck(app, {
662+
provider: customTokenProvider
663+
});
664+
await getScopedToken(appCheck as AppCheckService);
665+
666+
expect(getStateReference(app).token).to.be.undefined;
667+
expect(getStateReference(app).isTokenAutoRefreshEnabled).to.be.false;
668+
});
669+
670+
it('uses reCAPTCHA (V3) token to exchange for AppCheck token', async () => {
671+
const appCheck = initializeAppCheck(app, {
672+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
673+
});
674+
675+
const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
676+
Promise.resolve(fakeRecaptchaToken)
677+
);
678+
const exchangeTokenStub: SinonStub = stub(
679+
client,
680+
'exchangeToken'
681+
).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
682+
683+
const token = await getScopedToken(appCheck as AppCheckService);
684+
685+
expect(reCAPTCHASpy).to.be.called;
686+
687+
expect(exchangeTokenStub.args[0][0].body['recaptcha_v3_token']).to.equal(
688+
fakeRecaptchaToken
689+
);
690+
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
691+
});
692+
693+
it('uses reCAPTCHA (Enterprise) token to exchange for AppCheck token', async () => {
694+
const appCheck = initializeAppCheck(app, {
695+
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
696+
});
697+
698+
const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
699+
Promise.resolve(fakeRecaptchaToken)
700+
);
701+
const exchangeTokenStub: SinonStub = stub(
702+
client,
703+
'exchangeToken'
704+
).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
705+
706+
const token = await getScopedToken(appCheck as AppCheckService);
707+
708+
expect(reCAPTCHASpy).to.be.called;
709+
710+
expect(
711+
exchangeTokenStub.args[0][0].body['recaptcha_enterprise_token']
712+
).to.equal(fakeRecaptchaToken);
713+
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
714+
});
715+
716+
it('resolves with a dummy token and an error if failed to get a token', async () => {
717+
const errorStub = stub(console, 'error');
718+
const appCheck = initializeAppCheck(app, {
719+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
720+
});
721+
722+
const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
723+
Promise.resolve(fakeRecaptchaToken)
724+
);
725+
726+
const error = new Error('oops, something went wrong');
727+
stub(client, 'exchangeToken').returns(Promise.reject(error));
728+
729+
const token = await getScopedToken(appCheck as AppCheckService);
730+
731+
expect(reCAPTCHASpy).to.be.called;
732+
expect(token).to.deep.equal({
733+
token: formatDummyToken(defaultTokenErrorData),
734+
error
735+
});
736+
expect(errorStub.args[0][1].message).to.include(
737+
'oops, something went wrong'
738+
);
739+
errorStub.restore();
740+
});
741+
742+
it('exchanges debug token if in debug mode', async () => {
743+
const exchangeTokenStub: SinonStub = stub(
744+
client,
745+
'exchangeToken'
746+
).returns(Promise.resolve(fakeRecaptchaAppCheckToken));
747+
const debugState = getDebugState();
748+
debugState.enabled = true;
749+
debugState.token = new Deferred();
750+
debugState.token.resolve('my-debug-token');
751+
const appCheck = initializeAppCheck(app, {
752+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
753+
});
754+
755+
const token = await getScopedToken(appCheck as AppCheckService);
756+
expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal(
757+
'my-debug-token'
758+
);
759+
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
760+
});
761+
762+
it('throttles for a period less than 1d on 503', async () => {
763+
// More detailed check of exponential backoff in providers.test.ts
764+
const appCheck = initializeAppCheck(app, {
765+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
766+
});
767+
const warnStub = stub(logger, 'warn');
768+
stub(client, 'exchangeToken').returns(
769+
Promise.reject(
770+
ERROR_FACTORY.create(AppCheckError.FETCH_STATUS_ERROR, {
771+
httpStatus: 503
772+
})
773+
)
774+
);
775+
776+
const token = await getScopedToken(appCheck as AppCheckService);
777+
778+
// ReCaptchaV3Provider's _throttleData is private so checking
779+
// the resulting error message to be sure it has roughly the
780+
// correct throttle time. This also tests the time formatter.
781+
// Check both the error itself and that it makes it through to
782+
// console.warn
783+
expect(token.error?.message).to.include('503');
784+
expect(token.error?.message).to.include('00m');
785+
expect(token.error?.message).to.not.include('1d');
786+
expect(warnStub.args[0][0]).to.include('503');
787+
});
788+
789+
it('throttles 1d on 403', async () => {
790+
const appCheck = initializeAppCheck(app, {
791+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
792+
});
793+
const warnStub = stub(logger, 'warn');
794+
stub(client, 'exchangeToken').returns(
795+
Promise.reject(
796+
ERROR_FACTORY.create(AppCheckError.FETCH_STATUS_ERROR, {
797+
httpStatus: 403
798+
})
799+
)
800+
);
801+
802+
const token = await getScopedToken(appCheck as AppCheckService);
803+
804+
// ReCaptchaV3Provider's _throttleData is private so checking
805+
// the resulting error message to be sure it has roughly the
806+
// correct throttle time. This also tests the time formatter.
807+
// Check both the error itself and that it makes it through to
808+
// console.warn
809+
expect(token.error?.message).to.include('403');
810+
expect(token.error?.message).to.include('1d');
811+
expect(warnStub.args[0][0]).to.include('403');
812+
});
813+
});
814+
640815
describe('addTokenListener', () => {
641816
afterEach(async () => {
642817
clearState();

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,89 @@ export async function getToken(
205205
return interopTokenResult;
206206
}
207207

208+
/**
209+
* This function always resolves.
210+
* The result will contain an error field if there is any error.
211+
* In case there is an error, the token field in the result will be populated with a dummy value
212+
*/
213+
export async function getScopedToken(
214+
appCheck: AppCheckService
215+
): Promise<AppCheckTokenResult> {
216+
const app = appCheck.app;
217+
ensureActivated(app);
218+
219+
const { provider } = getStateReference(app);
220+
221+
/**
222+
* First check if there is a token in memory from a previous `getToken()` call.
223+
*/
224+
let token: AppCheckTokenInternal | undefined = undefined;
225+
let error: Error | undefined = undefined;
226+
227+
/**
228+
* DEBUG MODE
229+
* If debug mode is set, and there is no cached token, fetch a new App
230+
* Check token using the debug token, and return it directly.
231+
*/
232+
if (isDebugMode()) {
233+
const debugToken = await getDebugToken();
234+
const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken(
235+
getExchangeDebugTokenRequest(app, debugToken),
236+
appCheck.heartbeatServiceProvider
237+
);
238+
return { token: tokenFromDebugExchange.token };
239+
}
240+
241+
/**
242+
* There are no valid tokens in memory or indexedDB and we are not in
243+
* debug mode.
244+
* Request a new token from the exchange endpoint.
245+
*/
246+
try {
247+
token = await provider!.getToken();
248+
} catch (e) {
249+
if ((e as FirebaseError).code === `appCheck/${AppCheckError.THROTTLED}`) {
250+
// Warn if throttled, but do not treat it as an error.
251+
logger.warn((e as FirebaseError).message);
252+
} else {
253+
// `getToken()` should never throw, but logging error text to console will aid debugging.
254+
logger.error(e);
255+
}
256+
// Always save error to be added to dummy token.
257+
error = e as FirebaseError;
258+
}
259+
260+
let interopTokenResult: AppCheckTokenResult | undefined;
261+
if (!token) {
262+
// If token is undefined, there must be an error.
263+
// Return a dummy token along with the error.
264+
interopTokenResult = makeDummyTokenResult(error!);
265+
} else if (error) {
266+
if (isValid(token)) {
267+
// It's also possible a valid token exists, but there's also an error.
268+
// (Such as if the token is almost expired, tries to refresh, and
269+
// the exchange request fails.)
270+
// We add a special error property here so that the refresher will
271+
// count this as a failed attempt and use the backoff instead of
272+
// retrying repeatedly with no delay, but any 3P listeners will not
273+
// be hindered in getting the still-valid token.
274+
interopTokenResult = {
275+
token: token.token,
276+
internalError: error
277+
};
278+
} else {
279+
// No invalid tokens should make it to this step. Memory and cached tokens
280+
// are checked. Other tokens are from fresh exchanges. But just in case.
281+
interopTokenResult = makeDummyTokenResult(error!);
282+
}
283+
} else {
284+
interopTokenResult = {
285+
token: token.token
286+
};
287+
}
288+
return interopTokenResult;
289+
}
290+
208291
export function addTokenListener(
209292
appCheck: AppCheckService,
210293
type: ListenerType,

0 commit comments

Comments
 (0)