From 9fb78da03c28eb34fc634e794bd06f645e12143d Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 12 Apr 2023 16:14:28 -0400 Subject: [PATCH] feat(appcheck): Appcheck improvements --- .../app-check-api-client-internal.ts | 37 +++++++++++++++++++ src/app-check/app-check-api.ts | 21 +++++++++++ src/app-check/app-check.ts | 31 +++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/app-check/app-check-api-client-internal.ts b/src/app-check/app-check-api-client-internal.ts index aae736cf63..be876f122b 100644 --- a/src/app-check/app-check-api-client-internal.ts +++ b/src/app-check/app-check-api-client-internal.ts @@ -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()}` @@ -86,6 +87,31 @@ export class AppCheckApiClient { }); } + public verifyOneTimeProtection(token: string): Promise { + 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: { token } + }; + return this.httpClient.send(request); + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .then((resp) => { + return true; + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + private getUrl(appId: string): Promise { return this.getProjectId() .then((projectId) => { @@ -98,6 +124,17 @@ export class AppCheckApiClient { }); } + private getVerifyTokenUrl(): Promise { + 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 { if (this.projectId) { return Promise.resolve(this.projectId); diff --git a/src/app-check/app-check-api.ts b/src/app-check/app-check-api.ts index ab959af04d..2369957257 100644 --- a/src/app-check/app-check-api.ts +++ b/src/app-check/app-check-api.ts @@ -41,6 +41,18 @@ export interface AppCheckTokenOptions { ttlMillis?: number; } +/** + * Interface representing options for {@link AppCheck.verifyToken} method. + */ +export interface VerifyAppCheckTokenOptions { + /** + * Sets the one-time use tokens feature. + * When set to `true`, checks if this token has already been consumed. + * This feature requires an additional network call to the backend and could be slower when enabled. + */ + consume?: boolean; +} + /** * Interface representing a decoded Firebase App Check token, returned from the * {@link AppCheck.verifyToken} method. @@ -86,6 +98,15 @@ export interface DecodedAppCheckToken { * convenience, and is set as the value of the {@link DecodedAppCheckToken.sub | sub} property. */ app_id: string; + + /** + * 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. + */ + already_consumed?: boolean; [key: string]: any; } diff --git a/src/app-check/app-check.ts b/src/app-check/app-check.ts index 0785fd6621..e367926948 100644 --- a/src/app-check/app-check.ts +++ b/src/app-check/app-check.ts @@ -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'; @@ -26,6 +28,7 @@ import { cryptoSignerFromApp } from '../utils/crypto-signer'; import { AppCheckToken, AppCheckTokenOptions, + VerifyAppCheckTokenOptions, VerifyAppCheckTokenResponse, } from './app-check-api'; @@ -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 { + public verifyToken(appCheckToken: string, options?: VerifyAppCheckTokenOptions) + : Promise { + this.validateVerifyAppCheckTokenOptions(options); return this.appCheckTokenVerifier.verifyToken(appCheckToken) .then((decodedToken) => { + if (options?.consume) { + return this.client.verifyOneTimeProtection(appCheckToken) + .then((alreadyConsumed) => { + decodedToken.already_consumed = alreadyConsumed; + return { + 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.'); + } + } }