Skip to content

Commit

Permalink
feat(appcheck): Added replay protection feature to App Check `verifyT…
Browse files Browse the repository at this point in the history
…oken()` 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
  • Loading branch information
lahirumaramba committed May 2, 2023
1 parent 125c5bf commit a43df9e
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 5 deletions.
4 changes: 2 additions & 2 deletions etc/firebase-admin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ export namespace appCheck {
// Warning: (ae-forgotten-export) The symbol "AppCheckToken" needs to be exported by the entry point default-namespace.d.ts
export type AppCheckToken = AppCheckToken;
// Warning: (ae-forgotten-export) The symbol "AppCheckTokenOptions" needs to be exported by the entry point default-namespace.d.ts
//
// (undocumented)
export type AppCheckTokenOptions = AppCheckTokenOptions;
// Warning: (ae-forgotten-export) The symbol "DecodedAppCheckToken" needs to be exported by the entry point default-namespace.d.ts
export type DecodedAppCheckToken = DecodedAppCheckToken;
// Warning: (ae-forgotten-export) The symbol "VerifyAppCheckTokenOptions" needs to be exported by the entry point default-namespace.d.ts
export type VerifyAppCheckTokenOptions = VerifyAppCheckTokenOptions;
// Warning: (ae-forgotten-export) The symbol "VerifyAppCheckTokenResponse" needs to be exported by the entry point default-namespace.d.ts
export type VerifyAppCheckTokenResponse = VerifyAppCheckTokenResponse;
}
Expand Down
8 changes: 7 additions & 1 deletion etc/firebase-admin.app-check.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class AppCheck {
// (undocumented)
readonly app: App;
createToken(appId: string, options?: AppCheckTokenOptions): Promise<AppCheckToken>;
verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse>;
verifyToken(appCheckToken: string, options?: VerifyAppCheckTokenOptions): Promise<VerifyAppCheckTokenResponse>;
}

// @public
Expand Down Expand Up @@ -44,8 +44,14 @@ export interface DecodedAppCheckToken {
// @public
export function getAppCheck(app?: App): AppCheck;

// @public
export interface VerifyAppCheckTokenOptions {
consume?: boolean;
}

// @public
export interface VerifyAppCheckTokenResponse {
alreadyConsumed?: boolean;
appId: string;
token: DecodedAppCheckToken;
}
Expand Down
41 changes: 41 additions & 0 deletions src/app-check/app-check-api-client-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AppCheckToken } from './app-check-api'

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

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

public verifyReplayProtection(token: string): Promise<boolean> {
if (!validator.isNonEmptyString(token)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'`token` must be a non-empty string.');
}
return this.getVerifyTokenUrl()
.then((url) => {
const request: HttpRequestConfig = {
method: 'POST',
url,
headers: FIREBASE_APP_CHECK_CONFIG_HEADERS,
data: { app_check_token: token }
};
return this.httpClient.send(request);
})
.then((resp) => {
if (typeof resp.data.alreadyConsumed !== 'undefined'
&& !validator.isBoolean(resp.data?.alreadyConsumed)) {
throw new FirebaseAppCheckError(
'invalid-argument', '`alreadyConsumed` must be a boolean value.');
}
return resp.data.alreadyConsumed || false;
})
.catch((err) => {
throw this.toFirebaseError(err);
});
}

private getUrl(appId: string): Promise<string> {
return this.getProjectId()
.then((projectId) => {
Expand All @@ -98,6 +128,17 @@ export class AppCheckApiClient {
});
}

private getVerifyTokenUrl(): Promise<string> {
return this.getProjectId()
.then((projectId) => {
const urlParams = {
projectId
};
const baseUrl = utils.formatString(ONE_TIME_USE_TOKEN_VERIFICATION_URL_FORMAT, urlParams);
return utils.formatString(baseUrl);
});
}

private getProjectId(): Promise<string> {
if (this.projectId) {
return Promise.resolve(this.projectId);
Expand Down
36 changes: 36 additions & 0 deletions src/app-check/app-check-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ export interface AppCheckTokenOptions {
ttlMillis?: number;
}

/**
* Interface representing options for the {@link AppCheck.verifyToken} method.
*/
export interface VerifyAppCheckTokenOptions {
/**
* To use the replay protection feature, set this to `true`. The {@link AppCheck.verifyToken}
* method will mark the token as consumed after verifying it.
*
* Tokens that are found to be already consumed will be marked as such in the response.
*
* Tokens are only considered to be consumed if it is sent to App Check backend by calling the
* {@link AppCheck.verifyToken} method with this field set to `true`; other uses of the token
* do not consume it.
*
* This replay protection feature requires an additional network call to the App Check backend
* and forces your clients to obtain a fresh attestation from your chosen attestation providers.
* This can therefore negatively impact performance and can potentially deplete your attestation
* providers' quotas faster. We recommend that you use this feature only for protecting
* low volume, security critical, or expensive operations.
*/
consume?: boolean;
}

/**
* Interface representing a decoded Firebase App Check token, returned from the
* {@link AppCheck.verifyToken} method.
Expand Down Expand Up @@ -102,4 +125,17 @@ export interface VerifyAppCheckTokenResponse {
* The decoded Firebase App Check token.
*/
token: DecodedAppCheckToken;

/**
* Indicates weather this token was already consumed.
* If this is the first time {@link AppCheck.verifyToken} method has seen this token,
* this field will contain the value `false`. The given token will then be
* marked as `already_consumed` for all future invocations of this {@link AppCheck.verifyToken}
* method for this token.
*
* When this field is `true`, the caller is attempting to reuse a previously consumed token.
* You should take precautions against such a caller; for example, you can take actions such as
* rejecting the request or ask the caller to pass additional layers of security checks.
*/
alreadyConsumed?: boolean;
}
9 changes: 9 additions & 0 deletions src/app-check/app-check-namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AppCheckToken as TAppCheckToken,
AppCheckTokenOptions as TAppCheckTokenOptions,
DecodedAppCheckToken as TDecodedAppCheckToken,
VerifyAppCheckTokenOptions as TVerifyAppCheckTokenOptions,
VerifyAppCheckTokenResponse as TVerifyAppCheckTokenResponse,
} from './app-check-api';
import { AppCheck as TAppCheck } from './app-check';
Expand Down Expand Up @@ -73,5 +74,13 @@ export namespace appCheck {
*/
export type VerifyAppCheckTokenResponse = TVerifyAppCheckTokenResponse;

/**
* Type alias to {@link firebase-admin.app-check#AppCheckTokenOptions}.
*/
export type AppCheckTokenOptions = TAppCheckTokenOptions;

/**
* Type alias to {@link firebase-admin.app-check#VerifyAppCheckTokenOptions}.
*/
export type VerifyAppCheckTokenOptions = TVerifyAppCheckTokenOptions;
}
31 changes: 29 additions & 2 deletions src/app-check/app-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
* limitations under the License.
*/

import * as validator from '../utils/validator';

import { App } from '../app';
import { AppCheckApiClient } from './app-check-api-client-internal';
import { AppCheckApiClient, FirebaseAppCheckError } from './app-check-api-client-internal';
import {
appCheckErrorFromCryptoSignerError, AppCheckTokenGenerator,
} from './token-generator';
Expand All @@ -26,6 +28,7 @@ import { cryptoSignerFromApp } from '../utils/crypto-signer';
import {
AppCheckToken,
AppCheckTokenOptions,
VerifyAppCheckTokenOptions,
VerifyAppCheckTokenResponse,
} from './app-check-api';

Expand Down Expand Up @@ -75,17 +78,41 @@ export class AppCheck {
* rejected.
*
* @param appCheckToken - The App Check token to verify.
* @param options - Optional {@link VerifyAppCheckTokenOptions} object when verifying an App Check Token.
*
* @returns A promise fulfilled with the token's decoded claims
* if the App Check token is valid; otherwise, a rejected promise.
*/
public verifyToken(appCheckToken: string): Promise<VerifyAppCheckTokenResponse> {
public verifyToken(appCheckToken: string, options?: VerifyAppCheckTokenOptions)
: Promise<VerifyAppCheckTokenResponse> {
this.validateVerifyAppCheckTokenOptions(options);
return this.appCheckTokenVerifier.verifyToken(appCheckToken)
.then((decodedToken) => {
if (options?.consume) {
return this.client.verifyReplayProtection(appCheckToken)
.then((alreadyConsumed) => {
return {
alreadyConsumed,
appId: decodedToken.app_id,
token: decodedToken,
};
});
}
return {
appId: decodedToken.app_id,
token: decodedToken,
};
});
}

private validateVerifyAppCheckTokenOptions(options?: VerifyAppCheckTokenOptions): void {
if (typeof options === 'undefined') {
return;
}
if (!validator.isNonNullObject(options)) {
throw new FirebaseAppCheckError(
'invalid-argument',
'VerifyAppCheckTokenOptions must be a non-null object.');
}
}
}
1 change: 1 addition & 0 deletions src/app-check/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
AppCheckToken,
AppCheckTokenOptions,
DecodedAppCheckToken,
VerifyAppCheckTokenOptions,
VerifyAppCheckTokenResponse,
} from './app-check-api';
export { AppCheck } from './app-check';
Expand Down
129 changes: 129 additions & 0 deletions test/unit/app-check/app-check-api-client-internal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,133 @@ describe('AppCheckApiClient', () => {
});
});
});

describe('verifyReplayProtection', () => {
it('should reject when project id is not available', () => {
return clientWithoutProjectId.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.should.eventually.be.rejectedWith(noProjectId);
});

it('should throw given no token', () => {
expect(() => {
(apiClient as any).verifyReplayProtection(undefined);
}).to.throw('`token` must be a non-empty string.');
});

[null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop].forEach((invalidToken) => {
it('should throw given a non-string token: ' + JSON.stringify(invalidToken), () => {
expect(() => {
apiClient.verifyReplayProtection(invalidToken as any);
}).to.throw('`token` must be a non-empty string.');
});
});

it('should throw given an empty string token', () => {
expect(() => {
apiClient.verifyReplayProtection('');
}).to.throw('`token` must be a non-empty string.');
});

it('should reject when a full platform error response is received', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom(ERROR_RESPONSE, 404));
stubs.push(stub);
const expected = new FirebaseAppCheckError('not-found', 'Requested entity not found');
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.should.eventually.be.rejected.and.deep.include(expected);
});

it('should reject with unknown-error when error code is not present', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom({}, 404));
stubs.push(stub);
const expected = new FirebaseAppCheckError('unknown-error', 'Unknown server error: {}');
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.should.eventually.be.rejected.and.deep.include(expected);
});

it('should reject with unknown-error for non-json response', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom('not json', 404));
stubs.push(stub);
const expected = new FirebaseAppCheckError(
'unknown-error', 'Unexpected response with status: 404 and body: not json');
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.should.eventually.be.rejected.and.deep.include(expected);
});

it('should reject when rejected with a FirebaseAppError', () => {
const expected = new FirebaseAppError('network-error', 'socket hang up');
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(expected);
stubs.push(stub);
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.should.eventually.be.rejected.and.deep.include(expected);
});

['', 'abc', '3s2', 'sssa', '3.000000001', '3.2', null, NaN, [], {}, 100, 1.2, -200, -2.4]
.forEach((invalidAlreadyConsumed) => {
it(`should throw if the returned alreadyConsumed value is: ${invalidAlreadyConsumed}`, () => {
const response = { alreadyConsumed: invalidAlreadyConsumed };
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(response, 200));
stubs.push(stub);
const expected = new FirebaseAppCheckError(
'invalid-argument', '`alreadyConsumed` must be a boolean value.');
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.should.eventually.be.rejected.and.deep.include(expected);
});
});

it('should resolve with the alreadyConsumed status on success', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom({ alreadyConsumed: true }, 200));
stubs.push(stub);
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.then((alreadyConsumed) => {
expect(alreadyConsumed).to.equal(true);
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'POST',
url: 'https://firebaseappcheck.googleapis.com/v1beta/projects/test-project:verifyAppCheckToken',
headers: EXPECTED_HEADERS,
data: { app_check_token: TEST_TOKEN_TO_EXCHANGE }
});
});
});

[true, false].forEach((expectedAlreadyConsumed) => {
it(`should resolve with alreadyConsumed as ${expectedAlreadyConsumed} when alreadyConsumed
from server is: ${expectedAlreadyConsumed}`, () => {
const response = { alreadyConsumed: expectedAlreadyConsumed };
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(response, 200));
stubs.push(stub);
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.then((alreadyConsumed) => {
expect(alreadyConsumed).to.equal(expectedAlreadyConsumed);
});
});
});

it(`should resolve with alreadyConsumed as false when alreadyConsumed
from server is: undefined`, () => {
const response = { };
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(response, 200));
stubs.push(stub);
return apiClient.verifyReplayProtection(TEST_TOKEN_TO_EXCHANGE)
.then((alreadyConsumed) => {
expect(alreadyConsumed).to.equal(false);
});
});
});

});

0 comments on commit a43df9e

Please sign in to comment.