From 6cefbddc9375c0701b993fc7361e3c66070e0251 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Mon, 6 Jul 2020 17:39:16 -0700 Subject: [PATCH 1/7] feat(auth): Adds ability to enable MFA on a tenant. This includes the following capabilities: - Ability to enable disable MFA on a tenant. - Configure the MFA supported type. - Configure the test phone number / code pairs on the tenant. --- package-lock.json | 140 +++++++++++----- src/auth.d.ts | 49 ++++++ src/auth/auth-api-request.ts | 6 +- src/auth/auth-config.ts | 203 ++++++++++++++++++++++++ src/auth/tenant.ts | 54 ++++++- src/utils/error.ts | 10 ++ src/utils/index.ts | 34 ++-- test/integration/auth.spec.ts | 36 +++++ test/unit/auth/auth-api-request.spec.ts | 46 +++++- test/unit/auth/auth-config.spec.ts | 203 +++++++++++++++++++++++- test/unit/auth/tenant.spec.ts | 141 +++++++++++++++- test/unit/utils/index.spec.ts | 51 +++--- 12 files changed, 891 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff44fd069a..5e7e53ef76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "firebase-admin", - "version": "8.12.1", + "version": "8.13.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -383,7 +383,8 @@ "@google-cloud/promisify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-1.0.2.tgz", - "integrity": "sha512-7WfV4R/3YV5T30WRZW0lqmvZy9hE2/p9MvpI34WuKa2Wz62mLu5XplGTFEMK6uTbJCLWUxTcZ4J4IyClKucE5g==" + "integrity": "sha512-7WfV4R/3YV5T30WRZW0lqmvZy9hE2/p9MvpI34WuKa2Wz62mLu5XplGTFEMK6uTbJCLWUxTcZ4J4IyClKucE5g==", + "optional": true }, "@google-cloud/storage": { "version": "4.1.2", @@ -472,6 +473,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "optional": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -481,12 +483,14 @@ "safe-buffer": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", - "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "optional": true }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, "requires": { "safe-buffer": "~5.2.0" } @@ -523,27 +527,32 @@ "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=" + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=", + "optional": true }, "@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "optional": true }, "@protobufjs/codegen": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "optional": true }, "@protobufjs/eventemitter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=" + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=", + "optional": true }, "@protobufjs/fetch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "optional": true, "requires": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" @@ -552,27 +561,32 @@ "@protobufjs/float": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", + "optional": true }, "@protobufjs/inquire": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=" + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=", + "optional": true }, "@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=" + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=", + "optional": true }, "@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=" + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=", + "optional": true }, "@protobufjs/utf8": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=", + "optional": true }, "@sinonjs/commons": { "version": "1.4.0", @@ -696,7 +710,8 @@ "@types/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.0.tgz", - "integrity": "sha512-1w52Nyx4Gq47uuu0EVcsHBxZFJgurQ+rTKS3qMHxR1GY2T8c2AJYd6vZoZ9q1rupaDjU0yT+Jc2XTyXkjeMA+Q==" + "integrity": "sha512-1w52Nyx4Gq47uuu0EVcsHBxZFJgurQ+rTKS3qMHxR1GY2T8c2AJYd6vZoZ9q1rupaDjU0yT+Jc2XTyXkjeMA+Q==", + "optional": true }, "@types/minimatch": { "version": "3.0.3", @@ -907,6 +922,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "optional": true, "requires": { "event-target-shim": "^5.0.0" } @@ -951,6 +967,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "optional": true, "requires": { "es6-promisify": "^5.0.0" } @@ -1995,7 +2012,8 @@ "bignumber.js": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", - "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" + "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==", + "optional": true }, "binary-extensions": { "version": "1.13.1", @@ -3034,6 +3052,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "optional": true, "requires": { "safe-buffer": "^5.0.1" } @@ -3122,12 +3141,14 @@ "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "optional": true }, "es6-promisify": { "version": "5.0.0", "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "optional": true, "requires": { "es6-promise": "^4.0.3" } @@ -3437,7 +3458,8 @@ "event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "optional": true }, "execa": { "version": "1.0.0", @@ -3647,7 +3669,8 @@ "fast-text-encoding": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz", - "integrity": "sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==" + "integrity": "sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==", + "optional": true }, "faye-websocket": { "version": "0.11.3", @@ -4020,7 +4043,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4041,12 +4065,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4061,17 +4087,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4188,7 +4217,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4200,6 +4230,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4214,6 +4245,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4221,12 +4253,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4245,6 +4279,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4332,7 +4367,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4344,6 +4380,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -4429,7 +4466,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -4465,6 +4503,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4484,6 +4523,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4527,12 +4567,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -4550,6 +4592,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.1.0.tgz", "integrity": "sha512-Gtpb5sdQmb82sgVkT2GnS2n+Kx4dlFwbeMYcDlD395aEvsLCSQXJJcHt7oJ2LrGxDEAeiOkK79Zv2A8Pzt6CFg==", + "optional": true, "requires": { "abort-controller": "^3.0.0", "extend": "^3.0.2", @@ -4561,7 +4604,8 @@ "is-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "optional": true } } }, @@ -4569,6 +4613,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-3.2.1.tgz", "integrity": "sha512-JjDedBWnbXVXWwTpjBdpb9RpVLiowXG4/50rra4hPH8REXAi2si6Xbb48B2SwkQBLz9Wu6+o32GDTvVy2kkLoQ==", + "optional": true, "requires": { "gaxios": "^2.1.0", "json-bigint": "^0.3.0" @@ -4867,6 +4912,7 @@ "version": "5.5.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-5.5.1.tgz", "integrity": "sha512-zCtjQccWS/EHYyFdXRbfeSGM/gW+d7uMAcVnvXRnjBXON5ijo6s0nsObP0ifqileIDSbZjTlLtgo+UoN8IFJcg==", + "optional": true, "requires": { "arrify": "^2.0.0", "base64-js": "^1.3.0", @@ -4881,7 +4927,8 @@ "arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "optional": true } } }, @@ -4919,6 +4966,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.2.tgz", "integrity": "sha512-UfnEARfJKI6pbmC1hfFFm+UAcZxeIwTiEcHfqKe/drMsXD/ilnVjF7zgOGpHXyhuvX6jNJK3S8A0hOQjwtFxEw==", + "optional": true, "requires": { "node-forge": "^0.9.0" }, @@ -4926,7 +4974,8 @@ "node-forge": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", - "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" + "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==", + "optional": true } } }, @@ -4945,6 +4994,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-4.1.1.tgz", "integrity": "sha512-2FEmEDGi4NdM6u+mtaLjSDDtHiw5wT+nBsI+yrSeFO6fVqPEytYVF6uiIpRaOaZhRP+ozjYWuwwtMlrjAyTcYA==", + "optional": true, "requires": { "gaxios": "^2.1.0", "google-p12-pem": "^2.0.0", @@ -5521,6 +5571,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", + "optional": true, "requires": { "agent-base": "^4.3.0", "debug": "^3.1.0" @@ -6265,6 +6316,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-0.3.0.tgz", "integrity": "sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4=", + "optional": true, "requires": { "bignumber.js": "^7.0.0" } @@ -6393,6 +6445,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "optional": true, "requires": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.10", @@ -6403,6 +6456,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "optional": true, "requires": { "jwa": "^1.1.5", "safe-buffer": "^5.0.1" @@ -6752,12 +6806,14 @@ "long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "optional": true }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "optional": true, "requires": { "yallist": "^3.0.2" } @@ -6923,7 +6979,8 @@ "mime": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", - "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==" + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==", + "optional": true }, "mime-db": { "version": "1.37.0", @@ -7208,7 +7265,8 @@ "node-fetch": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "optional": true }, "node-forge": { "version": "0.7.6", @@ -7932,6 +7990,7 @@ "version": "6.8.8", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "optional": true, "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -7951,7 +8010,8 @@ "@types/node": { "version": "10.17.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.11.tgz", - "integrity": "sha512-dNd2pp8qTzzNLAs3O8nH3iU9DG9866KHq9L3ISPB7DOGERZN81nW/5/g/KzMJpCU8jrbCiMRBzV9/sCEdRosig==" + "integrity": "sha512-dNd2pp8qTzzNLAs3O8nH3iU9DG9866KHq9L3ISPB7DOGERZN81nW/5/g/KzMJpCU8jrbCiMRBzV9/sCEdRosig==", + "optional": true } } }, @@ -9074,6 +9134,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "optional": true, "requires": { "stubs": "^3.0.0" } @@ -9174,7 +9235,8 @@ "stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", - "integrity": "sha1-6NK6H6nJBXAwPAMLaQD31fiavls=" + "integrity": "sha1-6NK6H6nJBXAwPAMLaQD31fiavls=", + "optional": true }, "supports-color": { "version": "2.0.0", @@ -10301,7 +10363,8 @@ "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", - "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "optional": true }, "xml-name-validator": { "version": "3.0.0", @@ -10335,7 +10398,8 @@ "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "optional": true }, "yargs": { "version": "13.2.4", diff --git a/src/auth.d.ts b/src/auth.d.ts index f17a84b675..8aa9d66b35 100644 --- a/src/auth.d.ts +++ b/src/auth.d.ts @@ -971,12 +971,50 @@ export namespace admin.auth { passwordRequired?: boolean; }; + /** + * The multi-factor auth configuration on the current tenant. + */ + multiFactorConfig?: admin.auth.MultiFactorConfig; + + /** + * The map containing the test phone number / code pairs for the tenant. + */ + testPhoneNumbers?: {[phoneNumber: string]: string}; + /** * @return A JSON-serializable representation of this object. */ toJSON(): Object; } + /** + * Identifies a second factor type. + */ + type AuthFactorType = 'phone'; + + /** + * Identifies a multi-factor configuration state. + */ + type MultiFactorConfigState = 'ENABLED' | 'DISABLED'; + + /** + * Interface representing a multi-factor configuration. + * This can be used to define whether multi-factor authentication is enabled + * or disabled and the list of second factor challenges that are supported. + */ + interface MultiFactorConfig { + /** + * The multi-factor config state. + */ + state: admin.auth.MultiFactorConfigState; + + /** + * The list of identifiers for enabled second factors. + * Currently only ‘phone’ is supported. + */ + factorIds?: admin.auth.AuthFactorType[]; + } + /** * Interface representing the properties to update on the provided tenant. */ @@ -1003,6 +1041,17 @@ export namespace admin.auth { */ passwordRequired?: boolean; }; + + /** + * The multi-factor auth configuration to update on the tenant. + */ + multiFactorConfig?: admin.auth.MultiFactorConfig; + + /** + * The updated map containing the test phone number / code pairs for the tenant. + * Passing null clears the previously save phone number / code pairs. + */ + testPhoneNumbers?: {[phoneNumber: string]: string} | null; } /** diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index faa56a917b..5d254d9d20 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -1801,7 +1801,7 @@ const DELETE_TENANT = new ApiSettings('/tenants/{tenantId}', 'DELETE'); /** Instantiates the updateTenant endpoint settings. */ const UPDATE_TENANT = new ApiSettings('/tenants/{tenantId}?updateMask={updateMask}', 'PATCH') -// Set response validator. + // Set response validator. .setResponseValidator((response: any) => { // Response should always contain at least the tenant name. if (!validator.isNonEmptyString(response.name) || @@ -1982,7 +1982,9 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { try { // Construct backend request. const request = Tenant.buildServerRequest(tenantOptions, false); - const updateMask = utils.generateUpdateMask(request); + // Do not traverse deep into testPhoneNumbers. The entire content should be replaced + // and not just specific phone numbers. + const updateMask = utils.generateUpdateMask(request, {testPhoneNumbers: true}); return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request, {tenantId, updateMask: updateMask.join(',')}) .then((response: any) => { diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 95527eb1a7..6482ab880e 100755 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -18,6 +18,8 @@ import * as validator from '../utils/validator'; import {deepCopy} from '../utils/deep-copy'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +/** A maximum of 10 test phone number / code pairs can be configured. */ +export const MAXIMUM_TEST_PHONE_NUMBERS = 10; /** The filter interface used for listing provider configurations. */ export interface AuthProviderConfigFilter { @@ -160,6 +162,207 @@ export interface EmailSignInConfigServerRequest { enableEmailLinkSignin?: boolean; } +/** Identifies the public second factor type. */ +export type AuthFactorType = 'phone'; + +/** Identifies the server side second factor type. */ +export type AuthFactorServerType = 'PHONE_SMS'; + +/** Client Auth factor type to server auth factor type mapping. */ +export const AUTH_FACTOR_CLIENT_TO_SERVER_TYPE: {[key: string]: AuthFactorServerType} = { + phone: 'PHONE_SMS', +}; + +/** Server Auth factor type to client auth factor type mapping. */ +export const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: {[key: string]: AuthFactorType} = + Object.keys(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE) + .reduce((res: {[key: string]: AuthFactorType}, key) => { + res[AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[key]] = key as AuthFactorType; + return res; + }, {}); + +/** Identifies a multi-factor configuration state. */ +export type MultiFactorConfigState = 'ENABLED' | 'DISABLED'; + +/** + * Public API interface representing a multi-factor configuration. + */ +export interface MultiFactorConfig { + /** + * The multi-factor config state. + */ + state: MultiFactorConfigState; + + /** + * The list of identifiers for enabled second factors. + * Currently only ‘phone’ is supported. + */ + factorIds?: AuthFactorType[]; +} + +/** Server side multi-factor configuration. */ +export interface MultiFactorAuthServerConfig { + state?: MultiFactorConfigState; + enabledProviders?: AuthFactorServerType[]; +} + + +/** + * Defines the multi-factor config class used to convert client side MultiFactorConfig + * to a format that is understood by the Auth server. + */ +export class MultiFactorAuthConfig implements MultiFactorConfig { + public readonly state: MultiFactorConfigState; + public readonly factorIds: AuthFactorType[]; + + /** + * Static method to convert a client side request to a MultiFactorAuthServerConfig. + * Throws an error if validation fails. + * + * @param options The options object to convert to a server request. + * @return The resulting server request. + */ + public static buildServerRequest(options: MultiFactorConfig): MultiFactorAuthServerConfig { + const request: MultiFactorAuthServerConfig = {}; + MultiFactorAuthConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'state')) { + request.state = options.state; + } + if (Object.prototype.hasOwnProperty.call(options, 'factorIds')) { + (options.factorIds || []).forEach((factorId) => { + if (typeof request.enabledProviders === 'undefined') { + request.enabledProviders = []; + } + request.enabledProviders.push(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId]); + }); + } + return request; + } + + /** + * Validates the MultiFactorConfig options object. Throws an error on failure. + * + * @param options The options object to validate. + */ + private static validate(options: MultiFactorConfig): void { + const validKeys = { + state: true, + factorIds: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid MultiFactorConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.state !== 'undefined' && + options.state !== 'ENABLED' && + options.state !== 'DISABLED') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".', + ); + } + + if (typeof options.factorIds !== 'undefined' && + !validator.isArray(options.factorIds)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".', + ); + } else if (validator.isArray(options.factorIds)) { + // Validate content of array. + options.factorIds.forEach((factorId) => { + if (typeof AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId] === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${factorId}" is not a valid "AuthFactorType".`, + ); + } + }); + } + } + + /** + * The MultiFactorAuthConfig constructor. + * + * @param response The server side response used to initialize the + * MultiFactorAuthConfig object. + * @constructor + */ + constructor(response: MultiFactorAuthServerConfig) { + if (typeof response.state === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor configuration response'); + } + this.state = response.state; + this.factorIds = []; + (response.enabledProviders || []).forEach((enabledProvider) => { + // Ignore unsupported types. It is possible the current admin SDK version is + // not up to date and newer backend types are supported. + if (typeof AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider] !== 'undefined') { + this.factorIds.push(AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider]); + } + }) + } + + /** @return The plain object representation of the multi-factor config instance. */ + public toJSON(): object { + return { + state: this.state, + factorIds: this.factorIds.concat(), + }; + } +} + + +/** + * Validates the provided map of test phone number / code pairs. + * @param testPhoneNumbers The phone number / code pairs to validate. + */ +export function validateTestPhoneNumbers( + testPhoneNumbers: {[phoneNumber: string]: string}, +): void { + if (!validator.isObject(testPhoneNumbers)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"testPhoneNumbers" must be a map of phone number / code pairs.', + ); + } + if (Object.keys(testPhoneNumbers).length > MAXIMUM_TEST_PHONE_NUMBERS) { + throw new FirebaseAuthError(AuthClientErrorCode.MAXIMUM_TEST_PHONE_NUMBER_EXCEEDED); + } + for (const phoneNumber in testPhoneNumbers) { + // Validate phone number. + if (!validator.isPhoneNumber(phoneNumber)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER, + `"${phoneNumber}" is not a valid E.164 standard compliant phone number.` + ); + } + + // Validate code. + if (!validator.isString(testPhoneNumbers[phoneNumber]) || + !/^[\d]{6}$/.test(testPhoneNumbers[phoneNumber])) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER, + `"${testPhoneNumbers[phoneNumber]}" is not a valid 6 digit code string.` + ); + } + } +} + /** * Defines the email sign-in config class used to convert client side EmailSignInConfig diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 0b2ed1dca9..201d681007 100755 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -15,20 +15,27 @@ */ import * as validator from '../utils/validator'; +import {deepCopy} from '../utils/deep-copy'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import { EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, + MultiFactorConfig, MultiFactorAuthServerConfig, MultiFactorAuthConfig, + validateTestPhoneNumbers, } from './auth-config'; /** The TenantOptions interface used for create/read/update tenant operations. */ export interface TenantOptions { displayName?: string; emailSignInConfig?: EmailSignInProviderConfig; + multiFactorConfig?: MultiFactorConfig; + testPhoneNumbers?: {[phoneNumber: string]: string} | null; } /** The corresponding server side representation of a TenantOptions object. */ export interface TenantOptionsServerRequest extends EmailSignInConfigServerRequest { displayName?: string; + mfaConfig?: MultiFactorAuthServerConfig; + testPhoneNumbers?: {[key: string]: string}; } /** The tenant server response interface. */ @@ -37,6 +44,8 @@ export interface TenantServerResponse { displayName?: string; allowPasswordSignup?: boolean; enableEmailLinkSignin?: boolean; + mfaConfig?: MultiFactorAuthServerConfig; + testPhoneNumbers?: {[key: string]: string}; } /** The interface representing the listTenant API response. */ @@ -53,6 +62,8 @@ export class Tenant { public readonly tenantId: string; public readonly displayName?: string; public readonly emailSignInConfig?: EmailSignInConfig; + public readonly multiFactorConfig?: MultiFactorAuthConfig; + public readonly testPhoneNumbers?: {[phoneNumber: string]: string}; /** * Builds the corresponding server request for a TenantOptions object. @@ -71,6 +82,14 @@ export class Tenant { if (typeof tenantOptions.displayName !== 'undefined') { request.displayName = tenantOptions.displayName; } + if (typeof tenantOptions.multiFactorConfig !== 'undefined') { + request.mfaConfig = MultiFactorAuthConfig.buildServerRequest(tenantOptions.multiFactorConfig); + } + if (typeof tenantOptions.testPhoneNumbers !== 'undefined') { + // null will clear existing test phone numbers. Translate to empty object. + request.testPhoneNumbers = + (tenantOptions.testPhoneNumbers === null ? {} : tenantOptions.testPhoneNumbers); + } return request; } @@ -99,6 +118,8 @@ export class Tenant { const validKeys = { displayName: true, emailSignInConfig: true, + multiFactorConfig: true, + testPhoneNumbers: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; if (!validator.isNonNullObject(request)) { @@ -129,6 +150,22 @@ export class Tenant { // This will throw an error if invalid. EmailSignInConfig.buildServerRequest(request.emailSignInConfig); } + // Validate test phone numbers if provided. + if (typeof request.testPhoneNumbers !== 'undefined' && + request.testPhoneNumbers !== null) { + validateTestPhoneNumbers(request.testPhoneNumbers); + } else if (request.testPhoneNumbers === null && createRequest) { + // null allowed only for update operations. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${label}.testPhoneNumbers" must be a non-null object.`, + ); + } + // Validate multiFactorConfig type if provided. + if (typeof request.multiFactorConfig !== 'undefined') { + // This will throw an error if invalid. + MultiFactorAuthConfig.buildServerRequest(request.multiFactorConfig); + } } /** @@ -155,15 +192,30 @@ export class Tenant { allowPasswordSignup: false, }); } + if (typeof response.mfaConfig !== 'undefined') { + this.multiFactorConfig = new MultiFactorAuthConfig(response.mfaConfig); + } + if (typeof response.testPhoneNumbers !== 'undefined') { + this.testPhoneNumbers = deepCopy(response.testPhoneNumbers || {}); + } } /** @return {object} The plain object representation of the tenant. */ public toJSON(): object { - return { + const json = { tenantId: this.tenantId, displayName: this.displayName, emailSignInConfig: this.emailSignInConfig && this.emailSignInConfig.toJSON(), + multiFactorConfig: this.multiFactorConfig && this.multiFactorConfig.toJSON(), + testPhoneNumbers: this.testPhoneNumbers, }; + if (typeof json.multiFactorConfig === 'undefined') { + delete json.multiFactorConfig; + } + if (typeof json.testPhoneNumbers === 'undefined') { + delete json.testPhoneNumbers; + } + return json; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 746cc3ca5d..34ebced30d 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -537,6 +537,10 @@ export class AuthClientErrorCode { code: 'invalid-tenant-type', message: 'Tenant type must be either "full_service" or "lightweight".', }; + public static INVALID_TESTING_PHONE_NUMBER = { + code: 'invalid-testing-phone-number', + message: 'Invalid testing phone number or invalid test code provided.', + }; public static INVALID_UID = { code: 'invalid-uid', message: 'The uid must be a non-empty string with at most 128 characters.', @@ -600,6 +604,10 @@ export class AuthClientErrorCode { code: 'missing-saml-relying-party-config', message: 'The SAML configuration provided is missing a relying party configuration.', }; + public static MAXIMUM_TEST_PHONE_NUMBER_EXCEEDED = { + code: 'test-phone-number-limit-exceeded', + message: 'The maximum allowed number of test phone number / code pairs has been exceeded.', + }; public static MAXIMUM_USER_COUNT_EXCEEDED = { code: 'maximum-user-count-exceeded', message: 'The maximum allowed number of users to import has been exceeded.', @@ -875,6 +883,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_PROVIDER_ID: 'INVALID_PROVIDER_ID', // Invalid service account. INVALID_SERVICE_ACCOUNT: 'INVALID_SERVICE_ACCOUNT', + // Invalid testing phone number. + INVALID_TESTING_PHONE_NUMBER: 'INVALID_TESTING_PHONE_NUMBER', // Invalid tenant type. INVALID_TENANT_TYPE: 'INVALID_TENANT_TYPE', // Missing Android package name. diff --git a/src/utils/index.ts b/src/utils/index.ts index f6e2f41232..d5dbda84d7 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -142,23 +142,37 @@ export function formatString(str: string, params?: object): string { * Generates the update mask for the provided object. * Note this will ignore the last key with value undefined. * - * @param {[key: string]: any} obj The object to generate the update mask for. - * @return {Array} The computed update mask list. + * @param obj The object to generate the update mask for. + * @param maxPaths The optional map of keys for maximum paths to traverse. + * Nested objects beyond that path will be ignored. This is useful for + * keys with variable object values. + * @param currentPath The path so far. + * @return The computed update mask list. */ -export function generateUpdateMask(obj: {[key: string]: any}): string[] { +export function generateUpdateMask( + obj: any, maxPaths: {[key: string]: boolean} = {}, currentPath = '' +): string[] { const updateMask: string[] = []; if (!validator.isNonNullObject(obj)) { return updateMask; } for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] !== 'undefined') { - const maskList = generateUpdateMask(obj[key]); - if (maskList.length > 0) { - maskList.forEach((mask) => { - updateMask.push(`${key}.${mask}`); - }); - } else { + if (typeof obj[key] !== 'undefined') { + const nextPath = currentPath ? currentPath + '.' + key : key; + // We hit maximum path. + if (maxPaths[nextPath]) { + // Add key and stop traversing this branch. updateMask.push(key); + } else { + let maskList: string[] = []; + maskList = generateUpdateMask(obj[key], maxPaths, nextPath); + if (maskList.length > 0) { + maskList.forEach((mask) => { + updateMask.push(`${key}.${mask}`); + }); + } else { + updateMask.push(key); + } } } } diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 4031618174..b2e5435ef9 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -746,6 +746,14 @@ describe('admin.auth', () => { enabled: true, passwordRequired: true, }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const expectedCreatedTenant: any = { displayName: 'testTenant1', @@ -753,6 +761,14 @@ describe('admin.auth', () => { enabled: true, passwordRequired: true, }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const expectedUpdatedTenant: any = { displayName: 'testTenantUpdated', @@ -760,6 +776,14 @@ describe('admin.auth', () => { enabled: false, passwordRequired: true, }, + multiFactorConfig: { + state: 'DISABLED', + factorIds: [], + }, + // Cannot currently test clearing test phone numbers: b/160513153. + testPhoneNumbers: { + '+16505551234': '123456', + }, }; const expectedUpdatedTenant2: any = { displayName: 'testTenantUpdated', @@ -767,6 +791,14 @@ describe('admin.auth', () => { enabled: true, passwordRequired: false, }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '123456', + '+16505550000': '654321', + }, }; // https://mochajs.org/ @@ -1126,12 +1158,16 @@ describe('admin.auth', () => { emailSignInConfig: { enabled: false, }, + multiFactorConfig: deepCopy(expectedUpdatedTenant.multiFactorConfig), + testPhoneNumbers: deepCopy(expectedUpdatedTenant.testPhoneNumbers), }; const updatedOptions2: admin.auth.UpdateTenantRequest = { emailSignInConfig: { enabled: true, passwordRequired: false, }, + multiFactorConfig: deepCopy(expectedUpdatedTenant2.multiFactorConfig), + testPhoneNumbers: deepCopy(expectedUpdatedTenant2.testPhoneNumbers), }; return admin.auth().tenantManager().updateTenant(createdTenantId, updatedOptions) .then((actualTenant) => { diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 15f90407d7..8b498a9892 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -4452,11 +4452,27 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { enabled: true, passwordRequired: true, }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const expectedRequest = { displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, + mfaConfig: { + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const expectedResult = utils.responseFrom(deepExtend({ name: 'projects/project_id/tenants/tenant-id', @@ -4558,24 +4574,41 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { const path = '/v2/projects/project_id/tenants/tenant-id'; const patchMethod = 'PATCH'; const tenantId = 'tenant-id'; - const tenantOptions = { + const tenantOptions: TenantOptions = { displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: true, }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const expectedRequest = { displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, + mfaConfig: { + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const expectedResult = utils.responseFrom(deepExtend({ name: 'projects/project_id/tenants/tenant-id', }, expectedRequest)); it('should be fulfilled given full parameters', () => { - const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName,' + + 'mfaConfig.state,mfaConfig.enabledProviders,testPhoneNumbers'; const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); @@ -4661,7 +4694,8 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); it('should be rejected when the backend returns a response missing name', () => { - const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName,' + + 'mfaConfig.state,mfaConfig.enabledProviders,testPhoneNumbers'; const expectedError = new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to update tenant', @@ -4681,7 +4715,8 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); it('should be rejected when the backend returns a response missing tenant ID in response name', () => { - const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName,' + + 'mfaConfig.state,mfaConfig.enabledProviders,testPhoneNumbers'; const expectedError = new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to update tenant', @@ -4703,7 +4738,8 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); it('should be rejected when the backend returns an error', () => { - const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName,' + + 'mfaConfig.state,mfaConfig.enabledProviders,testPhoneNumbers'; const expectedServerError = utils.errorFrom({ error: { message: 'INTERNAL_ERROR', diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index d5777d787d..94fe8acd9c 100755 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -25,7 +25,8 @@ import { SAMLConfigServerResponse, OIDCConfigServerRequest, OIDCConfigServerResponse, SAMLUpdateAuthProviderRequest, OIDCUpdateAuthProviderRequest, SAMLAuthProviderConfig, OIDCAuthProviderConfig, - EmailSignInConfig, + EmailSignInConfig, MultiFactorAuthConfig, validateTestPhoneNumbers, + MAXIMUM_TEST_PHONE_NUMBERS, } from '../../../src/auth/auth-config'; @@ -152,6 +153,206 @@ describe('EmailSignInConfig', () => { }); }); +describe('MultiFactorAuthConfig', () => { + describe('constructor', () => { + const validConfig = new MultiFactorAuthConfig({ + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }); + + it('should throw on missing state', () => { + expect(() => new MultiFactorAuthConfig({ + enabledProviders: ['PHONE_SMS'], + } as any)).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor configuration response'); + }); + + it('should set readonly property "state" to ENABLED on state enabled', () => { + expect(validConfig.state).to.equals('ENABLED'); + }); + + it('should set readonly property "state" to DISABLED on state disabled', () => { + const disabledState = new MultiFactorAuthConfig({ + state: 'DISABLED', + enabledProviders: ['PHONE_SMS'], + }); + expect(disabledState.state).to.equals('DISABLED'); + }); + + it('should set readonly property "factorIds"', () => { + expect(validConfig.factorIds).to.deep.equal(['phone']); + }); + + it('should ignore unsupported backend types if found', () => { + const unsupportedType = new MultiFactorAuthConfig({ + state: 'ENABLED', + enabledProviders: ['UNSUPPORTED_TYPE', 'PHONE_SMS'], + } as any); + expect(unsupportedType.factorIds).to.deep.equal(['phone']); + }); + + it('should return empty factorIds array if no supported types are found', () => { + const unsupportedType = new MultiFactorAuthConfig({ + state: 'ENABLED', + enabledProviders: ['UNSUPPORTED_TYPE'], + } as any); + expect(unsupportedType.factorIds).to.deep.equal([]); + }); + }); + + describe('toJSON()', () => { + it('should return expected JSON representation', () => { + const config = new MultiFactorAuthConfig({ + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }); + expect(config.toJSON()).to.deep.equal({ + state: 'ENABLED', + factorIds: ['phone'], + }); + }); + }); + + describe('buildServerRequest()', () => { + it('should return expected server request on valid state and factorIds', () => { + expect(MultiFactorAuthConfig.buildServerRequest({ + state: 'ENABLED', + factorIds: ['phone'], + })).to.deep.equal({ + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }); + }); + + it('should return expected server request on valid state without factorIds', () => { + expect(MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + })).to.deep.equal({ + state: 'DISABLED', + }); + }); + + const invalidOptions = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; + invalidOptions.forEach((options) => { + it('should throw on invalid MultiFactorAuthConfig:' + JSON.stringify(options), () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest(options as any); + }).to.throw('"MultiFactorConfig" must be a non-null object.'); + }); + }); + + it('should throw on MultiFactorAuthConfig with unsupported attribute', () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + unsupported: true, + state: 'ENABLED', + factorIds: ['phone'], + } as any); + }).to.throw('"unsupported" is not a valid MultiFactorConfig parameter.'); + }); + + const invalidState = [ + null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop, true, false, + ]; + invalidState.forEach((state) => { + it('should throw on invalid MultiFactorConfig.state:' + JSON.stringify(state), () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state, + factorIds: ['phone'], + } as any); + }).to.throw('"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".'); + }); + }); + + it('should throw on non-array MultiFactorAuthConfig.factorIds', () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'ENABLED', + factorIds: 'phone', + } as any); + }).to.throw('"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".'); + }); + + const invalidFactorIds = invalidState; + invalidFactorIds.forEach((factorId) => { + it('should throw on invalid MultiFactorConfig.factorIds:' + JSON.stringify(factorId), () => { + expect(() => { + MultiFactorAuthConfig.buildServerRequest({ + state: 'ENABLED', + factorIds: [factorId], + } as any); + }).to.throw(`"${factorId}" is not a valid "AuthFactorType".`); + }); + }); + }); +}); + +describe('validateTestPhoneNumbers', () => { + it('should not throw an error on empty object', () => { + expect(() => validateTestPhoneNumbers({})).not.to.throw(); + }); + + it('should not throw an error on valid phone number / code pairs', () => { + const pairs = { + '+16505551234': '019287', + '+16505550676': '985235', + '+1 (123) 456-7890': '098765', + '+1 800 FLOwerS': '000000', + }; + + expect(() => validateTestPhoneNumbers(pairs)).not.to.throw(); + }); + + it(`should not throw when ${MAXIMUM_TEST_PHONE_NUMBERS} pairs are provided`, () => { + const pairs: {[key: string]: string} = {}; + for (let i = 0; i < MAXIMUM_TEST_PHONE_NUMBERS; i++) { + pairs['+1650555' + '0'.repeat(4 - i.toString().length) + i.toString()] = '012938'; + } + + expect(() => validateTestPhoneNumbers(pairs)).not.to.throw(); + }); + + it(`should throw when >${MAXIMUM_TEST_PHONE_NUMBERS} pairs are provided`, () => { + const pairs: {[key: string]: string} = {}; + for (let i = 0; i < MAXIMUM_TEST_PHONE_NUMBERS + 1; i++) { + pairs['+1650555' + '0'.repeat(4 - i.toString().length) + i.toString()] = '012938'; + } + + expect(() => validateTestPhoneNumbers(pairs)).to.throw(); + }); + + const nonObjects = [NaN, 0, 1, true, false, '', 'a', _.noop]; + nonObjects.forEach((nonObject) => { + it(`should throw when non-object ${JSON.stringify(nonObject)} is provided`, () => { + expect(() => validateTestPhoneNumbers(nonObject as any)).to.throw(); + }); + }); + + const invalidPhoneNumbers = [ + null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }, _.noop, '+', '+ ()-', + ]; + invalidPhoneNumbers.forEach((invalidPhoneNumber) => { + it(`should throw when "${JSON.stringify(invalidPhoneNumber)}" is used as phone number`, () => { + const pairs = { + [invalidPhoneNumber as any]: '123456', + }; + expect(() => validateTestPhoneNumbers(pairs)).to.throw(); + }); + }); + + const invalidCodes = [ + NaN, 0, 1, true, false, '', 'a', _.noop, '12345', '1234567', '123a56', '12 345', 123456, + ]; + invalidCodes.forEach((invalidCode) => { + it(`should throw when an invalid code ${JSON.stringify(invalidCode)} is provided`, () => { + const pairs = { + '+16505551234': invalidCode, + }; + expect(() => validateTestPhoneNumbers(pairs as any)).to.throw(); + }); + }); +}); + describe('SAMLConfig', () => { const serverRequest: SAMLConfigServerRequest = { idpConfig: { diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 1fb1e24dfa..b41e69e71a 100755 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -20,7 +20,9 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {EmailSignInConfig, EmailSignInProviderConfig} from '../../../src/auth/auth-config'; +import { + EmailSignInConfig, EmailSignInProviderConfig, MultiFactorAuthConfig, +} from '../../../src/auth/auth-config'; import { Tenant, TenantOptions, TenantServerResponse, } from '../../../src/auth/tenant'; @@ -38,6 +40,14 @@ describe('Tenant', () => { displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: true, + mfaConfig: { + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, }; const clientRequest: TenantOptions = { @@ -46,13 +56,44 @@ describe('Tenant', () => { enabled: true, passwordRequired: false, }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, + }; + + const serverRequestWithoutMfa: TenantServerResponse = { + name: 'projects/project1/tenants/TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: true, + }; + + const clientRequestWithoutMfa: TenantOptions = { + displayName: 'TENANT-DISPLAY-NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, }; describe('buildServerRequest()', () => { const createRequest = true; describe('for an update request', () => { - it('should return the expected server request', () => { + it('should return the expected server request without multi-factor and phone config', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithoutMfa); + const tenantOptionsServerRequest = deepCopy(serverRequestWithoutMfa); + delete tenantOptionsServerRequest.name; + expect(Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + .to.deep.equal(tenantOptionsServerRequest); + }); + + it('should return the expected server request with multi-factor and phone config', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); const tenantOptionsServerRequest = deepCopy(serverRequest); delete tenantOptionsServerRequest.name; @@ -75,6 +116,33 @@ describe('Tenant', () => { }).to.throw('"EmailSignInConfig.enabled" must be a boolean.'); }); + it('should throw on invalid MultiFactorConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.multiFactorConfig.state = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".'); + }); + + it('should throw on invalid testPhoneNumbers attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.testPhoneNumbers = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"testPhoneNumbers" must be a map of phone number / code pairs.'); + }); + + it('should not throw on null testPhoneNumbers attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest); + const tenantOptionsServerRequest = deepCopy(serverRequest); + tenantOptionsClientRequest.testPhoneNumbers = null; + delete tenantOptionsServerRequest.name; + tenantOptionsServerRequest.testPhoneNumbers = {}; + + expect(Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + .to.deep.equal(tenantOptionsServerRequest); + }); + it('should not throw on valid client request object', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); expect(() => { @@ -112,7 +180,16 @@ describe('Tenant', () => { }); describe('for a create request', () => { - it('should return the expected server request', () => { + it('should return the expected server request without multi-factor and phone config', () => { + const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequestWithoutMfa); + const tenantOptionsServerRequest: TenantServerResponse = deepCopy(serverRequestWithoutMfa); + delete tenantOptionsServerRequest.name; + + expect(Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + .to.deep.equal(tenantOptionsServerRequest); + }); + + it('should return the expected server request with multi-factor and phone config', () => { const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequest); const tenantOptionsServerRequest: TenantServerResponse = deepCopy(serverRequest); delete tenantOptionsServerRequest.name; @@ -129,6 +206,34 @@ describe('Tenant', () => { .to.throw('"EmailSignInConfig" must be a non-null object.'); }); + it('should throw on invalid MultiFactorConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.multiFactorConfig.factorIds = ['invalid']; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw(`"invalid" is not a valid "AuthFactorType".`,); + }); + + it('should throw on invalid testPhoneNumbers attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.testPhoneNumbers = {'invalid': '123456'}; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw(`"invalid" is not a valid E.164 standard compliant phone number.`); + }); + + it('should throw on null testPhoneNumbers attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest); + const tenantOptionsServerRequest = deepCopy(serverRequest); + tenantOptionsClientRequest.testPhoneNumbers = null; + delete tenantOptionsServerRequest.name; + tenantOptionsServerRequest.testPhoneNumbers = {}; + + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw(`"CreateTenantRequest.testPhoneNumbers" must be a non-null object.`); + }); + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((request) => { it('should throw on invalid CreateTenantRequest:' + JSON.stringify(request), () => { @@ -198,6 +303,19 @@ describe('Tenant', () => { expect(tenant.emailSignInConfig).to.deep.equal(expectedEmailSignInConfig); }); + it('should set readonly property multiFactorConfig', () => { + const expectedMultiFactorConfig = new MultiFactorAuthConfig({ + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }); + expect(tenant.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); + }); + + it('should set readonly property testPhoneNumbers', () => { + expect(tenant.testPhoneNumbers).to.deep.equal( + deepCopy(clientRequest.testPhoneNumbers)); + }); + it('should throw when no tenant ID is provided', () => { const invalidOptions = deepCopy(serverRequest); // Use resource name that does not include a tenant ID. @@ -233,6 +351,23 @@ describe('Tenant', () => { enabled: true, passwordRequired: false, }, + multiFactorConfig: deepCopy(clientRequest.multiFactorConfig), + testPhoneNumbers: deepCopy(clientRequest.testPhoneNumbers), + }); + }); + + it('should not populate optional fields if not available', () => { + const serverRequestCopyWithoutMfa: TenantServerResponse = deepCopy(serverRequest); + delete serverRequestCopyWithoutMfa.mfaConfig; + delete serverRequestCopyWithoutMfa.testPhoneNumbers; + + expect(new Tenant(serverRequestCopyWithoutMfa).toJSON()).to.deep.equal({ + tenantId: 'TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, }); }); }); diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index 2065e2fb49..0ad8323939 100755 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -327,10 +327,31 @@ describe('formatString()', () => { }); describe('generateUpdateMask()', () => { + const obj: any = { + a: undefined, + b: 'something', + c: ['stuff'], + d: false, + e: {}, + f: { + g: 1, + h: 0, + i: { + j: 2, + }, + }, + k: { + i: null, + j: undefined, + }, + l: { + m: undefined, + }, + }; const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((nonObject) => { it(`should return empty array for non object ${JSON.stringify(nonObject)}`, () => { - expect(generateUpdateMask(nonObject as any)).to.deep.equal([]); + expect(generateUpdateMask(nonObject)).to.deep.equal([]); }); }); @@ -339,30 +360,16 @@ describe('generateUpdateMask()', () => { }); it('should return expected update mask array for nested object', () => { - const obj: any = { - a: undefined, - b: 'something', - c: ['stuff'], - d: false, - e: {}, - f: { - g: 1, - h: 0, - i: { - j: 2, - }, - }, - k: { - i: null, - j: undefined, - }, - l: { - m: undefined, - }, - }; const expectedMaskArray = [ 'b', 'c', 'd', 'e', 'f.g', 'f.h', 'f.i.j', 'k.i', 'l', ]; expect(generateUpdateMask(obj)).to.deep.equal(expectedMaskArray); }); + + it('should return expected update mask array with max paths for nested object', () => { + expect(generateUpdateMask(obj, {'f.i': true, 'k': true})) + .to.deep.equal(['b', 'c', 'd', 'e', 'f.g', 'f.h', 'f.i', 'k', 'l']); + expect(generateUpdateMask(obj, {notfound: true, b: true, f: true, k: true, l: true})) + .to.deep.equal(['b', 'c', 'd', 'e', 'f', 'k', 'l']); + }); }); From 9e6be0776033da99b7be0166788102fb5f85cb29 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Tue, 7 Jul 2020 12:18:08 -0700 Subject: [PATCH 2/7] Addresses review comments. --- src/auth/auth-api-request.ts | 2 +- src/auth/auth-config.ts | 17 +++++++++-------- src/auth/tenant.ts | 11 +++++------ src/utils/index.ts | 13 ++++++------- test/integration/auth.spec.ts | 1 + test/unit/auth/auth-config.spec.ts | 4 ++-- test/unit/utils/index.spec.ts | 4 ++-- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 5d254d9d20..3289c53564 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -1984,7 +1984,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { const request = Tenant.buildServerRequest(tenantOptions, false); // Do not traverse deep into testPhoneNumbers. The entire content should be replaced // and not just specific phone numbers. - const updateMask = utils.generateUpdateMask(request, {testPhoneNumbers: true}); + const updateMask = utils.generateUpdateMask(request, new Set(['testPhoneNumbers'])); return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request, {tenantId, updateMask: updateMask.join(',')}) .then((response: any) => { diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 6482ab880e..e771580580 100755 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -274,13 +274,14 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { ); } - if (typeof options.factorIds !== 'undefined' && - !validator.isArray(options.factorIds)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".', - ); - } else if (validator.isArray(options.factorIds)) { + if (typeof options.factorIds !== 'undefined') { + if(!validator.isArray(options.factorIds)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".', + ); + } + // Validate content of array. options.factorIds.forEach((factorId) => { if (typeof AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId] === 'undefined') { @@ -321,7 +322,7 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { public toJSON(): object { return { state: this.state, - factorIds: this.factorIds.concat(), + factorIds: this.factorIds, }; } } diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 201d681007..f3ccb4f3cd 100755 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -87,8 +87,7 @@ export class Tenant { } if (typeof tenantOptions.testPhoneNumbers !== 'undefined') { // null will clear existing test phone numbers. Translate to empty object. - request.testPhoneNumbers = - (tenantOptions.testPhoneNumbers === null ? {} : tenantOptions.testPhoneNumbers); + request.testPhoneNumbers = tenantOptions.testPhoneNumbers ?? {}; } return request; } @@ -171,10 +170,10 @@ export class Tenant { /** * The Tenant object constructor. * - * @param {any} response The server side response used to initialize the Tenant object. + * @param response The server side response used to initialize the Tenant object. * @constructor */ - constructor(response: any) { + constructor(response: TenantServerResponse) { const tenantId = Tenant.getTenantIdFromResourceName(response.name); if (!tenantId) { throw new FirebaseAuthError( @@ -205,8 +204,8 @@ export class Tenant { const json = { tenantId: this.tenantId, displayName: this.displayName, - emailSignInConfig: this.emailSignInConfig && this.emailSignInConfig.toJSON(), - multiFactorConfig: this.multiFactorConfig && this.multiFactorConfig.toJSON(), + emailSignInConfig: this.emailSignInConfig?.toJSON(), + multiFactorConfig: this.multiFactorConfig?.toJSON(), testPhoneNumbers: this.testPhoneNumbers, }; if (typeof json.multiFactorConfig === 'undefined') { diff --git a/src/utils/index.ts b/src/utils/index.ts index d5dbda84d7..45713a169a 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -143,14 +143,14 @@ export function formatString(str: string, params?: object): string { * Note this will ignore the last key with value undefined. * * @param obj The object to generate the update mask for. - * @param maxPaths The optional map of keys for maximum paths to traverse. + * @param terminalPaths The optional map of keys for maximum paths to traverse. * Nested objects beyond that path will be ignored. This is useful for * keys with variable object values. - * @param currentPath The path so far. + * @param root The path so far. * @return The computed update mask list. */ export function generateUpdateMask( - obj: any, maxPaths: {[key: string]: boolean} = {}, currentPath = '' + obj: any, terminalPaths: Set = new Set(), root = '' ): string[] { const updateMask: string[] = []; if (!validator.isNonNullObject(obj)) { @@ -158,14 +158,13 @@ export function generateUpdateMask( } for (const key in obj) { if (typeof obj[key] !== 'undefined') { - const nextPath = currentPath ? currentPath + '.' + key : key; + const nextPath = root ? `${root}.${key}` : key; // We hit maximum path. - if (maxPaths[nextPath]) { + if (terminalPaths.has(nextPath)) { // Add key and stop traversing this branch. updateMask.push(key); } else { - let maskList: string[] = []; - maskList = generateUpdateMask(obj[key], maxPaths, nextPath); + const maskList = generateUpdateMask(obj[key], terminalPaths, nextPath); if (maskList.length > 0) { maskList.forEach((mask) => { updateMask.push(`${key}.${mask}`); diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index b2e5435ef9..e195ca5d1e 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -750,6 +750,7 @@ describe('admin.auth', () => { state: 'ENABLED', factorIds: ['phone'], }, + // Add random phone number / code pairs. testPhoneNumbers: { '+16505551234': '019287', '+16505550676': '985235', diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index 94fe8acd9c..cea94a54ca 100755 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -306,7 +306,7 @@ describe('validateTestPhoneNumbers', () => { it(`should not throw when ${MAXIMUM_TEST_PHONE_NUMBERS} pairs are provided`, () => { const pairs: {[key: string]: string} = {}; for (let i = 0; i < MAXIMUM_TEST_PHONE_NUMBERS; i++) { - pairs['+1650555' + '0'.repeat(4 - i.toString().length) + i.toString()] = '012938'; + pairs[`+1650555${'0'.repeat(4 - i.toString().length)}${i}`] = '012938'; } expect(() => validateTestPhoneNumbers(pairs)).not.to.throw(); @@ -315,7 +315,7 @@ describe('validateTestPhoneNumbers', () => { it(`should throw when >${MAXIMUM_TEST_PHONE_NUMBERS} pairs are provided`, () => { const pairs: {[key: string]: string} = {}; for (let i = 0; i < MAXIMUM_TEST_PHONE_NUMBERS + 1; i++) { - pairs['+1650555' + '0'.repeat(4 - i.toString().length) + i.toString()] = '012938'; + pairs[`+1650555${'0'.repeat(4 - i.toString().length)}${i}`] = '012938'; } expect(() => validateTestPhoneNumbers(pairs)).to.throw(); diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index 0ad8323939..b551dcbc21 100755 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -367,9 +367,9 @@ describe('generateUpdateMask()', () => { }); it('should return expected update mask array with max paths for nested object', () => { - expect(generateUpdateMask(obj, {'f.i': true, 'k': true})) + expect(generateUpdateMask(obj, new Set(['f.i', 'k']))) .to.deep.equal(['b', 'c', 'd', 'e', 'f.g', 'f.h', 'f.i', 'k', 'l']); - expect(generateUpdateMask(obj, {notfound: true, b: true, f: true, k: true, l: true})) + expect(generateUpdateMask(obj, new Set(['notfound', 'b', 'f', 'k', 'l']))) .to.deep.equal(['b', 'c', 'd', 'e', 'f', 'k', 'l']); }); }); From 751890a6753130f3ab6e1abf4937ab0a8e1a3276 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Tue, 7 Jul 2020 13:36:01 -0700 Subject: [PATCH 3/7] Switch to using array instead of set for terminalPaths. --- src/auth/auth-api-request.ts | 2 +- src/utils/index.ts | 5 +++-- test/unit/utils/index.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 3289c53564..b37240f953 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -1984,7 +1984,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { const request = Tenant.buildServerRequest(tenantOptions, false); // Do not traverse deep into testPhoneNumbers. The entire content should be replaced // and not just specific phone numbers. - const updateMask = utils.generateUpdateMask(request, new Set(['testPhoneNumbers'])); + const updateMask = utils.generateUpdateMask(request, ['testPhoneNumbers']); return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request, {tenantId, updateMask: updateMask.join(',')}) .then((response: any) => { diff --git a/src/utils/index.ts b/src/utils/index.ts index 45713a169a..608a0babfe 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -150,7 +150,7 @@ export function formatString(str: string, params?: object): string { * @return The computed update mask list. */ export function generateUpdateMask( - obj: any, terminalPaths: Set = new Set(), root = '' + obj: any, terminalPaths: string[] = [], root = '' ): string[] { const updateMask: string[] = []; if (!validator.isNonNullObject(obj)) { @@ -160,7 +160,8 @@ export function generateUpdateMask( if (typeof obj[key] !== 'undefined') { const nextPath = root ? `${root}.${key}` : key; // We hit maximum path. - if (terminalPaths.has(nextPath)) { + // Consider switching to Set if the list grows too large. + if (terminalPaths.indexOf(nextPath) !== -1) { // Add key and stop traversing this branch. updateMask.push(key); } else { diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index b551dcbc21..0902373893 100755 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -367,9 +367,9 @@ describe('generateUpdateMask()', () => { }); it('should return expected update mask array with max paths for nested object', () => { - expect(generateUpdateMask(obj, new Set(['f.i', 'k']))) + expect(generateUpdateMask(obj, ['f.i', 'k'])) .to.deep.equal(['b', 'c', 'd', 'e', 'f.g', 'f.h', 'f.i', 'k', 'l']); - expect(generateUpdateMask(obj, new Set(['notfound', 'b', 'f', 'k', 'l']))) + expect(generateUpdateMask(obj, ['notfound', 'b', 'f', 'k', 'l'])) .to.deep.equal(['b', 'c', 'd', 'e', 'f', 'k', 'l']); }); }); From 7f7085fa26bf538c5412ef0dc4ced9de1acd3207 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Fri, 10 Jul 2020 10:42:16 -0700 Subject: [PATCH 4/7] Adds additional edge case test for building MFA config HTTP request. --- test/unit/auth/auth-config.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index ef6dbad68a..03b52ed539 100755 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -231,6 +231,16 @@ describe('MultiFactorAuthConfig', () => { }); }); + it('should return empty enabledProviders when an empty "options.factorIds" is provided', () => { + expect(MultiFactorAuthConfig.buildServerRequest({ + state: 'DISABLED', + factorIds: [], + })).to.deep.equal({ + state: 'DISABLED', + enabledProviders: [], + }); + }); + const invalidOptions = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; invalidOptions.forEach((options) => { it('should throw on invalid MultiFactorAuthConfig:' + JSON.stringify(options), () => { From 56c716214cde164fe32e002a9fc11442f371f08d Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Thu, 16 Jul 2020 16:36:50 -0700 Subject: [PATCH 5/7] Adds integration test for clearing the map of test phone numbers. --- test/integration/auth.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index e7877b5c02..36185c8135 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -780,7 +780,6 @@ describe('admin.auth', () => { state: 'DISABLED', factorIds: [], }, - // Cannot currently test clearing test phone numbers: b/160513153. testPhoneNumbers: { '+16505551234': '123456', }, @@ -795,10 +794,6 @@ describe('admin.auth', () => { state: 'ENABLED', factorIds: ['phone'], }, - testPhoneNumbers: { - '+16505551234': '123456', - '+16505550000': '654321', - }, }; // https://mochajs.org/ @@ -1167,7 +1162,8 @@ describe('admin.auth', () => { passwordRequired: false, }, multiFactorConfig: deepCopy(expectedUpdatedTenant2.multiFactorConfig), - testPhoneNumbers: deepCopy(expectedUpdatedTenant2.testPhoneNumbers), + // Test clearing of phone numbers. + testPhoneNumbers: null, }; return admin.auth().tenantManager().updateTenant(createdTenantId, updatedOptions) .then((actualTenant) => { From 7ed6455319b3900a3de69902fb77813405b0980b Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Wed, 5 Aug 2020 17:41:09 -0700 Subject: [PATCH 6/7] Fix linter issue. --- src/auth/auth-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 3d82a6514b..8c8df58fb4 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -279,7 +279,7 @@ export class MultiFactorAuthConfig implements MultiFactorConfig { } if (typeof options.factorIds !== 'undefined') { - if(!validator.isArray(options.factorIds)) { + if (!validator.isArray(options.factorIds)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CONFIG, '"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".', From d591726c39a3898b551e05bc3e35ba3b12119141 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Wed, 5 Aug 2020 18:03:36 -0700 Subject: [PATCH 7/7] Adds MultiFactorConfig to docgen/content-sources/node/toc.yaml --- docgen/content-sources/node/toc.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docgen/content-sources/node/toc.yaml b/docgen/content-sources/node/toc.yaml index 6af80e2356..595f55ae7a 100644 --- a/docgen/content-sources/node/toc.yaml +++ b/docgen/content-sources/node/toc.yaml @@ -40,6 +40,8 @@ toc: path: /docs/reference/admin/node/admin.auth.ListProviderConfigResults - title: "ListTenantsResult" path: /docs/reference/admin/node/admin.auth.ListTenantsResult + - title: "MultiFactorConfig" + path: /docs/reference/admin/node/admin.auth.MultiFactorConfig - title: "MultiFactorCreateSettings" path: /docs/reference/admin/node/admin.auth.MultiFactorCreateSettings - title: "MultiFactorInfo"