Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(appcheck): Added replay protection feature to App Check verifyToken() API #2148

Merged
merged 9 commits into from
May 2, 2023
Merged
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> {
lahirumaramba marked this conversation as resolved.
Show resolved Hide resolved
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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Just for informational purposes, no action needed.) Right now the backend has two main error conditions (in this order of priority):

  1. If the token is invalid (expired, invalid signature, wrong audience, etc.), an HTTP 403 Forbidden will be returned.
  2. If the token had a provider of safety_net, an HTTP 400 Bad Request will be returned. (Maybe we could even detect this early and avoid the network call?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good point! Since we check the validity of the token prior to making the call to BE, 1) should be addressed already. How do we check 2)?, is it a custom claim encoded in the JWT?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For (2), yes, it is actually a private claim named provider in the JWT, but now that I think about this more, I think a more robust method for detecting whether the token supports Replay Protection is to see whether its jti claim is populated with a nonempty string rather than checking the provider claim against a hardcoded list of allowed providers.

});
}

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) {
weixifan marked this conversation as resolved.
Show resolved Hide resolved
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);
});
});
});

lahirumaramba marked this conversation as resolved.
Show resolved Hide resolved
});