Skip to content

Commit a43df9e

Browse files
feat(appcheck): Added replay protection feature to App Check verifyToken() API (#2148)
* feat(appcheck): Appcheck improvements * fix api extractor * Update docs and move already_consume outside * add unit tests * Added unit tests for verifyReplayProtection * fix docstrings * cleanup unit tests * update json payload * fix tests
1 parent 125c5bf commit a43df9e

9 files changed

+391
-5
lines changed

etc/firebase-admin.api.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ export namespace appCheck {
5757
// Warning: (ae-forgotten-export) The symbol "AppCheckToken" needs to be exported by the entry point default-namespace.d.ts
5858
export type AppCheckToken = AppCheckToken;
5959
// Warning: (ae-forgotten-export) The symbol "AppCheckTokenOptions" needs to be exported by the entry point default-namespace.d.ts
60-
//
61-
// (undocumented)
6260
export type AppCheckTokenOptions = AppCheckTokenOptions;
6361
// Warning: (ae-forgotten-export) The symbol "DecodedAppCheckToken" needs to be exported by the entry point default-namespace.d.ts
6462
export type DecodedAppCheckToken = DecodedAppCheckToken;
63+
// Warning: (ae-forgotten-export) The symbol "VerifyAppCheckTokenOptions" needs to be exported by the entry point default-namespace.d.ts
64+
export type VerifyAppCheckTokenOptions = VerifyAppCheckTokenOptions;
6565
// Warning: (ae-forgotten-export) The symbol "VerifyAppCheckTokenResponse" needs to be exported by the entry point default-namespace.d.ts
6666
export type VerifyAppCheckTokenResponse = VerifyAppCheckTokenResponse;
6767
}

etc/firebase-admin.app-check.api.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class AppCheck {
1515
// (undocumented)
1616
readonly app: App;
1717
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
18-
verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse>;
18+
verifyToken(appCheckToken: string, options?: VerifyAppCheckTokenOptions): Promise<VerifyAppCheckTokenResponse>;
1919
}
2020

2121
// @public
@@ -44,8 +44,14 @@ export interface DecodedAppCheckToken {
4444
// @public
4545
export function getAppCheck(app?: App): AppCheck;
4646

47+
// @public
48+
export interface VerifyAppCheckTokenOptions {
49+
consume?: boolean;
50+
}
51+
4752
// @public
4853
export interface VerifyAppCheckTokenResponse {
54+
alreadyConsumed?: boolean;
4955
appId: string;
5056
token: DecodedAppCheckToken;
5157
}

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

+41
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { AppCheckToken } from './app-check-api'
2727

2828
// App Check backend constants
2929
const FIREBASE_APP_CHECK_V1_API_URL_FORMAT = 'https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken';
30+
const ONE_TIME_USE_TOKEN_VERIFICATION_URL_FORMAT = 'https://firebaseappcheck.googleapis.com/v1beta/projects/{projectId}:verifyAppCheckToken';
3031

3132
const FIREBASE_APP_CHECK_CONFIG_HEADERS = {
3233
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
@@ -86,6 +87,35 @@ export class AppCheckApiClient {
8687
});
8788
}
8889

90+
public verifyReplayProtection(token: string): Promise<boolean> {
91+
if (!validator.isNonEmptyString(token)) {
92+
throw new FirebaseAppCheckError(
93+
'invalid-argument',
94+
'`token` must be a non-empty string.');
95+
}
96+
return this.getVerifyTokenUrl()
97+
.then((url) => {
98+
const request: HttpRequestConfig = {
99+
method: 'POST',
100+
url,
101+
headers: FIREBASE_APP_CHECK_CONFIG_HEADERS,
102+
data: { app_check_token: token }
103+
};
104+
return this.httpClient.send(request);
105+
})
106+
.then((resp) => {
107+
if (typeof resp.data.alreadyConsumed !== 'undefined'
108+
&& !validator.isBoolean(resp.data?.alreadyConsumed)) {
109+
throw new FirebaseAppCheckError(
110+
'invalid-argument', '`alreadyConsumed` must be a boolean value.');
111+
}
112+
return resp.data.alreadyConsumed || false;
113+
})
114+
.catch((err) => {
115+
throw this.toFirebaseError(err);
116+
});
117+
}
118+
89119
private getUrl(appId: string): Promise<string> {
90120
return this.getProjectId()
91121
.then((projectId) => {
@@ -98,6 +128,17 @@ export class AppCheckApiClient {
98128
});
99129
}
100130

131+
private getVerifyTokenUrl(): Promise<string> {
132+
return this.getProjectId()
133+
.then((projectId) => {
134+
const urlParams = {
135+
projectId
136+
};
137+
const baseUrl = utils.formatString(ONE_TIME_USE_TOKEN_VERIFICATION_URL_FORMAT, urlParams);
138+
return utils.formatString(baseUrl);
139+
});
140+
}
141+
101142
private getProjectId(): Promise<string> {
102143
if (this.projectId) {
103144
return Promise.resolve(this.projectId);

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

+36
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,29 @@ export interface AppCheckTokenOptions {
4141
ttlMillis?: number;
4242
}
4343

44+
/**
45+
* Interface representing options for the {@link AppCheck.verifyToken} method.
46+
*/
47+
export interface VerifyAppCheckTokenOptions {
48+
/**
49+
* To use the replay protection feature, set this to `true`. The {@link AppCheck.verifyToken}
50+
* method will mark the token as consumed after verifying it.
51+
*
52+
* Tokens that are found to be already consumed will be marked as such in the response.
53+
*
54+
* Tokens are only considered to be consumed if it is sent to App Check backend by calling the
55+
* {@link AppCheck.verifyToken} method with this field set to `true`; other uses of the token
56+
* do not consume it.
57+
*
58+
* This replay protection feature requires an additional network call to the App Check backend
59+
* and forces your clients to obtain a fresh attestation from your chosen attestation providers.
60+
* This can therefore negatively impact performance and can potentially deplete your attestation
61+
* providers' quotas faster. We recommend that you use this feature only for protecting
62+
* low volume, security critical, or expensive operations.
63+
*/
64+
consume?: boolean;
65+
}
66+
4467
/**
4568
* Interface representing a decoded Firebase App Check token, returned from the
4669
* {@link AppCheck.verifyToken} method.
@@ -102,4 +125,17 @@ export interface VerifyAppCheckTokenResponse {
102125
* The decoded Firebase App Check token.
103126
*/
104127
token: DecodedAppCheckToken;
128+
129+
/**
130+
* Indicates weather this token was already consumed.
131+
* If this is the first time {@link AppCheck.verifyToken} method has seen this token,
132+
* this field will contain the value `false`. The given token will then be
133+
* marked as `already_consumed` for all future invocations of this {@link AppCheck.verifyToken}
134+
* method for this token.
135+
*
136+
* When this field is `true`, the caller is attempting to reuse a previously consumed token.
137+
* You should take precautions against such a caller; for example, you can take actions such as
138+
* rejecting the request or ask the caller to pass additional layers of security checks.
139+
*/
140+
alreadyConsumed?: boolean;
105141
}

src/app-check/app-check-namespace.ts

+9
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
AppCheckToken as TAppCheckToken,
2020
AppCheckTokenOptions as TAppCheckTokenOptions,
2121
DecodedAppCheckToken as TDecodedAppCheckToken,
22+
VerifyAppCheckTokenOptions as TVerifyAppCheckTokenOptions,
2223
VerifyAppCheckTokenResponse as TVerifyAppCheckTokenResponse,
2324
} from './app-check-api';
2425
import { AppCheck as TAppCheck } from './app-check';
@@ -73,5 +74,13 @@ export namespace appCheck {
7374
*/
7475
export type VerifyAppCheckTokenResponse = TVerifyAppCheckTokenResponse;
7576

77+
/**
78+
* Type alias to {@link firebase-admin.app-check#AppCheckTokenOptions}.
79+
*/
7680
export type AppCheckTokenOptions = TAppCheckTokenOptions;
81+
82+
/**
83+
* Type alias to {@link firebase-admin.app-check#VerifyAppCheckTokenOptions}.
84+
*/
85+
export type VerifyAppCheckTokenOptions = TVerifyAppCheckTokenOptions;
7786
}

src/app-check/app-check.ts

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

18+
import * as validator from '../utils/validator';
19+
1820
import { App } from '../app';
19-
import { AppCheckApiClient } from './app-check-api-client-internal';
21+
import { AppCheckApiClient, FirebaseAppCheckError } from './app-check-api-client-internal';
2022
import {
2123
appCheckErrorFromCryptoSignerError, AppCheckTokenGenerator,
2224
} from './token-generator';
@@ -26,6 +28,7 @@ import { cryptoSignerFromApp } from '../utils/crypto-signer';
2628
import {
2729
AppCheckToken,
2830
AppCheckTokenOptions,
31+
VerifyAppCheckTokenOptions,
2932
VerifyAppCheckTokenResponse,
3033
} from './app-check-api';
3134

@@ -75,17 +78,41 @@ export class AppCheck {
7578
* rejected.
7679
*
7780
* @param appCheckToken - The App Check token to verify.
81+
* @param options - Optional {@link VerifyAppCheckTokenOptions} object when verifying an App Check Token.
7882
*
7983
* @returns A promise fulfilled with the token's decoded claims
8084
* if the App Check token is valid; otherwise, a rejected promise.
8185
*/
82-
public verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse> {
86+
public verifyToken(appCheckToken: string, options?: VerifyAppCheckTokenOptions)
87+
: Promise<VerifyAppCheckTokenResponse> {
88+
this.validateVerifyAppCheckTokenOptions(options);
8389
return this.appCheckTokenVerifier.verifyToken(appCheckToken)
8490
.then((decodedToken) => {
91+
if (options?.consume) {
92+
return this.client.verifyReplayProtection(appCheckToken)
93+
.then((alreadyConsumed) => {
94+
return {
95+
alreadyConsumed,
96+
appId: decodedToken.app_id,
97+
token: decodedToken,
98+
};
99+
});
100+
}
85101
return {
86102
appId: decodedToken.app_id,
87103
token: decodedToken,
88104
};
89105
});
90106
}
107+
108+
private validateVerifyAppCheckTokenOptions(options?: VerifyAppCheckTokenOptions): void {
109+
if (typeof options === 'undefined') {
110+
return;
111+
}
112+
if (!validator.isNonNullObject(options)) {
113+
throw new FirebaseAppCheckError(
114+
'invalid-argument',
115+
'VerifyAppCheckTokenOptions must be a non-null object.');
116+
}
117+
}
91118
}

src/app-check/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {
2929
AppCheckToken,
3030
AppCheckTokenOptions,
3131
DecodedAppCheckToken,
32+
VerifyAppCheckTokenOptions,
3233
VerifyAppCheckTokenResponse,
3334
} from './app-check-api';
3435
export { AppCheck } from './app-check';

test/unit/app-check/app-check-api-client-internal.spec.ts

+129
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,133 @@ describe('AppCheckApiClient', () => {
235235
});
236236
});
237237
});
238+
239+
describe('verifyReplayProtection', () => {
240+
it('should reject when project id is not available', () => {
241+
return clientWithoutProjectId.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
242+
.should.eventually.be.rejectedWith(noProjectId);
243+
});
244+
245+
it('should throw given no token', () => {
246+
expect(() => {
247+
(apiClient as any).verifyReplayProtection(undefined);
248+
}).to.throw('`token` must be a non-empty string.');
249+
});
250+
251+
[null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop].forEach((invalidToken) => {
252+
it('should throw given a non-string token: ' + JSON.stringify(invalidToken), () => {
253+
expect(() => {
254+
apiClient.verifyReplayProtection(invalidToken as any);
255+
}).to.throw('`token` must be a non-empty string.');
256+
});
257+
});
258+
259+
it('should throw given an empty string token', () => {
260+
expect(() => {
261+
apiClient.verifyReplayProtection('');
262+
}).to.throw('`token` must be a non-empty string.');
263+
});
264+
265+
it('should reject when a full platform error response is received', () => {
266+
const stub = sinon
267+
.stub(HttpClient.prototype, 'send')
268+
.rejects(utils.errorFrom(ERROR_RESPONSE, 404));
269+
stubs.push(stub);
270+
const expected = new FirebaseAppCheckError('not-found', 'Requested entity not found');
271+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
272+
.should.eventually.be.rejected.and.deep.include(expected);
273+
});
274+
275+
it('should reject with unknown-error when error code is not present', () => {
276+
const stub = sinon
277+
.stub(HttpClient.prototype, 'send')
278+
.rejects(utils.errorFrom({}, 404));
279+
stubs.push(stub);
280+
const expected = new FirebaseAppCheckError('unknown-error', 'Unknown server error: {}');
281+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
282+
.should.eventually.be.rejected.and.deep.include(expected);
283+
});
284+
285+
it('should reject with unknown-error for non-json response', () => {
286+
const stub = sinon
287+
.stub(HttpClient.prototype, 'send')
288+
.rejects(utils.errorFrom('not json', 404));
289+
stubs.push(stub);
290+
const expected = new FirebaseAppCheckError(
291+
'unknown-error', 'Unexpected response with status: 404 and body: not json');
292+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
293+
.should.eventually.be.rejected.and.deep.include(expected);
294+
});
295+
296+
it('should reject when rejected with a FirebaseAppError', () => {
297+
const expected = new FirebaseAppError('network-error', 'socket hang up');
298+
const stub = sinon
299+
.stub(HttpClient.prototype, 'send')
300+
.rejects(expected);
301+
stubs.push(stub);
302+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
303+
.should.eventually.be.rejected.and.deep.include(expected);
304+
});
305+
306+
['', 'abc', '3s2', 'sssa', '3.000000001', '3.2', null, NaN, [], {}, 100, 1.2, -200, -2.4]
307+
.forEach((invalidAlreadyConsumed) => {
308+
it(`should throw if the returned alreadyConsumed value is: ${invalidAlreadyConsumed}`, () => {
309+
const response = { alreadyConsumed: invalidAlreadyConsumed };
310+
const stub = sinon
311+
.stub(HttpClient.prototype, 'send')
312+
.resolves(utils.responseFrom(response, 200));
313+
stubs.push(stub);
314+
const expected = new FirebaseAppCheckError(
315+
'invalid-argument', '`alreadyConsumed` must be a boolean value.');
316+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
317+
.should.eventually.be.rejected.and.deep.include(expected);
318+
});
319+
});
320+
321+
it('should resolve with the alreadyConsumed status on success', () => {
322+
const stub = sinon
323+
.stub(HttpClient.prototype, 'send')
324+
.resolves(utils.responseFrom({ alreadyConsumed: true }, 200));
325+
stubs.push(stub);
326+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
327+
.then((alreadyConsumed) => {
328+
expect(alreadyConsumed).to.equal(true);
329+
expect(stub).to.have.been.calledOnce.and.calledWith({
330+
method: 'POST',
331+
url: 'https://firebaseappcheck.googleapis.com/v1beta/projects/test-project:verifyAppCheckToken',
332+
headers: EXPECTED_HEADERS,
333+
data: { app_check_token: TEST_TOKEN_TO_EXCHANGE }
334+
});
335+
});
336+
});
337+
338+
[true, false].forEach((expectedAlreadyConsumed) => {
339+
it(`should resolve with alreadyConsumed as ${expectedAlreadyConsumed} when alreadyConsumed
340+
from server is: ${expectedAlreadyConsumed}`, () => {
341+
const response = { alreadyConsumed: expectedAlreadyConsumed };
342+
const stub = sinon
343+
.stub(HttpClient.prototype, 'send')
344+
.resolves(utils.responseFrom(response, 200));
345+
stubs.push(stub);
346+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
347+
.then((alreadyConsumed) => {
348+
expect(alreadyConsumed).to.equal(expectedAlreadyConsumed);
349+
});
350+
});
351+
});
352+
353+
it(`should resolve with alreadyConsumed as false when alreadyConsumed
354+
from server is: undefined`, () => {
355+
const response = { };
356+
const stub = sinon
357+
.stub(HttpClient.prototype, 'send')
358+
.resolves(utils.responseFrom(response, 200));
359+
stubs.push(stub);
360+
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
361+
.then((alreadyConsumed) => {
362+
expect(alreadyConsumed).to.equal(false);
363+
});
364+
});
365+
});
366+
238367
});

0 commit comments

Comments
 (0)