diff --git a/.github/scripts/run_integration_tests.sh b/.github/scripts/run_integration_tests.sh index fd479df552..37dc7d1216 100755 --- a/.github/scripts/run_integration_tests.sh +++ b/.github/scripts/run_integration_tests.sh @@ -22,4 +22,6 @@ gpg --quiet --batch --yes --decrypt --passphrase="${FIREBASE_SERVICE_ACCT_KEY}" echo "${FIREBASE_API_KEY}" > test/resources/apikey.txt +echo "${FIREBASE_APP_ID}" > test/resources/appid.txt + npm run test:integration -- --updateRules --testMultiTenancy diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a36144816b..536827b1e5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -55,6 +55,7 @@ jobs: env: FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} - name: Package release artifacts run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4740665e9c..d2a2797765 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,7 @@ jobs: env: FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} - name: Package release artifacts run: | diff --git a/.gitignore b/.gitignore index 672f8c23a9..4c60db05ce 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ node_modules/ # Real key file should not be checked in test/resources/key.json test/resources/apikey.txt +test/resources/appid.txt # Release tarballs should not be checked in firebase-admin-*.tgz diff --git a/docgen/content-sources/node/toc.yaml b/docgen/content-sources/node/toc.yaml index 487d3fcc39..aaf1eb562d 100644 --- a/docgen/content-sources/node/toc.yaml +++ b/docgen/content-sources/node/toc.yaml @@ -19,6 +19,18 @@ toc: - title: "App" path: /docs/reference/admin/node/admin.app.App-1 +- title: "admin.appCheck" + path: /docs/reference/admin/node/admin.appCheck + section: + - title: "AppCheck" + path: /docs/reference/admin/node/admin.appCheck.AppCheck-1 + - title: "AppCheckToken" + path: /docs/reference/admin/node/admin.appCheck.AppCheckToken + - title: "DecodedAppCheckToken" + path: /docs/reference/admin/node/admin.appCheck.DecodedAppCheckToken + - title: "VerifyAppCheckTokenResponse" + path: /docs/reference/admin/node/admin.appCheck.VerifyAppCheckTokenResponse + - title: "admin.auth" path: /docs/reference/admin/node/admin.auth section: diff --git a/etc/firebase-admin.api.md b/etc/firebase-admin.api.md index eaf280aec8..39c413b7ec 100644 --- a/etc/firebase-admin.api.md +++ b/etc/firebase-admin.api.md @@ -15,6 +15,8 @@ export function app(name?: string): app.App; // @public (undocumented) export namespace app { export interface App { + // (undocumented) + appCheck(): appCheck.AppCheck; // (undocumented) auth(): auth.Auth; // (undocumented) @@ -41,6 +43,37 @@ export namespace app { } } +// @public +export function appCheck(app?: app.App): appCheck.AppCheck; + +// @public (undocumented) +export namespace appCheck { + export interface AppCheck { + // (undocumented) + app: app.App; + createToken(appId: string): Promise; + verifyToken(appCheckToken: string): Promise; + } + export interface AppCheckToken { + token: string; + ttlMillis: number; + } + export interface DecodedAppCheckToken { + // (undocumented) + [key: string]: any; + app_id: string; + aud: string[]; + exp: number; + iat: number; + iss: string; + sub: string; + } + export interface VerifyAppCheckTokenResponse { + appId: string; + token: appCheck.DecodedAppCheckToken; + } +} + // @public export interface AppOptions { credential?: credential.Credential; diff --git a/package-lock.json b/package-lock.json index ec28e32334..99e55cda11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -664,6 +664,11 @@ "integrity": "sha512-IpgPxHrNxZiMNUSXqR1l/gePKPkfAmIKoDRP9hp7OwjU29ZR8WCJsOJ8iBKgw0Qk+pFwR+8Y1cy8ImLY6e9m4A==", "dev": true }, + "@panva/asn1.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", + "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -876,6 +881,15 @@ "integrity": "sha512-dIOxFfI0C+jz89g6lQ+TqhGgPQ0MxSnh/E4xuC0blhFtyW269+mPG5QeLgbdwst/LvdP8o1y0o/Gz5EHXLec/g==", "dev": true }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/caseless": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", @@ -903,15 +917,61 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "requires": { + "@types/node": "*" + } + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", "dev": true }, + "@types/express": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.11.tgz", + "integrity": "sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-jwt": { + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.42.tgz", + "integrity": "sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==", + "requires": { + "@types/express": "*", + "@types/express-unless": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz", + "integrity": "sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/express-unless": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.5.1.tgz", + "integrity": "sha512-5fuvg7C69lemNgl0+v+CUxDYWVPSfXHhJPst4yTLcqi4zKJpORCxnDrnnilk3k0DTq/WrAUdvXFs01+vUqUZHw==", + "requires": { + "@types/express": "*" + } + }, "@types/firebase-token-generator": { "version": "2.0.28", - "resolved": "http://registry.npmjs.org/@types/firebase-token-generator/-/firebase-token-generator-2.0.28.tgz", + "resolved": "https://registry.npmjs.org/@types/firebase-token-generator/-/firebase-token-generator-2.0.28.tgz", "integrity": "sha1-Z1VIHZMk4mt6XItFXWgUg3aCw5Y=", "dev": true }, @@ -942,6 +1002,11 @@ "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==", "optional": true }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -974,6 +1039,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.26.tgz", "integrity": "sha512-myMwkO2Cr82kirHY8uknNRHEVtn0wV3DTQfkrjx17jmkstDRZ24gNUdl8AHXVyVclTYI/bNjgTPTAWvWLqXqkw==" }, + "@types/qs": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, "@types/request": { "version": "2.48.5", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz", @@ -996,6 +1071,15 @@ "@types/request": "*" } }, + "@types/serve-static": { + "version": "1.13.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz", + "integrity": "sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "@types/sinon": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-9.0.4.tgz", @@ -1646,7 +1730,7 @@ }, "binaryextensions": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/binaryextensions/-/binaryextensions-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-1.0.1.tgz", "integrity": "sha1-HmN0iLNbWL2l9HdL+WpSEqjJB1U=", "dev": true }, @@ -3349,7 +3433,7 @@ }, "firebase-token-generator": { "version": "2.0.0", - "resolved": "http://registry.npmjs.org/firebase-token-generator/-/firebase-token-generator-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/firebase-token-generator/-/firebase-token-generator-2.0.0.tgz", "integrity": "sha1-l2fXWewTq9yZuhFf1eqZ2Lk9EgY=", "dev": true }, @@ -3770,7 +3854,7 @@ }, "globby": { "version": "5.0.0", - "resolved": "http://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", "dev": true, "requires": { @@ -5023,7 +5107,7 @@ }, "istextorbinary": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/istextorbinary/-/istextorbinary-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-1.0.2.tgz", "integrity": "sha1-rOGTVNGpoBc+/rEITOD4ewrX3s8=", "dev": true, "requires": { @@ -5037,6 +5121,14 @@ "integrity": "sha1-o6vicYryQaKykE+EpiWXDzia4yo=", "dev": true }, + "jose": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.4.tgz", + "integrity": "sha512-EArN9f6aq1LT/fIGGsfghOnNXn4noD+3dG5lL/ljY3LcRjw1u9w+4ahu/4ahsN6N0kRLyyW6zqdoYk7LNx3+YQ==", + "requires": { + "@panva/asn1.js": "^1.0.0" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5225,6 +5317,18 @@ "safe-buffer": "^5.0.1" } }, + "jwks-rsa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-2.0.2.tgz", + "integrity": "sha512-oRnlZvmP21LxqEgEFiPycLn3jyw/QuynyaERe7GMxR4TlTg7BRGBgEyEN+rRN4xGHMekXur1RY/MSt8UJBiSgA==", + "requires": { + "@types/express-jwt": "0.0.42", + "debug": "^4.1.0", + "jose": "^2.0.2", + "limiter": "^1.1.5", + "lru-memoizer": "^2.1.2" + } + }, "jws": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", @@ -5304,6 +5408,11 @@ "resolve": "^1.1.7" } }, + "limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "load-json-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", @@ -5401,6 +5510,11 @@ "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", "optional": true }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, "lodash.escape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz", @@ -5598,6 +5712,31 @@ "yallist": "^3.0.2" } }, + "lru-memoizer": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.1.4.tgz", + "integrity": "sha512-IXAq50s4qwrOBrXJklY+KhgZF+5y98PDaNo0gi/v2KQBFLyWr+JyFvijZXkGKjQj/h9c0OwoE+JZbwUXce76hQ==", + "requires": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "~4.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", + "requires": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + } + } + }, "lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -7140,7 +7279,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, @@ -7302,7 +7441,7 @@ }, "pretty-hrtime": { "version": "1.0.3", - "resolved": "http://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "dev": true }, @@ -7361,8 +7500,7 @@ "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, "psl": { "version": "1.8.0", @@ -7846,7 +7984,7 @@ }, "safe-regex": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "dev": true, "requires": { @@ -8670,7 +8808,7 @@ }, "textextensions": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/textextensions/-/textextensions-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-1.0.2.tgz", "integrity": "sha1-ZUhjk+4fK7A5pgy7oFsLaL2VAdI=", "dev": true }, @@ -9142,9 +9280,9 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", - "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "optional": true }, "v8-compile-cache": { diff --git a/package.json b/package.json index 1d5132c8c1..d384d64579 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@types/node": "^10.10.0", "dicer": "^0.3.0", "jsonwebtoken": "^8.5.1", + "jwks-rsa": "^2.0.2", "node-forge": "^0.10.0" }, "optionalDependencies": { diff --git a/src/app-check/app-check-api-client-internal.ts b/src/app-check/app-check-api-client-internal.ts new file mode 100644 index 0000000000..8d25e23cf7 --- /dev/null +++ b/src/app-check/app-check-api-client-internal.ts @@ -0,0 +1,228 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { appCheck } from './index'; +import { + HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse +} from '../utils/api-request'; +import { FirebaseApp } from '../firebase-app'; +import { PrefixedFirebaseError } from '../utils/error'; + +import * as utils from '../utils/index'; +import * as validator from '../utils/validator'; + +import AppCheckToken = appCheck.AppCheckToken; + +// App Check backend constants +const FIREBASE_APP_CHECK_V1_API_URL_FORMAT = 'https://firebaseappcheck.googleapis.com/v1beta/projects/{projectId}/apps/{appId}:exchangeCustomToken'; + +const FIREBASE_APP_CHECK_CONFIG_HEADERS = { + 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}` +}; + +/** + * Class that facilitates sending requests to the Firebase App Check backend API. + * + * @internal + */ +export class AppCheckApiClient { + private readonly httpClient: HttpClient; + private projectId?: string; + + constructor(private readonly app: FirebaseApp) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + 'First argument passed to admin.appCheck() must be a valid Firebase app instance.'); + } + this.httpClient = new AuthorizedHttpClient(app); + } + + /** + * Exchange a signed custom token to App Check token + * + * @param customToken The custom token to be exchanged. + * @param appId The mobile App ID. + * @return A promise that fulfills with a `AppCheckToken`. + */ + public exchangeToken(customToken: string, appId: string): Promise { + if (!validator.isNonEmptyString(appId)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + '`appId` must be a non-empty string.'); + } + if (!validator.isNonEmptyString(customToken)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + '`customToken` must be a non-empty string.'); + } + return this.getUrl(appId) + .then((url) => { + const request: HttpRequestConfig = { + method: 'POST', + url, + headers: FIREBASE_APP_CHECK_CONFIG_HEADERS, + data: { customToken } + }; + return this.httpClient.send(request); + }) + .then((resp) => { + return this.toAppCheckToken(resp); + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + private getUrl(appId: string): Promise { + return this.getProjectId() + .then((projectId) => { + const urlParams = { + projectId, + appId, + }; + const baseUrl = utils.formatString(FIREBASE_APP_CHECK_V1_API_URL_FORMAT, urlParams); + return utils.formatString(baseUrl); + }); + } + + private getProjectId(): Promise { + if (this.projectId) { + return Promise.resolve(this.projectId); + } + return utils.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAppCheckError( + 'unknown-error', + 'Failed to determine project ID. Initialize the ' + + 'SDK with service account credentials or set project ID as an app option. ' + + 'Alternatively, set the GOOGLE_CLOUD_PROJECT environment variable.'); + } + this.projectId = projectId; + return projectId; + }); + } + + private toFirebaseError(err: HttpError): PrefixedFirebaseError { + if (err instanceof PrefixedFirebaseError) { + return err; + } + + const response = err.response; + if (!response.isJson()) { + return new FirebaseAppCheckError( + 'unknown-error', + `Unexpected response with status: ${response.status} and body: ${response.text}`); + } + + const error: Error = (response.data as ErrorResponse).error || {}; + let code: AppCheckErrorCode = 'unknown-error'; + if (error.status && error.status in APP_CHECK_ERROR_CODE_MAPPING) { + code = APP_CHECK_ERROR_CODE_MAPPING[error.status]; + } + const message = error.message || `Unknown server error: ${response.text}`; + return new FirebaseAppCheckError(code, message); + } + + /** + * Creates an AppCheckToken from the API response. + * + * @param resp API response object. + * @return An AppCheckToken instance. + */ + private toAppCheckToken(resp: HttpResponse): AppCheckToken { + const token = resp.data.attestationToken; + // `ttl` is a string with the suffix "s" preceded by the number of seconds, + // with nanoseconds expressed as fractional seconds. + const ttlMillis = this.stringToMilliseconds(resp.data.ttl); + return { + token, + ttlMillis + } + } + + /** + * Converts a duration string with the suffix `s` to milliseconds. + * + * @param duration The duration as a string with the suffix "s" preceded by the + * number of seconds, with fractional seconds. For example, 3 seconds with 0 nanoseconds + * is expressed as "3s", while 3 seconds and 1 nanosecond is expressed as "3.000000001s", + * and 3 seconds and 1 microsecond is expressed as "3.000001s". + * + * @return The duration in milliseconds. + */ + private stringToMilliseconds(duration: string): number { + if (!validator.isNonEmptyString(duration) || !duration.endsWith('s')) { + throw new FirebaseAppCheckError( + 'invalid-argument', '`ttl` must be a valid duration string with the suffix `s`.'); + } + const seconds = duration.slice(0, -1); + return Math.floor(Number(seconds) * 1000); + } +} + +interface ErrorResponse { + error?: Error; +} + +interface Error { + code?: number; + message?: string; + status?: string; +} + +export const APP_CHECK_ERROR_CODE_MAPPING: { [key: string]: AppCheckErrorCode } = { + ABORTED: 'aborted', + INVALID_ARGUMENT: 'invalid-argument', + INVALID_CREDENTIAL: 'invalid-credential', + INTERNAL: 'internal-error', + PERMISSION_DENIED: 'permission-denied', + UNAUTHENTICATED: 'unauthenticated', + NOT_FOUND: 'not-found', + UNKNOWN: 'unknown-error', +}; + +export type AppCheckErrorCode = + 'aborted' + | 'invalid-argument' + | 'invalid-credential' + | 'internal-error' + | 'permission-denied' + | 'unauthenticated' + | 'not-found' + | 'app-check-token-expired' + | 'unknown-error'; + +/** + * Firebase App Check error code structure. This extends PrefixedFirebaseError. + * + * @param {AppCheckErrorCode} code The error code. + * @param {string} message The error message. + * @constructor + */ +export class FirebaseAppCheckError extends PrefixedFirebaseError { + constructor(code: AppCheckErrorCode, message: string) { + super('app-check', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseAppCheckError.prototype; + } +} diff --git a/src/app-check/app-check.ts b/src/app-check/app-check.ts new file mode 100644 index 0000000000..42d8391043 --- /dev/null +++ b/src/app-check/app-check.ts @@ -0,0 +1,86 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '../firebase-app'; +import { appCheck } from './index'; +import { AppCheckApiClient } from './app-check-api-client-internal'; +import { + appCheckErrorFromCryptoSignerError, AppCheckTokenGenerator +} from './token-generator'; +import { AppCheckTokenVerifier } from './token-verifier'; +import { cryptoSignerFromApp } from '../utils/crypto-signer'; + +import AppCheckInterface = appCheck.AppCheck; +import AppCheckToken = appCheck.AppCheckToken; +import VerifyAppCheckTokenResponse = appCheck.VerifyAppCheckTokenResponse; + +/** + * AppCheck service bound to the provided app. + */ +export class AppCheck implements AppCheckInterface { + + private readonly client: AppCheckApiClient; + private readonly tokenGenerator: AppCheckTokenGenerator; + private readonly appCheckTokenVerifier: AppCheckTokenVerifier; + + /** + * @param app The app for this AppCheck service. + * @constructor + */ + constructor(readonly app: FirebaseApp) { + this.client = new AppCheckApiClient(app); + try { + this.tokenGenerator = new AppCheckTokenGenerator(cryptoSignerFromApp(app)); + } catch (err) { + throw appCheckErrorFromCryptoSignerError(err); + } + this.appCheckTokenVerifier = new AppCheckTokenVerifier(app); + } + + /** + * Creates a new {@link appCheck.AppCheckToken `AppCheckToken`} that can be sent + * back to a client. + * + * @param appId The app ID to use as the JWT app_id. + * + * @return A promise that fulfills with a `AppCheckToken`. + */ + public createToken(appId: string): Promise { + return this.tokenGenerator.createCustomToken(appId) + .then((customToken) => { + return this.client.exchangeToken(customToken, appId); + }); + } + + /** + * Veifies an App Check token. + * + * @param appCheckToken The App Check token to verify. + * + * @return A promise that fulfills with a `VerifyAppCheckTokenResponse` on successful + * verification. + */ + public verifyToken(appCheckToken: string): Promise { + return this.appCheckTokenVerifier.verifyToken(appCheckToken) + .then((decodedToken) => { + return { + appId: decodedToken.app_id, + token: decodedToken, + }; + }); + } +} diff --git a/src/app-check/index.ts b/src/app-check/index.ts new file mode 100644 index 0000000000..6552d9208d --- /dev/null +++ b/src/app-check/index.ts @@ -0,0 +1,164 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { app } from '../firebase-namespace-api'; + +/** + * Gets the {@link appCheck.AppCheck `AppCheck`} service for the + * default app or a given app. + * + * You can call `admin.appCheck()` with no arguments to access the default + * app's {@link appCheck.AppCheck `AppCheck`} service or as + * `admin.appCheck(app)` to access the + * {@link appCheck.AppCheck `AppCheck`} service associated with a + * specific app. + * + * @example + * ```javascript + * // Get the `AppCheck` service for the default app + * var defaultAppCheck = admin.appCheck(); + * ``` + * + * @example + * ```javascript + * // Get the `AppCheck` service for a given app + * var otherAppCheck = admin.appCheck(otherApp); + * ``` + * + * @param app Optional app for which to return the `AppCheck` service. + * If not provided, the default `AppCheck` service is returned. + * + * @return The default `AppCheck` service if no + * app is provided, or the `AppCheck` service associated with the provided + * app. + */ +export declare function appCheck(app?: app.App): appCheck.AppCheck; + +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace appCheck { + /** + * The Firebase `AppCheck` service interface. + */ + export interface AppCheck { + app: app.App; + + /** + * Creates a new {@link appCheck.AppCheckToken `AppCheckToken`} that can be sent + * back to a client. + * + * @param appId The App ID of the Firebase App the token belongs to. + * + * @return A promise that fulfills with a `AppCheckToken`. + */ + createToken(appId: string): Promise; + + /** + * Verifies a Firebase App Check token (JWT). If the token is valid, the promise is + * fulfilled with the token's decoded claims; otherwise, the promise is + * rejected. + * + * @param appCheckToken The App Check token to verify. + * + * @return A promise fulfilled with the + * token's decoded claims if the App Check token is valid; otherwise, a rejected + * promise. + */ + verifyToken(appCheckToken: string): Promise; + } + + /** + * Interface representing an App Check token. + */ + export interface AppCheckToken { + /** + * The Firebase App Check token. + */ + token: string; + + /** + * The time-to-live duration of the token in milliseconds. + */ + ttlMillis: number; + } + + /** + * Interface representing a decoded Firebase App Check token, returned from the + * {@link appCheck.AppCheck.verifyToken `verifyToken()`} method. + */ + export interface DecodedAppCheckToken { + /** + * The issuer identifier for the issuer of the response. + * + * This value is a URL with the format + * `https://firebaseappcheck.googleapis.com/`, where `` is the + * same project number specified in the [`aud`](#aud) property. + */ + iss: string; + + /** + * The Firebase App ID corresponding to the app the token belonged to. + * + * As a convenience, this value is copied over to the [`app_id`](#app_id) property. + */ + sub: string; + + /** + * The audience for which this token is intended. + * + * This value is a JSON array of two strings, the first is the project number of your + * Firebase project, and the second is the project ID of the same project. + */ + aud: string[]; + + /** + * The App Check token's expiration time, in seconds since the Unix epoch. That is, the + * time at which this App Check token expires and should no longer be considered valid. + */ + exp: number; + + /** + * The App Check token's issued-at time, in seconds since the Unix epoch. That is, the + * time at which this App Check token was issued and should start to be considered + * valid. + */ + iat: number; + + /** + * The App ID corresponding to the App the App Check token belonged to. + * + * This value is not actually one of the JWT token claims. It is added as a + * convenience, and is set as the value of the [`sub`](#sub) property. + */ + app_id: string; + [key: string]: any; + } + + /** + * Interface representing a verified App Check token response. + */ + export interface VerifyAppCheckTokenResponse { + /** + * The App ID corresponding to the App the App Check token belonged to. + */ + appId: string; + + /** + * The decoded Firebase App Check token. + */ + token: appCheck.DecodedAppCheckToken; + } +} diff --git a/src/app-check/token-generator.ts b/src/app-check/token-generator.ts new file mode 100644 index 0000000000..1b557438bb --- /dev/null +++ b/src/app-check/token-generator.ts @@ -0,0 +1,145 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from '../utils/validator'; +import { toWebSafeBase64 } from '../utils'; + +import { CryptoSigner, CryptoSignerError, CryptoSignerErrorCode } from '../utils/crypto-signer'; +import { + FirebaseAppCheckError, + AppCheckErrorCode, + APP_CHECK_ERROR_CODE_MAPPING, +} from './app-check-api-client-internal'; +import { HttpError } from '../utils/api-request'; + +const ONE_HOUR_IN_SECONDS = 60 * 60; + +// Audience to use for Firebase App Check Custom tokens +const FIREBASE_APP_CHECK_AUDIENCE = 'https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1beta.TokenExchangeService'; + +/** + * Class for generating Firebase App Check tokens. + * + * @internal + */ +export class AppCheckTokenGenerator { + + private readonly signer: CryptoSigner; + + /** + * The AppCheckTokenGenerator class constructor. + * + * @param signer The CryptoSigner instance for this token generator. + * @constructor + */ + constructor(signer: CryptoSigner) { + if (!validator.isNonNullObject(signer)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + 'INTERNAL ASSERT: Must provide a CryptoSigner to use AppCheckTokenGenerator.'); + } + this.signer = signer; + } + + /** + * Creates a new custom token that can be exchanged to an App Check token. + * + * @param appId The Application ID to use for the generated token. + * + * @return A Promise fulfilled with a custom token signed with a service account key + * that can be exchanged to an App Check token. + */ + public createCustomToken(appId: string): Promise { + if (!validator.isNonEmptyString(appId)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + '`appId` must be a non-empty string.'); + } + return this.signer.getAccountId().then((account) => { + const header = { + alg: this.signer.algorithm, + typ: 'JWT', + }; + const iat = Math.floor(Date.now() / 1000); + const body = { + iss: account, + sub: account, + // eslint-disable-next-line @typescript-eslint/camelcase + app_id: appId, + aud: FIREBASE_APP_CHECK_AUDIENCE, + exp: iat + ONE_HOUR_IN_SECONDS, + iat, + }; + const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`; + return this.signer.sign(Buffer.from(token)) + .then((signature) => { + return `${token}.${this.encodeSegment(signature)}`; + }); + }).catch((err) => { + throw appCheckErrorFromCryptoSignerError(err); + }); + } + + private encodeSegment(segment: object | Buffer): string { + const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment)); + return toWebSafeBase64(buffer).replace(/=+$/, ''); + } +} + +/** + * Creates a new FirebaseAppCheckError by extracting the error code, message and other relevant + * details from a CryptoSignerError. + * + * @param err The Error to convert into a FirebaseAppCheckError error + * @return A Firebase App Check error that can be returned to the user. + */ +export function appCheckErrorFromCryptoSignerError(err: Error): Error { + if (!(err instanceof CryptoSignerError)) { + return err; + } + if (err.code === CryptoSignerErrorCode.SERVER_ERROR && validator.isNonNullObject(err.cause)) { + const httpError = err.cause as HttpError + const errorResponse = httpError.response.data; + if (errorResponse?.error) { + const status = errorResponse.error.status; + const description = errorResponse.error.message || JSON.stringify(httpError.response); + + let code: AppCheckErrorCode = 'unknown-error'; + if (status && status in APP_CHECK_ERROR_CODE_MAPPING) { + code = APP_CHECK_ERROR_CODE_MAPPING[status]; + } + return new FirebaseAppCheckError(code, + `Error returned from server while siging a custom token: ${description}` + ); + } + return new FirebaseAppCheckError('internal-error', + 'Error returned from server: ' + JSON.stringify(errorResponse) + '.' + ); + } + return new FirebaseAppCheckError(mapToAppCheckErrorCode(err.code), err.message); +} + +function mapToAppCheckErrorCode(code: string): AppCheckErrorCode { + switch (code) { + case CryptoSignerErrorCode.INVALID_CREDENTIAL: + return 'invalid-credential'; + case CryptoSignerErrorCode.INVALID_ARGUMENT: + return 'invalid-argument'; + default: + return 'internal-error'; + } +} diff --git a/src/app-check/token-verifier.ts b/src/app-check/token-verifier.ts new file mode 100644 index 0000000000..318a1fd10b --- /dev/null +++ b/src/app-check/token-verifier.ts @@ -0,0 +1,165 @@ +/*! + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { appCheck } from '.'; +import * as validator from '../utils/validator'; +import * as util from '../utils/index'; +import { FirebaseAppCheckError } from './app-check-api-client-internal'; +import { FirebaseApp } from '../firebase-app'; +import { + ALGORITHM_RS256, DecodedToken, decodeJwt, JwtError, + JwtErrorCode, PublicKeySignatureVerifier, SignatureVerifier +} from '../utils/jwt'; + +import DecodedAppCheckToken = appCheck.DecodedAppCheckToken; + +const APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'; +const JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1beta/jwks'; + +/** + * Class for verifying Firebase App Check tokens. + * + * @internal + */ +export class AppCheckTokenVerifier { + private readonly signatureVerifier: SignatureVerifier; + + constructor(private readonly app: FirebaseApp) { + this.signatureVerifier = PublicKeySignatureVerifier.withJwksUrl(JWKS_URL); + } + + /** + * Verifies the format and signature of a Firebase App Check token. + * + * @param token The Firebase Auth JWT token to verify. + * @return A promise fulfilled with the decoded claims of the Firebase App Check token. + */ + public verifyToken(token: string): Promise { + if (!validator.isString(token)) { + throw new FirebaseAppCheckError( + 'invalid-argument', + 'App check token must be a non-null string.', + ); + } + + return this.ensureProjectId() + .then((projectId) => { + return this.decodeAndVerify(token, projectId); + }) + .then((decoded) => { + const decodedAppCheckToken = decoded.payload as DecodedAppCheckToken; + // eslint-disable-next-line @typescript-eslint/camelcase + decodedAppCheckToken.app_id = decodedAppCheckToken.sub; + return decodedAppCheckToken; + }); + } + + private ensureProjectId(): Promise { + return util.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAppCheckError( + 'invalid-credential', + 'Must initialize app with a cert credential or set your Firebase project ID as the ' + + 'GOOGLE_CLOUD_PROJECT environment variable to verify an App Check token.' + ); + } + return projectId; + }) + } + + private decodeAndVerify(token: string, projectId: string): Promise { + return this.safeDecode(token) + .then((decodedToken) => { + this.verifyContent(decodedToken, projectId); + return this.verifySignature(token) + .then(() => decodedToken); + }); + } + + private safeDecode(jwtToken: string): Promise { + return decodeJwt(jwtToken) + .catch(() => { + const errorMessage = 'Decoding App Check token failed. Make sure you passed ' + + 'the entire string JWT which represents the Firebase App Check token.'; + throw new FirebaseAppCheckError('invalid-argument', errorMessage); + }); + } + + /** + * Verifies the content of a Firebase App Check JWT. + * + * @param fullDecodedToken The decoded JWT. + * @param projectId The Firebase Project Id. + */ + private verifyContent(fullDecodedToken: DecodedToken, projectId: string | null): void { + const header = fullDecodedToken.header; + const payload = fullDecodedToken.payload; + + const projectIdMatchMessage = ' Make sure the App Check token comes from the same ' + + 'Firebase project as the service account used to authenticate this SDK.'; + const scopedProjectId = `projects/${projectId}`; + + let errorMessage: string | undefined; + if (header.alg !== ALGORITHM_RS256) { + errorMessage = 'The provided App Check token has incorrect algorithm. Expected "' + + ALGORITHM_RS256 + '" but got ' + '"' + header.alg + '".'; + } else if (!validator.isNonEmptyArray(payload.aud) || !payload.aud.includes(scopedProjectId)) { + errorMessage = 'The provided App Check token has incorrect "aud" (audience) claim. Expected "' + + scopedProjectId + '" but got "' + payload.aud + '".' + projectIdMatchMessage; + } else if (typeof payload.iss !== 'string' || !payload.iss.startsWith(APP_CHECK_ISSUER)) { + errorMessage = 'The provided App Check token has incorrect "iss" (issuer) claim.'; + } else if (typeof payload.sub !== 'string') { + errorMessage = 'The provided App Check token has no "sub" (subject) claim.'; + } else if (payload.sub === '') { + errorMessage = 'The provided App Check token has an empty string "sub" (subject) claim.'; + } + if (errorMessage) { + throw new FirebaseAppCheckError('invalid-argument', errorMessage); + } + } + + private verifySignature(jwtToken: string): + Promise { + return this.signatureVerifier.verify(jwtToken) + .catch((error: JwtError) => { + throw this.mapJwtErrorToAppCheckError(error); + }); + } + + /** + * Maps JwtError to FirebaseAppCheckError + * + * @param error JwtError to be mapped. + * @returns FirebaseAppCheckError instance. + */ + private mapJwtErrorToAppCheckError(error: JwtError): FirebaseAppCheckError { + if (error.code === JwtErrorCode.TOKEN_EXPIRED) { + const errorMessage = 'The provided App Check token has expired. Get a fresh App Check token' + + ' from your client app and try again.' + return new FirebaseAppCheckError('app-check-token-expired', errorMessage); + } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { + const errorMessage = 'The provided App Check token has invalid signature.'; + return new FirebaseAppCheckError('invalid-argument', errorMessage); + } else if (error.code === JwtErrorCode.NO_MATCHING_KID) { + const errorMessage = 'The provided App Check token has "kid" claim which does not ' + + 'correspond to a known public key. Most likely the provided App Check token ' + + 'is expired, so get a fresh token from your client app and try again.'; + return new FirebaseAppCheckError('invalid-argument', errorMessage); + } + return new FirebaseAppCheckError('invalid-argument', error.message); + } +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts index aa5d7b11ef..48f46c345a 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -21,7 +21,7 @@ import { isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, } from './identifier'; import { FirebaseApp } from '../firebase-app'; -import { FirebaseTokenGenerator, EmulatedSigner, cryptoSignerFromApp } from './token-generator'; +import { FirebaseTokenGenerator, EmulatedSigner, handleCryptoSignerError } from './token-generator'; import { AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, useEmulator, } from './auth-api-request'; @@ -36,6 +36,7 @@ import { SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, } from './auth-config'; import { TenantManager } from './tenant-manager'; +import { cryptoSignerFromApp } from '../utils/crypto-signer'; import UserIdentifier = auth.UserIdentifier; import CreateRequest = auth.CreateRequest; @@ -82,8 +83,7 @@ export class BaseAuth implements BaseAuthI if (tokenGenerator) { this.tokenGenerator = tokenGenerator; } else { - const cryptoSigner = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app); - this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); + this.tokenGenerator = createFirebaseTokenGenerator(app); } this.sessionCookieVerifier = createSessionCookieVerifier(app); @@ -772,9 +772,8 @@ export class TenantAwareAuth * @constructor */ constructor(app: FirebaseApp, tenantId: string) { - const cryptoSigner = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app); - const tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, tenantId); - super(app, new TenantAwareAuthRequestHandler(app, tenantId), tokenGenerator); + super(app, new TenantAwareAuthRequestHandler(app, tenantId), + createFirebaseTokenGenerator(app, tenantId)); utils.addReadonlyGetter(this, 'tenantId', tenantId); } @@ -887,3 +886,13 @@ export class Auth extends BaseAuth implements AuthInterface return this.tenantManager_; } } + +function createFirebaseTokenGenerator(app: FirebaseApp, + tenantId?: string): FirebaseTokenGenerator { + try { + const signer = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app); + return new FirebaseTokenGenerator(signer, tenantId); + } catch (err) { + throw handleCryptoSignerError(err); + } +} diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index a8a76c7b28..6c464ec5f2 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -15,17 +15,16 @@ * limitations under the License. */ -import { FirebaseApp } from '../firebase-app'; -import { ServiceAccountCredential } from '../credential/credential-internal'; -import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; -import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '../utils/api-request'; +import { + AuthClientErrorCode, ErrorInfo, FirebaseAuthError +} from '../utils/error'; +import { CryptoSigner, CryptoSignerError, CryptoSignerErrorCode } from '../utils/crypto-signer'; import * as validator from '../utils/validator'; import { toWebSafeBase64 } from '../utils'; import { Algorithm } from 'jsonwebtoken'; +import { HttpError } from '../utils/api-request'; - -const ALGORITHM_RS256: Algorithm = 'RS256' as const; const ALGORITHM_NONE: Algorithm = 'none' as const; const ONE_HOUR_IN_SECONDS = 60 * 60; @@ -39,32 +38,6 @@ export const BLACKLISTED_CLAIMS = [ // Audience to use for Firebase Auth Custom tokens const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; -/** - * CryptoSigner interface represents an object that can be used to sign JWTs. - */ -export interface CryptoSigner { - - /** - * The name of the signing algorithm. - */ - readonly algorithm: Algorithm; - - /** - * Cryptographically signs a buffer of data. - * - * @param {Buffer} buffer The data to be signed. - * @return {Promise} A promise that resolves with the raw bytes of a signature. - */ - sign(buffer: Buffer): Promise; - - /** - * Returns the ID of the service account used to sign tokens. - * - * @return {Promise} A promise that resolves with a service account ID. - */ - getAccountId(): Promise; -} - /** * Represents the header of a JWT. */ @@ -87,148 +60,6 @@ interface JWTBody { tenant_id?: string; } -/** - * A CryptoSigner implementation that uses an explicitly specified service account private key to - * sign data. Performs all operations locally, and does not make any RPC calls. - */ -export class ServiceAccountSigner implements CryptoSigner { - - algorithm = ALGORITHM_RS256; - - /** - * Creates a new CryptoSigner instance from the given service account credential. - * - * @param {ServiceAccountCredential} credential A service account credential. - */ - constructor(private readonly credential: ServiceAccountCredential) { - if (!credential) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'INTERNAL ASSERT: Must provide a service account credential to initialize ServiceAccountSigner.', - ); - } - } - - /** - * @inheritDoc - */ - public sign(buffer: Buffer): Promise { - const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires - const sign = crypto.createSign('RSA-SHA256'); - sign.update(buffer); - return Promise.resolve(sign.sign(this.credential.privateKey)); - } - - /** - * @inheritDoc - */ - public getAccountId(): Promise { - return Promise.resolve(this.credential.clientEmail); - } -} - -/** - * A CryptoSigner implementation that uses the remote IAM service to sign data. If initialized without - * a service account ID, attempts to discover a service account ID by consulting the local Metadata - * service. This will succeed in managed environments like Google Cloud Functions and App Engine. - * - * @see https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob - * @see https://cloud.google.com/compute/docs/storing-retrieving-metadata - */ -export class IAMSigner implements CryptoSigner { - algorithm = ALGORITHM_RS256; - - private readonly httpClient: AuthorizedHttpClient; - private serviceAccountId?: string; - - constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) { - if (!httpClient) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.', - ); - } - if (typeof serviceAccountId !== 'undefined' && !validator.isNonEmptyString(serviceAccountId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'INTERNAL ASSERT: Service account ID must be undefined or a non-empty string.', - ); - } - this.httpClient = httpClient; - this.serviceAccountId = serviceAccountId; - } - - /** - * @inheritDoc - */ - public sign(buffer: Buffer): Promise { - return this.getAccountId().then((serviceAccount) => { - const request: HttpRequestConfig = { - method: 'POST', - url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`, - data: { payload: buffer.toString('base64') }, - }; - return this.httpClient.send(request); - }).then((response: any) => { - // Response from IAM is base64 encoded. Decode it into a buffer and return. - return Buffer.from(response.data.signedBlob, 'base64'); - }).catch((err) => { - if (err instanceof HttpError) { - const error = err.response.data; - if (validator.isNonNullObject(error) && error.error) { - const errorCode = error.error.status; - const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + - 'for more details on how to use and troubleshoot this feature.'; - const errorMsg = `${error.error.message}; ${description}`; - - throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error); - } - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Error returned from server: ' + error + '. Additionally, an ' + - 'internal error occurred while attempting to extract the ' + - 'errorcode from the error.', - ); - } - throw err; - }); - } - - /** - * @inheritDoc - */ - public getAccountId(): Promise { - if (validator.isNonEmptyString(this.serviceAccountId)) { - return Promise.resolve(this.serviceAccountId); - } - const request: HttpRequestConfig = { - method: 'GET', - url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', - headers: { - 'Metadata-Flavor': 'Google', - }, - }; - const client = new HttpClient(); - return client.send(request).then((response) => { - if (!response.text) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'HTTP Response missing payload', - ); - } - this.serviceAccountId = response.text; - return response.text; - }).catch((err) => { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'Failed to determine service account. Make sure to initialize ' + - 'the SDK with a service account credential. Alternatively specify a service ' + - `account with iam.serviceAccounts.signBlob permission. Original error: ${err}`, - ); - }); - } -} - /** * A CryptoSigner implementation that is used when communicating with the Auth emulator. * It produces unsigned tokens. @@ -253,22 +84,6 @@ export class EmulatedSigner implements CryptoSigner { } } -/** - * Create a new CryptoSigner instance for the given app. If the app has been initialized with a service - * account credential, creates a ServiceAccountSigner. Otherwise creates an IAMSigner. - * - * @param {FirebaseApp} app A FirebaseApp instance. - * @return {CryptoSigner} A CryptoSigner instance. - */ -export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { - const credential = app.options.credential; - if (credential instanceof ServiceAccountCredential) { - return new ServiceAccountSigner(credential); - } - - return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); -} - /** * Class for generating different types of Firebase Auth tokens (JWTs). */ @@ -361,6 +176,8 @@ export class FirebaseTokenGenerator { return Promise.all([token, signPromise]); }).then(([token, signature]) => { return `${token}.${this.encodeSegment(signature)}`; + }).catch((err) => { + throw handleCryptoSignerError(err); }); } @@ -383,3 +200,44 @@ export class FirebaseTokenGenerator { } } +/** + * Creates a new FirebaseAuthError by extracting the error code, message and other relevant + * details from a CryptoSignerError. + * + * @param {Error} err The Error to convert into a FirebaseAuthError error + * @return {FirebaseAuthError} A Firebase Auth error that can be returned to the user. + */ +export function handleCryptoSignerError(err: Error): Error { + if (!(err instanceof CryptoSignerError)) { + return err; + } + if (err.code === CryptoSignerErrorCode.SERVER_ERROR && validator.isNonNullObject(err.cause)) { + const httpError = err.cause; + const errorResponse = (httpError as HttpError).response.data; + if (validator.isNonNullObject(errorResponse) && errorResponse.error) { + const errorCode = errorResponse.error.status; + const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + + 'for more details on how to use and troubleshoot this feature.'; + const errorMsg = `${errorResponse.error.message}; ${description}`; + + return FirebaseAuthError.fromServerError(errorCode, errorMsg, errorResponse); + } + return new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, + 'Error returned from server: ' + errorResponse + '. Additionally, an ' + + 'internal error occurred while attempting to extract the ' + + 'errorcode from the error.' + ); + } + return new FirebaseAuthError(mapToAuthClientErrorCode(err.code), err.message); +} + +function mapToAuthClientErrorCode(code: string): ErrorInfo { + switch (code) { + case CryptoSignerErrorCode.INVALID_CREDENTIAL: + return AuthClientErrorCode.INVALID_CREDENTIAL; + case CryptoSignerErrorCode.INVALID_ARGUMENT: + return AuthClientErrorCode.INVALID_ARGUMENT; + default: + return AuthClientErrorCode.INTERNAL_ERROR; + } +} diff --git a/src/firebase-app.ts b/src/firebase-app.ts index c9e9588d59..84b30a52c5 100644 --- a/src/firebase-app.ts +++ b/src/firebase-app.ts @@ -35,6 +35,7 @@ import { InstanceId } from './instance-id/instance-id'; import { ProjectManagement } from './project-management/project-management'; import { SecurityRules } from './security-rules/security-rules'; import { RemoteConfig } from './remote-config/remote-config'; +import { AppCheck } from './app-check/app-check'; import Credential = credential.Credential; import Database = database.Database; @@ -318,6 +319,18 @@ export class FirebaseApp implements app.App { }); } + /** + * Returns the AppCheck service instance associated with this app. + * + * @return The AppCheck service instance of this app. + */ + public appCheck(): AppCheck { + return this.ensureService_('appCheck', () => { + const appCheckService: typeof AppCheck = require('./app-check/app-check').AppCheck; + return new appCheckService(this); + }); + } + /** * Returns the name of the FirebaseApp instance. * diff --git a/src/firebase-namespace-api.ts b/src/firebase-namespace-api.ts index 36df1b778d..6507fa3a88 100644 --- a/src/firebase-namespace-api.ts +++ b/src/firebase-namespace-api.ts @@ -15,6 +15,7 @@ */ import { Agent } from 'http'; +import { appCheck } from './app-check/index'; import { auth } from './auth/index'; import { credential } from './credential/index'; import { database } from './database/index'; @@ -222,6 +223,7 @@ export namespace app { */ options: AppOptions; + appCheck(): appCheck.AppCheck; auth(): auth.Auth; database(url?: string): database.Database; firestore(): firestore.Firestore; diff --git a/src/firebase-namespace.d.ts b/src/firebase-namespace.d.ts index 3de06b1bbb..ab013c3cac 100644 --- a/src/firebase-namespace.d.ts +++ b/src/firebase-namespace.d.ts @@ -16,6 +16,7 @@ export * from './credential/index'; export * from './firebase-namespace-api'; +export * from './app-check/index'; export * from './auth/index'; export * from './database/index'; export * from './firestore/index'; diff --git a/src/firebase-namespace.ts b/src/firebase-namespace.ts index 43b12a92c9..fa1a409b48 100644 --- a/src/firebase-namespace.ts +++ b/src/firebase-namespace.ts @@ -23,6 +23,7 @@ import { FirebaseApp } from './firebase-app'; import { cert, refreshToken, applicationDefault } from './credential/credential'; import { getApplicationDefault } from './credential/credential-internal'; +import { appCheck } from './app-check/index'; import { auth } from './auth/index'; import { database } from './database/index'; import { firestore } from './firestore/index'; @@ -38,6 +39,7 @@ import * as validator from './utils/validator'; import { getSdkVersion } from './utils/index'; import App = app.App; +import AppCheck = appCheck.AppCheck; import Auth = auth.Auth; import Database = database.Database; import Firestore = firestore.Firestore; @@ -357,6 +359,18 @@ export class FirebaseNamespace { return Object.assign(fn, { RemoteConfig: remoteConfig }); } + /** + * Gets the `AppCheck` service namespace. The returned namespace can be used to get the + * `AppCheck` service for the default app or an explicitly specified app. + */ + get appCheck(): FirebaseServiceNamespace { + const fn: FirebaseServiceNamespace = (app?: App) => { + return this.ensureApp(app).appCheck(); + }; + const appCheck = require('./app-check/app-check').AppCheck; + return Object.assign(fn, { AppCheck: appCheck }); + } + // TODO: Change the return types to app.App in the following methods. /** diff --git a/src/utils/crypto-signer.ts b/src/utils/crypto-signer.ts new file mode 100644 index 0000000000..e8bb8b79ae --- /dev/null +++ b/src/utils/crypto-signer.ts @@ -0,0 +1,250 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '../firebase-app'; +import { ServiceAccountCredential } from '../credential/credential-internal'; +import { AuthorizedHttpClient, HttpRequestConfig, HttpClient, HttpError } from './api-request'; + +import { Algorithm } from 'jsonwebtoken'; +import { ErrorInfo } from '../utils/error'; +import * as validator from '../utils/validator'; + +const ALGORITHM_RS256: Algorithm = 'RS256' as const; + +/** + * CryptoSigner interface represents an object that can be used to sign JWTs. + */ +export interface CryptoSigner { + + /** + * The name of the signing algorithm. + */ + readonly algorithm: Algorithm; + + /** + * Cryptographically signs a buffer of data. + * + * @param {Buffer} buffer The data to be signed. + * @return {Promise} A promise that resolves with the raw bytes of a signature. + */ + sign(buffer: Buffer): Promise; + + /** + * Returns the ID of the service account used to sign tokens. + * + * @return {Promise} A promise that resolves with a service account ID. + */ + getAccountId(): Promise; +} + +/** + * A CryptoSigner implementation that uses an explicitly specified service account private key to + * sign data. Performs all operations locally, and does not make any RPC calls. + */ +export class ServiceAccountSigner implements CryptoSigner { + + algorithm = ALGORITHM_RS256; + + /** + * Creates a new CryptoSigner instance from the given service account credential. + * + * @param {ServiceAccountCredential} credential A service account credential. + */ + constructor(private readonly credential: ServiceAccountCredential) { + if (!credential) { + throw new CryptoSignerError({ + code: CryptoSignerErrorCode.INVALID_CREDENTIAL, + message: 'INTERNAL ASSERT: Must provide a service account credential to initialize ServiceAccountSigner.', + }); + } + } + + /** + * @inheritDoc + */ + public sign(buffer: Buffer): Promise { + const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires + const sign = crypto.createSign('RSA-SHA256'); + sign.update(buffer); + return Promise.resolve(sign.sign(this.credential.privateKey)); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + return Promise.resolve(this.credential.clientEmail); + } +} + +/** + * A CryptoSigner implementation that uses the remote IAM service to sign data. If initialized without + * a service account ID, attempts to discover a service account ID by consulting the local Metadata + * service. This will succeed in managed environments like Google Cloud Functions and App Engine. + * + * @see https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob + * @see https://cloud.google.com/compute/docs/storing-retrieving-metadata + */ +export class IAMSigner implements CryptoSigner { + algorithm = ALGORITHM_RS256; + + private readonly httpClient: AuthorizedHttpClient; + private serviceAccountId?: string; + + constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) { + if (!httpClient) { + throw new CryptoSignerError({ + code: CryptoSignerErrorCode.INVALID_ARGUMENT, + message: 'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.', + }); + } + if (typeof serviceAccountId !== 'undefined' && !validator.isNonEmptyString(serviceAccountId)) { + throw new CryptoSignerError({ + code: CryptoSignerErrorCode.INVALID_ARGUMENT, + message: 'INTERNAL ASSERT: Service account ID must be undefined or a non-empty string.', + }); + } + this.httpClient = httpClient; + this.serviceAccountId = serviceAccountId; + } + + /** + * @inheritDoc + */ + public sign(buffer: Buffer): Promise { + return this.getAccountId().then((serviceAccount) => { + const request: HttpRequestConfig = { + method: 'POST', + url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`, + data: { payload: buffer.toString('base64') }, + }; + return this.httpClient.send(request); + }).then((response: any) => { + // Response from IAM is base64 encoded. Decode it into a buffer and return. + return Buffer.from(response.data.signedBlob, 'base64'); + }).catch((err) => { + if (err instanceof HttpError) { + throw new CryptoSignerError({ + code: CryptoSignerErrorCode.SERVER_ERROR, + message: err.message, + cause: err + }); + } + throw err + }); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + if (validator.isNonEmptyString(this.serviceAccountId)) { + return Promise.resolve(this.serviceAccountId); + } + const request: HttpRequestConfig = { + method: 'GET', + url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', + headers: { + 'Metadata-Flavor': 'Google', + }, + }; + const client = new HttpClient(); + return client.send(request).then((response) => { + if (!response.text) { + throw new CryptoSignerError({ + code: CryptoSignerErrorCode.INTERNAL_ERROR, + message: 'HTTP Response missing payload', + }); + } + this.serviceAccountId = response.text; + return response.text; + }).catch((err) => { + throw new CryptoSignerError({ + code: CryptoSignerErrorCode.INVALID_CREDENTIAL, + message: 'Failed to determine service account. Make sure to initialize ' + + 'the SDK with a service account credential. Alternatively specify a service ' + + `account with iam.serviceAccounts.signBlob permission. Original error: ${err}`, + }); + }); + } +} + +/** + * Creates a new CryptoSigner instance for the given app. If the app has been initialized with a + * service account credential, creates a ServiceAccountSigner. + * + * @param {FirebaseApp} app A FirebaseApp instance. + * @return {CryptoSigner} A CryptoSigner instance. + */ +export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { + const credential = app.options.credential; + if (credential instanceof ServiceAccountCredential) { + return new ServiceAccountSigner(credential); + } + + return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); +} + +/** + * Defines extended error info type. This includes a code, message string, and error data. + */ +export interface ExtendedErrorInfo extends ErrorInfo { + cause?: Error; +} + +/** + * CryptoSigner error code structure. + * + * @param {ErrorInfo} errorInfo The error information (code and message). + * @constructor + */ +export class CryptoSignerError extends Error { + constructor(private errorInfo: ExtendedErrorInfo) { + super(errorInfo.message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = CryptoSignerError.prototype; + } + + /** @return {string} The error code. */ + public get code(): string { + return this.errorInfo.code; + } + + /** @return {string} The error message. */ + public get message(): string { + return this.errorInfo.message; + } + + /** @return {object} The error data. */ + public get cause(): Error | undefined { + return this.errorInfo.cause; + } +} + +/** + * Crypto Signer error codes and their default messages. + */ +export class CryptoSignerErrorCode { + public static INVALID_ARGUMENT = 'invalid-argument'; + public static INTERNAL_ERROR = 'internal-error'; + public static INVALID_CREDENTIAL = 'invalid-credential'; + public static SERVER_ERROR = 'server-error'; +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index d048567061..1fab2ff9fb 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -16,6 +16,7 @@ import * as validator from './validator'; import * as jwt from 'jsonwebtoken'; +import * as jwks from 'jwks-rsa'; import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; import { Agent } from 'http'; @@ -28,6 +29,9 @@ export const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; const JWT_CALLBACK_ERROR_PREFIX = 'error in secret or public key callback: '; const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; +const NO_KID_IN_HEADER_ERROR_MESSAGE = 'no-kid-in-header-error'; + +const ONE_DAY_IN_SECONDS = 24 * 3600; export type Dictionary = { [key: string]: any } @@ -44,6 +48,51 @@ interface KeyFetcher { fetchPublicKeys(): Promise<{ [key: string]: string }>; } +export class JwksFetcher implements KeyFetcher { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt = 0; + private client: jwks.JwksClient; + + constructor(jwksUrl: string) { + if (!validator.isURL(jwksUrl)) { + throw new Error('The provided JWKS URL is not a valid URL.'); + } + + this.client = jwks({ + jwksUri: jwksUrl, + cache: false, // disable jwks-rsa LRU cache as the keys are always cahced for 24 hours. + }); + } + + public fetchPublicKeys(): Promise<{ [key: string]: string }> { + if (this.shouldRefresh()) { + return this.refresh(); + } + return Promise.resolve(this.publicKeys); + } + + private shouldRefresh(): boolean { + return !this.publicKeys || this.publicKeysExpireAt <= Date.now(); + } + + private refresh(): Promise<{ [key: string]: string }> { + return this.client.getSigningKeys() + .then((signingKeys) => { + // reset expire at from previous set of keys. + this.publicKeysExpireAt = 0; + const newKeys = signingKeys.reduce((map: { [key: string]: string }, signingKey: jwks.SigningKey) => { + map[signingKey.kid] = signingKey.getPublicKey(); + return map; + }, {}); + this.publicKeysExpireAt = Date.now() + (ONE_DAY_IN_SECONDS * 1000); + this.publicKeys = newKeys; + return newKeys; + }).catch((err) => { + throw new Error(`Error fetching Json Web Keys: ${err.message}`); + }); + } +} + /** * Class to fetch public keys from a client certificates URL. */ @@ -141,13 +190,51 @@ export class PublicKeySignatureVerifier implements SignatureVerifier { return new PublicKeySignatureVerifier(new UrlKeyFetcher(clientCertUrl, httpAgent)); } + public static withJwksUrl(jwksUrl: string): PublicKeySignatureVerifier { + return new PublicKeySignatureVerifier(new JwksFetcher(jwksUrl)); + } + public verify(token: string): Promise { if (!validator.isString(token)) { return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, 'The provided token must be a string.')); } - return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] }); + return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] }) + .catch((error: JwtError) => { + if (error.code === JwtErrorCode.NO_KID_IN_HEADER) { + // No kid in JWT header. Try with all the public keys. + return this.verifyWithoutKid(token); + } + throw error; + }); + } + + private verifyWithoutKid(token: string): Promise { + return this.keyFetcher.fetchPublicKeys() + .then(publicKeys => this.verifyWithAllKeys(token, publicKeys)); + } + + private verifyWithAllKeys(token: string, keys: { [key: string]: string }): Promise { + const promises: Promise[] = []; + Object.values(keys).forEach((key) => { + const result = verifyJwtSignature(token, key) + .then(() => true) + .catch((error) => { + if (error.code === JwtErrorCode.TOKEN_EXPIRED) { + throw error; + } + return false; + }) + promises.push(result); + }); + + return Promise.all(promises) + .then((result) => { + if (result.every((r) => r === false)) { + throw new JwtError(JwtErrorCode.INVALID_SIGNATURE, 'Invalid token signature.'); + } + }); } } @@ -169,6 +256,9 @@ export class EmulatorSignatureVerifier implements SignatureVerifier { */ function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret { return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { + if (!header.kid) { + callback(new Error(NO_KID_IN_HEADER_ERROR_MESSAGE)); + } const kid = header.kid || ''; fetcher.fetchPublicKeys().then((publicKeys) => { if (!Object.prototype.hasOwnProperty.call(publicKeys, kid)) { @@ -212,8 +302,12 @@ export function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret } else if (error.name === 'JsonWebTokenError') { if (error.message && error.message.includes(JWT_CALLBACK_ERROR_PREFIX)) { const message = error.message.split(JWT_CALLBACK_ERROR_PREFIX).pop() || 'Error fetching public keys.'; - const code = (message === NO_MATCHING_KID_ERROR_MESSAGE) ? JwtErrorCode.NO_MATCHING_KID : - JwtErrorCode.KEY_FETCH_ERROR; + let code = JwtErrorCode.KEY_FETCH_ERROR; + if (message === NO_MATCHING_KID_ERROR_MESSAGE) { + code = JwtErrorCode.NO_MATCHING_KID; + } else if (message === NO_KID_IN_HEADER_ERROR_MESSAGE) { + code = JwtErrorCode.NO_KID_IN_HEADER; + } return reject(new JwtError(code, message)); } } @@ -271,5 +365,6 @@ export enum JwtErrorCode { TOKEN_EXPIRED = 'token-expired', INVALID_SIGNATURE = 'invalid-token', NO_MATCHING_KID = 'no-matching-kid-error', + NO_KID_IN_HEADER = 'no-kid-error', KEY_FETCH_ERROR = 'key-fetch-error', } diff --git a/test/integration/app-check.spec.ts b/test/integration/app-check.spec.ts new file mode 100644 index 0000000000..32386f32bc --- /dev/null +++ b/test/integration/app-check.spec.ts @@ -0,0 +1,103 @@ +/*! + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as _ from 'lodash'; +import * as admin from '../../lib/index'; +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import fs = require('fs'); +import path = require('path'); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const chalk = require('chalk'); + +chai.should(); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +let appId: string; + +describe('admin.appCheck', () => { + before(async () => { + try { + appId = fs.readFileSync(path.join(__dirname, '../resources/appid.txt')).toString().trim(); + } catch (error) { + console.log(chalk.yellow( + 'Unable to find an an App ID. Skipping tests that require a valid App ID.', + error, + )); + } + }); + + describe('createToken', () => { + it('should succeed with a vaild token', function() { + if (!appId) { + this.skip(); + } + return admin.appCheck().createToken(appId as string) + .then((token) => { + expect(token).to.have.keys(['token', 'ttlMillis']); + expect(token.token).to.be.a('string').and.to.not.be.empty; + expect(token.ttlMillis).to.be.a('number'); + }); + }); + + it('should propagate API errors', () => { + // rejects with invalid-argument when appId is incorrect + return admin.appCheck().createToken('incorrect-app-id') + .should.eventually.be.rejected.and.have.property('code', 'app-check/invalid-argument'); + }); + + const invalidAppIds = ['', null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidAppIds.forEach((invalidAppId) => { + it(`should throw given an invalid appId: ${JSON.stringify(invalidAppId)}`, () => { + expect(() => admin.appCheck().createToken(invalidAppId as any)) + .to.throw('appId` must be a non-empty string.'); + }); + }); + }); + + describe('verifyToken', () => { + let validToken: admin.appCheck.AppCheckToken; + + before(async () => { + if (!appId) { + return; + } + // obtain a valid app check token + validToken = await admin.appCheck().createToken(appId as string); + }); + + it('should succeed with a decoded verifed token response', function() { + if (!appId) { + this.skip(); + } + return admin.appCheck().verifyToken(validToken.token) + .then((verifedToken) => { + expect(verifedToken).to.have.keys(['token', 'appId']); + expect(verifedToken.token).to.have.keys(['iss', 'sub', 'aud', 'exp', 'iat', 'app_id']); + expect(verifedToken.token.app_id).to.be.a('string').and.equals(appId); + }); + }); + + it('should propagate API errors', () => { + // rejects with invalid-argument when the token is invalid + return admin.appCheck().verifyToken('invalid-token') + .should.eventually.be.rejected.and.have.property('code', 'app-check/invalid-argument'); + }); + }); +}); diff --git a/test/resources/mock.jwks.json b/test/resources/mock.jwks.json new file mode 100644 index 0000000000..08695991c3 --- /dev/null +++ b/test/resources/mock.jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU", + "alg": "RS256", + "n": "rFYQyEdjj43mnpXwj-3WgAE01TSYe1-XFE9mxUDShysFwtVZOHFSMm6kl-B3Y_O8NcPt5osntLlH6KHvygExAE0tDmFYq8aKt7LQQF8rTv0rI6MP92ezyCEp4MPmAPFD_tY160XGrkqApuY2_-L8eEXdkRyH2H7lCYypFC0u3DIY25Vlq-ZDkxB2kGykGgb1zVazCDDViqV1p9hSltmm4el9AyF08FsMCpk_NvwKOY4pJ_sm99CDKxMhQBaT9lrIQt0B1VqTpEwlOoiFiyXASRXp9ZTeL4mrLPqSeozwPvspD81wbgecd62F640scKBr3ko73L8M8UWcwgd-moKCJw" + } + ] +} diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index 5512756039..152364163e 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -36,6 +36,8 @@ const ONE_HOUR_IN_SECONDS = 60 * 60; export const uid = 'someUid'; export const projectId = 'project_id'; +export const projectNumber = '12345678'; +export const appId = '12345678:app:ID'; export const developerClaims = { one: 'uno', two: 'dos', @@ -146,6 +148,10 @@ export const refreshToken = { type: 'refreshToken', }; +// Randomly generated JSON Web Key Sets that do not correspond to anything related to Firebase. +// eslint-disable-next-line @typescript-eslint/no-var-requires +export const jwksResponse = require('./mock.jwks.json'); + // eslint-disable-next-line @typescript-eslint/no-var-requires export const certificateObject = require('./mock.key.json'); @@ -178,6 +184,14 @@ export const x509CertPairs = [ /* eslint-enable max-len */ ]; +// Randomly generated key pairs that don't correspond to anything related to Firebase or GCP +export const jwksKeyPair = { + /* eslint-disable max-len */ + // The private key for this key pair is identical to the one used in ./mock.jwks.json + private: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEArFYQyEdjj43mnpXwj+3WgAE01TSYe1+XFE9mxUDShysFwtVZ\nOHFSMm6kl+B3Y/O8NcPt5osntLlH6KHvygExAE0tDmFYq8aKt7LQQF8rTv0rI6MP\n92ezyCEp4MPmAPFD/tY160XGrkqApuY2/+L8eEXdkRyH2H7lCYypFC0u3DIY25Vl\nq+ZDkxB2kGykGgb1zVazCDDViqV1p9hSltmm4el9AyF08FsMCpk/NvwKOY4pJ/sm\n99CDKxMhQBaT9lrIQt0B1VqTpEwlOoiFiyXASRXp9ZTeL4mrLPqSeozwPvspD81w\nbgecd62F640scKBr3ko73L8M8UWcwgd+moKCJwIDAQABAoIBAEDPJQSMhE6KKL5e\n2NbntJDy4zGC1A0hh6llqtpnZETc0w/QN/tX8ndw0IklKwD1ukPl6OOYVVhLjVVZ\nANpQ1GKuo1ETHsuKoMQwhMyQfbL41m5SdkCuSRfsENmsEiUslkuRtzlBRlRpRDR/\nwxM8A4IflBFsT1IFdpC+yx8BVuwLc35iVnaGQpo/jhSDibt07j+FdOKEWkMGj+rL\nsHC6cpB2NMTBl9CIDLW/eq1amBOAGtsSKqoGJvaQY/mZf7SPkRjYIfIl2PWSaduT\nfmMrsYYFtHUKVOMYAD7P5RWNkS8oERucnXT3ouAECvip3Ew2JqlQc0FP7FS5CxH3\nWdfvLuECgYEA8Q7rJrDOdO867s7P/lXMklbAGnuNnAZJdAEXUMIaPJi7al97F119\n4DKBuF7c/dDf8CdiOvMzP8r/F8+FFx2D61xxkQNeuxo5Xjlt23OzW5EI2S6ABesZ\n/3sQWqvKCGuqN7WENYF3EiKyByQ22MYXk8CE7KZuO57Aj88t6TsaNhkCgYEAtwSs\nhbqKSCneC1bQ3wfSAF2kPYRrQEEa2VCLlX1Mz7zHufxksUWAnAbU8O3hIGnXjz6T\nqzivyJJhFSgNGeYpwV67GfXnibpr3OZ/yx2YXIQfp0daivj++kvEU7aNfM9rHZA9\nS3Gh7hKELdB9b0DkrX5GpLiZWA6NnJdrIRYbAj8CgYBCZSyJvJsxBA+EZTxOvk0Z\nZYGGCc/oUKb8p6xHVx8o35yHYQMjXWHlVaP7J03RLy3vFLnuqLvN71ixszviMQP7\n2LuDCJ2YBVIVzNWgY07cgqcgQrmKZ8YCY2AOyVBdX2JD8+AVaLJmMV49r1DYBj/K\nN3WlRPYJv+Ej+xmXKus+SQKBgHh/Zkthxxu+HQigL0M4teYxwSoTnj2e39uGsXBK\nICGCLIniiDVDCmswAFFkfV3G8frI+5a26t2Gqs6wIPgVVxaOlWeBROGkUNIPHMKR\niLgY8XJEg3OOfuoyql9niP5M3jyHtCOQ/Elv/YDgjUWLl0Q3KLHZLHUSl+AqvYj6\nMewnAoGBANgYzPZgP+wreI55BFR470blKh1mFz+YGa+53DCd7JdMH2pdp4hoh303\nXxpOSVlAuyv9SgTsZ7WjGO5UdhaBzVPKgN0OO6JQmQ5ZrOR8ZJ7VB73FiVHCEerj\n1m2zyFv6OT7vqdg+V1/SzxMEmXXFQv1g69k6nWGazne3IJlzrSpj\n-----END RSA PRIVATE KEY-----\n', + public: '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArFYQyEdjj43mnpXwj+3W\ngAE01TSYe1+XFE9mxUDShysFwtVZOHFSMm6kl+B3Y/O8NcPt5osntLlH6KHvygEx\nAE0tDmFYq8aKt7LQQF8rTv0rI6MP92ezyCEp4MPmAPFD/tY160XGrkqApuY2/+L8\neEXdkRyH2H7lCYypFC0u3DIY25Vlq+ZDkxB2kGykGgb1zVazCDDViqV1p9hSltmm\n4el9AyF08FsMCpk/NvwKOY4pJ/sm99CDKxMhQBaT9lrIQt0B1VqTpEwlOoiFiyXA\nSRXp9ZTeL4mrLPqSeozwPvspD81wbgecd62F640scKBr3ko73L8M8UWcwgd+moKC\nJwIDAQAB\n-----END PUBLIC KEY-----\n', +}; + /** * Generates a mocked Firebase ID token. * @@ -227,6 +241,27 @@ export function generateSessionCookie(overrides?: object, expiresIn?: number): s return jwt.sign(developerClaims, certificateObject.private_key, options); } +/** + * Generates a mocked App Check token. + * + * @param {object} overrides Overrides for the generated token's attributes. + * @return {string} A mocked App Check token with any provided overrides included. + */ +export function generateAppCheckToken(overrides?: object): string { + const options = _.assign({ + audience: ['projects/' + projectNumber, 'projects/' + projectId], + expiresIn: ONE_HOUR_IN_SECONDS, + issuer: 'https://firebaseappcheck.googleapis.com/' + projectNumber, + subject: appId, + algorithm: ALGORITHM, + header: { + kid: jwksResponse.keys[0].kid, + }, + }, overrides); + + return jwt.sign(developerClaims, jwksKeyPair.private, options); +} + /** Mock socket emitter class. */ export class MockSocketEmitter extends events.EventEmitter { public setTimeout: (_: number) => void = () => undefined; diff --git a/test/unit/app-check/app-check-api-client-internal.spec.ts b/test/unit/app-check/app-check-api-client-internal.spec.ts new file mode 100644 index 0000000000..fba1fba20d --- /dev/null +++ b/test/unit/app-check/app-check-api-client-internal.spec.ts @@ -0,0 +1,238 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { HttpClient } from '../../../src/utils/api-request'; +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; +import { getSdkVersion } from '../../../src/utils'; + +import { FirebaseApp } from '../../../src/firebase-app'; +import { AppCheckApiClient, FirebaseAppCheckError } from '../../../src/app-check/app-check-api-client-internal'; +import { FirebaseAppError } from '../../../src/utils/error'; +import { deepCopy } from '../../../src/utils/deep-copy'; + +const expect = chai.expect; + +describe('AppCheckApiClient', () => { + + const ERROR_RESPONSE = { + error: { + code: 404, + message: 'Requested entity not found', + status: 'NOT_FOUND', + }, + }; + + const EXPECTED_HEADERS = { + 'Authorization': 'Bearer mock-token', + 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, + }; + + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + const APP_ID = '1:1234:android:1234'; + + const TEST_TOKEN_TO_EXCHANGE = 'signed-custom-token'; + + const TEST_RESPONSE = { + attestationToken: 'token', + ttl: '3s' + }; + + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + }; + + const clientWithoutProjectId = new AppCheckApiClient( + mocks.mockCredentialApp()); + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + let app: FirebaseApp; + let apiClient: AppCheckApiClient; + + beforeEach(() => { + app = mocks.appWithOptions(mockOptions); + apiClient = new AppCheckApiClient(app); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + return app.delete(); + }); + + describe('Constructor', () => { + it('should reject when the app is null', () => { + expect(() => new AppCheckApiClient(null as unknown as FirebaseApp)) + .to.throw('First argument passed to admin.appCheck() must be a valid Firebase app instance.'); + }); + }); + + describe('exchangeToken', () => { + it('should reject when project id is not available', () => { + return clientWithoutProjectId.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should throw given no appId', () => { + expect(() => { + (apiClient as any).exchangeToken(TEST_TOKEN_TO_EXCHANGE); + }).to.throw('appId` must be a non-empty string.'); + }); + + const invalidAppIds = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidAppIds.forEach((invalidAppId) => { + it('should throw given a non-string appId: ' + JSON.stringify(invalidAppId), () => { + expect(() => { + apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, invalidAppId as any); + }).to.throw('appId` must be a non-empty string.'); + }); + }); + + it('should throw given an empty string appId', () => { + expect(() => { + apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, ''); + }).to.throw('appId` must be a non-empty string.'); + }); + + it('should throw given no customToken', () => { + expect(() => { + (apiClient as any).exchangeToken(undefined, APP_ID); + }).to.throw('customToken` must be a non-empty string.'); + }); + + const invalidCustomTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidCustomTokens.forEach((invalidCustomToken) => { + it('should throw given a non-string customToken: ' + JSON.stringify(invalidCustomToken), () => { + expect(() => { + apiClient.exchangeToken(invalidCustomToken as any, APP_ID); + }).to.throw('customToken` must be a non-empty string.'); + }); + }); + + it('should throw given an empty string customToken', () => { + expect(() => { + apiClient.exchangeToken('', APP_ID); + }).to.throw('customToken` 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.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .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.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .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.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .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.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + ['', 'abc', '3s2', 'sssa', '3.000000001', '3.2', null, NaN, true, [], {}, 100, 1.2, -200, -2.4] + .forEach((invalidDuration) => { + it(`should throw if the returned ttl duration is: ${invalidDuration}`, () => { + const response = deepCopy(TEST_RESPONSE); + (response as any).ttl = invalidDuration; + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(response, 200)); + stubs.push(stub); + const expected = new FirebaseAppCheckError( + 'invalid-argument', '`ttl` must be a valid duration string with the suffix `s`.'); + return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .should.eventually.be.rejected.and.deep.include(expected); + }); + }); + + it('should resolve with the App Check token on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + stubs.push(stub); + return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .then((resp) => { + expect(resp.token).to.deep.equal(TEST_RESPONSE.attestationToken); + expect(resp.ttlMillis).to.deep.equal(3000); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `https://firebaseappcheck.googleapis.com/v1beta/projects/test-project/apps/${APP_ID}:exchangeCustomToken`, + headers: EXPECTED_HEADERS, + data: { customToken: TEST_TOKEN_TO_EXCHANGE } + }); + }); + }); + + new Map([['3s', 3000], ['4.1s', 4100], ['3.000000001s', 3000], ['3.000001s', 3000]]) + .forEach((ttlMillis, ttlString) => { // value, key, map + // 3 seconds with 0 nanoseconds expressed as "3s" + // 3 seconds and 1 nanosecond expressed as "3.000000001s" + // 3 seconds and 1 microsecond expressed as "3.000001s" + it(`should resolve with ttlMillis as ${ttlMillis} when ttl + from server is: ${ttlString}`, () => { + const response = deepCopy(TEST_RESPONSE); + (response as any).ttl = ttlString; + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(response, 200)); + stubs.push(stub); + return apiClient.exchangeToken(TEST_TOKEN_TO_EXCHANGE, APP_ID) + .then((resp) => { + expect(resp.token).to.deep.equal(response.attestationToken); + expect(resp.ttlMillis).to.deep.equal(ttlMillis); + }); + }); + }); + }); +}); diff --git a/test/unit/app-check/app-check.spec.ts b/test/unit/app-check/app-check.spec.ts new file mode 100644 index 0000000000..c30970bc13 --- /dev/null +++ b/test/unit/app-check/app-check.spec.ts @@ -0,0 +1,195 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as mocks from '../../resources/mocks'; + +import { FirebaseApp } from '../../../src/firebase-app'; +import { AppCheck } from '../../../src/app-check/app-check'; +import { AppCheckApiClient, FirebaseAppCheckError } from '../../../src/app-check/app-check-api-client-internal'; +import { AppCheckTokenGenerator } from '../../../src/app-check/token-generator'; +import { HttpClient } from '../../../src/utils/api-request'; +import { ServiceAccountSigner } from '../../../src/utils/crypto-signer'; +import { AppCheckTokenVerifier } from '../../../src/app-check/token-verifier'; + +const expect = chai.expect; + +describe('AppCheck', () => { + + const INTERNAL_ERROR = new FirebaseAppCheckError('internal-error', 'message'); + const APP_ID = '1:1234:android:1234'; + const TEST_TOKEN_TO_EXCHANGE = 'signed-custom-token'; + + let appCheck: AppCheck; + + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + + before(() => { + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + appCheck = new AppCheck(mockApp); + }); + + after(() => { + return mockApp.delete(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + describe('Constructor', () => { + const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidApps.forEach((invalidApp) => { + it('should throw given invalid app: ' + JSON.stringify(invalidApp), () => { + expect(() => { + const appCheckAny: any = AppCheck; + return new appCheckAny(invalidApp); + }).to.throw( + 'First argument passed to admin.appCheck() must be a valid Firebase app ' + + 'instance.'); + }); + }); + + it('should throw given no app', () => { + expect(() => { + const appCheckAny: any = AppCheck; + return new appCheckAny(); + }).to.throw( + 'First argument passed to admin.appCheck() must be a valid Firebase app ' + + 'instance.'); + }); + + it('should reject when initialized without project ID', () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + const appCheckWithoutProjectId = new AppCheck(mockCredentialApp); + const stub = sinon.stub(AppCheckTokenGenerator.prototype, 'createCustomToken') + .resolves(TEST_TOKEN_TO_EXCHANGE); + stubs.push(stub); + return appCheckWithoutProjectId.createToken(APP_ID) + .should.eventually.rejectedWith(noProjectId); + }); + + it('should reject when failed to contact the Metadata server', () => { + // Remove the Project ID to force a request to the Metadata server + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const appCheckWithoutProjectId = new AppCheck(mockCredentialApp); + const stub = sinon.stub(HttpClient.prototype, 'send') + .rejects(new Error('network error.')); + stubs.push(stub); + const expected = 'Failed to determine service account. Make sure to initialize the SDK ' + + 'with a service account credential. Alternatively specify a service account with ' + + 'iam.serviceAccounts.signBlob permission. Original error: ' + + 'Error: network error.'; + return appCheckWithoutProjectId.createToken(APP_ID) + .should.eventually.be.rejectedWith(expected); + }); + + it('should reject when failed to sign the token', () => { + const expected = 'sign error'; + const stub = sinon.stub(ServiceAccountSigner.prototype, 'sign') + .rejects(new Error(expected)); + stubs.push(stub); + return appCheck.createToken(APP_ID) + .should.eventually.be.rejectedWith(expected); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return new AppCheck(mockApp); + }).not.to.throw(); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(appCheck.app).to.equal(mockApp); + }); + }); + + describe('createToken', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(AppCheckApiClient.prototype, 'exchangeToken') + .rejects(INTERNAL_ERROR); + stubs.push(stub); + return appCheck.createToken(APP_ID) + .should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should resolve with AppCheckToken on success', () => { + const response = { token: 'token', ttlMillis: 3000 }; + const stub = sinon + .stub(AppCheckApiClient.prototype, 'exchangeToken') + .resolves(response); + stubs.push(stub); + return appCheck.createToken(APP_ID) + .then((token) => { + expect(token.token).equals('token'); + expect(token.ttlMillis).equals(3000); + }); + }); + }); + + describe('verifyToken', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(AppCheckTokenVerifier.prototype, 'verifyToken') + .rejects(INTERNAL_ERROR); + stubs.push(stub); + return appCheck.verifyToken('token') + .should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should resolve with VerifyAppCheckTokenResponse on success', () => { + const response = { + sub: 'app-id', + iss: 'https://firebaseappcheck.googleapis.com/123456', + // eslint-disable-next-line @typescript-eslint/camelcase + app_id: 'app-id', + aud: ['123456', 'project-id'], + exp: 1617741496, + iat: 1516239022, + }; + const stub = sinon + .stub(AppCheckTokenVerifier.prototype, 'verifyToken') + .resolves(response); + stubs.push(stub); + return appCheck.verifyToken('token') + .then((tokenResponse) => { + expect(tokenResponse.appId).equals('app-id'); + expect(tokenResponse.token).equals(response); + }); + }); + }); +}); diff --git a/test/unit/app-check/token-generator.spec.ts b/test/unit/app-check/token-generator.spec.ts new file mode 100644 index 0000000000..66bb7cffba --- /dev/null +++ b/test/unit/app-check/token-generator.spec.ts @@ -0,0 +1,261 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as jwt from 'jsonwebtoken'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as mocks from '../../resources/mocks'; + +import { + appCheckErrorFromCryptoSignerError, + AppCheckTokenGenerator +} from '../../../src/app-check/token-generator'; +import { + CryptoSignerError, CryptoSignerErrorCode, ServiceAccountSigner +} from '../../../src/utils/crypto-signer'; +import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; +import { FirebaseAppCheckError } from '../../../src/app-check/app-check-api-client-internal'; +import * as utils from '../utils'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +const ALGORITHM = 'RS256'; +const ONE_HOUR_IN_SECONDS = 60 * 60; +const FIREBASE_APP_CHECK_AUDIENCE = 'https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1beta.TokenExchangeService'; + +/** + * Verifies a token is signed with the private key corresponding to the provided public key. + * + * @param {string} token The token to verify. + * @param {string} publicKey The public key to use to verify the token. + * @return {Promise} A promise fulfilled with the decoded token if it is valid; otherwise, a rejected promise. + */ +function verifyToken(token: string, publicKey: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, publicKey, { + algorithms: [ALGORITHM], + }, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res as object); + } + }); + }); +} + +describe('AppCheckTokenGenerator', () => { + const cert = new ServiceAccountCredential(mocks.certificateObject); + const APP_ID = 'test-app-id'; + + let clock: sinon.SinonFakeTimers | undefined; + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + describe('Constructor', () => { + it('should throw given no arguments', () => { + expect(() => { + // Need to overcome the type system to allow a call with no parameter + const anyFirebaseAppCheckTokenGenerator: any = AppCheckTokenGenerator; + return new anyFirebaseAppCheckTokenGenerator(); + }).to.throw('Must provide a CryptoSigner to use AppCheckTokenGenerator'); + }); + }); + + const invalidSigners: any[] = [null, NaN, 0, 1, true, false, '', 'a', [], _.noop]; + invalidSigners.forEach((invalidSigner) => { + it('should throw given invalid signer: ' + JSON.stringify(invalidSigner), () => { + expect(() => { + return new AppCheckTokenGenerator(invalidSigner as any); + }).to.throw('Must provide a CryptoSigner to use AppCheckTokenGenerator'); + }); + }); + + describe('createCustomToken()', () => { + const tokenGenerator = new AppCheckTokenGenerator(new ServiceAccountSigner(cert)); + + it('should throw given no appId', () => { + expect(() => { + (tokenGenerator as any).createCustomToken(); + }).to.throw(FirebaseAppCheckError).with.property('code', 'app-check/invalid-argument'); + }); + + const invalidAppIds = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidAppIds.forEach((invalidAppId) => { + it('should throw given a non-string appId: ' + JSON.stringify(invalidAppId), () => { + expect(() => { + tokenGenerator.createCustomToken(invalidAppId as any); + }).to.throw(FirebaseAppCheckError).with.property('code', 'app-check/invalid-argument'); + }); + }); + + it('should throw given an empty string appId', () => { + expect(() => { + tokenGenerator.createCustomToken(''); + }).to.throw(FirebaseAppCheckError).with.property('code', 'app-check/invalid-argument'); + }); + + it('should be fulfilled with a Firebase Custom JWT', () => { + return tokenGenerator.createCustomToken(APP_ID) + .should.eventually.be.a('string').and.not.be.empty; + }); + + it('should be fulfilled with a JWT with the correct decoded payload', () => { + clock = sinon.useFakeTimers(1000); + + return tokenGenerator.createCustomToken(APP_ID) + .then((token) => { + const decoded = jwt.decode(token); + const expected: { [key: string]: any } = { + // eslint-disable-next-line @typescript-eslint/camelcase + app_id: APP_ID, + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: FIREBASE_APP_CHECK_AUDIENCE, + iss: mocks.certificateObject.client_email, + sub: mocks.certificateObject.client_email, + }; + + expect(decoded).to.deep.equal(expected); + }); + }); + + it('should be fulfilled with a JWT with the correct header', () => { + clock = sinon.useFakeTimers(1000); + + return tokenGenerator.createCustomToken(APP_ID) + .then((token) => { + const decoded: any = jwt.decode(token, { + complete: true, + }); + expect(decoded.header).to.deep.equal({ + alg: ALGORITHM, + typ: 'JWT', + }); + }); + }); + + it('should be fulfilled with a JWT which can be verified by the service account public key', () => { + return tokenGenerator.createCustomToken(APP_ID) + .then((token) => { + return verifyToken(token, mocks.keyPairs[0].public); + }); + }); + + it('should be fulfilled with a JWT which cannot be verified by a random public key', () => { + return tokenGenerator.createCustomToken(APP_ID) + .then((token) => { + return verifyToken(token, mocks.keyPairs[1].public) + .should.eventually.be.rejectedWith('invalid signature'); + }); + }); + + it('should be fulfilled with a JWT which expires after one hour', () => { + clock = sinon.useFakeTimers(1000); + + let token: string; + return tokenGenerator.createCustomToken(APP_ID) + .then((result) => { + token = result; + + clock!.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); + + // Token should still be valid + return verifyToken(token, mocks.keyPairs[0].public); + }) + .then(() => { + clock!.tick(1); + + // Token should now be invalid + return verifyToken(token, mocks.keyPairs[0].public) + .should.eventually.be.rejectedWith('jwt expired'); + }); + }); + + describe('appCheckErrorFromCryptoSignerError', () => { + it('should convert CryptoSignerError to FirebaseAppCheckError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.INVALID_ARGUMENT, + message: 'test error.', + }); + const appCheckError = appCheckErrorFromCryptoSignerError(cryptoError); + expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError); + expect(appCheckError).to.have.property('code', 'app-check/invalid-argument'); + expect(appCheckError).to.have.property('message', 'test error.'); + }); + + it('should convert CryptoSignerError HttpError to FirebaseAppCheckError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.SERVER_ERROR, + message: 'test error.', + cause: utils.errorFrom({ + error: { + message: 'server error.', + }, + }) + }); + const appCheckError = appCheckErrorFromCryptoSignerError(cryptoError); + expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError); + expect(appCheckError).to.have.property('code', 'app-check/unknown-error'); + expect(appCheckError).to.have.property('message', + 'Error returned from server while siging a custom token: server error.'); + }); + + it('should convert CryptoSignerError HttpError with no error.message to FirebaseAppCheckError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.SERVER_ERROR, + message: 'test error.', + cause: utils.errorFrom({ + error: {}, + }) + }); + const appCheckError = appCheckErrorFromCryptoSignerError(cryptoError); + expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError); + expect(appCheckError).to.have.property('code', 'app-check/unknown-error'); + expect(appCheckError).to.have.property('message', + 'Error returned from server while siging a custom token: '+ + '{"status":500,"headers":{},"data":{"error":{}},"text":"{\\"error\\":{}}"}'); + }); + + it('should convert CryptoSignerError HttpError with no errorcode to FirebaseAppCheckError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.SERVER_ERROR, + message: 'test error.', + cause: utils.errorFrom('server error.') + }); + const appCheckError = appCheckErrorFromCryptoSignerError(cryptoError); + expect(appCheckError).to.be.an.instanceof(FirebaseAppCheckError); + expect(appCheckError).to.have.property('code', 'app-check/internal-error'); + expect(appCheckError).to.have.property('message', + 'Error returned from server: null.'); + }); + }); + }); +}); diff --git a/test/unit/app-check/token-verifier.spec.ts b/test/unit/app-check/token-verifier.spec.ts new file mode 100644 index 0000000000..27d0be2fbf --- /dev/null +++ b/test/unit/app-check/token-verifier.spec.ts @@ -0,0 +1,245 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as mocks from '../../resources/mocks'; +import * as nock from 'nock'; + +import { AppCheckTokenVerifier } from '../../../src/app-check/token-verifier'; +import { JwtError, JwtErrorCode, PublicKeySignatureVerifier } from '../../../src/utils/jwt'; + +const expect = chai.expect; + +const ONE_HOUR_IN_SECONDS = 60 * 60; + +describe('AppCheckTokenVerifier', () => { + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + let tokenVerifier: AppCheckTokenVerifier; + let clock: sinon.SinonFakeTimers | undefined; + + before(() => { + tokenVerifier = new AppCheckTokenVerifier(mocks.app()); + }); + + after(() => { + nock.cleanAll(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + describe('verifyJWT()', () => { + let mockedRequests: nock.Scope[] = []; + let stubs: sinon.SinonStub[] = []; + + afterEach(() => { + _.forEach(mockedRequests, (mockedRequest) => mockedRequest.done()); + mockedRequests = []; + + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should throw given no App Check token', () => { + expect(() => { + (tokenVerifier as any).verifyToken(); + }).to.throw('App check token must be a non-null string'); + }); + + const invalidTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidTokens.forEach((invalidToken) => { + it('should throw given a non-string App Check token: ' + JSON.stringify(invalidToken), () => { + expect(() => { + tokenVerifier.verifyToken(invalidToken as any); + }).to.throw('App check token must be a non-null string'); + }); + }); + + it('should throw given an empty string App Check token', () => { + return tokenVerifier.verifyToken('') + .should.eventually.be.rejectedWith('Decoding App Check token failed'); + }); + + it('should be rejected given an invalid App Check token', () => { + return tokenVerifier.verifyToken('invalid-token') + .should.eventually.be.rejectedWith('Decoding App Check token failed'); + }); + + it('should throw if the token verifier was initialized with no "project_id"', () => { + const tokenVerifierWithNoProjectId = new AppCheckTokenVerifier(mocks.mockCredentialApp()); + const expected = 'Must initialize app with a cert credential or set your Firebase project ID as ' + + 'the GOOGLE_CLOUD_PROJECT environment variable to verify an App Check token.'; + return tokenVerifierWithNoProjectId.verifyToken('app.check.token') + .should.eventually.be.rejectedWith(expected); + }); + + it('should be rejected given an App Check token with an incorrect algorithm', () => { + const mockAppCheckToken = mocks.generateAppCheckToken({ + algorithm: 'HS256', + }); + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has incorrect algorithm'); + }); + + const invalidAudiences = [ + 'incorrectAudience', [], [mocks.projectNumber, mocks.projectId], + ['projects/' + mocks.projectNumber, mocks.projectId] + ]; + invalidAudiences.forEach((invalidAudience) => { + it('should be rejected given an App Check token with an incorrect audience:' + + JSON.stringify(invalidAudience), () => { + const mockAppCheckToken = mocks.generateAppCheckToken({ + audience: invalidAudience, + }); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has incorrect "aud" (audience) claim'); + }); + }); + + it('should be rejected given an App Check token with an incorrect issuer', () => { + const mockAppCheckToken = mocks.generateAppCheckToken({ + issuer: 'incorrectIssuer', + }); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has incorrect "iss" (issuer) claim'); + }); + + it('should be rejected given an App Check token with an empty subject', () => { + const mockAppCheckToken = mocks.generateAppCheckToken({ + subject: '', + }); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has an empty string "sub" (subject) claim'); + }); + + it('should be rejected when the verifier throws no maching kid error', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.NO_MATCHING_KID, 'No matching key ID.')); + stubs.push(verifierStub); + + const mockAppCheckToken = mocks.generateAppCheckToken({ + header: { + kid: 'wrongkid', + }, + }); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has "kid" claim which does not ' + + 'correspond to a known public key'); + }); + + it('should be rejected when the verifier throws expired token error', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.TOKEN_EXPIRED, 'Expired token.')); + stubs.push(verifierStub); + + const mockAppCheckToken = mocks.generateAppCheckToken(); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has expired. ' + + 'Get a fresh App Check token from your client app and try again.') + .and.have.property('code', 'app-check/app-check-token-expired'); + }); + + it('should be rejected when the verifier throws invalid signature error.', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.INVALID_SIGNATURE, 'invalid signature.')); + stubs.push(verifierStub); + + const mockAppCheckToken = mocks.generateAppCheckToken(); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('The provided App Check token has invalid signature'); + }); + + it('should be rejected when the verifier throws key fetch error.', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .rejects(new JwtError(JwtErrorCode.KEY_FETCH_ERROR, 'Error fetching Json Web Keys.')); + stubs.push(verifierStub); + + const mockAppCheckToken = mocks.generateAppCheckToken(); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.rejectedWith('Error fetching Json Web Keys.'); + }); + + it('should be fulfilled when the kid is not present in the header (should try all the keys)', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .resolves(); + stubs.push(verifierStub); + + clock = sinon.useFakeTimers(1000); + + const mockAppCheckToken = mocks.generateAppCheckToken({ + header: {}, + }); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.fulfilled.and.deep.equal({ + one: 'uno', + two: 'dos', + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: ['projects/' + mocks.projectNumber, 'projects/' + mocks.projectId], + iss: 'https://firebaseappcheck.googleapis.com/' + mocks.projectNumber, + sub: mocks.appId, + // eslint-disable-next-line @typescript-eslint/camelcase + app_id: mocks.appId, + }); + }); + + it('should be fulfilled with decoded claims given a valid App Check token', () => { + const verifierStub = sinon.stub(PublicKeySignatureVerifier.prototype, 'verify') + .resolves(); + stubs.push(verifierStub); + + clock = sinon.useFakeTimers(1000); + + const mockAppCheckToken = mocks.generateAppCheckToken(); + + return tokenVerifier.verifyToken(mockAppCheckToken) + .should.eventually.be.fulfilled.and.deep.equal({ + one: 'uno', + two: 'dos', + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: ['projects/' + mocks.projectNumber, 'projects/' + mocks.projectId], + iss: 'https://firebaseappcheck.googleapis.com/' + mocks.projectNumber, + sub: mocks.appId, + // eslint-disable-next-line @typescript-eslint/camelcase + app_id: mocks.appId, + }); + }); + + }); +}); diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index c519c7a3ed..6a6d148b09 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -26,14 +26,13 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { - BLACKLISTED_CLAIMS, FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner, EmulatedSigner + BLACKLISTED_CLAIMS, FirebaseTokenGenerator, EmulatedSigner, handleCryptoSignerError } from '../../../src/auth/token-generator'; +import { CryptoSignerError, CryptoSignerErrorCode, ServiceAccountSigner } from '../../../src/utils/crypto-signer'; import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; -import { AuthorizedHttpClient, HttpClient } from '../../../src/utils/api-request'; -import { FirebaseApp } from '../../../src/firebase-app'; -import * as utils from '../utils'; import { FirebaseAuthError } from '../../../src/utils/error'; +import * as utils from '../utils'; chai.should(); chai.use(sinonChai); @@ -66,195 +65,6 @@ function verifyToken(token: string, publicKey: string): Promise { }); } -describe('CryptoSigner', () => { - describe('ServiceAccountSigner', () => { - it('should throw given no arguments', () => { - expect(() => { - const anyServiceAccountSigner: any = ServiceAccountSigner; - return new anyServiceAccountSigner(); - }).to.throw('Must provide a service account credential to initialize ServiceAccountSigner'); - }); - - it('should not throw given a valid certificate', () => { - expect(() => { - return new ServiceAccountSigner(new ServiceAccountCredential(mocks.certificateObject)); - }).not.to.throw(); - }); - - it('should sign using the private_key in the certificate', () => { - const payload = Buffer.from('test'); - const cert = new ServiceAccountCredential(mocks.certificateObject); - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const crypto = require('crypto'); - const rsa = crypto.createSign('RSA-SHA256'); - rsa.update(payload); - const result = rsa.sign(cert.privateKey, 'base64'); - - const signer = new ServiceAccountSigner(cert); - return signer.sign(payload).then((signature) => { - expect(signature.toString('base64')).to.equal(result); - }); - }); - - it('should return the client_email from the certificate', () => { - const cert = new ServiceAccountCredential(mocks.certificateObject); - const signer = new ServiceAccountSigner(cert); - return signer.getAccountId().should.eventually.equal(cert.clientEmail); - }); - }); - - describe('IAMSigner', () => { - let mockApp: FirebaseApp; - let getTokenStub: sinon.SinonStub; - const mockAccessToken: string = utils.generateRandomAccessToken(); - - beforeEach(() => { - mockApp = mocks.app(); - getTokenStub = utils.stubGetAccessToken(mockAccessToken, mockApp); - return mockApp.INTERNAL.getToken(); - }); - - afterEach(() => { - getTokenStub.restore(); - return mockApp.delete(); - }); - - it('should throw given no arguments', () => { - expect(() => { - const anyIAMSigner: any = IAMSigner; - return new anyIAMSigner(); - }).to.throw('Must provide a HTTP client to initialize IAMSigner'); - }); - - describe('explicit service account ID', () => { - const response = { signedBlob: Buffer.from('testsignature').toString('base64') }; - const input = Buffer.from('input'); - const signRequest = { - method: 'POST', - url: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-service-account:signBlob', - headers: { Authorization: `Bearer ${mockAccessToken}` }, - data: { payload: input.toString('base64') }, - }; - let stub: sinon.SinonStub; - - afterEach(() => { - stub.restore(); - }); - - it('should sign using the IAM service', () => { - const expectedResult = utils.responseFrom(response); - stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - const requestHandler = new AuthorizedHttpClient(mockApp); - const signer = new IAMSigner(requestHandler, 'test-service-account'); - return signer.sign(input).then((signature) => { - expect(signature.toString('base64')).to.equal(response.signedBlob); - expect(stub).to.have.been.calledOnce.and.calledWith(signRequest); - }); - }); - - it('should fail if the IAM service responds with an error', () => { - const expectedResult = utils.errorFrom({ - error: { - status: 'PROJECT_NOT_FOUND', - message: 'test reason', - }, - }); - stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); - const requestHandler = new AuthorizedHttpClient(mockApp); - const signer = new IAMSigner(requestHandler, 'test-service-account'); - return signer.sign(input).catch((err) => { - const message = 'test reason; Please refer to ' + - 'https://firebase.google.com/docs/auth/admin/create-custom-tokens for more details on ' + - 'how to use and troubleshoot this feature.'; - expect(err.message).to.equal(message); - expect(stub).to.have.been.calledOnce.and.calledWith(signRequest); - }); - }); - - it('should return the explicitly specified service account', () => { - const signer = new IAMSigner(new AuthorizedHttpClient(mockApp), 'test-service-account'); - return signer.getAccountId().should.eventually.equal('test-service-account'); - }); - }); - - describe('auto discovered service account', () => { - const input = Buffer.from('input'); - const response = { signedBlob: Buffer.from('testsignature').toString('base64') }; - const metadataRequest = { - method: 'GET', - url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', - headers: { 'Metadata-Flavor': 'Google' }, - }; - const signRequest = { - method: 'POST', - url: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/discovered-service-account:signBlob', - headers: { Authorization: `Bearer ${mockAccessToken}` }, - data: { payload: input.toString('base64') }, - }; - let stub: sinon.SinonStub; - - afterEach(() => { - stub.restore(); - }); - - it('should sign using the IAM service', () => { - stub = sinon.stub(HttpClient.prototype, 'send'); - stub.onCall(0).resolves(utils.responseFrom('discovered-service-account')); - stub.onCall(1).resolves(utils.responseFrom(response)); - const requestHandler = new AuthorizedHttpClient(mockApp); - const signer = new IAMSigner(requestHandler); - return signer.sign(input).then((signature) => { - expect(signature.toString('base64')).to.equal(response.signedBlob); - expect(stub).to.have.been.calledTwice; - expect(stub.getCall(0).args[0]).to.deep.equal(metadataRequest); - expect(stub.getCall(1).args[0]).to.deep.equal(signRequest); - }); - }); - - it('should fail if the IAM service responds with an error', () => { - const expectedResult = { - error: { - status: 'PROJECT_NOT_FOUND', - message: 'test reason', - }, - }; - stub = sinon.stub(HttpClient.prototype, 'send'); - stub.onCall(0).resolves(utils.responseFrom('discovered-service-account')); - stub.onCall(1).rejects(utils.errorFrom(expectedResult)); - const requestHandler = new AuthorizedHttpClient(mockApp); - const signer = new IAMSigner(requestHandler); - return signer.sign(input).catch((err) => { - const message = 'test reason; Please refer to ' + - 'https://firebase.google.com/docs/auth/admin/create-custom-tokens for more details on ' + - 'how to use and troubleshoot this feature.'; - expect(err.message).to.equal(message); - expect(stub).to.have.been.calledTwice; - expect(stub.getCall(0).args[0]).to.deep.equal(metadataRequest); - expect(stub.getCall(1).args[0]).to.deep.equal(signRequest); - }); - }); - - it('should return the discovered service account', () => { - stub = sinon.stub(HttpClient.prototype, 'send'); - stub.onCall(0).resolves(utils.responseFrom('discovered-service-account')); - const signer = new IAMSigner(new AuthorizedHttpClient(mockApp)); - return signer.getAccountId().should.eventually.equal('discovered-service-account'); - }); - - it('should return the expected error when failed to contact the Metadata server', () => { - stub = sinon.stub(HttpClient.prototype, 'send'); - stub.onCall(0).rejects(utils.errorFrom('test error')); - const signer = new IAMSigner(new AuthorizedHttpClient(mockApp)); - const expected = 'Failed to determine service account. Make sure to initialize the SDK with ' + - 'a service account credential. Alternatively specify a service account with ' + - 'iam.serviceAccounts.signBlob permission.'; - return signer.getAccountId().should.eventually.be.rejectedWith(expected); - }); - }); - }); -}); - describe('FirebaseTokenGenerator', () => { const tenantId = 'tenantId1'; const cert = new ServiceAccountCredential(mocks.certificateObject); @@ -384,7 +194,7 @@ describe('FirebaseTokenGenerator', () => { BLACKLISTED_CLAIMS.forEach((blacklistedClaim) => { it('should throw given a developer claims object with a blacklisted claim: ' + blacklistedClaim, () => { - const blacklistedDeveloperClaims: {[key: string]: any} = _.clone(mocks.developerClaims); + const blacklistedDeveloperClaims: { [key: string]: any } = _.clone(mocks.developerClaims); blacklistedDeveloperClaims[blacklistedClaim] = true; expect(() => { tokenGenerator.createCustomToken(mocks.uid, blacklistedDeveloperClaims); @@ -415,7 +225,7 @@ describe('FirebaseTokenGenerator', () => { return tokenGenerator.createCustomToken(mocks.uid) .then((token) => { const decoded = jwt.decode(token); - const expected: {[key: string]: any} = { + const expected: { [key: string]: any } = { uid: mocks.uid, iat: 1, exp: ONE_HOUR_IN_SECONDS + 1, @@ -440,7 +250,7 @@ describe('FirebaseTokenGenerator', () => { .then((token) => { const decoded = jwt.decode(token); - const expected: {[key: string]: any} = { + const expected: { [key: string]: any } = { uid: mocks.uid, iat: 1, exp: ONE_HOUR_IN_SECONDS + 1, @@ -526,4 +336,47 @@ describe('FirebaseTokenGenerator', () => { }); }); }); + + describe('handleCryptoSignerError', () => { + it('should convert CryptoSignerError to FirebaseAuthError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.INVALID_ARGUMENT, + message: 'test error.', + }); + const authError = handleCryptoSignerError(cryptoError); + expect(authError).to.be.an.instanceof(FirebaseAuthError); + expect(authError).to.have.property('code', 'auth/argument-error'); + expect(authError).to.have.property('message', 'test error.'); + }); + + it('should convert CryptoSignerError HttpError to FirebaseAuthError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.SERVER_ERROR, + message: 'test error.', + cause: utils.errorFrom({ + error: { + message: 'server error.', + }, + }) + }); + const authError = handleCryptoSignerError(cryptoError); + expect(authError).to.be.an.instanceof(FirebaseAuthError); + expect(authError).to.have.property('code', 'auth/internal-error'); + expect(authError).to.have.property('message', 'server error.; Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens for more details on how to use and troubleshoot this feature. Raw server response: "{"error":{"message":"server error."}}"'); + }); + + it('should convert CryptoSignerError HttpError with no errorcode to FirebaseAuthError', () => { + const cryptoError = new CryptoSignerError({ + code: CryptoSignerErrorCode.SERVER_ERROR, + message: 'test error.', + cause: utils.errorFrom('server error.') + }); + const authError = handleCryptoSignerError(cryptoError); + expect(authError).to.be.an.instanceof(FirebaseAuthError); + expect(authError).to.have.property('code', 'auth/internal-error'); + expect(authError).to.have.property('message', + 'Error returned from server: null. Additionally, an internal error occurred ' + + 'while attempting to extract the errorcode from the error.'); + }); + }); }); diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index 1f7e3546f8..a8afe7167b 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -27,7 +27,8 @@ import { Agent } from 'http'; import LegacyFirebaseTokenGenerator = require('firebase-token-generator'); import * as mocks from '../../resources/mocks'; -import { FirebaseTokenGenerator, ServiceAccountSigner } from '../../../src/auth/token-generator'; +import { FirebaseTokenGenerator } from '../../../src/auth/token-generator'; +import { ServiceAccountSigner } from '../../../src/utils/crypto-signer'; import * as verifier from '../../../src/auth/token-verifier'; import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; diff --git a/test/unit/firebase-app.spec.ts b/test/unit/firebase-app.spec.ts index 6edf73b7ef..0989e718d0 100644 --- a/test/unit/firebase-app.spec.ts +++ b/test/unit/firebase-app.spec.ts @@ -41,6 +41,7 @@ import { instanceId } from '../../src/instance-id/index'; import { projectManagement } from '../../src/project-management/index'; import { securityRules } from '../../src/security-rules/index'; import { remoteConfig } from '../../src/remote-config/index'; +import { appCheck } from '../../src/app-check/index'; import { FirebaseAppError, AppErrorCodes } from '../../src/utils/error'; import Auth = auth.Auth; @@ -53,6 +54,7 @@ import InstanceId = instanceId.InstanceId; import ProjectManagement = projectManagement.ProjectManagement; import SecurityRules = securityRules.SecurityRules; import RemoteConfig = remoteConfig.RemoteConfig; +import AppCheck = appCheck.AppCheck; chai.should(); chai.use(sinonChai); @@ -669,6 +671,32 @@ describe('FirebaseApp', () => { }); }); + describe('appCheck()', () => { + it('should throw if the app has already been deleted', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + + return app.delete().then(() => { + expect(() => { + return app.appCheck(); + }).to.throw(`Firebase app named "${mocks.appName}" has already been deleted.`); + }); + }); + + it('should return the AppCheck client', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + + const appCheck: AppCheck = app.appCheck(); + expect(appCheck).to.not.be.null; + }); + + it('should return a cached version of AppCheck on subsequent calls', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + const service1: AppCheck = app.appCheck(); + const service2: AppCheck = app.appCheck(); + expect(service1).to.equal(service2); + }); + }); + describe('INTERNAL.getToken()', () => { it('throws a custom credential implementation which returns invalid access tokens', () => { diff --git a/test/unit/firebase-namespace.spec.ts b/test/unit/firebase-namespace.spec.ts index 07e5a8ba78..e2efb84f4e 100644 --- a/test/unit/firebase-namespace.spec.ts +++ b/test/unit/firebase-namespace.spec.ts @@ -56,7 +56,9 @@ import { instanceId } from '../../src/instance-id/index'; import { projectManagement } from '../../src/project-management/index'; import { securityRules } from '../../src/security-rules/index'; import { remoteConfig } from '../../src/remote-config/index'; +import { appCheck } from '../../src/app-check/index'; +import { AppCheck as AppCheckImpl } from '../../src/app-check/app-check'; import { Auth as AuthImpl } from '../../src/auth/auth'; import { InstanceId as InstanceIdImpl } from '../../src/instance-id/instance-id'; import { MachineLearning as MachineLearningImpl } from '../../src/machine-learning/machine-learning'; @@ -67,6 +69,7 @@ import { SecurityRules as SecurityRulesImpl } from '../../src/security-rules/sec import { Storage as StorageImpl } from '../../src/storage/storage'; import App = app.App; +import AppCheck = appCheck.AppCheck; import Auth = auth.Auth; import Database = database.Database; import Firestore = firestore.Firestore; @@ -759,4 +762,42 @@ describe('FirebaseNamespace', () => { expect(service1).to.equal(service2); }); }); + + describe('#appCheck()', () => { + it('should throw when called before initializing an app', () => { + expect(() => { + firebaseNamespace.appCheck(); + }).to.throw(DEFAULT_APP_NOT_FOUND); + }); + + it('should throw when default app is not initialized', () => { + firebaseNamespace.initializeApp(mocks.appOptions, 'testApp'); + expect(() => { + firebaseNamespace.appCheck(); + }).to.throw(DEFAULT_APP_NOT_FOUND); + }); + + it('should return a valid namespace when the default app is initialized', () => { + const app: App = firebaseNamespace.initializeApp(mocks.appOptions); + const fac: AppCheck = firebaseNamespace.appCheck(); + expect(fac.app).to.be.deep.equal(app); + }); + + it('should return a valid namespace when the named app is initialized', () => { + const app: App = firebaseNamespace.initializeApp(mocks.appOptions, 'testApp'); + const fac: AppCheck = firebaseNamespace.appCheck(app); + expect(fac.app).to.be.deep.equal(app); + }); + + it('should return a reference to AppCheck type', () => { + expect(firebaseNamespace.appCheck.AppCheck).to.be.deep.equal(AppCheckImpl); + }); + + it('should return a cached version of AppCheck on subsequent calls', () => { + firebaseNamespace.initializeApp(mocks.appOptions); + const service1: AppCheck = firebaseNamespace.appCheck(); + const service2: AppCheck = firebaseNamespace.appCheck(); + expect(service1).to.equal(service2); + }); + }); }); diff --git a/test/unit/firebase.spec.ts b/test/unit/firebase.spec.ts index 3048121529..da1256a2c2 100644 --- a/test/unit/firebase.spec.ts +++ b/test/unit/firebase.spec.ts @@ -235,6 +235,21 @@ describe('Firebase', () => { }); }); + describe('#appCheck', () => { + it('should throw if the app has not been initialized', () => { + expect(() => { + return firebaseAdmin.appCheck(); + }).to.throw('The default Firebase app does not exist.'); + }); + + it('should return the appCheck service', () => { + firebaseAdmin.initializeApp(mocks.appOptions); + expect(() => { + return firebaseAdmin.appCheck(); + }).not.to.throw(); + }); + }); + describe('#storage', () => { it('should throw if the app has not be initialized', () => { expect(() => { diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index e8c5a6d17d..48c87e24e4 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -26,6 +26,7 @@ import './utils/error.spec'; import './utils/validator.spec'; import './utils/api-request.spec'; import './utils/jwt.spec'; +import './utils/crypto-signer.spec'; // Auth import './auth/auth.spec'; @@ -76,3 +77,9 @@ import './security-rules/security-rules-api-client.spec'; // RemoteConfig import './remote-config/remote-config.spec'; import './remote-config/remote-config-api-client.spec'; + +// AppCheck +import './app-check/app-check.spec'; +import './app-check/app-check-api-client-internal.spec'; +import './app-check/token-generator.spec'; +import './app-check/token-verifier.spec.ts'; diff --git a/test/unit/utils/crypto-signer.spec.ts b/test/unit/utils/crypto-signer.spec.ts new file mode 100644 index 0000000000..9a59fd10eb --- /dev/null +++ b/test/unit/utils/crypto-signer.spec.ts @@ -0,0 +1,224 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import { ServiceAccountSigner, IAMSigner, CryptoSignerError } from '../../../src/utils/crypto-signer'; + +import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; +import { AuthorizedHttpClient, HttpClient } from '../../../src/utils/api-request'; +import { FirebaseApp } from '../../../src/firebase-app'; +import * as utils from '../utils'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('CryptoSigner', () => { + describe('ServiceAccountSigner', () => { + it('should throw given no arguments', () => { + expect(() => { + const anyServiceAccountSigner: any = ServiceAccountSigner; + return new anyServiceAccountSigner(); + }).to.throw('Must provide a service account credential to initialize ServiceAccountSigner'); + }); + + it('should not throw given a valid certificate', () => { + expect(() => { + return new ServiceAccountSigner(new ServiceAccountCredential(mocks.certificateObject)); + }).not.to.throw(); + }); + + it('should sign using the private_key in the certificate', () => { + const payload = Buffer.from('test'); + const cert = new ServiceAccountCredential(mocks.certificateObject); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require('crypto'); + const rsa = crypto.createSign('RSA-SHA256'); + rsa.update(payload); + const result = rsa.sign(cert.privateKey, 'base64'); + + const signer = new ServiceAccountSigner(cert); + return signer.sign(payload).then((signature) => { + expect(signature.toString('base64')).to.equal(result); + }); + }); + + it('should return the client_email from the certificate', () => { + const cert = new ServiceAccountCredential(mocks.certificateObject); + const signer = new ServiceAccountSigner(cert); + return signer.getAccountId().should.eventually.equal(cert.clientEmail); + }); + }); + + describe('IAMSigner', () => { + let mockApp: FirebaseApp; + let getTokenStub: sinon.SinonStub; + const mockAccessToken: string = utils.generateRandomAccessToken(); + + beforeEach(() => { + mockApp = mocks.app(); + getTokenStub = utils.stubGetAccessToken(mockAccessToken, mockApp); + return mockApp.INTERNAL.getToken(); + }); + + afterEach(() => { + getTokenStub.restore(); + return mockApp.delete(); + }); + + it('should throw given no arguments', () => { + expect(() => { + const anyIAMSigner: any = IAMSigner; + return new anyIAMSigner(); + }).to.throw('Must provide a HTTP client to initialize IAMSigner'); + }); + + describe('explicit service account ID', () => { + const response = { signedBlob: Buffer.from('testsignature').toString('base64') }; + const input = Buffer.from('input'); + const signRequest = { + method: 'POST', + url: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-service-account:signBlob', + headers: { Authorization: `Bearer ${mockAccessToken}` }, + data: { payload: input.toString('base64') }, + }; + let stub: sinon.SinonStub; + + afterEach(() => { + stub.restore(); + }); + + it('should sign using the IAM service', () => { + const expectedResult = utils.responseFrom(response); + stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + const requestHandler = new AuthorizedHttpClient(mockApp); + const signer = new IAMSigner(requestHandler, 'test-service-account'); + return signer.sign(input).then((signature) => { + expect(signature.toString('base64')).to.equal(response.signedBlob); + expect(stub).to.have.been.calledOnce.and.calledWith(signRequest); + }); + }); + + it('should fail if the IAM service responds with an error', () => { + const expectedResult = utils.errorFrom({ + error: { + status: 'PROJECT_NOT_FOUND', + message: 'test reason', + }, + }); + stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + const requestHandler = new AuthorizedHttpClient(mockApp); + const signer = new IAMSigner(requestHandler, 'test-service-account'); + return signer.sign(input).catch((err) => { + expect(err).to.be.instanceOf(CryptoSignerError); + expect(err.message).to.equal('Server responded with status 500.'); + expect(err.cause).to.deep.equal(expectedResult); + expect(stub).to.have.been.calledOnce.and.calledWith(signRequest); + }); + }); + + it('should return the explicitly specified service account', () => { + const signer = new IAMSigner(new AuthorizedHttpClient(mockApp), 'test-service-account'); + return signer.getAccountId().should.eventually.equal('test-service-account'); + }); + }); + + describe('auto discovered service account', () => { + const input = Buffer.from('input'); + const response = { signedBlob: Buffer.from('testsignature').toString('base64') }; + const metadataRequest = { + method: 'GET', + url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', + headers: { 'Metadata-Flavor': 'Google' }, + }; + const signRequest = { + method: 'POST', + url: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/discovered-service-account:signBlob', + headers: { Authorization: `Bearer ${mockAccessToken}` }, + data: { payload: input.toString('base64') }, + }; + let stub: sinon.SinonStub; + + afterEach(() => { + stub.restore(); + }); + + it('should sign using the IAM service', () => { + stub = sinon.stub(HttpClient.prototype, 'send'); + stub.onCall(0).resolves(utils.responseFrom('discovered-service-account')); + stub.onCall(1).resolves(utils.responseFrom(response)); + const requestHandler = new AuthorizedHttpClient(mockApp); + const signer = new IAMSigner(requestHandler); + return signer.sign(input).then((signature) => { + expect(signature.toString('base64')).to.equal(response.signedBlob); + expect(stub).to.have.been.calledTwice; + expect(stub.getCall(0).args[0]).to.deep.equal(metadataRequest); + expect(stub.getCall(1).args[0]).to.deep.equal(signRequest); + }); + }); + + it('should fail if the IAM service responds with an error', () => { + const expectedResult = utils.errorFrom({ + error: { + status: 'PROJECT_NOT_FOUND', + message: 'test reason', + }, + }); + stub = sinon.stub(HttpClient.prototype, 'send'); + stub.onCall(0).resolves(utils.responseFrom('discovered-service-account')); + stub.onCall(1).rejects(expectedResult); + const requestHandler = new AuthorizedHttpClient(mockApp); + const signer = new IAMSigner(requestHandler); + return signer.sign(input).catch((err) => { + expect(err).to.be.instanceOf(CryptoSignerError); + expect(err.message).to.equal('Server responded with status 500.'); + expect(err.cause).to.deep.equal(expectedResult); + expect(stub).to.have.been.calledTwice; + expect(stub.getCall(0).args[0]).to.deep.equal(metadataRequest); + expect(stub.getCall(1).args[0]).to.deep.equal(signRequest); + }); + }); + + it('should return the discovered service account', () => { + stub = sinon.stub(HttpClient.prototype, 'send'); + stub.onCall(0).resolves(utils.responseFrom('discovered-service-account')); + const signer = new IAMSigner(new AuthorizedHttpClient(mockApp)); + return signer.getAccountId().should.eventually.equal('discovered-service-account'); + }); + + it('should return the expected error when failed to contact the Metadata server', () => { + stub = sinon.stub(HttpClient.prototype, 'send'); + stub.onCall(0).rejects(utils.errorFrom('test error')); + const signer = new IAMSigner(new AuthorizedHttpClient(mockApp)); + const expected = 'Failed to determine service account. Make sure to initialize the SDK with ' + + 'a service account credential. Alternatively specify a service account with ' + + 'iam.serviceAccounts.signBlob permission.'; + return signer.getAccountId().should.eventually.be.rejectedWith(expected); + }); + }); + }); +}); diff --git a/test/unit/utils/jwt.spec.ts b/test/unit/utils/jwt.spec.ts index 525608feef..775bdd63b9 100644 --- a/test/unit/utils/jwt.spec.ts +++ b/test/unit/utils/jwt.spec.ts @@ -23,16 +23,19 @@ import * as _ from 'lodash'; import * as chai from 'chai'; import * as nock from 'nock'; import * as sinon from 'sinon'; -//import * as sinonChai from 'sinon-chai'; -//import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; -import * as jwtUtil from '../../../src/utils/jwt'; +import { + ALGORITHM_RS256, DecodedToken, decodeJwt, EmulatorSignatureVerifier, JwksFetcher, + JwtErrorCode, PublicKeySignatureVerifier, UrlKeyFetcher, verifyJwtSignature +} from '../../../src/utils/jwt'; const expect = chai.expect; const ONE_HOUR_IN_SECONDS = 60 * 60; +const ONE_DAY_IN_SECONDS = 86400; const publicCertPath = '/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; +const jwksPath = '/v1alpha/jwks'; /** * Returns a mocked out success response from the URL containing the public keys for the Google certs. @@ -80,6 +83,43 @@ function mockFailedFetchPublicKeys(): nock.Scope { .replyWithError('message'); } +/** + * Returns a mocked out success JWKS response. + * + * @return {Object} A nock response object. + */ +function mockFetchJsonWebKeys(path: string = jwksPath): nock.Scope { + return nock('https://firebaseappcheck.googleapis.com') + .get(path) + .reply(200, mocks.jwksResponse); +} + +/** + * Returns a mocked out error response for JWKS. + * The status code is 200 but the response itself will contain an 'error' key. + * + * @return {Object} A nock response object. + */ +function mockFetchJsonWebKeysWithErrorResponse(): nock.Scope { + return nock('https://firebaseappcheck.googleapis.com') + .get(jwksPath) + .reply(200, { + error: 'message', + error_description: 'description', // eslint-disable-line @typescript-eslint/camelcase + }); +} + +/** + * Returns a mocked out failed JSON Web Keys response. + * The status code is non-200 and the response itself will fail. + * + * @return {Object} A nock response object. + */ +function mockFailedFetchJsonWebKeys(): nock.Scope { + return nock('https://firebaseappcheck.googleapis.com') + .get(jwksPath) + .replyWithError('message'); +} const TOKEN_PAYLOAD = { one: 'uno', @@ -91,7 +131,7 @@ const TOKEN_PAYLOAD = { sub: mocks.uid, }; -const DECODED_SIGNED_TOKEN: jwtUtil.DecodedToken = { +const DECODED_SIGNED_TOKEN: DecodedToken = { header: { alg: 'RS256', kid: 'aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd', @@ -100,7 +140,7 @@ const DECODED_SIGNED_TOKEN: jwtUtil.DecodedToken = { payload: TOKEN_PAYLOAD }; -const DECODED_UNSIGNED_TOKEN: jwtUtil.DecodedToken = { +const DECODED_UNSIGNED_TOKEN: DecodedToken = { header: { alg: 'none', typ: 'JWT', @@ -122,25 +162,25 @@ describe('decodeJwt', () => { }); it('should reject given no token', () => { - return (jwtUtil.decodeJwt as any)() + return (decodeJwt as any)() .should.eventually.be.rejectedWith('The provided token must be a string.'); }); const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; invalidIdTokens.forEach((invalidIdToken) => { it('should reject given a non-string token: ' + JSON.stringify(invalidIdToken), () => { - return jwtUtil.decodeJwt(invalidIdToken as any) + return decodeJwt(invalidIdToken as any) .should.eventually.be.rejectedWith('The provided token must be a string.'); }); }); it('should reject given an empty string token', () => { - return jwtUtil.decodeJwt('') + return decodeJwt('') .should.eventually.be.rejectedWith('Decoding token failed.'); }); it('should reject given an invalid token', () => { - return jwtUtil.decodeJwt('invalid-token') + return decodeJwt('invalid-token') .should.eventually.be.rejectedWith('Decoding token failed.'); }); @@ -149,7 +189,7 @@ describe('decodeJwt', () => { const mockIdToken = mocks.generateIdToken(); - return jwtUtil.decodeJwt(mockIdToken) + return decodeJwt(mockIdToken) .should.eventually.be.fulfilled.and.deep.equal(DECODED_SIGNED_TOKEN); }); @@ -161,7 +201,7 @@ describe('decodeJwt', () => { header: {} }); - return jwtUtil.decodeJwt(mockIdToken) + return decodeJwt(mockIdToken) .should.eventually.be.fulfilled.and.deep.equal(DECODED_UNSIGNED_TOKEN); }); }); @@ -178,28 +218,28 @@ describe('verifyJwtSignature', () => { }); it('should throw given no token', () => { - return (jwtUtil.verifyJwtSignature as any)() + return (verifyJwtSignature as any)() .should.eventually.be.rejectedWith('The provided token must be a string.'); }); const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; invalidIdTokens.forEach((invalidIdToken) => { it('should reject given a non-string token: ' + JSON.stringify(invalidIdToken), () => { - return jwtUtil.verifyJwtSignature(invalidIdToken as any, mocks.keyPairs[0].public) + return verifyJwtSignature(invalidIdToken as any, mocks.keyPairs[0].public) .should.eventually.be.rejectedWith('The provided token must be a string.'); }); }); it('should reject given an empty string token', () => { - return jwtUtil.verifyJwtSignature('', mocks.keyPairs[0].public) + return verifyJwtSignature('', mocks.keyPairs[0].public) .should.eventually.be.rejectedWith('jwt must be provided'); }); it('should be fulfilled given a valid signed token and public key', () => { const mockIdToken = mocks.generateIdToken(); - return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: [ALGORITHM_RS256] }) .should.eventually.be.fulfilled; }); @@ -209,7 +249,7 @@ describe('verifyJwtSignature', () => { header: {} }); - return jwtUtil.verifyJwtSignature(mockIdToken, '') + return verifyJwtSignature(mockIdToken, '') .should.eventually.be.fulfilled; }); @@ -217,18 +257,18 @@ describe('verifyJwtSignature', () => { const mockIdToken = mocks.generateIdToken(); const getKeyCallback = (_: any, callback: any): void => callback(null, mocks.keyPairs[0].public); - return jwtUtil.verifyJwtSignature(mockIdToken, getKeyCallback, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, getKeyCallback, + { algorithms: [ALGORITHM_RS256] }) .should.eventually.be.fulfilled; }); it('should be rejected when the given algorithm does not match the token', () => { const mockIdToken = mocks.generateIdToken(); - return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + return verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, { algorithms: ['RS384'] }) .should.eventually.be.rejectedWith('invalid algorithm') - .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + .with.property('code', JwtErrorCode.INVALID_SIGNATURE); }); it('should be rejected given an expired token', () => { @@ -237,18 +277,18 @@ describe('verifyJwtSignature', () => { clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); // token should still be valid - return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: [ALGORITHM_RS256] }) .then(() => { clock!.tick(1); // token should now be invalid - return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, mocks.keyPairs[0].public, + { algorithms: [ALGORITHM_RS256] }) .should.eventually.be.rejectedWith( 'The provided token has expired. Get a fresh token from your client app and try again.' ) - .with.property('code', jwtUtil.JwtErrorCode.TOKEN_EXPIRED); + .with.property('code', JwtErrorCode.TOKEN_EXPIRED); }); }); @@ -257,10 +297,10 @@ describe('verifyJwtSignature', () => { const getKeyCallback = (_: any, callback: any): void => callback(new Error('key fetch failed.')); - return jwtUtil.verifyJwtSignature(mockIdToken, getKeyCallback, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, getKeyCallback, + { algorithms: [ALGORITHM_RS256] }) .should.eventually.be.rejectedWith('key fetch failed.') - .with.property('code', jwtUtil.JwtErrorCode.KEY_FETCH_ERROR); + .with.property('code', JwtErrorCode.KEY_FETCH_ERROR); }); it('should be rejected with correct no matching key id found error.', () => { @@ -268,43 +308,49 @@ describe('verifyJwtSignature', () => { const getKeyCallback = (_: any, callback: any): void => callback(new Error('no-matching-kid-error')); - return jwtUtil.verifyJwtSignature(mockIdToken, getKeyCallback, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, getKeyCallback, + { algorithms: [ALGORITHM_RS256] }) .should.eventually.be.rejectedWith('no-matching-kid-error') - .with.property('code', jwtUtil.JwtErrorCode.NO_MATCHING_KID); + .with.property('code', JwtErrorCode.NO_MATCHING_KID); }); it('should be rejected given a public key that does not match the token.', () => { const mockIdToken = mocks.generateIdToken(); - return jwtUtil.verifyJwtSignature(mockIdToken, mocks.keyPairs[1].public, - { algorithms: [jwtUtil.ALGORITHM_RS256] }) + return verifyJwtSignature(mockIdToken, mocks.keyPairs[1].public, + { algorithms: [ALGORITHM_RS256] }) .should.eventually.be.rejectedWith('invalid signature') - .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + .with.property('code', JwtErrorCode.INVALID_SIGNATURE); }); it('should be rejected given an invalid JWT.', () => { - return jwtUtil.verifyJwtSignature('invalid-token', mocks.keyPairs[0].public) + return verifyJwtSignature('invalid-token', mocks.keyPairs[0].public) .should.eventually.be.rejectedWith('jwt malformed') - .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + .with.property('code', JwtErrorCode.INVALID_SIGNATURE); }); }); describe('PublicKeySignatureVerifier', () => { let stubs: sinon.SinonStub[] = []; - const verifier = new jwtUtil.PublicKeySignatureVerifier( - new jwtUtil.UrlKeyFetcher('https://www.example.com/publicKeys')); + let clock: sinon.SinonFakeTimers | undefined; + const verifier = new PublicKeySignatureVerifier( + new UrlKeyFetcher('https://www.example.com/publicKeys')); afterEach(() => { _.forEach(stubs, (stub) => stub.restore()); stubs = []; + + if (clock) { + clock.restore(); + clock = undefined; + } }); describe('Constructor', () => { it('should not throw when valid key fetcher is provided', () => { expect(() => { - new jwtUtil.PublicKeySignatureVerifier( - new jwtUtil.UrlKeyFetcher('https://www.example.com/publicKeys')); + new PublicKeySignatureVerifier( + new UrlKeyFetcher('https://www.example.com/publicKeys')); }).not.to.throw(); }); @@ -312,17 +358,27 @@ describe('PublicKeySignatureVerifier', () => { invalidKeyFetchers.forEach((invalidKeyFetcher) => { it('should throw given an invalid key fetcher: ' + JSON.stringify(invalidKeyFetcher), () => { expect(() => { - new jwtUtil.PublicKeySignatureVerifier(invalidKeyFetchers as any); + new PublicKeySignatureVerifier(invalidKeyFetchers as any); }).to.throw('The provided key fetcher is not an object or null.'); }); }); }); describe('withCertificateUrl', () => { - it('should return a PublicKeySignatureVerifier instance when a valid cert url is provided', () => { - expect( - jwtUtil.PublicKeySignatureVerifier.withCertificateUrl('https://www.example.com/publicKeys') - ).to.be.an.instanceOf(jwtUtil.PublicKeySignatureVerifier); + it('should return a PublicKeySignatureVerifier instance with a UrlKeyFetcher when a ' + + 'valid cert url is provided', () => { + const verifier = PublicKeySignatureVerifier.withCertificateUrl('https://www.example.com/publicKeys'); + expect(verifier).to.be.an.instanceOf(PublicKeySignatureVerifier); + expect((verifier as any).keyFetcher).to.be.an.instanceOf(UrlKeyFetcher); + }); + }); + + describe('withJwksUrl', () => { + it('should return a PublicKeySignatureVerifier instance with a JwksFetcher when a ' + + 'valid jwks url is provided', () => { + const verifier = PublicKeySignatureVerifier.withJwksUrl('https://www.example.com/publicKeys'); + expect(verifier).to.be.an.instanceOf(PublicKeySignatureVerifier); + expect((verifier as any).keyFetcher).to.be.an.instanceOf(JwksFetcher); }); }); @@ -346,7 +402,7 @@ describe('PublicKeySignatureVerifier', () => { }); it('should be fullfilled given a valid token', () => { - const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + const keyFetcherStub = sinon.stub(UrlKeyFetcher.prototype, 'fetchPublicKeys') .resolves(VALID_PUBLIC_KEYS_RESPONSE); stubs.push(keyFetcherStub); const mockIdToken = mocks.generateIdToken(); @@ -354,8 +410,41 @@ describe('PublicKeySignatureVerifier', () => { return verifier.verify(mockIdToken).should.eventually.be.fulfilled; }); + it('should be fullfilled given a valid token without a kid (should check against all the keys)', () => { + const keyFetcherStub = sinon.stub(UrlKeyFetcher.prototype, 'fetchPublicKeys') + .resolves({ 'kid-other': 'key-other', ...VALID_PUBLIC_KEYS_RESPONSE }); + stubs.push(keyFetcherStub); + const mockIdToken = mocks.generateIdToken({ + header: {} + }); + + return verifier.verify(mockIdToken).should.eventually.be.fulfilled; + }); + + it('should be rejected given an expired token without a kid (should check against all the keys)', () => { + const keyFetcherStub = sinon.stub(UrlKeyFetcher.prototype, 'fetchPublicKeys') + .resolves({ 'kid-other': 'key-other', ...VALID_PUBLIC_KEYS_RESPONSE }); + stubs.push(keyFetcherStub); + clock = sinon.useFakeTimers(1000); + const mockIdToken = mocks.generateIdToken({ + header: {} + }); + clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); + + // token should still be valid + return verifier.verify(mockIdToken) + .then(() => { + clock!.tick(1); + + // token should now be invalid + return verifier.verify(mockIdToken).should.eventually.be.rejectedWith( + 'The provided token has expired. Get a fresh token from your client app and try again.') + .with.property('code', JwtErrorCode.TOKEN_EXPIRED); + }); + }); + it('should be rejected given a token with an incorrect algorithm', () => { - const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + const keyFetcherStub = sinon.stub(UrlKeyFetcher.prototype, 'fetchPublicKeys') .resolves(VALID_PUBLIC_KEYS_RESPONSE); stubs.push(keyFetcherStub); const mockIdToken = mocks.generateIdToken({ @@ -364,36 +453,36 @@ describe('PublicKeySignatureVerifier', () => { return verifier.verify(mockIdToken).should.eventually.be .rejectedWith('invalid algorithm') - .with.property('code', jwtUtil.JwtErrorCode.INVALID_SIGNATURE); + .with.property('code', JwtErrorCode.INVALID_SIGNATURE); }); // tests to cover the private getKeyCallback function. it('should reject when no matching kid found', () => { - const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + const keyFetcherStub = sinon.stub(UrlKeyFetcher.prototype, 'fetchPublicKeys') .resolves({ 'not-a-matching-key': 'public-key' }); stubs.push(keyFetcherStub); const mockIdToken = mocks.generateIdToken(); return verifier.verify(mockIdToken).should.eventually.be .rejectedWith('no-matching-kid-error') - .with.property('code', jwtUtil.JwtErrorCode.NO_MATCHING_KID); + .with.property('code', JwtErrorCode.NO_MATCHING_KID); }); it('should reject when an error occurs while fetching the keys', () => { - const keyFetcherStub = sinon.stub(jwtUtil.UrlKeyFetcher.prototype, 'fetchPublicKeys') + const keyFetcherStub = sinon.stub(UrlKeyFetcher.prototype, 'fetchPublicKeys') .rejects(new Error('Error fetching public keys.')); stubs.push(keyFetcherStub); const mockIdToken = mocks.generateIdToken(); return verifier.verify(mockIdToken).should.eventually.be .rejectedWith('Error fetching public keys.') - .with.property('code', jwtUtil.JwtErrorCode.KEY_FETCH_ERROR); + .with.property('code', JwtErrorCode.KEY_FETCH_ERROR); }); }); }); describe('EmulatorSignatureVerifier', () => { - const emulatorVerifier = new jwtUtil.EmulatorSignatureVerifier(); + const emulatorVerifier = new EmulatorSignatureVerifier(); describe('verify', () => { it('should be fullfilled given a valid unsigned (emulator) token', () => { @@ -415,12 +504,12 @@ describe('EmulatorSignatureVerifier', () => { describe('UrlKeyFetcher', () => { const agent = new https.Agent(); - let keyFetcher: jwtUtil.UrlKeyFetcher; + let keyFetcher: UrlKeyFetcher; let clock: sinon.SinonFakeTimers | undefined; let httpsSpy: sinon.SinonSpy; beforeEach(() => { - keyFetcher = new jwtUtil.UrlKeyFetcher( + keyFetcher = new UrlKeyFetcher( 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); httpsSpy = sinon.spy(https, 'request'); @@ -441,7 +530,7 @@ describe('UrlKeyFetcher', () => { describe('Constructor', () => { it('should not throw when valid key parameters are provided', () => { expect(() => { - new jwtUtil.UrlKeyFetcher('https://www.example.com/publicKeys', agent); + new UrlKeyFetcher('https://www.example.com/publicKeys', agent); }).not.to.throw(); }); @@ -449,7 +538,7 @@ describe('UrlKeyFetcher', () => { invalidCertURLs.forEach((invalidCertUrl) => { it('should throw given a non-URL public cert: ' + JSON.stringify(invalidCertUrl), () => { expect(() => { - new jwtUtil.UrlKeyFetcher(invalidCertUrl as any, agent); + new UrlKeyFetcher(invalidCertUrl as any, agent); }).to.throw('The provided public client certificate URL is not a valid URL.'); }); }); @@ -465,7 +554,7 @@ describe('UrlKeyFetcher', () => { it('should use the given HTTP Agent', () => { const agent = new https.Agent(); - const urlKeyFetcher = new jwtUtil.UrlKeyFetcher('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); + const urlKeyFetcher = new UrlKeyFetcher('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); mockedRequests.push(mockFetchPublicKeys()); return urlKeyFetcher.fetchPublicKeys() @@ -478,7 +567,7 @@ describe('UrlKeyFetcher', () => { it('should not fetch the public keys until the first time fetchPublicKeys() is called', () => { mockedRequests.push(mockFetchPublicKeys()); - const urlKeyFetcher = new jwtUtil.UrlKeyFetcher('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); + const urlKeyFetcher = new UrlKeyFetcher('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', agent); expect(https.request).not.to.have.been.called; return urlKeyFetcher.fetchPublicKeys() @@ -539,3 +628,121 @@ describe('UrlKeyFetcher', () => { }); }); }); + +describe('JwksFetcher', () => { + let keyFetcher: JwksFetcher; + let clock: sinon.SinonFakeTimers | undefined; + let httpsSpy: sinon.SinonSpy; + + beforeEach(() => { + keyFetcher = new JwksFetcher( + 'https://firebaseappcheck.googleapis.com/v1alpha/jwks' + ); + httpsSpy = sinon.spy(https, 'request'); + }); + + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + httpsSpy.restore(); + }); + + after(() => { + nock.cleanAll(); + }); + + describe('Constructor', () => { + it('should not throw when valid url is provided', () => { + expect(() => { + new JwksFetcher('https://www.example.com/publicKeys'); + }).not.to.throw(); + }); + + const invalidJwksURLs = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, 'file://invalid']; + invalidJwksURLs.forEach((invalidJwksURL) => { + it('should throw given a non-URL jwks endpoint: ' + JSON.stringify(invalidJwksURL), () => { + expect(() => { + new JwksFetcher(invalidJwksURL as any); + }).to.throw('The provided JWKS URL is not a valid URL.'); + }); + }); + }); + + describe('fetchPublicKeys', () => { + let mockedRequests: nock.Scope[] = []; + + afterEach(() => { + _.forEach(mockedRequests, (mockedRequest) => mockedRequest.done()); + mockedRequests = []; + }); + + it('should not fetch the public keys until the first time fetchPublicKeys() is called', () => { + mockedRequests.push(mockFetchJsonWebKeys()); + + const jwksFetcher = new JwksFetcher('https://firebaseappcheck.googleapis.com/v1alpha/jwks'); + expect(https.request).not.to.have.been.called; + + return jwksFetcher.fetchPublicKeys() + .then((result) => { + expect(https.request).to.have.been.calledOnce; + expect(result).to.have.key(mocks.jwksResponse.keys[0].kid); + }); + }); + + it('should not re-fetch the public keys every time fetchPublicKeys() is called', () => { + mockedRequests.push(mockFetchJsonWebKeys()); + + return keyFetcher.fetchPublicKeys().then(() => { + expect(https.request).to.have.been.calledOnce; + return keyFetcher.fetchPublicKeys(); + }).then(() => expect(https.request).to.have.been.calledOnce); + }); + + it('should refresh the public keys after the previous set of keys expire', () => { + mockedRequests.push(mockFetchJsonWebKeys()); + mockedRequests.push(mockFetchJsonWebKeys()); + mockedRequests.push(mockFetchJsonWebKeys()); + + clock = sinon.useFakeTimers(1000); + + return keyFetcher.fetchPublicKeys().then(() => { + expect(https.request).to.have.been.calledOnce; + clock!.tick((ONE_DAY_IN_SECONDS - 1) * 1000); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + expect(https.request).to.have.been.calledOnce; + clock!.tick(ONE_DAY_IN_SECONDS * 1000); // 24 hours in milliseconds + return keyFetcher.fetchPublicKeys(); + }).then(() => { + // App check keys do not contain cache headers so we cache the keys for 24 hours. + // 24 hours has passed + expect(https.request).to.have.been.calledTwice; + clock!.tick((ONE_DAY_IN_SECONDS - 1) * 1000); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + expect(https.request).to.have.been.calledTwice; + clock!.tick(ONE_DAY_IN_SECONDS * 1000); + return keyFetcher.fetchPublicKeys(); + }).then(() => { + // 48 hours have passed + expect(https.request).to.have.been.calledThrice; + }); + }); + + it('should be rejected if fetching the public keys fails', () => { + mockedRequests.push(mockFailedFetchJsonWebKeys()); + + return keyFetcher.fetchPublicKeys() + .should.eventually.be.rejectedWith('message'); + }); + + it('should be rejected if fetching the public keys returns a response with an error message', () => { + mockedRequests.push(mockFetchJsonWebKeysWithErrorResponse()); + + return keyFetcher.fetchPublicKeys() + .should.eventually.be.rejectedWith('Error fetching Json Web Keys'); + }); + }); +});