From f94423f0e4179df66234f7c4a3389161f7efbb48 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 23 May 2019 15:29:32 -0700 Subject: [PATCH 01/14] Starts defining multi-tenancy APIs. This includes: (#526) * Starts defining multi-tenancy APIs. This includes: - Defining type definitions. - Adding tenantId to UserRecord and UserImportBuilder. - Adding new errors associated with tenant operations. - Defines the Tenant object. As the changes are quite large. This will be split into multiple PRs. * Minor fixes and tweaks. * Addresses comments from review. * Addresses review comments. --- src/auth/auth-config.ts | 137 +++++++++-- src/auth/tenant.ts | 198 +++++++++++++++ src/auth/user-import-builder.ts | 3 + src/auth/user-record.ts | 3 + src/index.d.ts | 44 ++++ src/utils/error.ts | 18 ++ test/unit/auth/auth-config.spec.ts | 118 +++++++++ test/unit/auth/tenant.spec.ts | 267 +++++++++++++++++++++ test/unit/auth/user-import-builder.spec.ts | 2 + test/unit/auth/user-record.spec.ts | 37 ++- test/unit/index.spec.ts | 1 + 11 files changed, 811 insertions(+), 17 deletions(-) create mode 100644 src/auth/tenant.ts create mode 100644 test/unit/auth/tenant.spec.ts diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index b115f17102..fee29414e2 100755 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -149,6 +149,115 @@ export interface OIDCAuthProviderRequest extends OIDCUpdateAuthProviderRequest { /** The public API request interface for updating a generic Auth provider. */ export type UpdateAuthProviderRequest = SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest; +/** The email provider configuration interface. */ +export interface EmailSignInProviderConfig { + enabled?: boolean; + passwordRequired?: boolean; // In the backend API, default is true if not provided +} + +/** The server side email configuration request interface. */ +export interface EmailSignInConfigServerRequest { + allowPasswordSignup?: boolean; + enableEmailLinkSignin?: boolean; +} + + +/** + * Defines the email sign-in config class used to convert client side EmailSignInConfig + * to a format that is understood by the Auth server. + */ +export class EmailSignInConfig implements EmailSignInProviderConfig { + public readonly enabled?: boolean; + public readonly passwordRequired?: boolean; + + /** + * Static method to convert a client side request to a EmailSignInConfigServerRequest. + * Throws an error if validation fails. + * + * @param {any} options The options object to convert to a server request. + * @return {EmailSignInConfigServerRequest} The resulting server request. + */ + public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest { + const request: EmailSignInConfigServerRequest = {}; + EmailSignInConfig.validate(options); + if (options.hasOwnProperty('enabled')) { + request.allowPasswordSignup = options.enabled; + } + if (options.hasOwnProperty('passwordRequired')) { + request.enableEmailLinkSignin = !options.passwordRequired; + } + return request; + } + + /** + * Validates the EmailSignInConfig options object. Throws an error on failure. + * + * @param {any} options The options object to validate. + */ + private static validate(options: {[key: string]: any}) { + // TODO: Validate the request. + const validKeys = { + enabled: true, + passwordRequired: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig" 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_ARGUMENT, + `"${key}" is not a valid EmailSignInConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.enabled" must be a boolean.', + ); + } + if (typeof options.passwordRequired !== 'undefined' && + !validator.isBoolean(options.passwordRequired)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.passwordRequired" must be a boolean.', + ); + } + } + + /** + * The EmailSignInConfig constructor. + * + * @param {any} response The server side response used to initialize the + * EmailSignInConfig object. + * @constructor + */ + constructor(response: {[key: string]: any}) { + if (typeof response.allowPasswordSignup === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); + } + this.enabled = response.allowPasswordSignup; + this.passwordRequired = !response.enableEmailLinkSignin; + } + + /** @return {object} The plain object representation of the email sign-in config. */ + public toJSON(): object { + return { + enabled: this.enabled, + passwordRequired: this.passwordRequired, + }; + } +} + /** * Defines the SAMLConfig class used to convert a client side configuration to its @@ -367,24 +476,24 @@ export class SAMLConfig implements SAMLAuthProviderConfig { AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); } - utils.addReadonlyGetter(this, 'providerId', SAMLConfig.getProviderIdFromResourceName(response.name)); + this.providerId = SAMLConfig.getProviderIdFromResourceName(response.name); // RP config. - utils.addReadonlyGetter(this, 'rpEntityId', response.spConfig.spEntityId); - utils.addReadonlyGetter(this, 'callbackURL', response.spConfig.callbackUri); + this.rpEntityId = response.spConfig.spEntityId; + this.callbackURL = response.spConfig.callbackUri; // IdP config. - utils.addReadonlyGetter(this, 'idpEntityId', response.idpConfig.idpEntityId); - utils.addReadonlyGetter(this, 'ssoURL', response.idpConfig.ssoUrl); - utils.addReadonlyGetter(this, 'enableRequestSigning', !!response.idpConfig.signRequest); + this.idpEntityId = response.idpConfig.idpEntityId; + this.ssoURL = response.idpConfig.ssoUrl; + this.enableRequestSigning = !!response.idpConfig.signRequest; const x509Certificates: string[] = []; for (const cert of (response.idpConfig.idpCertificates || [])) { if (cert.x509Certificate) { x509Certificates.push(cert.x509Certificate); } } - utils.addReadonlyGetter(this, 'x509Certificates', x509Certificates); + this.x509Certificates = x509Certificates; // When enabled is undefined, it takes its default value of false. - utils.addReadonlyGetter(this, 'enabled', !!response.enabled); - utils.addReadonlyGetter(this, 'displayName', response.displayName); + this.enabled = !!response.enabled; + this.displayName = response.displayName; } /** @return {SAMLAuthProviderConfig} The plain object representation of the SAMLConfig. */ @@ -555,12 +664,12 @@ export class OIDCConfig implements OIDCAuthProviderConfig { AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); } - utils.addReadonlyGetter(this, 'providerId', OIDCConfig.getProviderIdFromResourceName(response.name)); - utils.addReadonlyGetter(this, 'clientId', response.clientId); - utils.addReadonlyGetter(this, 'issuer', response.issuer); + this.providerId = OIDCConfig.getProviderIdFromResourceName(response.name); + this.clientId = response.clientId; + this.issuer = response.issuer; // When enabled is undefined, it takes its default value of false. - utils.addReadonlyGetter(this, 'enabled', !!response.enabled); - utils.addReadonlyGetter(this, 'displayName', response.displayName); + this.enabled = !!response.enabled; + this.displayName = response.displayName; } /** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */ diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts new file mode 100644 index 0000000000..6d08d9cafb --- /dev/null +++ b/src/auth/tenant.ts @@ -0,0 +1,198 @@ +/*! + * Copyright 2019 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 utils from '../utils'; +import * as validator from '../utils/validator'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import { + EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, +} from './auth-config'; + +/** The server side tenant type enum. */ +export type TenantServerType = 'LIGHTWEIGHT' | 'FULL_SERVICE' | 'TYPE_UNSPECIFIED'; + +/** The client side tenant type enum. */ +export type TenantType = 'lightweight' | 'full_service' | 'type_unspecified'; + +/** The TenantOptions interface used for create/read/update tenant operations. */ +export interface TenantOptions { + displayName?: string; + type?: TenantType; + emailSignInConfig?: EmailSignInProviderConfig; +} + +/** The corresponding server side representation of a TenantOptions object. */ +export interface TenantOptionsServerRequest extends EmailSignInConfigServerRequest { + displayName?: string; + type?: TenantServerType; +} + +/** The tenant server response interface. */ +export interface TenantServerResponse { + name: string; + type?: TenantServerType; + displayName?: string; + allowPasswordSignup: boolean; + enableEmailLinkSignin: boolean; +} + +/** The interface representing the listTenant API response. */ +export interface ListTenantsResult { + tenants: Tenant[]; + pageToken?: string; +} + + +/** + * Tenant class that defines a Firebase Auth tenant. + */ +export class Tenant { + public readonly tenantId: string; + public readonly type?: TenantType; + public readonly displayName?: string; + public readonly emailSignInConfig?: EmailSignInConfig; + + /** + * Builds the corresponding server request for a TenantOptions object. + * + * @param {TenantOptions} tenantOptions The properties to convert to a server request. + * @param {boolean} createRequest Whether this is a create request. + * @return {object} The equivalent server request. + */ + public static buildServerRequest( + tenantOptions: TenantOptions, createRequest: boolean): TenantOptionsServerRequest { + Tenant.validate(tenantOptions, createRequest); + let request: TenantOptionsServerRequest = {}; + if (typeof tenantOptions.emailSignInConfig !== 'undefined') { + request = EmailSignInConfig.buildServerRequest(tenantOptions.emailSignInConfig); + } + if (typeof tenantOptions.displayName !== 'undefined') { + request.displayName = tenantOptions.displayName; + } + if (typeof tenantOptions.type !== 'undefined') { + request.type = tenantOptions.type.toUpperCase() as TenantServerType; + } + return request; + } + + /** + * Returns the tenant ID corresponding to the resource name if available. + * + * @param {string} resourceName The server side resource name + * @return {?string} The tenant ID corresponding to the resource, null otherwise. + */ + public static getTenantIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/tenants/tenant1 + const matchTenantRes = resourceName.match(/\/tenants\/(.*)$/); + if (!matchTenantRes || matchTenantRes.length < 2) { + return null; + } + return matchTenantRes[1]; + } + + /** + * Validates a tenant options object. Throws an error on failure. + * + * @param {any} request The tenant options object to validate. + * @param {boolean} createRequest Whether this is a create request. + */ + private static validate(request: any, createRequest: boolean) { + const validKeys = { + displayName: true, + type: true, + emailSignInConfig: true, + }; + const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; + if (!validator.isNonNullObject(request)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${label}" must be a valid non-null object.`, + ); + } + // Check for unsupported top level attributes. + for (const key in request) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${key}" is not a valid ${label} parameter.`, + ); + } + } + // Validate displayName type if provided. + if (typeof request.displayName !== 'undefined' && + !validator.isNonEmptyString(request.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${label}.displayName" must be a valid non-empty string.`, + ); + } + // Validate type if provided. + if (typeof request.type !== 'undefined' && !createRequest) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"Tenant.type" is an immutable property.', + ); + } + if (createRequest && + request.type !== 'full_service' && + request.type !== 'lightweight') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${label}.type" must be either "full_service" or "lightweight".`, + ); + } + // Validate emailSignInConfig type if provided. + if (typeof request.emailSignInConfig !== 'undefined') { + // This will throw an error if invalid. + EmailSignInConfig.buildServerRequest(request.emailSignInConfig); + } + } + + /** + * The Tenant object constructor. + * + * @param {any} response The server side response used to initialize the Tenant object. + * @constructor + */ + constructor(response: any) { + const tenantId = Tenant.getTenantIdFromResourceName(response.name); + if (!tenantId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid tenant response', + ); + } + this.tenantId = tenantId; + this.displayName = response.displayName; + this.type = (response.type && response.type.toLowerCase()) || undefined; + try { + this.emailSignInConfig = new EmailSignInConfig(response); + } catch (e) { + this.emailSignInConfig = undefined; + } + } + + /** @return {object} The plain object representation of the tenant. */ + public toJSON(): object { + return { + tenantId: this.tenantId, + displayName: this.displayName, + type: this.type, + emailSignInConfig: this.emailSignInConfig && this.emailSignInConfig.toJSON(), + }; + } +} + diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts index 7b5f30c99c..a661023321 100644 --- a/src/auth/user-import-builder.ts +++ b/src/auth/user-import-builder.ts @@ -63,6 +63,7 @@ export interface UserImportRecord { customClaims?: object; passwordHash?: Buffer; passwordSalt?: Buffer; + tenantId?: string; } @@ -87,6 +88,7 @@ interface UploadAccountUser { lastLoginAt?: number; createdAt?: number; customAttributes?: string; + tenantId?: string; } @@ -153,6 +155,7 @@ function populateUploadAccountUser( photoUrl: user.photoURL, phoneNumber: user.phoneNumber, providerUserInfo: [], + tenantId: user.tenantId, customAttributes: user.customClaims && JSON.stringify(user.customClaims), }; if (typeof user.passwordHash !== 'undefined') { diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index 4fb52502c4..2b908e9f0d 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -148,6 +148,7 @@ export class UserRecord { public readonly passwordHash?: string; public readonly passwordSalt?: string; public readonly customClaims: object; + public readonly tenantId?: string | null; public readonly tokensValidAfterTime?: string; constructor(response: any) { @@ -187,6 +188,7 @@ export class UserRecord { validAfterTime = parseDate(response.validSince * 1000); } utils.addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime || undefined); + utils.addReadonlyGetter(this, 'tenantId', response.tenantId); } /** @return {object} The plain object representation of the user record. */ @@ -205,6 +207,7 @@ export class UserRecord { passwordSalt: this.passwordSalt, customClaims: deepCopy(this.customClaims), tokensValidAfterTime: this.tokensValidAfterTime, + tenantId: this.tenantId, }; json.providerData = []; for (const entry of this.providerData) { diff --git a/src/index.d.ts b/src/index.d.ts index f02bc45c03..443ad82009 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -116,6 +116,7 @@ declare namespace admin.auth { passwordSalt?: string; customClaims?: Object; tokensValidAfterTime?: string; + tenantId?: string | null; toJSON(): Object; } @@ -143,6 +144,7 @@ declare namespace admin.auth { [key: string]: any; }; sign_in_provider: string; + tenant?: string; [key: string]: any; }; iat: number; @@ -203,6 +205,7 @@ declare namespace admin.auth { customClaims?: Object; passwordHash?: Buffer; passwordSalt?: Buffer; + tenantId?: string | null; } interface SessionCookieOptions { @@ -223,6 +226,36 @@ declare namespace admin.auth { dynamicLinkDomain?: string; } + type TenantType = 'lightweight' | 'full_service'; + + interface Tenant { + tenantId: string; + type?: admin.auth.TenantType; + displayName?: string; + emailSignInConfig?: { + enabled: boolean; + passwordRequired?: boolean + }; + toJSON(): Object; + } + + interface UpdateTenantRequest { + displayName: string; + emailSignInConfig?: { + enabled: boolean; + passwordRequired?: boolean; + }; + } + + interface CreateTenantRequest extends UpdateTenantRequest { + type: admin.auth.TenantType; + } + + interface ListTenantsResult { + tenants: admin.auth.Tenant[]; + pageToken?: string; + } + interface AuthProviderConfigFilter { type: 'saml' | 'oidc'; maxResults?: number; @@ -324,8 +357,19 @@ declare namespace admin.auth { ): Promise; } + interface TenantAwareAuth extends BaseAuth { + tenantId: string; + } + interface Auth extends admin.auth.BaseAuth { app: admin.app.App; + + forTenant(tenantId: string): admin.auth.TenantAwareAuth; + getTenant(tenantId: string): Promise; + listTenants(maxResults?: number, pageToken?: string): Promise; + deleteTenant(tenantId: string): Promise; + createTenant(tenantOptions: admin.auth.CreateTenantRequest): Promise; + updateTenant(tenantId: string, tenantOptions: admin.auth.UpdateTenantRequest): Promise; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 2f3fc2d3fe..95e18c7dba 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -499,6 +499,10 @@ export class AuthClientErrorCode { message: 'The session cookie duration must be a valid number in milliseconds ' + 'between 5 minutes and 2 weeks.', }; + public static INVALID_TENANT_ID = { + code: 'invalid-tenant-id', + message: 'The tenant ID must be a valid non-empty string.', + }; public static INVALID_UID = { code: 'invalid-uid', message: 'The uid must be a non-empty string with at most 128 characters.', @@ -511,6 +515,10 @@ export class AuthClientErrorCode { code: 'invalid-tokens-valid-after-time', message: 'The tokensValidAfterTime must be a valid UTC number in seconds.', }; + public static MISMATCHING_TENANT_ID = { + code: 'mismatching-tenant-id', + message: 'User tenant ID does not match with the current TenantAwareAuth tenant ID.', + }; public static MISSING_ANDROID_PACKAGE_NAME = { code: 'missing-android-pkg-name', message: 'An Android Package Name must be provided if the Android App is ' + @@ -586,6 +594,10 @@ export class AuthClientErrorCode { code: 'session-cookie-revoked', message: 'The Firebase session cookie has been revoked.', }; + public static TENANT_NOT_FOUND = { + code: 'tenant-not-found', + message: 'There is no tenant corresponding to the provided identifier.', + }; public static UID_ALREADY_EXISTS = { code: 'uid-already-exists', message: 'The user with the provided uid already exists.', @@ -763,6 +775,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION', // Invalid email provided. INVALID_EMAIL: 'INVALID_EMAIL', + // Invalid tenant display name. This can be thrown on CreateTenant and UpdateTenant. + INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME', // Invalid ID token provided. INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', // OIDC configuration has an invalid OAuth client ID. @@ -781,6 +795,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { MISSING_CONFIG: 'MISSING_CONFIG', // Missing configuration identifier. MISSING_CONFIG_ID: 'MISSING_PROVIDER_ID', + // Missing tenant display name: This can be thrown on CreateTenant and UpdateTenant. + MISSING_DISPLAY_NAME: 'MISSING_DISPLAY_NAME', // Missing iOS bundle ID. MISSING_IOS_BUNDLE_ID: 'MISSING_IOS_BUNDLE_ID', // Missing OIDC issuer. @@ -803,6 +819,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS', // Project not found. PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + // Tenant not found. + TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', // Token expired error. TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index 3ed9a3291b..e37cec8e09 100755 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -25,6 +25,7 @@ import { SAMLConfigServerResponse, OIDCConfigServerRequest, OIDCConfigServerResponse, SAMLUpdateAuthProviderRequest, OIDCUpdateAuthProviderRequest, SAMLAuthProviderConfig, OIDCAuthProviderConfig, + EmailSignInConfig, } from '../../../src/auth/auth-config'; @@ -34,6 +35,123 @@ chai.use(chaiAsPromised); const expect = chai.expect; +describe('EmailSignInConfig', () => { + describe('constructor', () => { + const validConfig = new EmailSignInConfig({ + allowPasswordSignup: true, + enableEmailLinkSignin: true, + }); + + it('should throw on missing allowPasswordSignup', () => { + expect(() => new EmailSignInConfig({ + enableEmailLinkSignin: false, + })).to.throw('INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); + }); + + it('should set readonly property "enabled" to true on allowPasswordSignup enabled', () => { + expect(validConfig.enabled).to.be.true; + }); + + it('should set readonly property "enabled" to false on allowPasswordSignup disabled', () => { + const passwordSignupDisabledConfig = new EmailSignInConfig({ + allowPasswordSignup: false, + enableEmailLinkSignin: false, + }); + expect(passwordSignupDisabledConfig.enabled).to.be.false; + }); + + it('should set readonly property "passwordRequired" to false on email link sign in enabled', () => { + expect(validConfig.passwordRequired).to.be.false; + }); + + it('should set readonly property "passwordRequired" to true on email link sign in disabled', () => { + const passwordSignupEnabledConfig = new EmailSignInConfig({ + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }); + expect(passwordSignupEnabledConfig.passwordRequired).to.be.true; + }); + }); + + describe('toJSON()', () => { + it('should return expected JSON representation', () => { + const config = new EmailSignInConfig({ + allowPasswordSignup: true, + enableEmailLinkSignin: true, + }); + expect(config.toJSON()).to.deep.equal({ + enabled: true, + passwordRequired: false, + }); + }); + }); + + describe('buildServerRequest()', () => { + it('should return expected server request on valid input with email link sign-in', () => { + expect(EmailSignInConfig.buildServerRequest({ + enabled: true, + passwordRequired: false, + })).to.deep.equal({ + allowPasswordSignup: true, + enableEmailLinkSignin: true, + }); + }); + + it('should return expected server request on valid input without email link sign-in', () => { + expect(EmailSignInConfig.buildServerRequest({ + enabled: true, + passwordRequired: true, + })).to.deep.equal({ + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }); + }); + + const invalidOptions = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; + invalidOptions.forEach((options) => { + it('should throw on invalid EmailSignInConfig:' + JSON.stringify(options), () => { + expect(() => { + EmailSignInConfig.buildServerRequest(options as any); + }).to.throw('"EmailSignInConfig" must be a non-null object.'); + }); + }); + + it('should throw on EmailSignInConfig with unsupported attribute', () => { + expect(() => { + EmailSignInConfig.buildServerRequest({ + unsupported: true, + enabled: true, + passwordRequired: false, + } as any); + }).to.throw('"unsupported" is not a valid EmailSignInConfig parameter.'); + }); + + const invalidEnabled = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidEnabled.forEach((enabled) => { + it('should throw on invalid EmailSignInConfig.enabled:' + JSON.stringify(enabled), () => { + expect(() => { + EmailSignInConfig.buildServerRequest({ + enabled, + passwordRequired: false, + } as any); + }).to.throw('"EmailSignInConfig.enabled" must be a boolean.'); + }); + }); + + const invalidPasswordRequired = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidEnabled.forEach((passwordRequired) => { + it('should throw on invalid EmailSignInConfig.passwordRequired:' + JSON.stringify(passwordRequired), () => { + expect(() => { + EmailSignInConfig.buildServerRequest({ + enabled: true, + passwordRequired, + } as any); + }).to.throw('"EmailSignInConfig.passwordRequired" must be a boolean.'); + }); + }); + }); +}); + describe('SAMLConfig', () => { const serverRequest: SAMLConfigServerRequest = { idpConfig: { diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts new file mode 100644 index 0000000000..be1a3a576b --- /dev/null +++ b/test/unit/auth/tenant.spec.ts @@ -0,0 +1,267 @@ +/*! + * Copyright 2019 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 chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import {deepCopy} from '../../../src/utils/deep-copy'; +import {EmailSignInConfig} from '../../../src/auth/auth-config'; +import { + Tenant, TenantOptions, TenantServerResponse, +} from '../../../src/auth/tenant'; + + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('Tenant', () => { + const serverRequest = { + name: 'projects/project1/tenants/TENANT_ID', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: true, + }; + + const clientRequest = { + displayName: 'TENANT_DISPLAY_NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + }; + + const tenantOptions: 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', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest); + const tenantOptionsServerRequest = deepCopy(serverRequest); + delete tenantOptionsServerRequest.name; + expect(Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + .to.deep.equal(tenantOptionsServerRequest); + }); + + it('should throw on invalid EmailSignInConfig object', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest); + tenantOptionsClientRequest.emailSignInConfig = null; + expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + .to.throw('"EmailSignInConfig" must be a non-null object.'); + }); + + it('should throw on invalid EmailSignInConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.emailSignInConfig.enabled = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"EmailSignInConfig.enabled" must be a boolean.'); + }); + + it('should throw when type is specified in an update request', () => { + const tenantOptionsClientRequest: TenantOptions = deepCopy(tenantOptions); + tenantOptionsClientRequest.type = 'lightweight'; + expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + .to.throw('"Tenant.type" is an immutable property.'); + }); + + it('should not throw on valid client request object', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest); + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).not.to.throw; + }); + + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; + nonObjects.forEach((request) => { + it('should throw on invalid UpdateTenantRequest:' + JSON.stringify(request), () => { + expect(() => { + Tenant.buildServerRequest(request as any, !createRequest); + }).to.throw('"UpdateTenantRequest" must be a valid non-null object.'); + }); + }); + + it('should throw on unsupported attribute for update request', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.unsupported = 'value'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw(`"unsupported" is not a valid UpdateTenantRequest parameter.`); + }); + + const invalidTenantNames = [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidTenantNames.forEach((displayName) => { + it('should throw on invalid UpdateTenantRequest displayName:' + JSON.stringify(displayName), () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.displayName = displayName; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"UpdateTenantRequest.displayName" must be a valid non-empty string.'); + }); + }); + }); + + describe('for a create request', () => { + it('should return the expected server request', () => { + const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequest); + tenantOptionsClientRequest.type = 'lightweight'; + const tenantOptionsServerRequest: TenantServerResponse = deepCopy(serverRequest); + delete tenantOptionsServerRequest.name; + tenantOptionsServerRequest.type = 'LIGHTWEIGHT'; + + expect(Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + .to.deep.equal(tenantOptionsServerRequest); + }); + + const invalidTypes = [undefined, 'invalid', null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidTypes.forEach((invalidType) => { + it('should throw on invalid type ' + JSON.stringify(invalidType), () => { + const tenantOptionsClientRequest: TenantOptions = deepCopy(tenantOptions); + tenantOptionsClientRequest.type = invalidType as any; + expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + .to.throw(`"CreateTenantRequest.type" must be either "full_service" or "lightweight".`); + }); + }); + + it('should throw on invalid EmailSignInConfig', () => { + const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequest); + tenantOptionsClientRequest.emailSignInConfig = null; + tenantOptionsClientRequest.type = 'full_service'; + + expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + .to.throw('"EmailSignInConfig" 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), () => { + expect(() => { + Tenant.buildServerRequest(request as any, createRequest); + }).to.throw('"CreateTenantRequest" must be a valid non-null object.'); + }); + }); + + it('should throw on unsupported attribute for create request', () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.unsupported = 'value'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw(`"unsupported" is not a valid CreateTenantRequest parameter.`); + }); + + const invalidTenantNames = [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidTenantNames.forEach((displayName) => { + it('should throw on invalid CreateTenantRequest displayName:' + JSON.stringify(displayName), () => { + const tenantOptionsClientRequest = deepCopy(clientRequest) as any; + tenantOptionsClientRequest.displayName = displayName; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"CreateTenantRequest.displayName" must be a valid non-empty string.'); + }); + }); + + invalidTypes.forEach((invalidType) => { + it('should throw on creation with invalid type ' + JSON.stringify(invalidType), () => { + const tenantOptionsClientRequest: TenantOptions = deepCopy(tenantOptions); + tenantOptionsClientRequest.type = invalidType as any; + expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + .to.throw(`"CreateTenantRequest.type" must be either "full_service" or "lightweight".`); + }); + }); + }); + }); + + describe('getTenantIdFromResourceName()', () => { + it('should return the expected tenant ID from resource name', () => { + expect(Tenant.getTenantIdFromResourceName('projects/project1/tenants/TENANT_ID')) + .to.equal('TENANT_ID'); + }); + + it('should return the expected tenant ID from resource name whose project ID contains "tenants" substring', () => { + expect(Tenant.getTenantIdFromResourceName('projects/projecttenants/tenants/TENANT_ID')) + .to.equal('TENANT_ID'); + }); + + it('should return null when no tenant ID is found', () => { + expect(Tenant.getTenantIdFromResourceName('projects/project1')).to.be.null; + }); + }); + + describe('constructor', () => { + const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); + serverRequestCopy.type = 'LIGHTWEIGHT'; + const tenant = new Tenant(serverRequestCopy); + it('should not throw on valid initialization', () => { + expect(() => new Tenant(serverRequest)).not.to.throw(); + }); + + it('should set readonly property tenantId', () => { + expect(tenant.tenantId).to.equal('TENANT_ID'); + }); + + it('should set readonly property displayName', () => { + expect(tenant.displayName).to.equal('TENANT_DISPLAY_NAME'); + }); + + it('should set readonly property type', () => { + expect(tenant.type).to.equal('lightweight'); + }); + + it('should set readonly property emailSignInConfig', () => { + const expectedEmailSignInConfig = new EmailSignInConfig({ + allowPasswordSignup: true, + enableEmailLinkSignin: true, + }); + expect(tenant.emailSignInConfig).to.deep.equal(expectedEmailSignInConfig); + }); + + it('should throw when no tenant ID is provided', () => { + const invalidOptions = deepCopy(serverRequest); + // Use resource name that does not include a tenant ID. + invalidOptions.name = 'projects/project1'; + expect(() => new Tenant(invalidOptions)) + .to.throw('INTERNAL ASSERT FAILED: Invalid tenant response'); + }); + }); + + describe('toJSON()', () => { + const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); + serverRequestCopy.type = 'LIGHTWEIGHT'; + it('should return the expected object representation of a tenant', () => { + expect(new Tenant(serverRequestCopy).toJSON()).to.deep.equal({ + tenantId: 'TENANT_ID', + type: 'lightweight', + displayName: 'TENANT_DISPLAY_NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + }); + }); + }); +}); diff --git a/test/unit/auth/user-import-builder.spec.ts b/test/unit/auth/user-import-builder.spec.ts index e1359292cc..da0911810c 100644 --- a/test/unit/auth/user-import-builder.spec.ts +++ b/test/unit/auth/user-import-builder.spec.ts @@ -66,6 +66,7 @@ describe('UserImportBuilder', () => { }, ], customClaims: {admin: true}, + tenantId: 'TENANT_ID', }, { uid: '9012', @@ -96,6 +97,7 @@ describe('UserImportBuilder', () => { }, ], customAttributes: JSON.stringify({admin: true}), + tenantId: 'TENANT_ID', }, { localId: '9012', diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index e2af7a8925..21c93934c4 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -29,11 +29,12 @@ chai.use(chaiAsPromised); const expect = chai.expect; /** + * @param {string=} tenantId The optional tenant ID to add to the response. * @return {object} A sample valid user response as returned from getAccountInfo * endpoint. */ -function getValidUserResponse(): object { - return { +function getValidUserResponse(tenantId?: string): {[key: string]: any} { + const response: any = { localId: 'abcdefghijklmnopqrstuvwxyz', email: 'user@gmail.com', emailVerified: true, @@ -79,13 +80,18 @@ function getValidUserResponse(): object { admin: true, }), }; + if (typeof tenantId !== 'undefined') { + response.tenantId = tenantId; + } + return response; } /** + * @param {string=} tenantId The optional tenant ID to add to the user. * @return {object} The expected user JSON representation for the above user * server response. */ -function getUserJSON(): object { +function getUserJSON(tenantId?: string): object { return { uid: 'abcdefghijklmnopqrstuvwxyz', email: 'user@gmail.com', @@ -138,6 +144,7 @@ function getUserJSON(): object { admin: true, }, tokensValidAfterTime: new Date(1476136676000).toUTCString(), + tenantId, }; } @@ -626,6 +633,24 @@ describe('UserRecord', () => { (userRecord.providerData[0] as any).displayName = 'John Smith'; }).to.throw(Error); }); + + it('should return undefined tenantId when not available', () => { + expect(userRecord.tenantId).to.be.undefined; + }); + + it('should return expected tenantId', () => { + const resp = deepCopy(getValidUserResponse('TENANT_ID')); + const tenantUserRecord = new UserRecord(resp); + expect(tenantUserRecord.tenantId).to.equal('TENANT_ID'); + }); + + it('should throw when modifying readonly tenantId property', () => { + expect(() => { + const resp = deepCopy(getValidUserResponse('TENANT_ID')); + const tenantUserRecord = new UserRecord(resp); + (tenantUserRecord as any).tenantId = 'OTHER_TENANT_ID'; + }).to.throw(Error); + }); }); describe('toJSON', () => { @@ -640,5 +665,11 @@ describe('UserRecord', () => { it('should return undefined tokensValidAfterTime when not available', () => { expect((userRecordNoValidSince.toJSON() as any).tokensValidAfterTime).to.be.undefined; }); + + it('should return expected JSON object with tenant ID when available', () => { + const resp = deepCopy(getValidUserResponse('TENANT_ID')); + const tenantUserRecord = new UserRecord(resp); + expect(tenantUserRecord.toJSON()).to.deep.equal(getUserJSON('TENANT_ID')); + }); }); }); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index e8e73a3aa3..bebe719b3b 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -35,6 +35,7 @@ import './auth/auth-api-request.spec'; import './auth/user-import-builder.spec'; import './auth/action-code-settings-builder.spec'; import './auth/auth-config.spec'; +import './auth/tenant.spec'; // Database import './database/database.spec'; From 5c7d44dc482f8ca530c344ca1b64d143befe25e9 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 3 Jun 2019 16:01:35 -0700 Subject: [PATCH 02/14] Multi-tenancy changes to shared Auth API requests (#539) * Defines BaseFirebaseAuthRequestHandler class for sending Auth requests related to user management APIs and SAML/OIDC config mgmt APIs, link generation, etc. Defines FirebaseAuthRequestHandler which extends the base class for project level only calls and which will also be extended to include tenant mgmt APIs. Defines FirebaseTenantRequestHandler which extends the base class for tenant level only calls. Unit tests have been modified to run tests on both subclasses. * Addresses review comments. * Comment clean up. * Address more comments. --- src/auth/auth-api-request.ts | 210 +- src/auth/auth.ts | 6 +- src/utils/error.ts | 2 + test/unit/auth/auth-api-request.spec.ts | 4749 ++++++++++++----------- test/unit/auth/auth.spec.ts | 122 +- 5 files changed, 2650 insertions(+), 2439 deletions(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 0428c4b4f1..439966d345 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -79,10 +79,14 @@ const MAX_SESSION_COOKIE_DURATION_SECS = 14 * 24 * 60 * 60; /** Maximum allowed number of provider configurations to batch download at one time. */ const MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE = 100; -/** The Firebase Auth backend URL format. */ +/** The Firebase Auth backend base URL format. */ const FIREBASE_AUTH_BASE_URL_FORMAT = 'https://identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; +/** The Firebase Auth backend multi-tenancy base URL format. */ +const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace( + 'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}'); + /** Defines a base utility to help with resource URL construction. */ class AuthResourceUrlBuilder { @@ -120,6 +124,35 @@ class AuthResourceUrlBuilder { } +/** Tenant aware resource builder utility. */ +class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { + /** + * The tenant aware resource URL builder constructor. + * + * @param {string} projectId The resource project ID. + * @param {string} version The endpoint API version. + * @param {string} tenantId The tenant ID. + * @constructor + */ + constructor(protected projectId: string, protected version: string, protected tenantId: string) { + super(projectId, version); + this.urlFormat = FIREBASE_AUTH_TENANT_URL_FORMAT; + } + + /** + * Returns the resource URL corresponding to the provided parameters. + * + * @param {string=} api The backend API name. + * @param {object=} params The optional additional parameters to substitute in the + * URL path. + * @return {string} The corresponding resource URL. + */ + public getUrl(api?: string, params?: object) { + return utils.formatString(super.getUrl(api, params), {tenantId: this.tenantId}); + } +} + + /** * Validates a providerUserInfo object. All unsupported parameters * are removed from the original request. If an invalid field is passed @@ -205,6 +238,7 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = phoneNumber: true, customAttributes: true, validSince: true, + tenantId: true, passwordHash: uploadAccountRequest, salt: uploadAccountRequest, createdAt: uploadAccountRequest, @@ -217,6 +251,10 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = delete request[key]; } } + if (typeof request.tenantId !== 'undefined' && + !validator.isNonEmptyString(request.tenantId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + } // For any invalid parameter, use the external key name in the error description. // displayName should be a string. if (typeof request.displayName !== 'undefined' && @@ -633,10 +671,11 @@ const LIST_INBOUND_SAML_CONFIGS = new ApiSettings('/inboundSamlConfigs', 'GET') /** * Class that provides the mechanism to send requests to the Firebase Auth backend endpoints. */ -export class FirebaseAuthRequestHandler { - private readonly httpClient: AuthorizedHttpClient; - private readonly authUrlBuilder: AuthResourceUrlBuilder; - private readonly projectConfigUrlBuilder: AuthResourceUrlBuilder; +export abstract class AbstractAuthRequestHandler { + protected readonly projectId: string; + protected readonly httpClient: AuthorizedHttpClient; + private authUrlBuilder: AuthResourceUrlBuilder; + private projectConfigUrlBuilder: AuthResourceUrlBuilder; /** * @param {any} response The response to check for errors. @@ -651,10 +690,8 @@ export class FirebaseAuthRequestHandler { * @constructor */ constructor(app: FirebaseApp) { - const projectId = utils.getProjectId(app); + this.projectId = utils.getProjectId(app); this.httpClient = new AuthorizedHttpClient(app); - this.authUrlBuilder = new AuthResourceUrlBuilder(projectId, 'v1'); - this.projectConfigUrlBuilder = new AuthResourceUrlBuilder(projectId, 'v2beta1'); } /** @@ -673,7 +710,7 @@ export class FirebaseAuthRequestHandler { // Convert to seconds. validDuration: expiresIn / 1000, }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_CREATE_SESSION_COOKIE, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_CREATE_SESSION_COOKIE, request) .then((response: any) => response.sessionCookie); } @@ -691,7 +728,7 @@ export class FirebaseAuthRequestHandler { const request = { localId: [uid], }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); } /** @@ -708,7 +745,7 @@ export class FirebaseAuthRequestHandler { const request = { email: [email], }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); } /** @@ -725,7 +762,7 @@ export class FirebaseAuthRequestHandler { const request = { phoneNumber: [phoneNumber], }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); } /** @@ -753,7 +790,7 @@ export class FirebaseAuthRequestHandler { if (typeof request.nextPageToken === 'undefined') { delete request.nextPageToken; } - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_DOWNLOAD_ACCOUNT, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_DOWNLOAD_ACCOUNT, request) .then((response: any) => { // No more users available. if (!response.users) { @@ -799,7 +836,7 @@ export class FirebaseAuthRequestHandler { if (request.users.length === 0) { return Promise.resolve(userImportBuilder.buildResponse([])); } - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_UPLOAD_ACCOUNT, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_UPLOAD_ACCOUNT, request) .then((response: any) => { // No error object is returned if no error encountered. const failedUploads = (response.error || []) as Array<{index: number, message: string}>; @@ -822,7 +859,7 @@ export class FirebaseAuthRequestHandler { const request = { localId: uid, }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_DELETE_ACCOUNT, request); + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_DELETE_ACCOUNT, request); } /** @@ -854,7 +891,7 @@ export class FirebaseAuthRequestHandler { localId: uid, customAttributes: JSON.stringify(customUserClaims), }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_SET_ACCOUNT_INFO, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request) .then((response: any) => { return response.localId as string; }); @@ -880,6 +917,15 @@ export class FirebaseAuthRequestHandler { ); } + if (properties.hasOwnProperty('tenantId')) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Tenant ID cannot be modified on an existing user.', + ), + ); + } + // Build the setAccountInfo request. const request: any = deepCopy(properties); request.localId = uid; @@ -931,7 +977,7 @@ export class FirebaseAuthRequestHandler { request.disableUser = request.disabled; delete request.disabled; } - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_SET_ACCOUNT_INFO, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request) .then((response: any) => { return response.localId as string; }); @@ -960,7 +1006,7 @@ export class FirebaseAuthRequestHandler { // validSince is in UTC seconds. validSince: Math.ceil(new Date().getTime() / 1000), }; - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_SET_ACCOUNT_INFO, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request) .then((response: any) => { return response.localId as string; }); @@ -995,7 +1041,7 @@ export class FirebaseAuthRequestHandler { request.localId = request.uid; delete request.uid; } - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_SIGN_UP_NEW_USER, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SIGN_UP_NEW_USER, request) .then((response: any) => { // Return the user id. return response.localId as string; @@ -1028,7 +1074,7 @@ export class FirebaseAuthRequestHandler { return Promise.reject(e); } } - return this.invokeRequestHandler(this.authUrlBuilder, FIREBASE_AUTH_GET_OOB_CODE, request) + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_OOB_CODE, request) .then((response: any) => { // Return the link. return response.oobLink as string; @@ -1045,7 +1091,7 @@ export class FirebaseAuthRequestHandler { if (!OIDCConfig.isProviderId(providerId)) { return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); } - return this.invokeRequestHandler(this.projectConfigUrlBuilder, GET_OAUTH_IDP_CONFIG, {}, {providerId}); + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), GET_OAUTH_IDP_CONFIG, {}, {providerId}); } /** @@ -1071,7 +1117,7 @@ export class FirebaseAuthRequestHandler { if (typeof pageToken !== 'undefined') { request.pageToken = pageToken; } - return this.invokeRequestHandler(this.projectConfigUrlBuilder, LIST_OAUTH_IDP_CONFIGS, request) + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), LIST_OAUTH_IDP_CONFIGS, request) .then((response: any) => { if (!response.oauthIdpConfigs) { response.oauthIdpConfigs = []; @@ -1091,7 +1137,7 @@ export class FirebaseAuthRequestHandler { if (!OIDCConfig.isProviderId(providerId)) { return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); } - return this.invokeRequestHandler(this.projectConfigUrlBuilder, DELETE_OAUTH_IDP_CONFIG, {}, {providerId}) + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), DELETE_OAUTH_IDP_CONFIG, {}, {providerId}) .then((response: any) => { // Return nothing. }); @@ -1113,7 +1159,7 @@ export class FirebaseAuthRequestHandler { return Promise.reject(e); } const providerId = options.providerId; - return this.invokeRequestHandler(this.projectConfigUrlBuilder, CREATE_OAUTH_IDP_CONFIG, request, {providerId}) + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), CREATE_OAUTH_IDP_CONFIG, request, {providerId}) .then((response: any) => { if (!OIDCConfig.getProviderIdFromResourceName(response.name)) { throw new FirebaseAuthError( @@ -1145,7 +1191,7 @@ export class FirebaseAuthRequestHandler { return Promise.reject(e); } const updateMask = utils.generateUpdateMask(request); - return this.invokeRequestHandler(this.projectConfigUrlBuilder, UPDATE_OAUTH_IDP_CONFIG, request, + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), UPDATE_OAUTH_IDP_CONFIG, request, {providerId, updateMask: updateMask.join(',')}) .then((response: any) => { if (!OIDCConfig.getProviderIdFromResourceName(response.name)) { @@ -1167,7 +1213,7 @@ export class FirebaseAuthRequestHandler { if (!SAMLConfig.isProviderId(providerId)) { return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); } - return this.invokeRequestHandler(this.projectConfigUrlBuilder, GET_INBOUND_SAML_CONFIG, {}, {providerId}); + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), GET_INBOUND_SAML_CONFIG, {}, {providerId}); } /** @@ -1193,7 +1239,7 @@ export class FirebaseAuthRequestHandler { if (typeof pageToken !== 'undefined') { request.pageToken = pageToken; } - return this.invokeRequestHandler(this.projectConfigUrlBuilder, LIST_INBOUND_SAML_CONFIGS, request) + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), LIST_INBOUND_SAML_CONFIGS, request) .then((response: any) => { if (!response.inboundSamlConfigs) { response.inboundSamlConfigs = []; @@ -1213,7 +1259,7 @@ export class FirebaseAuthRequestHandler { if (!SAMLConfig.isProviderId(providerId)) { return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); } - return this.invokeRequestHandler(this.projectConfigUrlBuilder, DELETE_INBOUND_SAML_CONFIG, {}, {providerId}) + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), DELETE_INBOUND_SAML_CONFIG, {}, {providerId}) .then((response: any) => { // Return nothing. }); @@ -1235,7 +1281,8 @@ export class FirebaseAuthRequestHandler { return Promise.reject(e); } const providerId = options.providerId; - return this.invokeRequestHandler(this.projectConfigUrlBuilder, CREATE_INBOUND_SAML_CONFIG, request, {providerId}) + return this.invokeRequestHandler( + this.getProjectConfigUrlBuilder(), CREATE_INBOUND_SAML_CONFIG, request, {providerId}) .then((response: any) => { if (!SAMLConfig.getProviderIdFromResourceName(response.name)) { throw new FirebaseAuthError( @@ -1267,7 +1314,7 @@ export class FirebaseAuthRequestHandler { return Promise.reject(e); } const updateMask = utils.generateUpdateMask(request); - return this.invokeRequestHandler(this.projectConfigUrlBuilder, UPDATE_INBOUND_SAML_CONFIG, request, + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), UPDATE_INBOUND_SAML_CONFIG, request, {providerId, updateMask: updateMask.join(',')}) .then((response: any) => { if (!SAMLConfig.getProviderIdFromResourceName(response.name)) { @@ -1316,10 +1363,111 @@ export class FirebaseAuthRequestHandler { .catch((err) => { if (err instanceof HttpError) { const error = err.response.data; - const errorCode = FirebaseAuthRequestHandler.getErrorCode(error); + const errorCode = AbstractAuthRequestHandler.getErrorCode(error); throw FirebaseAuthError.fromServerError(errorCode, /* message */ undefined, error); } throw err; }); } + + /** + * @return {AuthResourceUrlBuilder} A new Auth user management resource URL builder instance. + */ + protected abstract newAuthUrlBuilder(): AuthResourceUrlBuilder; + + /** + * @return {AuthResourceUrlBuilder} A new project config resource URL builder instance. + */ + protected abstract newProjectConfigUrlBuilder(): AuthResourceUrlBuilder; + + /** + * @return {AuthResourceUrlBuilder} The current Auth user management resource URL builder. + */ + private getAuthUrlBuilder(): AuthResourceUrlBuilder { + if (!this.authUrlBuilder) { + this.authUrlBuilder = this.newAuthUrlBuilder(); + } + return this.authUrlBuilder; + } + + /** + * @return {AuthResourceUrlBuilder} The current project config resource URL builder. + */ + private getProjectConfigUrlBuilder(): AuthResourceUrlBuilder { + if (!this.projectConfigUrlBuilder) { + this.projectConfigUrlBuilder = this.newProjectConfigUrlBuilder(); + } + return this.projectConfigUrlBuilder; + } +} + + +/** + * Utility for sending requests to Auth server that are Auth instance related. This includes user and + * tenant management related APIs. This extends the BaseFirebaseAuthRequestHandler class and defines + * additional tenant management related APIs. + */ +export class AuthRequestHandler extends AbstractAuthRequestHandler { + + protected readonly tenantMgmtResourceBuilder: AuthResourceUrlBuilder; + + /** + * The FirebaseAuthRequestHandler constructor used to initialize an instance using a FirebaseApp. + * + * @param {FirebaseApp} app The app used to fetch access tokens to sign API requests. + * @constructor. + */ + constructor(private readonly app: FirebaseApp) { + super(app); + this.tenantMgmtResourceBuilder = new AuthResourceUrlBuilder(utils.getProjectId(app), 'v2beta1'); + } + + /** + * @return {AuthResourceUrlBuilder} A new Auth user management resource URL builder instance. + */ + protected newAuthUrlBuilder(): AuthResourceUrlBuilder { + return new AuthResourceUrlBuilder(this.projectId, 'v1'); + } + + /** + * @return {AuthResourceUrlBuilder} A new project config resource URL builder instance. + */ + protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { + return new AuthResourceUrlBuilder(this.projectId, 'v2beta1'); + } + + // TODO: add tenant management APIs. +} + +/** + * Utility for sending requests to Auth server that are tenant Auth instance related. This includes user + * management related APIs for specified tenants. + * This extends the BaseFirebaseAuthRequestHandler class. + */ +export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler { + /** + * The FirebaseTenantRequestHandler constructor used to initialize an instance using a + * FirebaseApp and a tenant ID. + * + * @param {FirebaseApp} app The app used to fetch access tokens to sign API requests. + * @param {string} tenantId The request handler's tenant ID. + * @constructor + */ + constructor(app: FirebaseApp, private readonly tenantId: string) { + super(app); + } + + /** + * @return {AuthResourceUrlBuilder} A new Auth user management resource URL builder instance. + */ + protected newAuthUrlBuilder(): AuthResourceUrlBuilder { + return new TenantAwareAuthResourceUrlBuilder(this.projectId, 'v1', this.tenantId); + } + + /** + * @return {AuthResourceUrlBuilder} A new project config resource URL builder instance. + */ + protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { + return new TenantAwareAuthResourceUrlBuilder(this.projectId, 'v2beta1', this.tenantId); + } } diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 8539bd7fe0..f598747335 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -17,7 +17,7 @@ import {UserRecord, CreateRequest, UpdateRequest} from './user-record'; import {FirebaseApp} from '../firebase-app'; import {FirebaseTokenGenerator, CryptoSigner, cryptoSignerFromApp} from './token-generator'; -import {FirebaseAuthRequestHandler} from './auth-api-request'; +import {AuthRequestHandler} from './auth-api-request'; import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error'; import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; import { @@ -101,7 +101,7 @@ class BaseAuth { * @constructor */ constructor(protected readonly projectId: string, - protected readonly authRequestHandler: FirebaseAuthRequestHandler, + protected readonly authRequestHandler: AuthRequestHandler, cryptoSigner: CryptoSigner) { this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); this.sessionCookieVerifier = createSessionCookieVerifier(projectId); @@ -629,7 +629,7 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface { constructor(app: FirebaseApp) { super( Auth.getProjectId(app), - new FirebaseAuthRequestHandler(app), + new AuthRequestHandler(app), cryptoSignerFromApp(app)); this.app_ = app; } diff --git a/src/utils/error.ts b/src/utils/error.ts index 95e18c7dba..7e0ae272ed 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -821,6 +821,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', // Tenant not found. TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', + // Tenant ID mismatch. + TENANT_ID_MISMATCH: 'MISMATCHING_TENANT_ID', // Token expired error. TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 9d3e63960f..0d3690d223 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -30,11 +30,11 @@ import {FirebaseApp} from '../../../src/firebase-app'; import {HttpClient, HttpRequestConfig} from '../../../src/utils/api-request'; import * as validator from '../../../src/utils/validator'; import { - FirebaseAuthRequestHandler, FIREBASE_AUTH_GET_ACCOUNT_INFO, + AuthRequestHandler, FIREBASE_AUTH_GET_ACCOUNT_INFO, FIREBASE_AUTH_DELETE_ACCOUNT, FIREBASE_AUTH_SET_ACCOUNT_INFO, FIREBASE_AUTH_SIGN_UP_NEW_USER, FIREBASE_AUTH_DOWNLOAD_ACCOUNT, RESERVED_CLAIMS, FIREBASE_AUTH_UPLOAD_ACCOUNT, FIREBASE_AUTH_CREATE_SESSION_COOKIE, - EMAIL_ACTION_REQUEST_TYPES, + EMAIL_ACTION_REQUEST_TYPES, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler, } from '../../../src/auth/auth-api-request'; import {UserImportBuilder, UserImportRecord} from '../../../src/auth/user-import-builder'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; @@ -53,6 +53,14 @@ const host = 'identitytoolkit.googleapis.com'; const timeout = 25000; +interface HandlerTest { + name: string; + supportsTenantManagement: boolean; + init(app: FirebaseApp): AbstractAuthRequestHandler; + path(version: string, api: string, projectId: string): string; +} + + /** * @param {number} numOfChars The number of random characters within the string. * @return {string} A string with a specific number of random characters. @@ -723,1216 +731,842 @@ describe('FIREBASE_AUTH_SIGN_UP_NEW_USER', () => { }); }); -describe('FirebaseAuthRequestHandler', () => { - let mockApp: FirebaseApp; - let stubs: sinon.SinonStub[] = []; - let getTokenStub: sinon.SinonStub; - const mockAccessToken: string = utils.generateRandomAccessToken(); - const expectedHeaders: {[key: string]: string} = { - 'X-Client-Version': 'Node/Admin/', - 'Authorization': 'Bearer ' + mockAccessToken, - }; - const callParams = (path: string, method: any, data: any): HttpRequestConfig => { - return { - method, - url: `https://${host}${path}`, - headers: expectedHeaders, - data, - timeout, - }; - }; - - before(() => { - getTokenStub = utils.stubGetAccessToken(mockAccessToken); - }); - after(() => { - stubs = []; - getTokenStub.restore(); - }); +const AUTH_REQUEST_HANDLER_TESTS: HandlerTest[] = [ + { + name: 'FirebaseAuthRequestHandler', + init: (app: FirebaseApp) => { + return new AuthRequestHandler(app); + }, + path: (version: string, api: string, projectId: string) => { + return `/${version}/projects/${projectId}${api}`; + }, + supportsTenantManagement: true, + }, + { + name: 'FirebaseTenantRequestHandler', + init: (app: FirebaseApp) => { + return new TenantAwareAuthRequestHandler(app, TENANT_ID); + }, + path: (version: string, api: string, projectId: string) => { + return `/${version}/projects/${projectId}/tenants/${TENANT_ID}${api}`; + }, + supportsTenantManagement: false, + }, +]; + +const TENANT_ID = 'tenantId'; +AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { + describe(handler.name, () => { + let mockApp: FirebaseApp; + let stubs: sinon.SinonStub[] = []; + let getTokenStub: sinon.SinonStub; + const mockAccessToken: string = utils.generateRandomAccessToken(); + const expectedHeaders: {[key: string]: string} = { + 'X-Client-Version': 'Node/Admin/', + 'Authorization': 'Bearer ' + mockAccessToken, + }; + const callParams = (path: string, method: any, data: any): HttpRequestConfig => { + return { + method, + url: `https://${host}${path}`, + headers: expectedHeaders, + data, + timeout, + }; + }; - beforeEach(() => { - mockApp = mocks.app(); - return mockApp.INTERNAL.getToken(); - }); + before(() => { + getTokenStub = utils.stubGetAccessToken(mockAccessToken); + }); - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - return mockApp.delete(); - }); + after(() => { + stubs = []; + getTokenStub.restore(); + }); - describe('Constructor', () => { - it('should succeed with a FirebaseApp instance', () => { - expect(() => { - return new FirebaseAuthRequestHandler(mockApp); - }).not.to.throw(Error); + beforeEach(() => { + mockApp = mocks.app(); + return mockApp.INTERNAL.getToken(); }); - }); - describe('createSessionCookie', () => { - const durationInMs = 24 * 60 * 60 * 1000; - const path = '/v1/projects/project_id:createSessionCookie'; - const method = 'POST'; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + return mockApp.delete(); + }); - it('should be fulfilled given a valid localId', () => { - const expectedResult = utils.responseFrom({ - sessionCookie: 'SESSION_COOKIE', + describe('Constructor', () => { + it('should succeed with a FirebaseApp instance', () => { + expect(() => { + return handler.init(mockApp); + }).not.to.throw(Error); }); - const data = {idToken: 'ID_TOKEN', validDuration: durationInMs / 1000}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('ID_TOKEN', durationInMs) - .then((result) => { - expect(result).to.deep.equal('SESSION_COOKIE'); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be fulfilled given a duration equal to the maximum allowed', () => { - const expectedResult = utils.responseFrom({ - sessionCookie: 'SESSION_COOKIE', - }); - const durationAtLimitInMs = 14 * 24 * 60 * 60 * 1000; - const data = {idToken: 'ID_TOKEN', validDuration: durationAtLimitInMs / 1000}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('ID_TOKEN', durationAtLimitInMs) - .then((result) => { - expect(result).to.deep.equal('SESSION_COOKIE'); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be fulfilled given a duration equal to the minimum allowed', () => { - const expectedResult = utils.responseFrom({ - sessionCookie: 'SESSION_COOKIE', - }); - const durationAtLimitInMs = 5 * 60 * 1000; - const data = {idToken: 'ID_TOKEN', validDuration: durationAtLimitInMs / 1000}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('ID_TOKEN', durationAtLimitInMs) - .then((result) => { - expect(result).to.deep.equal('SESSION_COOKIE'); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be rejected given an invalid ID token', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ID_TOKEN, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('', durationInMs) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); - it('should be rejected given an invalid duration', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('ID_TOKEN', 'invalid' as any) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); - it('should be rejected given a duration less than minimum allowed', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, - ); - const outOfBoundDuration = 60 * 1000 * 5 - 1; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('ID_TOKEN', outOfBoundDuration) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); }); - it('should be rejected given a duration greater than maximum allowed', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, - ); - const outOfBoundDuration = 60 * 60 * 1000 * 24 * 14 + 1; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('ID_TOKEN', outOfBoundDuration) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); + + describe('createSessionCookie', () => { + const durationInMs = 24 * 60 * 60 * 1000; + const path = handler.path('v1', ':createSessionCookie', 'project_id'); + const method = 'POST'; + + it('should be fulfilled given a valid localId', () => { + const expectedResult = utils.responseFrom({ + sessionCookie: 'SESSION_COOKIE', }); - }); - it('should be rejected when the backend returns an error', () => { - const expectedResult = utils.errorFrom({ - error: { - message: 'INVALID_ID_TOKEN', - }, + const data = {idToken: 'ID_TOKEN', validDuration: durationInMs / 1000}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', durationInMs) + .then((result) => { + expect(result).to.deep.equal('SESSION_COOKIE'); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); - const data = {idToken: 'invalid-token', validDuration: durationInMs / 1000}; - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createSessionCookie('invalid-token', durationInMs) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + it('should be fulfilled given a duration equal to the maximum allowed', () => { + const expectedResult = utils.responseFrom({ + sessionCookie: 'SESSION_COOKIE', }); - }); - }); + const durationAtLimitInMs = 14 * 24 * 60 * 60 * 1000; + const data = {idToken: 'ID_TOKEN', validDuration: durationAtLimitInMs / 1000}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - describe('getAccountInfoByEmail', () => { - const path = '/v1/projects/project_id/accounts:lookup'; - const method = 'POST'; - it('should be fulfilled given a valid email', () => { - const expectedResult = utils.responseFrom({ - users : [ - {email: 'user@example.com'}, - ], - }); - const data = {email: ['user@example.com']}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByEmail('user@example.com') - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method, - url: `https://${host}${path}`, - data, - headers: expectedHeaders, - timeout, + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', durationAtLimitInMs) + .then((result) => { + expect(result).to.deep.equal('SESSION_COOKIE'); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); }); + }); + it('should be fulfilled given a duration equal to the minimum allowed', () => { + const expectedResult = utils.responseFrom({ + sessionCookie: 'SESSION_COOKIE', }); - }); - it('should be rejected given an invalid email', () => { - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#GetAccountInfoResponse', - }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const data = {email: ['user@example.com']}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByEmail('user@example.com') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - }); + const durationAtLimitInMs = 5 * 60 * 1000; + const data = {idToken: 'ID_TOKEN', validDuration: durationAtLimitInMs / 1000}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - describe('getAccountInfoByUid', () => { - const path = '/v1/projects/project_id/accounts:lookup'; - const method = 'POST'; - it('should be fulfilled given a valid localId', () => { - const expectedResult = utils.responseFrom({ - users : [ - {localId: 'uid'}, - ], + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', durationAtLimitInMs) + .then((result) => { + expect(result).to.deep.equal('SESSION_COOKIE'); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); }); - const data = {localId: ['uid']}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); + it('should be rejected given an invalid ID token', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ID_TOKEN, + ); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByUid('uid') - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be rejected given an invalid localId', () => { - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#GetAccountInfoResponse', - }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const data = {localId: ['uid']}; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByUid('uid') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be rejected when the backend returns an error', () => { - const expectedResult = utils.errorFrom({ - error: { - message: 'OPERATION_NOT_ALLOWED', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('', durationInMs) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); - const data = {localId: ['uid']}; + it('should be rejected given an invalid duration', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, + ); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); - stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', 'invalid' as any) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given a duration less than minimum allowed', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, + ); + const outOfBoundDuration = 60 * 1000 * 5 - 1; - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByUid('uid') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', outOfBoundDuration) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given a duration greater than maximum allowed', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, + ); + const outOfBoundDuration = 60 * 60 * 1000 * 24 * 14 + 1; - describe('getAccountInfoByPhoneNumber', () => { - const path = '/v1/projects/project_id/accounts:lookup'; - const method = 'POST'; - it('should be fulfilled given a valid phoneNumber', () => { - const expectedResult = utils.responseFrom({ - users : [ - { - localId: 'uid', - phoneNumber: '+11234567890', - providerUserInfo: [ - { - providerId: 'phone', - rawId: '+11234567890', - phoneNumber: '+11234567890', - }, - ], - }, - ], + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', outOfBoundDuration) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - const data = { - phoneNumber: ['+11234567890'], - }; + it('should be rejected when the backend returns an error', () => { + const expectedResult = utils.errorFrom({ + error: { + message: 'INVALID_ID_TOKEN', + }, + }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + const data = {idToken: 'invalid-token', validDuration: durationInMs / 1000}; + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.createSessionCookie('invalid-token', durationInMs) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + }); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByPhoneNumber('+11234567890') - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + describe('getAccountInfoByEmail', () => { + const path = handler.path('v1', '/accounts:lookup', 'project_id'); + const method = 'POST'; + it('should be fulfilled given a valid email', () => { + const expectedResult = utils.responseFrom({ + users : [ + {email: 'user@example.com'}, + ], }); - }); - it('should be rejected given an invalid phoneNumber', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_PHONE_NUMBER); - - const stub = sinon.stub(HttpClient.prototype, 'send'); - stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByPhoneNumber('invalid') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.not.been.called; + const data = {email: ['user@example.com']}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByEmail('user@example.com') + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method, + url: `https://${host}${path}`, + data, + headers: expectedHeaders, + timeout, + }); + }); + }); + it('should be rejected given an invalid email', () => { + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#GetAccountInfoResponse', }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const data = {email: ['user@example.com']}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - }); - it('should be rejected when the backend returns an error', () => { - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#GetAccountInfoResponse', + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByEmail('user@example.com') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const data = { - phoneNumber: ['+11234567890'], - }; + }); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); + describe('getAccountInfoByUid', () => { + const path = handler.path('v1', '/accounts:lookup', 'project_id'); + const method = 'POST'; + it('should be fulfilled given a valid localId', () => { + const expectedResult = utils.responseFrom({ + users : [ + {localId: 'uid'}, + ], + }); + const data = {localId: ['uid']}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByPhoneNumber('+11234567890') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByUid('uid') + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be rejected given an invalid localId', () => { + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#GetAccountInfoResponse', }); - }); - }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const data = {localId: ['uid']}; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - describe('uploadAccount', () => { - const path = '/v1/projects/project_id/accounts:batchCreate'; - const method = 'POST'; - const nowString = new Date().toUTCString(); - const users = [ - { - uid: '1234', - email: 'user@example.com', - passwordHash: Buffer.from('password'), - passwordSalt: Buffer.from('salt'), - displayName: 'Test User', - photoURL: 'https://www.example.com/1234/photo.png', - disabled: true, - metadata: { - lastSignInTime: nowString, - creationTime: nowString, - }, - providerData: [ - { - uid: 'google1234', - email: 'user@example.com', - photoURL: 'https://www.google.com/1234/photo.png', - displayName: 'Google User', - providerId: 'google.com', + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByUid('uid') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be rejected when the backend returns an error', () => { + const expectedResult = utils.errorFrom({ + error: { + message: 'OPERATION_NOT_ALLOWED', }, - ], - customClaims: {admin: true}, - }, - { - uid: '9012', - email: 'johndoe@example.com', - passwordHash: Buffer.from('userpass'), - passwordSalt: Buffer.from('NaCl'), - }, - {uid: '5678', phoneNumber: '+16505550101'}, - ]; - const options = { - hash: { - algorithm: 'BCRYPT' as any, - }, - }; + }); + const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); + const data = {localId: ['uid']}; - it('should throw on invalid options without making an underlying API call', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ALGORITHM, - `Unsupported hash algorithm provider "invalid".`, - ); - const invalidOptions = { - hash: { - algorithm: 'invalid', - }, - } as any; - const stub = sinon.stub(HttpClient.prototype, 'send'); - stubs.push(stub); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - expect(() => { - requestHandler.uploadAccount(users, invalidOptions); - }).to.throw(expectedError.message); - expect(stub).to.have.not.been.called; + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByUid('uid') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); }); - it('should throw when 1001 UserImportRecords are provided', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.MAXIMUM_USER_COUNT_EXCEEDED, - `A maximum of 1000 users can be imported at once.`, - ); - const stub = sinon.stub(HttpClient.prototype, 'send'); - stubs.push(stub); - - const testUsers: UserImportRecord[] = []; - for (let i = 0; i < 1001; i++) { - testUsers.push({ - uid: 'USER' + i.toString(), - email: 'user' + i.toString() + '@example.com', - passwordHash: Buffer.from('password'), - }); - } - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - expect(() => { - requestHandler.uploadAccount(testUsers, options); - }).to.throw(expectedError.message); - expect(stub).to.have.not.been.called; - }); + describe('getAccountInfoByPhoneNumber', () => { + const path = handler.path('v1', '/accounts:lookup', 'project_id'); + const method = 'POST'; + it('should be fulfilled given a valid phoneNumber', () => { + const expectedResult = utils.responseFrom({ + users : [ + { + localId: 'uid', + phoneNumber: '+11234567890', + providerUserInfo: [ + { + providerId: 'phone', + rawId: '+11234567890', + phoneNumber: '+11234567890', + }, + ], + }, + ], + }); + const data = { + phoneNumber: ['+11234567890'], + }; - it('should resolve successfully when 1000 UserImportRecords are provided', () => { - const expectedResult = utils.responseFrom({}); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const testUsers = []; - for (let i = 0; i < 1000; i++) { - testUsers.push({ - uid: 'USER' + i.toString(), - email: 'user' + i.toString() + '@example.com', - passwordHash: Buffer.from('password'), - }); - } - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - const userImportBuilder = new UserImportBuilder(testUsers, options); - return requestHandler.uploadAccount(testUsers, options) - .then((result) => { - expect(result).to.deep.equal(userImportBuilder.buildResponse([])); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, userImportBuilder.buildRequest())); - }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByPhoneNumber('+11234567890') + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be rejected given an invalid phoneNumber', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER); - it('should resolve with expected result on underlying API success', () => { - const expectedResult = utils.responseFrom({}); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - const userImportBuilder = new UserImportBuilder(users, options); - return requestHandler.uploadAccount(users, options) - .then((result) => { - expect(result).to.deep.equal(userImportBuilder.buildResponse([])); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, userImportBuilder.buildRequest())); - }); - }); + const stub = sinon.stub(HttpClient.prototype, 'send'); + stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByPhoneNumber('invalid') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.not.been.called; + }); - it('should resolve with expected result on underlying API partial succcess', () => { - const expectedResult = utils.responseFrom({ - error: [ - {index: 0, message: 'Some error occurred'}, - {index: 1, message: 'Another error occurred'}, - ], }); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - const userImportBuilder = new UserImportBuilder(users, options); - return requestHandler.uploadAccount(users, options) - .then((result) => { - expect(result).to.deep.equal(userImportBuilder.buildResponse(expectedResult.data.error)); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, userImportBuilder.buildRequest())); + it('should be rejected when the backend returns an error', () => { + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#GetAccountInfoResponse', }); - }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const data = { + phoneNumber: ['+11234567890'], + }; - it('should resolve without underlying API call when users are processed client side', () => { - // These users should fail to upload due to invalid phone number and email fields. - const testUsers = [ - {uid: '1234', phoneNumber: 'invalid'}, - {uid: '5678', email: 'invalid'}, - ] as any; - const expectedResult = { - successCount: 0, - failureCount: 2, - errors: [ - {index: 0, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)}, - {index: 1, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)}, - ], - }; - const stub = sinon.stub(HttpClient.prototype, 'send'); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.uploadAccount(testUsers) - .then((result) => { - expect(result).to.deep.equal(expectedResult); - expect(stub).to.have.not.been.called; - }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByPhoneNumber('+11234567890') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); }); - it('should validate underlying users and resolve with expected errors', () => { - const testUsers = [ - {uid: 'user1', displayName: false}, - {uid: 123}, - {uid: 'user2', email: 'invalid'}, - {uid: 'user3', phoneNumber: 'invalid'}, - {uid: 'user4', emailVerified: 'invalid'}, - {uid: 'user5', photoURL: 'invalid'}, - {uid: 'user6', disabled: 'invalid'}, - {uid: 'user7', metadata: {creationTime: 'invalid'}}, - {uid: 'user8', metadata: {lastSignInTime: 'invalid'}}, - {uid: 'user9', customClaims: {admin: true, aud: 'bla'}}, - {uid: 'user10', email: 'user10@example.com', passwordHash: 'invalid'}, - {uid: 'user11', email: 'user11@example.com', passwordSalt: 'invalid'}, - {uid: 'user12', providerData: [{providerId: 'google.com'}]}, - { - uid: 'user13', - providerData: [{providerId: 'google.com', uid: 'RAW_ID', displayName: false}], - }, + describe('uploadAccount', () => { + const path = handler.path('v1', '/accounts:batchCreate', 'project_id'); + const method = 'POST'; + const nowString = new Date().toUTCString(); + const users = [ { - uid: 'user14', - providerData: [{providerId: 'google.com', uid: 'RAW_ID', email: 'invalid'}], + uid: '1234', + email: 'user@example.com', + passwordHash: Buffer.from('password'), + passwordSalt: Buffer.from('salt'), + displayName: 'Test User', + photoURL: 'https://www.example.com/1234/photo.png', + disabled: true, + metadata: { + lastSignInTime: nowString, + creationTime: nowString, + }, + providerData: [ + { + uid: 'google1234', + email: 'user@example.com', + photoURL: 'https://www.google.com/1234/photo.png', + displayName: 'Google User', + providerId: 'google.com', + }, + ], + customClaims: {admin: true}, + // Tenant ID accepted on user batch upload. + tenantId: 'TENANT_ID', }, { - uid: 'user15', - providerData: [{providerId: 'google.com', uid: 'RAW_ID', photoURL: 'invalid'}], + uid: '9012', + email: 'johndoe@example.com', + passwordHash: Buffer.from('userpass'), + passwordSalt: Buffer.from('NaCl'), }, - {uid: 'user16', providerData: [{}]}, - {email: 'user17@example.com'}, - ] as any; - const validOptions = { + {uid: '5678', phoneNumber: '+16505550101'}, + ]; + const options = { hash: { - algorithm: 'BCRYPT', + algorithm: 'BCRYPT' as any, }, - } as any; - const expectedResult = { - successCount: 0, - failureCount: testUsers.length, - errors: [ - {index: 0, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_DISPLAY_NAME)}, - {index: 1, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)}, - {index: 2, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)}, - {index: 3, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)}, - {index: 4, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL_VERIFIED)}, - {index: 5, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHOTO_URL)}, - {index: 6, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD)}, - {index: 7, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_CREATION_TIME)}, - {index: 8, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_LAST_SIGN_IN_TIME)}, - { - index: 9, - error: new FirebaseAuthError( - AuthClientErrorCode.FORBIDDEN_CLAIM, - `Developer claim "aud" is reserved and cannot be specified.`, - ), - }, - {index: 10, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH)}, - {index: 11, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT)}, - { - index: 12, - error: new FirebaseAuthError( - AuthClientErrorCode.INVALID_UID, - `The provider "uid" for "google.com" must be a valid non-empty string.`, - ), - }, - { - index: 13, - error: new FirebaseAuthError( - AuthClientErrorCode.INVALID_DISPLAY_NAME, - `The provider "displayName" for "google.com" must be a valid string.`, - ), - }, - { - index: 14, - error: new FirebaseAuthError( - AuthClientErrorCode.INVALID_EMAIL, - `The provider "email" for "google.com" must be a valid email string.`, - ), - }, - { - index: 15, - error: new FirebaseAuthError( - AuthClientErrorCode.INVALID_PHOTO_URL, - `The provider "photoURL" for "google.com" must be a valid URL string.`, - ), - }, - {index: 16, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)}, - {index: 17, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)}, - ], }; - const stub = sinon.stub(HttpClient.prototype, 'send'); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.uploadAccount(testUsers, validOptions) - .then((result) => { - expect(result).to.deep.equal(expectedResult); - expect(stub).to.have.not.been.called; - }); - }); - it('should be rejected when the backend returns an error', () => { - const expectedServerError = utils.errorFrom({ - error: { - message: 'INTERNAL_ERROR', - }, + it('should throw on invalid options without making an underlying API call', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + `Unsupported hash algorithm provider "invalid".`, + ); + const invalidOptions = { + hash: { + algorithm: 'invalid', + }, + } as any; + const stub = sinon.stub(HttpClient.prototype, 'send'); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + expect(() => { + requestHandler.uploadAccount(users, invalidOptions); + }).to.throw(expectedError.message); + expect(stub).to.have.not.been.called; }); - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - `An internal error has occurred. Raw server response: ` + - `"${JSON.stringify(expectedServerError.response.data)}"`, - ); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - const userImportBuilder = new UserImportBuilder(users, options); - return requestHandler.uploadAccount(users, options) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, userImportBuilder.buildRequest())); - }); - }); - }); + it('should throw when 1001 UserImportRecords are provided', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.MAXIMUM_USER_COUNT_EXCEEDED, + `A maximum of 1000 users can be imported at once.`, + ); + const stub = sinon.stub(HttpClient.prototype, 'send'); + stubs.push(stub); - describe('downloadAccount', () => { - const path = '/v1/projects/project_id/accounts:batchGet'; - const method = 'GET'; - const nextPageToken = 'PAGE_TOKEN'; - const maxResults = 500; - const expectedResult = utils.responseFrom({ - users : [ - {localId: 'uid1'}, - {localId: 'uid2'}, - ], - nextPageToken: 'NEXT_PAGE_TOKEN', - }); - it('should be fulfilled given a valid parameters', () => { - const data = { - maxResults, - nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.downloadAccount(maxResults, nextPageToken) - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be fulfilled with empty user array when no users exist', () => { - const data = { - maxResults, - nextPageToken, - }; + const testUsers: UserImportRecord[] = []; + for (let i = 0; i < 1001; i++) { + testUsers.push({ + uid: 'USER' + i.toString(), + email: 'user' + i.toString() + '@example.com', + passwordHash: Buffer.from('password'), + }); + } - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); + const requestHandler = handler.init(mockApp); + expect(() => { + requestHandler.uploadAccount(testUsers, options); + }).to.throw(expectedError.message); + expect(stub).to.have.not.been.called; + }); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.downloadAccount(maxResults, nextPageToken) - .then((result) => { - expect(result).to.deep.equal({users: []}); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be fulfilled given no parameters', () => { - // Default maxResults should be used. - const data = { - maxResults: 1000, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.downloadAccount() - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be rejected given an invalid maxResults', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `Required "maxResults" must be a positive integer that does not ` + - `exceed 1000.`, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.downloadAccount(1001, nextPageToken) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); - it('should be rejected given an invalid next page token', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_PAGE_TOKEN, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.downloadAccount(maxResults, '') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); - it('should be rejected when the backend returns an error', () => { - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_PAGE_SELECTION', - }, - }); - const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); - const data = { - maxResults, - nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.downloadAccount(maxResults, nextPageToken) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - }); + it('should resolve successfully when 1000 UserImportRecords are provided', () => { + const expectedResult = utils.responseFrom({}); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - describe('deleteAccount', () => { - const path = '/v1/projects/project_id/accounts:delete'; - const method = 'POST'; - it('should be fulfilled given a valid localId', () => { - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#DeleteAccountResponse', - }); - const data = {localId: 'uid'}; + const testUsers = []; + for (let i = 0; i < 1000; i++) { + testUsers.push({ + uid: 'USER' + i.toString(), + email: 'user' + i.toString() + '@example.com', + passwordHash: Buffer.from('password'), + }); + } + + const requestHandler = handler.init(mockApp); + const userImportBuilder = new UserImportBuilder(testUsers, options); + return requestHandler.uploadAccount(testUsers, options) + .then((result) => { + expect(result).to.deep.equal(userImportBuilder.buildResponse([])); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, userImportBuilder.buildRequest())); + }); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteAccount('uid') - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - it('should be rejected when the backend returns an error', () => { - const expectedResult = utils.errorFrom({ - error: { - message: 'OPERATION_NOT_ALLOWED', - }, }); - const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); - const data = {localId: 'uid'}; - - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); - stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteAccount('uid') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); - }); - }); - }); - describe('updateExistingAccount', () => { - const path = '/v1/projects/project_id/accounts:update'; - const method = 'POST'; - const uid = '12345678'; - const validData = { - displayName: 'John Doe', - email: 'user@example.com', - emailVerified: true, - disabled: false, - photoURL: 'http://localhost/1234/photo.png', - password: 'password', - phoneNumber: '+11234567890', - ignoredProperty: 'value', - }; - const expectedValidData = { - localId: uid, - displayName: 'John Doe', - email: 'user@example.com', - emailVerified: true, - disableUser: false, - photoUrl: 'http://localhost/1234/photo.png', - password: 'password', - phoneNumber: '+11234567890', - }; - // Valid request to delete photoURL and displayName. - const validDeleteData = deepCopy(validData); - validDeleteData.displayName = null; - validDeleteData.photoURL = null; - const expectedValidDeleteData = { - localId: uid, - email: 'user@example.com', - emailVerified: true, - disableUser: false, - password: 'password', - phoneNumber: '+11234567890', - deleteAttribute: ['DISPLAY_NAME', 'PHOTO_URL'], - }; - // Valid request to delete phoneNumber. - const validDeletePhoneNumberData = deepCopy(validData); - validDeletePhoneNumberData.phoneNumber = null; - const expectedValidDeletePhoneNumberData = { - localId: uid, - displayName: 'John Doe', - email: 'user@example.com', - emailVerified: true, - disableUser: false, - photoUrl: 'http://localhost/1234/photo.png', - password: 'password', - deleteProvider: ['phone'], - }; - const invalidData = { - uid, - email: 'user@invalid@', - }; - const invalidPhoneNumberData = { - uid, - phoneNumber: 'invalid', - }; + it('should resolve with expected result on underlying API success', () => { + const expectedResult = utils.responseFrom({}); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - it('should be fulfilled given a valid localId', () => { - // Successful result server response. - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SetAccountInfoResponse', - localId: uid, + const requestHandler = handler.init(mockApp); + const userImportBuilder = new UserImportBuilder(users, options); + return requestHandler.uploadAccount(users, options) + .then((result) => { + expect(result).to.deep.equal(userImportBuilder.buildResponse([])); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, userImportBuilder.buildRequest())); + }); }); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send empty update request. - return requestHandler.updateExistingAccount(uid, {}) - .then((returnedUid: string) => { - // uid should be returned. - expect(returnedUid).to.be.equal(uid); - // Confirm expected rpc request parameters sent. - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, {localId: uid})); - }); - }); - it('should be fulfilled given valid parameters', () => { - // Successful result server response. - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SetAccountInfoResponse', - localId: uid, - }); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send update request with all possible valid parameters. - return requestHandler.updateExistingAccount(uid, validData) - .then((returnedUid: string) => { - // uid should be returned. - expect(returnedUid).to.be.equal(uid); - // Confirm expected rpc request parameters sent. - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidData)); + it('should resolve with expected result on underlying API partial succcess', () => { + const expectedResult = utils.responseFrom({ + error: [ + {index: 0, message: 'Some error occurred'}, + {index: 1, message: 'Another error occurred'}, + ], }); - }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - it('should be fulfilled given valid profile parameters to delete', () => { - // Successful result server response. - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SetAccountInfoResponse', - localId: uid, + const requestHandler = handler.init(mockApp); + const userImportBuilder = new UserImportBuilder(users, options); + return requestHandler.uploadAccount(users, options) + .then((result) => { + expect(result).to.deep.equal(userImportBuilder.buildResponse(expectedResult.data.error)); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, userImportBuilder.buildRequest())); + }); }); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send update request to delete display name and photo URL. - return requestHandler.updateExistingAccount(uid, validDeleteData) - .then((returnedUid: string) => { - // uid should be returned. - expect(returnedUid).to.be.equal(uid); - // Confirm expected rpc request parameters sent. In this case, displayName - // and photoURL removed from request and deleteAttribute added. - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidDeleteData)); - }); - }); - it('should be fulfilled given phone number to delete', () => { - // Successful result server response. - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SetAccountInfoResponse', - localId: uid, - }); + it('should resolve without underlying API call when users are processed client side', () => { + // These users should fail to upload due to invalid phone number and email fields. + const testUsers = [ + {uid: '1234', phoneNumber: 'invalid'}, + {uid: '5678', email: 'invalid'}, + ] as any; + const expectedResult = { + successCount: 0, + failureCount: 2, + errors: [ + {index: 0, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)}, + {index: 1, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)}, + ], + }; + const stub = sinon.stub(HttpClient.prototype, 'send'); + stubs.push(stub); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.uploadAccount(testUsers) + .then((result) => { + expect(result).to.deep.equal(expectedResult); + expect(stub).to.have.not.been.called; + }); + }); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send update request to delete phone number. - return requestHandler.updateExistingAccount(uid, validDeletePhoneNumberData) - .then((returnedUid: string) => { - // uid should be returned. - expect(returnedUid).to.be.equal(uid); - // Confirm expected rpc request parameters sent. In this case, phoneNumber - // removed from request and deleteProvider added. - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidDeletePhoneNumberData)); - }); - }); + it('should validate underlying users and resolve with expected errors', () => { + const testUsers = [ + {uid: 'user1', displayName: false}, + {uid: 123}, + {uid: 'user2', email: 'invalid'}, + {uid: 'user3', phoneNumber: 'invalid'}, + {uid: 'user4', emailVerified: 'invalid'}, + {uid: 'user5', photoURL: 'invalid'}, + {uid: 'user6', disabled: 'invalid'}, + {uid: 'user7', metadata: {creationTime: 'invalid'}}, + {uid: 'user8', metadata: {lastSignInTime: 'invalid'}}, + {uid: 'user9', customClaims: {admin: true, aud: 'bla'}}, + {uid: 'user10', email: 'user10@example.com', passwordHash: 'invalid'}, + {uid: 'user11', email: 'user11@example.com', passwordSalt: 'invalid'}, + {uid: 'user12', providerData: [{providerId: 'google.com'}]}, + { + uid: 'user13', + providerData: [{providerId: 'google.com', uid: 'RAW_ID', displayName: false}], + }, + { + uid: 'user14', + providerData: [{providerId: 'google.com', uid: 'RAW_ID', email: 'invalid'}], + }, + { + uid: 'user15', + providerData: [{providerId: 'google.com', uid: 'RAW_ID', photoURL: 'invalid'}], + }, + {uid: 'user16', providerData: [{}]}, + {email: 'user17@example.com'}, + ] as any; + const validOptions = { + hash: { + algorithm: 'BCRYPT', + }, + } as any; + const expectedResult = { + successCount: 0, + failureCount: testUsers.length, + errors: [ + {index: 0, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_DISPLAY_NAME)}, + {index: 1, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)}, + {index: 2, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)}, + {index: 3, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)}, + {index: 4, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL_VERIFIED)}, + {index: 5, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHOTO_URL)}, + {index: 6, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD)}, + {index: 7, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_CREATION_TIME)}, + {index: 8, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_LAST_SIGN_IN_TIME)}, + { + index: 9, + error: new FirebaseAuthError( + AuthClientErrorCode.FORBIDDEN_CLAIM, + `Developer claim "aud" is reserved and cannot be specified.`, + ), + }, + {index: 10, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH)}, + {index: 11, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT)}, + { + index: 12, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + `The provider "uid" for "google.com" must be a valid non-empty string.`, + ), + }, + { + index: 13, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The provider "displayName" for "google.com" must be a valid string.`, + ), + }, + { + index: 14, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_EMAIL, + `The provider "email" for "google.com" must be a valid email string.`, + ), + }, + { + index: 15, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHOTO_URL, + `The provider "photoURL" for "google.com" must be a valid URL string.`, + ), + }, + {index: 16, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)}, + {index: 17, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)}, + ], + }; + const stub = sinon.stub(HttpClient.prototype, 'send'); + stubs.push(stub); - it('should be rejected given invalid parameters such as email', () => { - // Expected error when an invalid email is provided. - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send update request with invalid email. - return requestHandler.updateExistingAccount(uid, invalidData) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid email error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.uploadAccount(testUsers, validOptions) + .then((result) => { + expect(result).to.deep.equal(expectedResult); + expect(stub).to.have.not.been.called; + }); + }); - it('should be rejected given invalid parameters such as phoneNumber', () => { - // Expected error when an invalid phone number is provided. - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send update request with invalid phone number. - return requestHandler.updateExistingAccount(uid, invalidPhoneNumberData) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid phone number error should be thrown. - expect(error).to.deep.equal(expectedError); + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INTERNAL_ERROR', + }, }); - }); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + `An internal error has occurred. Raw server response: ` + + `"${JSON.stringify(expectedServerError.response.data)}"`, + ); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - it('should be rejected when the backend returns an error', () => { - // Backend returned error. - const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); - const expectedResult = utils.errorFrom({ - error: { - message: 'OPERATION_NOT_ALLOWED', - }, + const requestHandler = handler.init(mockApp); + const userImportBuilder = new UserImportBuilder(users, options); + return requestHandler.uploadAccount(users, options) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, userImportBuilder.buildRequest())); + }); }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateExistingAccount(uid, validData) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidData)); - }); }); - }); - describe('setCustomUserClaims', () => { - const path = '/v1/projects/project_id/accounts:update'; - const method = 'POST'; - const uid = '12345678'; - const claims = {admin: true, groupId: '1234'}; - const expectedValidData = { - localId: uid, - customAttributes: JSON.stringify(claims), - }; - const expectedEmptyClaimsData = { - localId: uid, - customAttributes: JSON.stringify({}), - }; - const expectedResult = utils.responseFrom({ - localId: uid, - }); + describe('downloadAccount', () => { + const path = handler.path('v1', '/accounts:batchGet', 'project_id'); + const method = 'GET'; + const nextPageToken = 'PAGE_TOKEN'; + const maxResults = 500; + const expectedResult = utils.responseFrom({ + users : [ + {localId: 'uid1'}, + {localId: 'uid2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }); + it('should be fulfilled given a valid parameters', () => { + const data = { + maxResults, + nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - it('should be fulfilled given a valid localId and customAttributes', () => { - // Successful result server response. - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send empty request. - return requestHandler.setCustomUserClaims(uid, claims) - .then((returnedUid: string) => { - // uid should be returned. - expect(returnedUid).to.be.equal(uid); - // Confirm expected rpc request parameters sent. - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidData)); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.downloadAccount(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be fulfilled with empty user array when no users exist', () => { + const data = { + maxResults, + nextPageToken, + }; - it('should be fulfilled given valid localId and null claims', () => { - // Successful result server response. - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send request to delete custom claims. - return requestHandler.setCustomUserClaims(uid, null) - .then((returnedUid: string) => { - // uid should be returned. - expect(returnedUid).to.be.equal(uid); - // Confirm expected rpc request parameters sent. - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedEmptyClaimsData)); - }); - }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); - it('should be rejected given invalid parameters such as uid', () => { - // Expected error when an invalid uid is provided. - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send request with invalid uid. - return requestHandler.setCustomUserClaims('', claims) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid uid error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.downloadAccount(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal({users: []}); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be fulfilled given no parameters', () => { + // Default maxResults should be used. + const data = { + maxResults: 1000, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - it('should be rejected given invalid parameters such as customClaims', () => { - // Expected error when invalid claims are provided. - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'CustomUserClaims argument must be an object or null.', - ); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send request with invalid claims. - return requestHandler.setCustomUserClaims(uid, 'invalid' as any) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid argument error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.downloadAccount() + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be rejected given an invalid maxResults', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Required "maxResults" must be a positive integer that does not ` + + `exceed 1000.`, + ); - it('should be rejected given customClaims with blacklisted claims', () => { - // Expected error when invalid claims are provided. - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.FORBIDDEN_CLAIM, - `Developer claim "aud" is reserved and cannot be specified.`, - ); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - const blacklistedClaims = {admin: true, aud: 'bla'}; - // Send request with blacklisted claims. - return requestHandler.setCustomUserClaims(uid, blacklistedClaims) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - // Forbidden claims error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.downloadAccount(1001, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given an invalid next page token', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_PAGE_TOKEN, + ); - it('should be rejected when the backend returns an error', () => { - // Backend returned error. - const expectedError = FirebaseAuthError.fromServerError('USER_NOT_FOUND'); - const expectedServerError = utils.errorFrom({ - error: { - message: 'USER_NOT_FOUND', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.downloadAccount(maxResults, '') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.setCustomUserClaims(uid, claims) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidData)); + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_PAGE_SELECTION', + }, }); - }); - }); - - describe('revokeRefreshTokens', () => { - const path = '/v1/projects/project_id/accounts:update'; - const method = 'POST'; - const uid = '12345678'; - const now = new Date(); - const expectedResult = utils.responseFrom({ - localId: uid, - }); - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = sinon.useFakeTimers(now.getTime()); - }); + const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); + const data = { + maxResults, + nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - afterEach(() => { - clock.restore(); + const requestHandler = handler.init(mockApp); + return requestHandler.downloadAccount(maxResults, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); }); - it('should be fulfilled given a valid uid', () => { - const requestData = { - localId: uid, - // Current time should be passed, rounded up. - validSince: Math.ceil((now.getTime() + 5000) / 1000), - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Simulate 5 seconds passed. - clock.tick(5000); - return requestHandler.revokeRefreshTokens(uid) - .then((returnedUid: string) => { - expect(returnedUid).to.be.equal(uid); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + describe('deleteAccount', () => { + const path = handler.path('v1', '/accounts:delete', 'project_id'); + const method = 'POST'; + it('should be fulfilled given a valid localId', () => { + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#DeleteAccountResponse', }); - }); + const data = {localId: 'uid'}; - it('should be rejected given an invalid uid', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); - const invalidUid: any = {localId: uid}; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.revokeRefreshTokens(invalidUid as any) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid uid error should be thrown. - expect(error).to.deep.equal(expectedError); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.deleteAccount('uid') + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + it('should be rejected when the backend returns an error', () => { + const expectedResult = utils.errorFrom({ + error: { + message: 'OPERATION_NOT_ALLOWED', + }, }); - }); + const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); + const data = {localId: 'uid'}; - it('should be rejected when the backend returns an error', () => { - // Backend returned error. - const expectedError = FirebaseAuthError.fromServerError('USER_NOT_FOUND'); - const expectedServerError = utils.errorFrom({ - error: { - message: 'USER_NOT_FOUND', - }, + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.deleteAccount('uid') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); }); - const requestData = { - localId: uid, - validSince: Math.ceil((now.getTime() + 5000) / 1000), - }; - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Simulate 5 seconds passed. - clock.tick(5000); - return requestHandler.revokeRefreshTokens(uid) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); - }); }); - }); - describe('createNewAccount', () => { - describe('with uid specified', () => { - const path = '/v1/projects/project_id/accounts'; + describe('updateExistingAccount', () => { + const path = handler.path('v1', '/accounts:update', 'project_id'); const method = 'POST'; const uid = '12345678'; const validData = { - uid, displayName: 'John Doe', email: 'user@example.com', emailVerified: true, @@ -1947,11 +1581,37 @@ describe('FirebaseAuthRequestHandler', () => { displayName: 'John Doe', email: 'user@example.com', emailVerified: true, - disabled: false, + disableUser: false, photoUrl: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', }; + // Valid request to delete photoURL and displayName. + const validDeleteData = deepCopy(validData); + validDeleteData.displayName = null; + validDeleteData.photoURL = null; + const expectedValidDeleteData = { + localId: uid, + email: 'user@example.com', + emailVerified: true, + disableUser: false, + password: 'password', + phoneNumber: '+11234567890', + deleteAttribute: ['DISPLAY_NAME', 'PHOTO_URL'], + }; + // Valid request to delete phoneNumber. + const validDeletePhoneNumberData = deepCopy(validData); + validDeletePhoneNumberData.phoneNumber = null; + const expectedValidDeletePhoneNumberData = { + localId: uid, + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disableUser: false, + photoUrl: 'http://localhost/1234/photo.png', + password: 'password', + deleteProvider: ['phone'], + }; const invalidData = { uid, email: 'user@invalid@', @@ -1960,40 +1620,40 @@ describe('FirebaseAuthRequestHandler', () => { uid, phoneNumber: 'invalid', }; - const emptyRequest = { - localId: uid, - }; + it('should be fulfilled given a valid localId', () => { - // Successful uploadAccount response. + // Successful result server response. const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SignupNewUserResponse', + kind: 'identitytoolkit#SetAccountInfoResponse', localId: uid, }); const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send empty create new account request with only a uid provided. - return requestHandler.createNewAccount({uid}) + const requestHandler = handler.init(mockApp); + // Send empty update request. + return requestHandler.updateExistingAccount(uid, {}) .then((returnedUid: string) => { // uid should be returned. expect(returnedUid).to.be.equal(uid); // Confirm expected rpc request parameters sent. - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, emptyRequest)); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, {localId: uid})); }); }); it('should be fulfilled given valid parameters', () => { + // Successful result server response. const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SignupNewUserResponse', + kind: 'identitytoolkit#SetAccountInfoResponse', localId: uid, }); const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Create a new account with all possible valid data. - return requestHandler.createNewAccount(validData) + const requestHandler = handler.init(mockApp); + // Send update request with all possible valid parameters. + return requestHandler.updateExistingAccount(uid, validData) .then((returnedUid: string) => { // uid should be returned. expect(returnedUid).to.be.equal(uid); @@ -2003,73 +1663,112 @@ describe('FirebaseAuthRequestHandler', () => { }); }); + it('should be fulfilled given valid profile parameters to delete', () => { + // Successful result server response. + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SetAccountInfoResponse', + localId: uid, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send update request to delete display name and photo URL. + return requestHandler.updateExistingAccount(uid, validDeleteData) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. In this case, displayName + // and photoURL removed from request and deleteAttribute added. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidDeleteData)); + }); + }); + + it('should be fulfilled given phone number to delete', () => { + // Successful result server response. + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SetAccountInfoResponse', + localId: uid, + }); + + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send update request to delete phone number. + return requestHandler.updateExistingAccount(uid, validDeletePhoneNumberData) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. In this case, phoneNumber + // removed from request and deleteProvider added. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidDeletePhoneNumberData)); + }); + }); + it('should be rejected given invalid parameters such as email', () => { // Expected error when an invalid email is provided. const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Create new account with invalid email. - return requestHandler.createNewAccount(invalidData) + const requestHandler = handler.init(mockApp); + // Send update request with invalid email. + return requestHandler.updateExistingAccount(uid, invalidData) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { - // Expected invalid email error should be thrown. + // Invalid email error should be thrown. expect(error).to.deep.equal(expectedError); }); }); - it('should be rejected given invalid parameters such as phoneNumber', () => { - // Expected error when an invalid phone number is provided. - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Create new account with invalid phone number. - return requestHandler.createNewAccount(invalidPhoneNumberData) + it('should be rejected given a tenant ID to modify', () => { + const dataWithModifiedTenantId = deepCopy(validData); + (dataWithModifiedTenantId as any).tenantId = 'MODIFIED_TENANT_ID'; + // Expected error when a tenant ID is provided. + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Tenant ID cannot be modified on an existing user.', + ); + const requestHandler = handler.init(mockApp); + // Send update request with tenant ID. + return requestHandler.updateExistingAccount(uid, dataWithModifiedTenantId) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { - // Expected invalid phone number error should be thrown. + // Invalid argument error should be thrown. expect(error).to.deep.equal(expectedError); }); }); - it('should be rejected when the backend returns a user exists error', () => { - // Expected error when the uid already exists. - const expectedError = new FirebaseAuthError(AuthClientErrorCode.UID_ALREADY_EXISTS); - const expectedResult = utils.errorFrom({ - error: { - message: 'DUPLICATE_LOCAL_ID', - }, - }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send create new account request and simulate a backend error that the user - // already exists. - return requestHandler.createNewAccount(validData) + it('should be rejected given invalid parameters such as phoneNumber', () => { + // Expected error when an invalid phone number is provided. + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); + const requestHandler = handler.init(mockApp); + // Send update request with invalid phone number. + return requestHandler.updateExistingAccount(uid, invalidPhoneNumberData) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { + // Invalid phone number error should be thrown. expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidData)); }); }); - it('should be rejected when the backend returns an email exists error', () => { - // Expected error when the email already exists. - const expectedError = new FirebaseAuthError(AuthClientErrorCode.EMAIL_ALREADY_EXISTS); + it('should be rejected when the backend returns an error', () => { + // Backend returned error. + const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); const expectedResult = utils.errorFrom({ error: { - message: 'EMAIL_EXISTS', + message: 'OPERATION_NOT_ALLOWED', }, }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send create new account request and simulate a backend error that the email - // already exists. - return requestHandler.createNewAccount(validData) + const requestHandler = handler.init(mockApp); + return requestHandler.updateExistingAccount(uid, validData) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { @@ -2078,125 +1777,121 @@ describe('FirebaseAuthRequestHandler', () => { callParams(path, method, expectedValidData)); }); }); + }); - it('should be rejected when the backend returns a generic error', () => { - // Some generic backend error. - const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); - const expectedResult = utils.errorFrom({ - error: { - message: 'OPERATION_NOT_ALLOWED', - }, - }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + describe('setCustomUserClaims', () => { + const path = handler.path('v1', '/accounts:update', 'project_id'); + const method = 'POST'; + const uid = '12345678'; + const claims = {admin: true, groupId: '1234'}; + const expectedValidData = { + localId: uid, + customAttributes: JSON.stringify(claims), + }; + const expectedEmptyClaimsData = { + localId: uid, + customAttributes: JSON.stringify({}), + }; + const expectedResult = utils.responseFrom({ + localId: uid, + }); + + it('should be fulfilled given a valid localId and customAttributes', () => { + // Successful result server response. + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send create new account request with valid data but simulate backend error. - return requestHandler.createNewAccount(validData) + const requestHandler = handler.init(mockApp); + // Send empty request. + return requestHandler.setCustomUserClaims(uid, claims) .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. expect(stub).to.have.been.calledOnce.and.calledWith( callParams(path, method, expectedValidData)); }); }); - }); - - describe('with no uid specified', () => { - const path = '/v1/projects/project_id/accounts'; - const method = 'POST'; - const uid = '12345678'; - const validData = { - displayName: 'John Doe', - email: 'user@example.com', - emailVerified: true, - disabled: false, - photoURL: 'http://localhost/1234/photo.png', - password: 'password', - phoneNumber: '+11234567890', - ignoredProperty: 'value', - }; - const expectedValidData = { - displayName: 'John Doe', - email: 'user@example.com', - emailVerified: true, - disabled: false, - photoUrl: 'http://localhost/1234/photo.png', - password: 'password', - phoneNumber: '+11234567890', - }; - const invalidData = { - email: 'user@invalid@', - }; - const invalidPhoneNumberData = { - uid, - phoneNumber: 'invalid', - }; - it('should be fulfilled given valid parameters', () => { - // signupNewUser successful response. - const expectedResult = utils.responseFrom({ - kind: 'identitytoolkit#SignupNewUserResponse', - localId: uid, - }); + it('should be fulfilled given valid localId and null claims', () => { + // Successful result server response. const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send request with valid data. - return requestHandler.createNewAccount(validData) + const requestHandler = handler.init(mockApp); + // Send request to delete custom claims. + return requestHandler.setCustomUserClaims(uid, null) .then((returnedUid: string) => { // uid should be returned. expect(returnedUid).to.be.equal(uid); // Confirm expected rpc request parameters sent. expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, method, expectedValidData)); + callParams(path, method, expectedEmptyClaimsData)); }); }); - it('should be rejected given invalid parameters such as email', () => { - // Expected error when an invalid email is provided. - const expectedError = - new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send create new account request with invalid data. - return requestHandler.createNewAccount(invalidData) + it('should be rejected given invalid parameters such as uid', () => { + // Expected error when an invalid uid is provided. + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + const requestHandler = handler.init(mockApp); + // Send request with invalid uid. + return requestHandler.setCustomUserClaims('', claims) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { + // Invalid uid error should be thrown. expect(error).to.deep.equal(expectedError); }); }); - it('should be rejected given invalid parameters such as phone number', () => { - // Expected error when an invalid phone number is provided. - const expectedError = - new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send create new account request with invalid data. - return requestHandler.createNewAccount(invalidPhoneNumberData) + it('should be rejected given invalid parameters such as customClaims', () => { + // Expected error when invalid claims are provided. + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'CustomUserClaims argument must be an object or null.', + ); + const requestHandler = handler.init(mockApp); + // Send request with invalid claims. + return requestHandler.setCustomUserClaims(uid, 'invalid' as any) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { + // Invalid argument error should be thrown. expect(error).to.deep.equal(expectedError); }); }); - it('should be rejected when the backend returns a generic error', () => { - // Some generic backend error. - const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); - const expectedResult = utils.errorFrom({ + it('should be rejected given customClaims with blacklisted claims', () => { + // Expected error when invalid claims are provided. + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.FORBIDDEN_CLAIM, + `Developer claim "aud" is reserved and cannot be specified.`, + ); + const requestHandler = handler.init(mockApp); + const blacklistedClaims = {admin: true, aud: 'bla'}; + // Send request with blacklisted claims. + return requestHandler.setCustomUserClaims(uid, blacklistedClaims) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + // Forbidden claims error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns an error', () => { + // Backend returned error. + const expectedError = FirebaseAuthError.fromServerError('USER_NOT_FOUND'); + const expectedServerError = utils.errorFrom({ error: { - message: 'OPERATION_NOT_ALLOWED', + message: 'USER_NOT_FOUND', }, }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - // Send valid create new account request and simulate backend error. - return requestHandler.createNewAccount(validData) + const requestHandler = handler.init(mockApp); + return requestHandler.setCustomUserClaims(uid, claims) .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { @@ -2206,1284 +1901,1650 @@ describe('FirebaseAuthRequestHandler', () => { }); }); }); - }); - describe('getEmailActionLink', () => { - const path = '/v1/projects/project_id/accounts:sendOobCode'; - const method = 'POST'; - const email = 'user@example.com'; - const actionCodeSettings = { - url: 'https://www.example.com/path/file?a=1&b=2', - handleCodeInApp: true, - iOS: { - bundleId: 'com.example.ios', - }, - android: { - packageName: 'com.example.android', - installApp: true, - minimumVersion: '6', - }, - dynamicLinkDomain: 'custom.page.link', - }; - const expectedActionCodeSettingsRequest = new ActionCodeSettingsBuilder(actionCodeSettings).buildRequest(); - const expectedLink = 'https://custom.page.link?link=' + - encodeURIComponent('https://projectId.firebaseapp.com/__/auth/action?oobCode=CODE') + - '&apn=com.example.android&ibi=com.example.ios'; - const expectedResult = utils.responseFrom({ - email, - oobLink: expectedLink, - }); + describe('revokeRefreshTokens', () => { + const path = handler.path('v1', '/accounts:update', 'project_id'); + const method = 'POST'; + const uid = '12345678'; + const now = new Date(); + const expectedResult = utils.responseFrom({ + localId: uid, + }); + let clock: sinon.SinonFakeTimers; - it('should be fulfilled given a valid email', () => { - const requestData = deepExtend({ - requestType: 'PASSWORD_RESET', - email, - returnOobLink: true, - }, expectedActionCodeSettingsRequest); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink('PASSWORD_RESET', email, actionCodeSettings) - .then((oobLink: string) => { - expect(oobLink).to.be.equal(expectedLink); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); - }); - }); + beforeEach(() => { + clock = sinon.useFakeTimers(now.getTime()); + }); - EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => { - it('should be fulfilled given a valid requestType:' + requestType + ' and ActionCodeSettings', () => { - const requestData = deepExtend({ - requestType, - email, - returnOobLink: true, - }, expectedActionCodeSettingsRequest); + afterEach(() => { + clock.restore(); + }); + + it('should be fulfilled given a valid uid', () => { + const requestData = { + localId: uid, + // Current time should be passed, rounded up. + validSince: Math.ceil((now.getTime() + 5000) / 1000), + }; const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink(requestType, email, actionCodeSettings) - .then((oobLink: string) => { - expect(oobLink).to.be.equal(expectedLink); + const requestHandler = handler.init(mockApp); + // Simulate 5 seconds passed. + clock.tick(5000); + return requestHandler.revokeRefreshTokens(uid) + .then((returnedUid: string) => { + expect(returnedUid).to.be.equal(uid); expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); }); }); - }); - EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => { - if (requestType === 'EMAIL_SIGNIN') { - return; - } - it('should be fulfilled given requestType:' + requestType + ' and no ActionCodeSettings', () => { + it('should be rejected given an invalid uid', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + const invalidUid: any = {localId: uid}; + + const requestHandler = handler.init(mockApp); + return requestHandler.revokeRefreshTokens(invalidUid as any) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + // Invalid uid error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns an error', () => { + // Backend returned error. + const expectedError = FirebaseAuthError.fromServerError('USER_NOT_FOUND'); + const expectedServerError = utils.errorFrom({ + error: { + message: 'USER_NOT_FOUND', + }, + }); const requestData = { - requestType, - email, - returnOobLink: true, + localId: uid, + validSince: Math.ceil((now.getTime() + 5000) / 1000), }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink(requestType, email) - .then((oobLink: string) => { - expect(oobLink).to.be.equal(expectedLink); + const requestHandler = handler.init(mockApp); + // Simulate 5 seconds passed. + clock.tick(5000); + return requestHandler.revokeRefreshTokens(uid) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); }); }); }); - it('should be rejected given requestType:EMAIL_SIGNIN and no ActionCodeSettings', () => { - const invalidRequestType = 'EMAIL_SIGNIN'; - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"ActionCodeSettings" must be a non-null object.`, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink('EMAIL_SIGNIN', email) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid argument error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); - - it('should be rejected given an invalid email', () => { - const invalidEmail = 'invalid'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink('PASSWORD_RESET', invalidEmail, actionCodeSettings) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid email error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); - - it('should be rejected given an invalid request type', () => { - const invalidRequestType = 'invalid'; - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"invalid" is not a supported email action request type.`, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink(invalidRequestType, email, actionCodeSettings) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid argument error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); + describe('createNewAccount', () => { + describe('with uid specified', () => { + const path = handler.path('v1', '/accounts', 'project_id'); + const method = 'POST'; + const uid = '12345678'; + const validData = { + uid, + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disabled: false, + photoURL: 'http://localhost/1234/photo.png', + password: 'password', + phoneNumber: '+11234567890', + ignoredProperty: 'value', + // Tenant ID accepted on creation and relayed to Auth server. + tenantId: 'TENANT_ID', + }; + const expectedValidData = { + localId: uid, + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disabled: false, + photoUrl: 'http://localhost/1234/photo.png', + password: 'password', + phoneNumber: '+11234567890', + tenantId: 'TENANT_ID', + }; + const invalidData = { + uid, + email: 'user@invalid@', + }; + const invalidPhoneNumberData = { + uid, + phoneNumber: 'invalid', + }; + const emptyRequest = { + localId: uid, + }; + it('should be fulfilled given a valid localId', () => { + // Successful uploadAccount response. + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SignupNewUserResponse', + localId: uid, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send empty create new account request with only a uid provided. + return requestHandler.createNewAccount({uid}) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, emptyRequest)); + }); + }); + + it('should be fulfilled given valid parameters', () => { + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SignupNewUserResponse', + localId: uid, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Create a new account with all possible valid data. + return requestHandler.createNewAccount(validData) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidData)); + }); + }); + + it('should be rejected given invalid parameters such as email', () => { + // Expected error when an invalid email is provided. + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); + const requestHandler = handler.init(mockApp); + // Create new account with invalid email. + return requestHandler.createNewAccount(invalidData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + // Expected invalid email error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected given invalid parameters such as phoneNumber', () => { + // Expected error when an invalid phone number is provided. + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); + const requestHandler = handler.init(mockApp); + // Create new account with invalid phone number. + return requestHandler.createNewAccount(invalidPhoneNumberData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + // Expected invalid phone number error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns a user exists error', () => { + // Expected error when the uid already exists. + const expectedError = new FirebaseAuthError(AuthClientErrorCode.UID_ALREADY_EXISTS); + const expectedResult = utils.errorFrom({ + error: { + message: 'DUPLICATE_LOCAL_ID', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send create new account request and simulate a backend error that the user + // already exists. + return requestHandler.createNewAccount(validData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidData)); + }); + }); + + it('should be rejected when the backend returns an email exists error', () => { + // Expected error when the email already exists. + const expectedError = new FirebaseAuthError(AuthClientErrorCode.EMAIL_ALREADY_EXISTS); + const expectedResult = utils.errorFrom({ + error: { + message: 'EMAIL_EXISTS', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send create new account request and simulate a backend error that the email + // already exists. + return requestHandler.createNewAccount(validData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidData)); + }); + }); + + it('should be rejected when the backend returns a generic error', () => { + // Some generic backend error. + const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); + const expectedResult = utils.errorFrom({ + error: { + message: 'OPERATION_NOT_ALLOWED', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send create new account request with valid data but simulate backend error. + return requestHandler.createNewAccount(validData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidData)); + }); + }); + }); + + describe('with no uid specified', () => { + const path = handler.path('v1', '/accounts', 'project_id'); + const method = 'POST'; + const uid = '12345678'; + const validData = { + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disabled: false, + photoURL: 'http://localhost/1234/photo.png', + password: 'password', + phoneNumber: '+11234567890', + ignoredProperty: 'value', + }; + const expectedValidData = { + displayName: 'John Doe', + email: 'user@example.com', + emailVerified: true, + disabled: false, + photoUrl: 'http://localhost/1234/photo.png', + password: 'password', + phoneNumber: '+11234567890', + }; + const invalidData = { + email: 'user@invalid@', + }; + const invalidPhoneNumberData = { + uid, + phoneNumber: 'invalid', + }; - it('should be rejected given an invalid ActionCodeSettings object', () => { - const invalidActionCodeSettings = 'invalid' as any; - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"ActionCodeSettings" must be a non-null object.', - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink('EMAIL_SIGNIN', email, invalidActionCodeSettings) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - // Invalid argument error should be thrown. - expect(error).to.deep.equal(expectedError); - }); - }); + it('should be fulfilled given valid parameters', () => { + // signupNewUser successful response. + const expectedResult = utils.responseFrom({ + kind: 'identitytoolkit#SignupNewUserResponse', + localId: uid, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + // Send request with valid data. + return requestHandler.createNewAccount(validData) + .then((returnedUid: string) => { + // uid should be returned. + expect(returnedUid).to.be.equal(uid); + // Confirm expected rpc request parameters sent. + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidData)); + }); + }); + + it('should be rejected given invalid parameters such as email', () => { + // Expected error when an invalid email is provided. + const expectedError = + new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); + const requestHandler = handler.init(mockApp); + // Send create new account request with invalid data. + return requestHandler.createNewAccount(invalidData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected given invalid parameters such as phone number', () => { + // Expected error when an invalid phone number is provided. + const expectedError = + new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); + const requestHandler = handler.init(mockApp); + // Send create new account request with invalid data. + return requestHandler.createNewAccount(invalidPhoneNumberData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns a generic error', () => { + // Some generic backend error. + const expectedError = FirebaseAuthError.fromServerError('OPERATION_NOT_ALLOWED'); + const expectedResult = utils.errorFrom({ + error: { + message: 'OPERATION_NOT_ALLOWED', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedResult); + stubs.push(stub); - it('should be rejected when the response does not contain a link', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Unable to create the email action link'); - const requestData = deepExtend({ - requestType: 'VERIFY_EMAIL', - email, - returnOobLink: true, - }, expectedActionCodeSettingsRequest); - // Simulate response missing link. - const stub = sinon.stub(HttpClient.prototype, 'send') - .resolves(utils.responseFrom({email})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + const requestHandler = handler.init(mockApp); + // Send valid create new account request and simulate backend error. + return requestHandler.createNewAccount(validData) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, method, expectedValidData)); + }); }); + }); }); - it('should be rejected when the backend returns an error', () => { - // Backend returned error. - const expectedError = FirebaseAuthError.fromServerError('USER_NOT_FOUND'); - const expectedServerError = utils.errorFrom({ - error: { - message: 'USER_NOT_FOUND', + describe('getEmailActionLink', () => { + const path = handler.path('v1', '/accounts:sendOobCode', 'project_id'); + const method = 'POST'; + const email = 'user@example.com'; + const actionCodeSettings = { + url: 'https://www.example.com/path/file?a=1&b=2', + handleCodeInApp: true, + iOS: { + bundleId: 'com.example.ios', }, - }); - const requestData = deepExtend({ - requestType: 'VERIFY_EMAIL', + android: { + packageName: 'com.example.android', + installApp: true, + minimumVersion: '6', + }, + dynamicLinkDomain: 'custom.page.link', + }; + const expectedActionCodeSettingsRequest = new ActionCodeSettingsBuilder(actionCodeSettings).buildRequest(); + const expectedLink = 'https://custom.page.link?link=' + + encodeURIComponent('https://projectId.firebaseapp.com/__/auth/action?oobCode=CODE') + + '&apn=com.example.android&ibi=com.example.ios'; + const expectedResult = utils.responseFrom({ email, - returnOobLink: true, - }, expectedActionCodeSettingsRequest); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings) - .then((returnedUid: string) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); - }); - }); - }); + oobLink: expectedLink, + }); - describe('getOAuthIdpConfig()', () => { - const providerId = 'oidc.provider'; - const path = `/v2beta1/projects/project_id/oauthIdpConfigs/${providerId}`; - const expectedHttpMethod = 'GET'; - const expectedResult = utils.responseFrom({ - name: `projects/project1/oauthIdpConfigs/${providerId}`, - }); + it('should be fulfilled given a valid email', () => { + const requestData = deepExtend({ + requestType: 'PASSWORD_RESET', + email, + returnOobLink: true, + }, expectedActionCodeSettingsRequest); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('PASSWORD_RESET', email, actionCodeSettings) + .then((oobLink: string) => { + expect(oobLink).to.be.equal(expectedLink); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + }); + }); - it('should be fulfilled given a valid provider ID', () => { - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); + EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => { + it('should be fulfilled given a valid requestType:' + requestType + ' and ActionCodeSettings', () => { + const requestData = deepExtend({ + requestType, + email, + returnOobLink: true, + }, expectedActionCodeSettingsRequest); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink(requestType, email, actionCodeSettings) + .then((oobLink: string) => { + expect(oobLink).to.be.equal(expectedLink); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + }); + }); + }); + + EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => { + if (requestType === 'EMAIL_SIGNIN') { + return; + } + it('should be fulfilled given requestType:' + requestType + ' and no ActionCodeSettings', () => { + const requestData = { + requestType, + email, + returnOobLink: true, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getOAuthIdpConfig(providerId) - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, {})); + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink(requestType, email) + .then((oobLink: string) => { + expect(oobLink).to.be.equal(expectedLink); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + }); }); - }); + }); - const invalidProviderIds = [ - null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + it('should be rejected given requestType:EMAIL_SIGNIN and no ActionCodeSettings', () => { + const invalidRequestType = 'EMAIL_SIGNIN'; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"ActionCodeSettings" must be a non-null object.`, + ); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getOAuthIdpConfig(invalidProviderId as any) - .then((result) => { + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('EMAIL_SIGNIN', email) + .then((resp) => { throw new Error('Unexpected success'); }, (error) => { + // Invalid argument error should be thrown. expect(error).to.deep.equal(expectedError); }); }); - }); - it('should be rejected given a backend error', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); - const expectedServerError = utils.errorFrom({ - error: { - message: 'CONFIGURATION_NOT_FOUND', - }, - }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getOAuthIdpConfig(providerId) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, {})); - }); - }); - }); + it('should be rejected given an invalid email', () => { + const invalidEmail = 'invalid'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); - describe('listOAuthIdpConfigs()', () => { - const path = '/v2beta1/projects/project_id/oauthIdpConfigs'; - const expectedHttpMethod = 'GET'; - const nextPageToken = 'PAGE_TOKEN'; - const maxResults = 50; - const expectedResult = utils.responseFrom({ - oauthIdpConfigs : [ - {name: 'projects/project1/oauthIdpConfigs/oidc.provider1'}, - {name: 'projects/project1/oauthIdpConfigs/oidc.provider2'}, - ], - nextPageToken: 'NEXT_PAGE_TOKEN', - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('PASSWORD_RESET', invalidEmail, actionCodeSettings) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + // Invalid email error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); - it('should be fulfilled given a valid parameters', () => { - const data = { - pageSize: maxResults, - pageToken: nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listOAuthIdpConfigs(maxResults, nextPageToken) - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, data)); - }); - }); + it('should be rejected given an invalid request type', () => { + const invalidRequestType = 'invalid'; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"invalid" is not a supported email action request type.`, + ); - it('should be fulfilled with empty configuration array when no configurations exist', () => { - const data = { - pageSize: maxResults, - pageToken: nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listOAuthIdpConfigs(maxResults, nextPageToken) - .then((result) => { - expect(result).to.deep.equal({oauthIdpConfigs: []}); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, data)); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink(invalidRequestType, email, actionCodeSettings) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + // Invalid argument error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); - it('should be fulfilled given no parameters', () => { - // Default maxResults should be used. - const data = { - pageSize: 100, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listOAuthIdpConfigs() - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, data)); - }); - }); + it('should be rejected given an invalid ActionCodeSettings object', () => { + const invalidActionCodeSettings = 'invalid' as any; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"ActionCodeSettings" must be a non-null object.', + ); - it('should be rejected given an invalid maxResults', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `Required "maxResults" must be a positive integer that does not ` + - `exceed 100.`, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listOAuthIdpConfigs(101, nextPageToken) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('EMAIL_SIGNIN', email, invalidActionCodeSettings) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + // Invalid argument error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); - it('should be rejected given an invalid next page token', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_PAGE_TOKEN, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listOAuthIdpConfigs(maxResults, '') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); + it('should be rejected when the response does not contain a link', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create the email action link'); + const requestData = deepExtend({ + requestType: 'VERIFY_EMAIL', + email, + returnOobLink: true, + }, expectedActionCodeSettingsRequest); + // Simulate response missing link. + const stub = sinon.stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({email})); + stubs.push(stub); - it('should be rejected when the backend returns an error', () => { - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_PAGE_SELECTION', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + }); }); - const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); - const data = { - pageSize: maxResults, - pageToken: nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listOAuthIdpConfigs(maxResults, nextPageToken) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, data)); - }); - }); - }); - describe('deleteOAuthIdpConfig()', () => { - const providerId = 'oidc.provider'; - const path = `/v2beta1/projects/project_id/oauthIdpConfigs/${providerId}`; - const expectedHttpMethod = 'DELETE'; - const expectedResult = utils.responseFrom({}); - - it('should be fulfilled given a valid provider ID', () => { - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteOAuthIdpConfig(providerId) - .then((result) => { - expect(result).to.be.undefined; - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, {})); + it('should be rejected when the backend returns an error', () => { + // Backend returned error. + const expectedError = FirebaseAuthError.fromServerError('USER_NOT_FOUND'); + const expectedServerError = utils.errorFrom({ + error: { + message: 'USER_NOT_FOUND', + }, }); - }); - - const invalidProviderIds = [ - null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + const requestData = deepExtend({ + requestType: 'VERIFY_EMAIL', + email, + returnOobLink: true, + }, expectedActionCodeSettingsRequest); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteOAuthIdpConfig(invalidProviderId as any) - .then((result) => { + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings) + .then((returnedUid: string) => { throw new Error('Unexpected success'); }, (error) => { expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); }); }); }); - it('should be rejected given a backend error', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); - const expectedServerError = utils.errorFrom({ - error: { - message: 'CONFIGURATION_NOT_FOUND', - }, + describe('getOAuthIdpConfig()', () => { + const providerId = 'oidc.provider'; + const path = handler.path('v2beta1', `/oauthIdpConfigs/${providerId}`, 'project_id'); + const expectedHttpMethod = 'GET'; + const expectedResult = utils.responseFrom({ + name: `projects/project1/oauthIdpConfigs/${providerId}`, }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteOAuthIdpConfig(providerId) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, {})); - }); - }); - }); - describe('createOAuthIdpConfig', () => { - const providerId = 'oidc.provider'; - const path = `/v2beta1/projects/project_id/oauthIdpConfigs?oauthIdpConfigId=${providerId}`; - const expectedHttpMethod = 'POST'; - const configOptions = { - providerId, - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const expectedRequest = { - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const expectedResult = utils.responseFrom(deepExtend({ - name: `projects/project1/oauthIdpConfigs/${providerId}`, - }, expectedRequest)); - - it('should be fulfilled given valid parameters', () => { - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createOAuthIdpConfig(configOptions) - .then((response) => { - expect(response).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, expectedRequest)); - }); - }); + it('should be fulfilled given a valid provider ID', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - it('should be rejected given invalid parameters', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', - ); - const invalidOptions: OIDCAuthProviderConfig = deepCopy(configOptions); - invalidOptions.issuer = 'invalid'; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createOAuthIdpConfig(invalidOptions) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getOAuthIdpConfig(providerId) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, {})); + }); + }); - it('should be rejected when the backend returns a response missing name', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Unable to create new OIDC configuration', - ); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createOAuthIdpConfig(configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, expectedRequest)); + const invalidProviderIds = [ + null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + + const requestHandler = handler.init(mockApp); + return requestHandler.getOAuthIdpConfig(invalidProviderId as any) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); + }); + + it('should be rejected given a backend error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + const expectedServerError = utils.errorFrom({ + error: { + message: 'CONFIGURATION_NOT_FOUND', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getOAuthIdpConfig(providerId) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, {})); + }); + }); }); - it('should be rejected when the backend returns an error', () => { - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_CONFIG', - }, + describe('listOAuthIdpConfigs()', () => { + const path = handler.path('v2beta1', '/oauthIdpConfigs', 'project_id'); + const expectedHttpMethod = 'GET'; + const nextPageToken = 'PAGE_TOKEN'; + const maxResults = 50; + const expectedResult = utils.responseFrom({ + oauthIdpConfigs : [ + {name: 'projects/project1/oauthIdpConfigs/oidc.provider1'}, + {name: 'projects/project1/oauthIdpConfigs/oidc.provider2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }); + + it('should be fulfilled given a valid parameters', () => { + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.listOAuthIdpConfigs(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, data)); + }); + }); + + it('should be fulfilled with empty configuration array when no configurations exist', () => { + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.listOAuthIdpConfigs(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal({oauthIdpConfigs: []}); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, data)); + }); }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createOAuthIdpConfig(configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, expectedRequest)); + + it('should be fulfilled given no parameters', () => { + // Default maxResults should be used. + const data = { + pageSize: 100, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.listOAuthIdpConfigs() + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, data)); + }); + }); + + it('should be rejected given an invalid maxResults', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Required "maxResults" must be a positive integer that does not ` + + `exceed 100.`, + ); + + const requestHandler = handler.init(mockApp); + return requestHandler.listOAuthIdpConfigs(101, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected given an invalid next page token', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_PAGE_TOKEN, + ); + + const requestHandler = handler.init(mockApp); + return requestHandler.listOAuthIdpConfigs(maxResults, '') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_PAGE_SELECTION', + }, }); + const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.listOAuthIdpConfigs(maxResults, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, data)); + }); + }); }); - }); - describe('updateOAuthIdpConfig()', () => { - const providerId = 'oidc.provider'; - const path = `/v2beta1/projects/project_id/oauthIdpConfigs/${providerId}`; - const expectedHttpMethod = 'PATCH'; - const configOptions = { - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const expectedRequest = { - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const expectedResult = utils.responseFrom(deepExtend({ - name: `projects/project_id/oauthIdpConfigs/${providerId}`, - }, expectedRequest)); - const expectedPartialResult = utils.responseFrom(deepExtend({ - name: `projects/project_id/oauthIdpConfigs/${providerId}`, - }, { - displayName: 'OIDC_DISPLAY_NAME', - enabled: false, - clientId: 'NEW_CLIENT_ID', - issuer: 'https://oidc.com/issuer2', - })); - - it('should be fulfilled given full parameters', () => { - const expectedPath = path + '?updateMask=enabled,displayName,issuer,clientId'; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(providerId, configOptions) - .then((response) => { - expect(response).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, expectedRequest)); + describe('deleteOAuthIdpConfig()', () => { + const providerId = 'oidc.provider'; + const path = handler.path('v2beta1', `/oauthIdpConfigs/${providerId}`, 'project_id'); + const expectedHttpMethod = 'DELETE'; + const expectedResult = utils.responseFrom({}); + + it('should be fulfilled given a valid provider ID', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.deleteOAuthIdpConfig(providerId) + .then((result) => { + expect(result).to.be.undefined; + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, {})); + }); + }); + + const invalidProviderIds = [ + null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + + const requestHandler = handler.init(mockApp); + return requestHandler.deleteOAuthIdpConfig(invalidProviderId as any) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - }); + }); - it('should be fulfilled given partial parameters', () => { - const expectedPath = path + '?updateMask=enabled,clientId'; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); - const partialConfigOptions = { - enabled: false, - clientId: 'NEW_CLIENT_ID', - }; - const partialRequest: OIDCUpdateAuthProviderRequest = { - enabled: false, - displayName: undefined, - issuer: undefined, - clientId: 'NEW_CLIENT_ID', - }; - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(providerId, partialConfigOptions) - .then((response) => { - expect(response).to.deep.equal(expectedPartialResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, partialRequest)); + it('should be rejected given a backend error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + const expectedServerError = utils.errorFrom({ + error: { + message: 'CONFIGURATION_NOT_FOUND', + }, }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.deleteOAuthIdpConfig(providerId) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, {})); + }); + }); }); - it('should be fulfilled given single parameter to change', () => { - const expectedPath = path + '?updateMask=issuer'; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); - const partialConfigOptions = { - issuer: 'https://oidc.com/issuer2', + describe('createOAuthIdpConfig', () => { + const providerId = 'oidc.provider'; + const path = handler.path('v2beta1', `/oauthIdpConfigs?oauthIdpConfigId=${providerId}`, 'project_id'); + const expectedHttpMethod = 'POST'; + const configOptions = { + providerId, + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', }; - const partialRequest: OIDCUpdateAuthProviderRequest = { - clientId: undefined, - displayName: undefined, - enabled: undefined, - issuer: 'https://oidc.com/issuer2', + const expectedRequest = { + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', }; - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(providerId, partialConfigOptions) - .then((response) => { - expect(response).to.deep.equal(expectedPartialResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, partialRequest)); - }); - }); + const expectedResult = utils.responseFrom(deepExtend({ + name: `projects/project1/oauthIdpConfigs/${providerId}`, + }, expectedRequest)); - const invalidProviderIds = [ - null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + it('should be fulfilled given valid parameters', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(invalidProviderId as any, configOptions) + const requestHandler = handler.init(mockApp); + return requestHandler.createOAuthIdpConfig(configOptions) + .then((response) => { + expect(response).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be rejected given invalid parameters', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', + ); + const invalidOptions: OIDCAuthProviderConfig = deepCopy(configOptions); + invalidOptions.issuer = 'invalid'; + + const requestHandler = handler.init(mockApp); + return requestHandler.createOAuthIdpConfig(invalidOptions) .then((result) => { throw new Error('Unexpected success'); }, (error) => { expect(error).to.deep.equal(expectedError); }); }); - }); - it('should be rejected given invalid parameters', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', - ); - const invalidOptions: OIDCUpdateAuthProviderRequest = deepCopy(configOptions); - invalidOptions.issuer = 'invalid'; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(providerId, invalidOptions) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); + it('should be rejected when the backend returns a response missing name', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new OIDC configuration', + ); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); - it('should be rejected when the backend returns a response missing name', () => { - const expectedPath = path + '?updateMask=enabled,displayName,issuer,clientId'; - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Unable to update OIDC configuration', - ); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(providerId, configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, expectedRequest)); + const requestHandler = handler.init(mockApp); + return requestHandler.createOAuthIdpConfig(configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_CONFIG', + }, }); - }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - it('should be rejected when the backend returns an error', () => { - const expectedPath = path + '?updateMask=enabled,displayName,issuer,clientId'; - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_CONFIG', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.createOAuthIdpConfig(configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, expectedRequest)); + }); }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateOAuthIdpConfig(providerId, configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, expectedRequest)); - }); }); - }); - describe('getInboundSamlConfig()', () => { - const providerId = 'saml.provider'; - const path = `/v2beta1/projects/project_id/inboundSamlConfigs/${providerId}`; - const expectedHttpMethod = 'GET'; - const expectedResult = utils.responseFrom({ - name: `projects/project1/inboundSamlConfigs/${providerId}`, - }); + describe('updateOAuthIdpConfig()', () => { + const providerId = 'oidc.provider'; + const path = handler.path('v2beta1', `/oauthIdpConfigs/${providerId}`, 'project_id'); + const expectedHttpMethod = 'PATCH'; + const configOptions = { + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const expectedRequest = { + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const expectedResult = utils.responseFrom(deepExtend({ + name: `projects/project_id/oauthIdpConfigs/${providerId}`, + }, expectedRequest)); + const expectedPartialResult = utils.responseFrom(deepExtend({ + name: `projects/project_id/oauthIdpConfigs/${providerId}`, + }, { + displayName: 'OIDC_DISPLAY_NAME', + enabled: false, + clientId: 'NEW_CLIENT_ID', + issuer: 'https://oidc.com/issuer2', + })); + + it('should be fulfilled given full parameters', () => { + const expectedPath = path + '?updateMask=enabled,displayName,issuer,clientId'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(providerId, configOptions) + .then((response) => { + expect(response).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be fulfilled given partial parameters', () => { + const expectedPath = path + '?updateMask=enabled,clientId'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); + const partialConfigOptions = { + enabled: false, + clientId: 'NEW_CLIENT_ID', + }; + const partialRequest: OIDCUpdateAuthProviderRequest = { + enabled: false, + displayName: undefined, + issuer: undefined, + clientId: 'NEW_CLIENT_ID', + }; + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(providerId, partialConfigOptions) + .then((response) => { + expect(response).to.deep.equal(expectedPartialResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, partialRequest)); + }); + }); + + it('should be fulfilled given single parameter to change', () => { + const expectedPath = path + '?updateMask=issuer'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); + const partialConfigOptions = { + issuer: 'https://oidc.com/issuer2', + }; + const partialRequest: OIDCUpdateAuthProviderRequest = { + clientId: undefined, + displayName: undefined, + enabled: undefined, + issuer: 'https://oidc.com/issuer2', + }; + stubs.push(stub); - it('should be fulfilled given a valid provider ID', () => { - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(providerId, partialConfigOptions) + .then((response) => { + expect(response).to.deep.equal(expectedPartialResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, partialRequest)); + }); + }); + + const invalidProviderIds = [ + null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getInboundSamlConfig(providerId) - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(invalidProviderId as any, configOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - }); + }); - const invalidProviderIds = [ - null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + it('should be rejected given invalid parameters', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', + ); + const invalidOptions: OIDCUpdateAuthProviderRequest = deepCopy(configOptions); + invalidOptions.issuer = 'invalid'; - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getInboundSamlConfig(invalidProviderId as any) + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(providerId, invalidOptions) .then((result) => { throw new Error('Unexpected success'); }, (error) => { expect(error).to.deep.equal(expectedError); }); }); - }); - it('should be rejected given a backend error', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); - const expectedServerError = utils.errorFrom({ - error: { - message: 'CONFIGURATION_NOT_FOUND', - }, + it('should be rejected when the backend returns a response missing name', () => { + const expectedPath = path + '?updateMask=enabled,displayName,issuer,clientId'; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update OIDC configuration', + ); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(providerId, configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, expectedRequest)); + }); }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getInboundSamlConfig(providerId) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + it('should be rejected when the backend returns an error', () => { + const expectedPath = path + '?updateMask=enabled,displayName,issuer,clientId'; + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_CONFIG', + }, }); - }); - }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - describe('listInboundSamlConfigs()', () => { - const path = '/v2beta1/projects/project_id/inboundSamlConfigs'; - const expectedHttpMethod = 'GET'; - const nextPageToken = 'PAGE_TOKEN'; - const maxResults = 50; - const expectedResult = utils.responseFrom({ - inboundSamlConfigs : [ - {name: 'projects/project1/inboundSamlConfigs/saml.provider1'}, - {name: 'projects/project1/inboundSamlConfigs/saml.provider2'}, - ], - nextPageToken: 'NEXT_PAGE_TOKEN', + const requestHandler = handler.init(mockApp); + return requestHandler.updateOAuthIdpConfig(providerId, configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, expectedRequest)); + }); + }); }); - it('should be fulfilled given a valid parameters', () => { - const data = { - pageSize: maxResults, - pageToken: nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listInboundSamlConfigs(maxResults, nextPageToken) - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); - }); - }); + describe('getInboundSamlConfig()', () => { + const providerId = 'saml.provider'; + const path = handler.path('v2beta1', `/inboundSamlConfigs/${providerId}`, 'project_id'); - it('should be fulfilled with empty configuration array when no configurations exist', () => { - const data = { - pageSize: maxResults, - pageToken: nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listInboundSamlConfigs(maxResults, nextPageToken) - .then((result) => { - expect(result).to.deep.equal({inboundSamlConfigs: []}); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); - }); - }); + const expectedHttpMethod = 'GET'; + const expectedResult = utils.responseFrom({ + name: `projects/project1/inboundSamlConfigs/${providerId}`, + }); - it('should be fulfilled given no parameters', () => { - // Default maxResults should be used. - const data = { - pageSize: 100, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listInboundSamlConfigs() - .then((result) => { - expect(result).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); - }); - }); + it('should be fulfilled given a valid provider ID', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getInboundSamlConfig(providerId) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + }); + }); + + const invalidProviderIds = [ + null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); - it('should be rejected given an invalid maxResults', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `Required "maxResults" must be a positive integer that does not ` + - `exceed 100.`, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listInboundSamlConfigs(101, nextPageToken) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); + const requestHandler = handler.init(mockApp); + return requestHandler.getInboundSamlConfig(invalidProviderId as any) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - }); + }); - it('should be rejected given an invalid next page token', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_PAGE_TOKEN, - ); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listInboundSamlConfigs(maxResults, '') - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); + it('should be rejected given a backend error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + const expectedServerError = utils.errorFrom({ + error: { + message: 'CONFIGURATION_NOT_FOUND', + }, }); - }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - it('should be rejected when the backend returns an error', () => { - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_PAGE_SELECTION', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.getInboundSamlConfig(providerId) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + }); }); - const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); - const data = { - pageSize: maxResults, - pageToken: nextPageToken, - }; - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.listInboundSamlConfigs(maxResults, nextPageToken) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); - }); }); - }); - describe('deleteInboundSamlConfig()', () => { - const providerId = 'saml.provider'; - const path = `/v2beta1/projects/project_id/inboundSamlConfigs/${providerId}`; - const expectedHttpMethod = 'DELETE'; - const expectedResult = utils.responseFrom({}); - - it('should be fulfilled given a valid provider ID', () => { - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteInboundSamlConfig(providerId) - .then((result) => { - expect(result).to.be.undefined; - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); - }); - }); + describe('listInboundSamlConfigs()', () => { + const path = handler.path('v2beta1', '/inboundSamlConfigs', 'project_id'); + const expectedHttpMethod = 'GET'; + const nextPageToken = 'PAGE_TOKEN'; + const maxResults = 50; + const expectedResult = utils.responseFrom({ + inboundSamlConfigs : [ + {name: 'projects/project1/inboundSamlConfigs/saml.provider1'}, + {name: 'projects/project1/inboundSamlConfigs/saml.provider2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }); + + it('should be fulfilled given a valid parameters', () => { + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.listInboundSamlConfigs(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); + }); + }); + + it('should be fulfilled with empty configuration array when no configurations exist', () => { + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.listInboundSamlConfigs(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal({inboundSamlConfigs: []}); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); + }); + }); - const invalidProviderIds = [ - null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + it('should be fulfilled given no parameters', () => { + // Default maxResults should be used. + const data = { + pageSize: 100, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteInboundSamlConfig(invalidProviderId as any) + const requestHandler = handler.init(mockApp); + return requestHandler.listInboundSamlConfigs() .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); + }); + }); + + it('should be rejected given an invalid maxResults', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Required "maxResults" must be a positive integer that does not ` + + `exceed 100.`, + ); + + const requestHandler = handler.init(mockApp); + return requestHandler.listInboundSamlConfigs(101, nextPageToken) + .then((resp) => { throw new Error('Unexpected success'); }, (error) => { expect(error).to.deep.equal(expectedError); }); }); - }); - it('should be rejected given a backend error', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); - const expectedServerError = utils.errorFrom({ - error: { - message: 'CONFIGURATION_NOT_FOUND', - }, + it('should be rejected given an invalid next page token', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_PAGE_TOKEN, + ); + + const requestHandler = handler.init(mockApp); + return requestHandler.listInboundSamlConfigs(maxResults, '') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.deleteInboundSamlConfig(providerId) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_PAGE_SELECTION', + }, }); - }); - }); + const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - describe('createInboundSamlConfig', () => { - const providerId = 'saml.provider'; - const path = `/v2beta1/projects/project_id/inboundSamlConfigs?inboundSamlConfigId=${providerId}`; - const expectedHttpMethod = 'POST'; - const configOptions = { - providerId, - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID', - ssoURL: 'https://example.com/login', - x509Certificates: ['CERT1', 'CERT2'], - rpEntityId: 'RP_ENTITY_ID', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const expectedRequest = { - idpConfig: { - idpEntityId: 'IDP_ENTITY_ID', - ssoUrl: 'https://example.com/login', - signRequest: true, - idpCertificates: [ - {x509Certificate: 'CERT1'}, - {x509Certificate: 'CERT2'}, - ], - }, - spConfig: { - spEntityId: 'RP_ENTITY_ID', - callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', - }, - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - }; - const expectedResult = utils.responseFrom(deepExtend({ - name: 'projects/project1/inboundSamlConfigs/saml.provider', - }, expectedRequest)); - - it('should be fulfilled given valid parameters', () => { - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createInboundSamlConfig(configOptions) - .then((response) => { - expect(response).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, expectedRequest)); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.listInboundSamlConfigs(maxResults, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, data)); + }); + }); }); - it('should be rejected given invalid parameters', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', - ); - const invalidOptions: SAMLAuthProviderConfig = deepCopy(configOptions); - invalidOptions.callbackURL = 'invalid'; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createInboundSamlConfig(invalidOptions) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); + describe('deleteInboundSamlConfig()', () => { + const providerId = 'saml.provider'; + const path = handler.path('v2beta1', `/inboundSamlConfigs/${providerId}`, 'project_id'); + const expectedHttpMethod = 'DELETE'; + const expectedResult = utils.responseFrom({}); - it('should be rejected when the backend returns a response missing name', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Unable to create new SAML configuration', - ); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createInboundSamlConfig(configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, expectedRequest)); - }); - }); + it('should be fulfilled given a valid provider ID', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); - it('should be rejected when the backend returns an error', () => { - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_CONFIG', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.deleteInboundSamlConfig(providerId) + .then((result) => { + expect(result).to.be.undefined; + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + }); }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.createInboundSamlConfig(configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(path, expectedHttpMethod, expectedRequest)); + + const invalidProviderIds = [ + null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + + const requestHandler = handler.init(mockApp); + return requestHandler.deleteInboundSamlConfig(invalidProviderId as any) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - }); - }); + }); - describe('updateInboundSamlConfig()', () => { - const providerId = 'saml.provider'; - const path = `/v2beta1/projects/project_id/inboundSamlConfigs/${providerId}`; - const expectedHttpMethod = 'PATCH'; - const configOptions = { - idpEntityId: 'IDP_ENTITY_ID', - ssoURL: 'https://example.com/login', - x509Certificates: ['CERT1', 'CERT2'], - rpEntityId: 'RP_ENTITY_ID', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - enabled: true, - displayName: 'samlProviderName', - }; - const expectedRequest = { - idpConfig: { - idpEntityId: 'IDP_ENTITY_ID', - ssoUrl: 'https://example.com/login', - signRequest: true, - idpCertificates: [ - {x509Certificate: 'CERT1'}, - {x509Certificate: 'CERT2'}, - ], - }, - spConfig: { - spEntityId: 'RP_ENTITY_ID', - callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', - }, - displayName: 'samlProviderName', - enabled: true, - }; - const expectedResult = utils.responseFrom(deepExtend({ - name: `projects/project_id/inboundSamlConfigs/${providerId}`, - }, expectedRequest)); - const expectedPartialResult = utils.responseFrom(deepExtend({ - name: `projects/project_id/inboundSamlConfigs/${providerId}`, - }, { - idpConfig: { - idpEntityId: 'IDP_ENTITY_ID', - ssoUrl: 'https://example.com/login2', - signRequest: true, - idpCertificates: [ - {x509Certificate: 'CERT1'}, - {x509Certificate: 'CERT2'}, - ], - }, - spConfig: { - spEntityId: 'RP_ENTITY_ID2', - callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', - }, - displayName: 'samlProviderName', - enabled: false, - })); - const fullUpadateMask = - 'enabled,displayName,idpConfig.idpEntityId,idpConfig.ssoUrl,' + - 'idpConfig.signRequest,idpConfig.idpCertificates,spConfig.spEntityId,spConfig.callbackUri'; - - it('should be fulfilled given full parameters', () => { - const expectedPath = path + `?updateMask=${fullUpadateMask}`; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(providerId, configOptions) - .then((response) => { - expect(response).to.deep.equal(expectedResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, expectedRequest)); + it('should be rejected given a backend error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + const expectedServerError = utils.errorFrom({ + error: { + message: 'CONFIGURATION_NOT_FOUND', + }, }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.deleteInboundSamlConfig(providerId) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, expectedHttpMethod, {})); + }); + }); }); - it('should be fulfilled given partial parameters', () => { - const expectedPath = path + '?updateMask=enabled,idpConfig.ssoUrl,spConfig.spEntityId'; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); - const partialConfigOptions = { - ssoURL: 'https://example.com/login2', - rpEntityId: 'RP_ENTITY_ID2', - enabled: false, + describe('createInboundSamlConfig', () => { + const providerId = 'saml.provider'; + const path = handler.path('v2beta1', `/inboundSamlConfigs?inboundSamlConfigId=${providerId}`, 'project_id'); + const expectedHttpMethod = 'POST'; + const configOptions = { + providerId, + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + idpEntityId: 'IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['CERT1', 'CERT2'], + rpEntityId: 'RP_ENTITY_ID', + callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', + enableRequestSigning: true, }; - const partialRequest: SAMLConfigServerResponse = { + const expectedRequest = { idpConfig: { - idpEntityId: undefined, - ssoUrl: 'https://example.com/login2', - signRequest: undefined, - idpCertificates: undefined, + idpEntityId: 'IDP_ENTITY_ID', + ssoUrl: 'https://example.com/login', + signRequest: true, + idpCertificates: [ + {x509Certificate: 'CERT1'}, + {x509Certificate: 'CERT2'}, + ], }, spConfig: { - spEntityId: 'RP_ENTITY_ID2', - callbackUri: undefined, + spEntityId: 'RP_ENTITY_ID', + callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', }, - displayName: undefined, - enabled: false, + displayName: 'SAML_DISPLAY_NAME', + enabled: true, }; - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(providerId, partialConfigOptions) - .then((response) => { - expect(response).to.deep.equal(expectedPartialResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, partialRequest)); + const expectedResult = utils.responseFrom(deepExtend({ + name: 'projects/project1/inboundSamlConfigs/saml.provider', + }, expectedRequest)); + + it('should be fulfilled given valid parameters', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.createInboundSamlConfig(configOptions) + .then((response) => { + expect(response).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be rejected given invalid parameters', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', + ); + const invalidOptions: SAMLAuthProviderConfig = deepCopy(configOptions); + invalidOptions.callbackURL = 'invalid'; + + const requestHandler = handler.init(mockApp); + return requestHandler.createInboundSamlConfig(invalidOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns a response missing name', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new SAML configuration', + ); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.createInboundSamlConfig(configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_CONFIG', + }, }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.createInboundSamlConfig(configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(path, expectedHttpMethod, expectedRequest)); + }); + }); }); - it('should be fulfilled given single parameter to change', () => { - const expectedPath = path + '?updateMask=idpConfig.ssoUrl'; - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); - const partialConfigOptions = { - ssoURL: 'https://example.com/login2', + describe('updateInboundSamlConfig()', () => { + const providerId = 'saml.provider'; + const path = handler.path('v2beta1', `/inboundSamlConfigs/${providerId}`, 'project_id'); + + const expectedHttpMethod = 'PATCH'; + const configOptions = { + idpEntityId: 'IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['CERT1', 'CERT2'], + rpEntityId: 'RP_ENTITY_ID', + callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', + enableRequestSigning: true, + enabled: true, + displayName: 'samlProviderName', }; - const partialRequest: SAMLConfigServerResponse = { + const expectedRequest = { idpConfig: { - idpEntityId: undefined, - ssoUrl: 'https://example.com/login2', - signRequest: undefined, - idpCertificates: undefined, + idpEntityId: 'IDP_ENTITY_ID', + ssoUrl: 'https://example.com/login', + signRequest: true, + idpCertificates: [ + {x509Certificate: 'CERT1'}, + {x509Certificate: 'CERT2'}, + ], }, - displayName: undefined, - enabled: undefined, + spConfig: { + spEntityId: 'RP_ENTITY_ID', + callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', + }, + displayName: 'samlProviderName', + enabled: true, }; - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(providerId, partialConfigOptions) - .then((response) => { - expect(response).to.deep.equal(expectedPartialResult.data); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, partialRequest)); + const expectedResult = utils.responseFrom(deepExtend({ + name: `projects/project_id/inboundSamlConfigs/${providerId}`, + }, expectedRequest)); + const expectedPartialResult = utils.responseFrom(deepExtend({ + name: `projects/project_id/inboundSamlConfigs/${providerId}`, + }, { + idpConfig: { + idpEntityId: 'IDP_ENTITY_ID', + ssoUrl: 'https://example.com/login2', + signRequest: true, + idpCertificates: [ + {x509Certificate: 'CERT1'}, + {x509Certificate: 'CERT2'}, + ], + }, + spConfig: { + spEntityId: 'RP_ENTITY_ID2', + callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', + }, + displayName: 'samlProviderName', + enabled: false, + })); + const fullUpadateMask = + 'enabled,displayName,idpConfig.idpEntityId,idpConfig.ssoUrl,' + + 'idpConfig.signRequest,idpConfig.idpCertificates,spConfig.spEntityId,spConfig.callbackUri'; + + it('should be fulfilled given full parameters', () => { + const expectedPath = path + `?updateMask=${fullUpadateMask}`; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(providerId, configOptions) + .then((response) => { + expect(response).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be fulfilled given partial parameters', () => { + const expectedPath = path + '?updateMask=enabled,idpConfig.ssoUrl,spConfig.spEntityId'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); + const partialConfigOptions = { + ssoURL: 'https://example.com/login2', + rpEntityId: 'RP_ENTITY_ID2', + enabled: false, + }; + const partialRequest: SAMLConfigServerResponse = { + idpConfig: { + idpEntityId: undefined, + ssoUrl: 'https://example.com/login2', + signRequest: undefined, + idpCertificates: undefined, + }, + spConfig: { + spEntityId: 'RP_ENTITY_ID2', + callbackUri: undefined, + }, + displayName: undefined, + enabled: false, + }; + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(providerId, partialConfigOptions) + .then((response) => { + expect(response).to.deep.equal(expectedPartialResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, partialRequest)); + }); + }); + + it('should be fulfilled given single parameter to change', () => { + const expectedPath = path + '?updateMask=idpConfig.ssoUrl'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedPartialResult); + const partialConfigOptions = { + ssoURL: 'https://example.com/login2', + }; + const partialRequest: SAMLConfigServerResponse = { + idpConfig: { + idpEntityId: undefined, + ssoUrl: 'https://example.com/login2', + signRequest: undefined, + idpCertificates: undefined, + }, + displayName: undefined, + enabled: undefined, + }; + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(providerId, partialConfigOptions) + .then((response) => { + expect(response).to.deep.equal(expectedPartialResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, partialRequest)); + }); + }); + + const invalidProviderIds = [ + null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(invalidProviderId as any, configOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); }); - }); + }); - const invalidProviderIds = [ - null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + it('should be rejected given invalid parameters', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', + ); + const invalidOptions: SAMLUpdateAuthProviderRequest = deepCopy(configOptions); + invalidOptions.ssoURL = 'invalid'; - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(invalidProviderId as any, configOptions) + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(providerId, invalidOptions) .then((result) => { throw new Error('Unexpected success'); }, (error) => { expect(error).to.deep.equal(expectedError); }); }); - }); - it('should be rejected given invalid parameters', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', - ); - const invalidOptions: SAMLUpdateAuthProviderRequest = deepCopy(configOptions); - invalidOptions.ssoURL = 'invalid'; - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(providerId, invalidOptions) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); + it('should be rejected when the backend returns a response missing name', () => { + const expectedPath = path + `?updateMask=${fullUpadateMask}`; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update SAML configuration', + ); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); - it('should be rejected when the backend returns a response missing name', () => { - const expectedPath = path + `?updateMask=${fullUpadateMask}`; - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Unable to update SAML configuration', - ); - const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(providerId, configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, expectedRequest)); + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(providerId, configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedPath = path + `?updateMask=${fullUpadateMask}`; + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_CONFIG', + }, }); - }); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); - it('should be rejected when the backend returns an error', () => { - const expectedPath = path + `?updateMask=${fullUpadateMask}`; - const expectedServerError = utils.errorFrom({ - error: { - message: 'INVALID_CONFIG', - }, + const requestHandler = handler.init(mockApp); + return requestHandler.updateInboundSamlConfig(providerId, configOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, expectedHttpMethod, expectedRequest)); + }); }); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); - stubs.push(stub); - - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.updateInboundSamlConfig(providerId, configOptions) - .then((resp) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - expect(stub).to.have.been.calledOnce.and.calledWith( - callParams(expectedPath, expectedHttpMethod, expectedRequest)); - }); }); - }); - describe('non-2xx responses', () => { - it('should be rejected given a simulated non-2xx response with a known error code', () => { - const mockErrorResponse = utils.errorFrom({ - error: { - message: 'USER_NOT_FOUND', - }, - }, 400); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(mockErrorResponse); - stubs.push(stub); + describe('non-2xx responses', () => { + it('should be rejected given a simulated non-2xx response with a known error code', () => { + const mockErrorResponse = utils.errorFrom({ + error: { + message: 'USER_NOT_FOUND', + }, + }, 400); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(mockErrorResponse); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByEmail('user@example.com') - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByEmail('user@example.com') + .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); + }); - it('should be rejected given a simulated non-2xx response with an unknown error code', () => { - const mockErrorResponse = utils.errorFrom({ - error: { - message: 'UNKNOWN_ERROR_CODE', - }, - }, 400); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(mockErrorResponse); - stubs.push(stub); + it('should be rejected given a simulated non-2xx response with an unknown error code', () => { + const mockErrorResponse = utils.errorFrom({ + error: { + message: 'UNKNOWN_ERROR_CODE', + }, + }, 400); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(mockErrorResponse); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByEmail('user@example.com') - .should.eventually.be.rejected.and.have.property('code', 'auth/internal-error'); - }); + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByEmail('user@example.com') + .should.eventually.be.rejected.and.have.property('code', 'auth/internal-error'); + }); - it('should be rejected given a simulated non-2xx response with no error code', () => { - const mockErrorResponse = utils.errorFrom({ - error: { - foo: 'bar', - }, - }, 400); - const stub = sinon.stub(HttpClient.prototype, 'send').rejects(mockErrorResponse); - stubs.push(stub); + it('should be rejected given a simulated non-2xx response with no error code', () => { + const mockErrorResponse = utils.errorFrom({ + error: { + foo: 'bar', + }, + }, 400); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(mockErrorResponse); + stubs.push(stub); - const requestHandler = new FirebaseAuthRequestHandler(mockApp); - return requestHandler.getAccountInfoByEmail('user@example.com') - .should.eventually.be.rejected.and.have.property('code', 'auth/internal-error'); + const requestHandler = handler.init(mockApp); + return requestHandler.getAccountInfoByEmail('user@example.com') + .should.eventually.be.rejected.and.have.property('code', 'auth/internal-error'); + }); }); }); }); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 9f2ffbe070..3b344fb80b 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -29,7 +29,7 @@ import {Auth, DecodedIdToken} from '../../../src/auth/auth'; import {UserRecord} from '../../../src/auth/user-record'; import {FirebaseApp} from '../../../src/firebase-app'; import {FirebaseTokenGenerator} from '../../../src/auth/token-generator'; -import {FirebaseAuthRequestHandler} from '../../../src/auth/auth-api-request'; +import {AuthRequestHandler} from '../../../src/auth/auth-api-request'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import * as validator from '../../../src/utils/validator'; @@ -790,7 +790,7 @@ describe('Auth', () => { it('should resolve with a UserRecord on success', () => { // Stub getAccountInfoByUid to return expected result. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .resolves(expectedGetAccountInfoResult); stubs.push(stub); return auth.getUser(uid) @@ -804,7 +804,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub getAccountInfoByUid to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .rejects(expectedError); stubs.push(stub); return auth.getUser(uid) @@ -868,7 +868,7 @@ describe('Auth', () => { it('should resolve with a UserRecord on success', () => { // Stub getAccountInfoByEmail to return expected result. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByEmail') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByEmail') .resolves(expectedGetAccountInfoResult); stubs.push(stub); return auth.getUserByEmail(email) @@ -882,7 +882,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub getAccountInfoByEmail to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByEmail') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByEmail') .rejects(expectedError); stubs.push(stub); return auth.getUserByEmail(email) @@ -947,7 +947,7 @@ describe('Auth', () => { it('should resolve with a UserRecord on success', () => { // Stub getAccountInfoByPhoneNumber to return expected result. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByPhoneNumber') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByPhoneNumber') .resolves(expectedGetAccountInfoResult); stubs.push(stub); return auth.getUserByPhoneNumber(phoneNumber) @@ -961,7 +961,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub getAccountInfoByPhoneNumber to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByPhoneNumber') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByPhoneNumber') .rejects(expectedError); stubs.push(stub); return auth.getUserByPhoneNumber(phoneNumber) @@ -1024,7 +1024,7 @@ describe('Auth', () => { it('should resolve with void on success', () => { // Stub deleteAccount to return expected result. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'deleteAccount') + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteAccount') .resolves(expectedDeleteAccountResult); stubs.push(stub); return auth.deleteUser(uid) @@ -1038,7 +1038,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub deleteAccount to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'deleteAccount') + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteAccount') .rejects(expectedError); stubs.push(stub); return auth.deleteUser(uid) @@ -1113,10 +1113,10 @@ describe('Auth', () => { it('should resolve with a UserRecord on createNewAccount request success', () => { // Stub createNewAccount to return expected uid. - const createUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createNewAccount') + const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') .resolves(uid); // Stub getAccountInfoByUid to return expected result. - const getUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .resolves(expectedGetAccountInfoResult); stubs.push(createUserStub); stubs.push(getUserStub); @@ -1132,7 +1132,7 @@ describe('Auth', () => { it('should throw an error when createNewAccount returns an error', () => { // Stub createNewAccount to throw a backend error. - const createUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createNewAccount') + const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') .rejects(expectedError); stubs.push(createUserStub); return auth.createUser(propertiesToCreate) @@ -1148,11 +1148,11 @@ describe('Auth', () => { it('should throw an error when getUser returns a User not found error', () => { // Stub createNewAccount to return expected uid. - const createUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createNewAccount') + const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') .resolves(uid); // Stub getAccountInfoByUid to throw user not found error. const userNotFoundError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const getUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .rejects(userNotFoundError); stubs.push(createUserStub); stubs.push(getUserStub); @@ -1170,10 +1170,10 @@ describe('Auth', () => { it('should echo getUser error if an error occurs while retrieving the user record', () => { // Stub createNewAccount to return expected uid. - const createUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createNewAccount') + const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') .resolves(uid); // Stub getAccountInfoByUid to throw expected error. - const getUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .rejects(expectedError); stubs.push(createUserStub); stubs.push(getUserStub); @@ -1266,10 +1266,10 @@ describe('Auth', () => { it('should resolve with a UserRecord on updateExistingAccount request success', () => { // Stub updateExistingAccount to return expected uid. - const updateUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateExistingAccount') + const updateUserStub = sinon.stub(AuthRequestHandler.prototype, 'updateExistingAccount') .resolves(uid); // Stub getAccountInfoByUid to return expected result. - const getUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .resolves(expectedGetAccountInfoResult); stubs.push(updateUserStub); stubs.push(getUserStub); @@ -1285,7 +1285,7 @@ describe('Auth', () => { it('should throw an error when updateExistingAccount returns an error', () => { // Stub updateExistingAccount to throw a backend error. - const updateUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateExistingAccount') + const updateUserStub = sinon.stub(AuthRequestHandler.prototype, 'updateExistingAccount') .rejects(expectedError); stubs.push(updateUserStub); return auth.updateUser(uid, propertiesToEdit) @@ -1301,10 +1301,10 @@ describe('Auth', () => { it('should echo getUser error if an error occurs while retrieving the user record', () => { // Stub updateExistingAccount to return expected uid. - const updateUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateExistingAccount') + const updateUserStub = sinon.stub(AuthRequestHandler.prototype, 'updateExistingAccount') .resolves(uid); // Stub getAccountInfoByUid to throw an expected error. - const getUserStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getAccountInfoByUid') + const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') .rejects(expectedError); stubs.push(updateUserStub); stubs.push(getUserStub); @@ -1392,7 +1392,7 @@ describe('Auth', () => { it('should resolve on setCustomUserClaims request success', () => { // Stub setCustomUserClaims to return expected uid. const setCustomUserClaimsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'setCustomUserClaims') + .stub(AuthRequestHandler.prototype, 'setCustomUserClaims') .resolves(uid); stubs.push(setCustomUserClaimsStub); return auth.setCustomUserClaims(uid, customClaims) @@ -1407,7 +1407,7 @@ describe('Auth', () => { it('should throw an error when setCustomUserClaims returns an error', () => { // Stub setCustomUserClaims to throw a backend error. const setCustomUserClaimsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'setCustomUserClaims') + .stub(AuthRequestHandler.prototype, 'setCustomUserClaims') .rejects(expectedError); stubs.push(setCustomUserClaimsStub); return auth.setCustomUserClaims(uid, customClaims) @@ -1506,7 +1506,7 @@ describe('Auth', () => { it('should resolve on downloadAccount request success with users in response', () => { // Stub downloadAccount to return expected response. const downloadAccountStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'downloadAccount') + .stub(AuthRequestHandler.prototype, 'downloadAccount') .resolves(downloadAccountResponse); stubs.push(downloadAccountStub); return auth.listUsers(maxResult, pageToken) @@ -1521,7 +1521,7 @@ describe('Auth', () => { it('should resolve on downloadAccount request success with default options', () => { // Stub downloadAccount to return expected response. const downloadAccountStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'downloadAccount') + .stub(AuthRequestHandler.prototype, 'downloadAccount') .resolves(downloadAccountResponse); stubs.push(downloadAccountStub); return auth.listUsers() @@ -1537,7 +1537,7 @@ describe('Auth', () => { it('should resolve on downloadAccount request success with no users in response', () => { // Stub downloadAccount to return expected response. const downloadAccountStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'downloadAccount') + .stub(AuthRequestHandler.prototype, 'downloadAccount') .resolves(emptyDownloadAccountResponse); stubs.push(downloadAccountStub); return auth.listUsers(maxResult, pageToken) @@ -1552,7 +1552,7 @@ describe('Auth', () => { it('should throw an error when downloadAccount returns an error', () => { // Stub downloadAccount to throw a backend error. const downloadAccountStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'downloadAccount') + .stub(AuthRequestHandler.prototype, 'downloadAccount') .rejects(expectedError); stubs.push(downloadAccountStub); return auth.listUsers(maxResult, pageToken) @@ -1617,7 +1617,7 @@ describe('Auth', () => { it('should resolve on underlying revokeRefreshTokens request success', () => { // Stub revokeRefreshTokens to return expected uid. const revokeRefreshTokensStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'revokeRefreshTokens') + sinon.stub(AuthRequestHandler.prototype, 'revokeRefreshTokens') .resolves(uid); stubs.push(revokeRefreshTokensStub); return auth.revokeRefreshTokens(uid) @@ -1632,7 +1632,7 @@ describe('Auth', () => { it('should throw when underlying revokeRefreshTokens request returns an error', () => { // Stub revokeRefreshTokens to throw a backend error. const revokeRefreshTokensStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'revokeRefreshTokens') + sinon.stub(AuthRequestHandler.prototype, 'revokeRefreshTokens') .rejects(expectedError); stubs.push(revokeRefreshTokensStub); return auth.revokeRefreshTokens(uid) @@ -1698,7 +1698,7 @@ describe('Auth', () => { it('should resolve on underlying uploadAccount request resolution', () => { // Stub uploadAccount to return expected result. const uploadAccountStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'uploadAccount') + sinon.stub(AuthRequestHandler.prototype, 'uploadAccount') .resolves(expectedUserImportResult); stubs.push(uploadAccountStub); return auth.importUsers(users, options) @@ -1713,7 +1713,7 @@ describe('Auth', () => { it('should reject when underlying uploadAccount request rejects with an error', () => { // Stub uploadAccount to reject with expected error. const uploadAccountStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'uploadAccount') + sinon.stub(AuthRequestHandler.prototype, 'uploadAccount') .rejects(expectedServerError); stubs.push(uploadAccountStub); return auth.importUsers(users, options) @@ -1730,7 +1730,7 @@ describe('Auth', () => { it('should throw and fail quickly when underlying uploadAccount throws', () => { // Stub uploadAccount to throw with expected error. const uploadAccountStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'uploadAccount') + sinon.stub(AuthRequestHandler.prototype, 'uploadAccount') .throws(expectedOptionsError); stubs.push(uploadAccountStub); expect(() => { @@ -1812,7 +1812,7 @@ describe('Auth', () => { it('should resolve on underlying createSessionCookie request success', () => { // Stub createSessionCookie to return expected sessionCookie. const createSessionCookieStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'createSessionCookie') + sinon.stub(AuthRequestHandler.prototype, 'createSessionCookie') .resolves(sessionCookie); stubs.push(createSessionCookieStub); return auth.createSessionCookie(idToken, options) @@ -1828,7 +1828,7 @@ describe('Auth', () => { it('should throw when underlying createSessionCookie request returns an error', () => { // Stub createSessionCookie to throw a backend error. const createSessionCookieStub = - sinon.stub(FirebaseAuthRequestHandler.prototype, 'createSessionCookie') + sinon.stub(AuthRequestHandler.prototype, 'createSessionCookie') .rejects(expectedError); stubs.push(createSessionCookieStub); return auth.createSessionCookie(idToken, options) @@ -1908,7 +1908,7 @@ describe('Auth', () => { it('should resolve when called with actionCodeSettings with a generated link on success', () => { // Stub getEmailActionLink to return expected link. - const getEmailActionLinkStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getEmailActionLink') + const getEmailActionLinkStub = sinon.stub(AuthRequestHandler.prototype, 'getEmailActionLink') .resolves(expectedLink); stubs.push(getEmailActionLinkStub); return (auth as any)[emailActionFlow.api](email, actionCodeSettings) @@ -1929,7 +1929,7 @@ describe('Auth', () => { } else { it('should resolve when called without actionCodeSettings with a generated link on success', () => { // Stub getEmailActionLink to return expected link. - const getEmailActionLinkStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getEmailActionLink') + const getEmailActionLinkStub = sinon.stub(AuthRequestHandler.prototype, 'getEmailActionLink') .resolves(expectedLink); stubs.push(getEmailActionLinkStub); return (auth as any)[emailActionFlow.api](email) @@ -1945,7 +1945,7 @@ describe('Auth', () => { it('should throw an error when getEmailAction returns an error', () => { // Stub getEmailActionLink to throw a backend error. - const getEmailActionLinkStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getEmailActionLink') + const getEmailActionLinkStub = sinon.stub(AuthRequestHandler.prototype, 'getEmailActionLink') .rejects(expectedError); stubs.push(getEmailActionLinkStub); return (auth as any)[emailActionFlow.api](email, actionCodeSettings) @@ -2021,7 +2021,7 @@ describe('Auth', () => { it('should resolve with an OIDCConfig on success', () => { // Stub getOAuthIdpConfig to return expected result. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getOAuthIdpConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getOAuthIdpConfig') .resolves(serverResponse); stubs.push(stub); return (auth as Auth).getProviderConfig(providerId) @@ -2035,7 +2035,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub getOAuthIdpConfig to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getOAuthIdpConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getOAuthIdpConfig') .rejects(expectedError); stubs.push(stub); return (auth as Auth).getProviderConfig(providerId) @@ -2075,7 +2075,7 @@ describe('Auth', () => { it('should resolve with a SAMLConfig on success', () => { // Stub getInboundSamlConfig to return expected result. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getInboundSamlConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getInboundSamlConfig') .resolves(serverResponse); stubs.push(stub); return (auth as Auth).getProviderConfig(providerId) @@ -2089,7 +2089,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub getInboundSamlConfig to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'getInboundSamlConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'getInboundSamlConfig') .rejects(expectedError); stubs.push(stub); return (auth as Auth).getProviderConfig(providerId) @@ -2177,7 +2177,7 @@ describe('Auth', () => { it('should resolve on success with configs in response', () => { // Stub listOAuthIdpConfigs to return expected response. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listOAuthIdpConfigs') + .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') .resolves(listConfigsResponse); stubs.push(listConfigsStub); return auth.listProviderConfigs(filterOptions) @@ -2192,7 +2192,7 @@ describe('Auth', () => { it('should resolve on success with default options', () => { // Stub listOAuthIdpConfigs to return expected response. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listOAuthIdpConfigs') + .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') .resolves(listConfigsResponse); stubs.push(listConfigsStub); return (auth as Auth).listProviderConfigs({type: 'oidc'}) @@ -2208,7 +2208,7 @@ describe('Auth', () => { it('should resolve on success with no configs in response', () => { // Stub listOAuthIdpConfigs to return expected response. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listOAuthIdpConfigs') + .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') .resolves(emptyListConfigsResponse); stubs.push(listConfigsStub); return auth.listProviderConfigs(filterOptions) @@ -2223,7 +2223,7 @@ describe('Auth', () => { it('should throw an error when listOAuthIdpConfigs returns an error', () => { // Stub listOAuthIdpConfigs to throw a backend error. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listOAuthIdpConfigs') + .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') .rejects(expectedError); stubs.push(listConfigsStub); return auth.listProviderConfigs(filterOptions) @@ -2272,7 +2272,7 @@ describe('Auth', () => { it('should resolve on success with configs in response', () => { // Stub listInboundSamlConfigs to return expected response. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listInboundSamlConfigs') + .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') .resolves(listConfigsResponse); stubs.push(listConfigsStub); return auth.listProviderConfigs(filterOptions) @@ -2287,7 +2287,7 @@ describe('Auth', () => { it('should resolve on success with default options', () => { // Stub listInboundSamlConfigs to return expected response. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listInboundSamlConfigs') + .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') .resolves(listConfigsResponse); stubs.push(listConfigsStub); return (auth as Auth).listProviderConfigs({type: 'saml'}) @@ -2303,7 +2303,7 @@ describe('Auth', () => { it('should resolve on success with no configs in response', () => { // Stub listInboundSamlConfigs to return expected response. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listInboundSamlConfigs') + .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') .resolves(emptyListConfigsResponse); stubs.push(listConfigsStub); return auth.listProviderConfigs(filterOptions) @@ -2318,7 +2318,7 @@ describe('Auth', () => { it('should throw an error when listInboundSamlConfigs returns an error', () => { // Stub listInboundSamlConfigs to throw a backend error. const listConfigsStub = sinon - .stub(FirebaseAuthRequestHandler.prototype, 'listInboundSamlConfigs') + .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') .rejects(expectedError); stubs.push(listConfigsStub); return auth.listProviderConfigs(filterOptions) @@ -2383,7 +2383,7 @@ describe('Auth', () => { it('should resolve with void on success', () => { // Stub deleteOAuthIdpConfig to resolve. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'deleteOAuthIdpConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteOAuthIdpConfig') .resolves(); stubs.push(stub); return (auth as Auth).deleteProviderConfig(providerId) @@ -2397,7 +2397,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub deleteOAuthIdpConfig to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'deleteOAuthIdpConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteOAuthIdpConfig') .rejects(expectedError); stubs.push(stub); return (auth as Auth).deleteProviderConfig(providerId) @@ -2419,7 +2419,7 @@ describe('Auth', () => { it('should resolve with void on success', () => { // Stub deleteInboundSamlConfig to resolve. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'deleteInboundSamlConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteInboundSamlConfig') .resolves(); stubs.push(stub); return (auth as Auth).deleteProviderConfig(providerId) @@ -2433,7 +2433,7 @@ describe('Auth', () => { it('should throw an error when the backend returns an error', () => { // Stub deleteInboundSamlConfig to throw a backend error. - const stub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'deleteInboundSamlConfig') + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteInboundSamlConfig') .rejects(expectedError); stubs.push(stub); return (auth as Auth).deleteProviderConfig(providerId) @@ -2528,7 +2528,7 @@ describe('Auth', () => { it('should resolve with an OIDCConfig on updateOAuthIdpConfig request success', () => { // Stub updateOAuthIdpConfig to return expected server response. - const updateConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateOAuthIdpConfig') + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateOAuthIdpConfig') .resolves(serverResponse); stubs.push(updateConfigStub); @@ -2543,7 +2543,7 @@ describe('Auth', () => { it('should throw an error when updateOAuthIdpConfig returns an error', () => { // Stub updateOAuthIdpConfig to throw a backend error. - const updateConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateOAuthIdpConfig') + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateOAuthIdpConfig') .rejects(expectedError); stubs.push(updateConfigStub); @@ -2594,7 +2594,7 @@ describe('Auth', () => { it('should resolve with a SAMLConfig on updateInboundSamlConfig request success', () => { // Stub updateInboundSamlConfig to return expected server response. - const updateConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateInboundSamlConfig') + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateInboundSamlConfig') .resolves(serverResponse); stubs.push(updateConfigStub); @@ -2609,7 +2609,7 @@ describe('Auth', () => { it('should throw an error when updateInboundSamlConfig returns an error', () => { // Stub updateInboundSamlConfig to throw a backend error. - const updateConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'updateInboundSamlConfig') + const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateInboundSamlConfig') .rejects(expectedError); stubs.push(updateConfigStub); @@ -2694,7 +2694,7 @@ describe('Auth', () => { it('should resolve with an OIDCConfig on createOAuthIdpConfig request success', () => { // Stub createOAuthIdpConfig to return expected server response. - const createConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createOAuthIdpConfig') + const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createOAuthIdpConfig') .resolves(serverResponse); stubs.push(createConfigStub); @@ -2709,7 +2709,7 @@ describe('Auth', () => { it('should throw an error when createOAuthIdpConfig returns an error', () => { // Stub createOAuthIdpConfig to throw a backend error. - const createConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createOAuthIdpConfig') + const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createOAuthIdpConfig') .rejects(expectedError); stubs.push(createConfigStub); @@ -2761,7 +2761,7 @@ describe('Auth', () => { it('should resolve with a SAMLConfig on createInboundSamlConfig request success', () => { // Stub createInboundSamlConfig to return expected server response. - const createConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createInboundSamlConfig') + const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createInboundSamlConfig') .resolves(serverResponse); stubs.push(createConfigStub); @@ -2776,7 +2776,7 @@ describe('Auth', () => { it('should throw an error when createInboundSamlConfig returns an error', () => { // Stub createInboundSamlConfig to throw a backend error. - const createConfigStub = sinon.stub(FirebaseAuthRequestHandler.prototype, 'createInboundSamlConfig') + const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createInboundSamlConfig') .rejects(expectedError); stubs.push(createConfigStub); From 8507d9330ae95e371ca7b7e4416e93449fa83952 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Sun, 9 Jun 2019 18:51:11 -0700 Subject: [PATCH 03/14] Defines TenantAwareAuth (#551) * Defines TenantAwareAuth and its user management APIs, email action link APIs, OIDC/SAML provider config mgmt APIs. * Throws error when tenantId is provided in createUser and updateUser Auth API requests. Adds detailed tenant mismatch error for uploadAccount on tenant Id mismatch for TenantAwareAuth. * Addresses comments. * Added missing mapping to client error of MISSING_DISPLAY_NAME error. --- src/auth/auth-api-request.ts | 54 +- src/auth/auth.ts | 144 +- src/utils/error.ts | 10 + test/unit/auth/auth-api-request.spec.ts | 56 +- test/unit/auth/auth.spec.ts | 4766 ++++++++++++----------- 5 files changed, 2759 insertions(+), 2271 deletions(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 439966d345..8cf8402a15 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -238,7 +238,8 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = phoneNumber: true, customAttributes: true, validSince: true, - tenantId: true, + // Pass tenantId only for uploadAccount requests. + tenantId: uploadAccountRequest, passwordHash: uploadAccountRequest, salt: uploadAccountRequest, createdAt: uploadAccountRequest, @@ -474,6 +475,12 @@ export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings('/accounts:update' AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Server request is missing user identifier'); } + // Throw error when tenantId is passed in POST body. + if (typeof request.tenantId !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"tenantId" is an invalid "UpdateRequest" property.'); + } validateCreateEditRequest(request); }) // Set response validator. @@ -505,6 +512,12 @@ export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('/accounts', 'POST `"validSince" cannot be set when creating a new user.`, ); } + // Throw error when tenantId is passed in POST body. + if (typeof request.tenantId !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"tenantId" is an invalid "CreateRequest" property.'); + } validateCreateEditRequest(request); }) // Set response validator. @@ -917,15 +930,6 @@ export abstract class AbstractAuthRequestHandler { ); } - if (properties.hasOwnProperty('tenantId')) { - return Promise.reject( - new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'Tenant ID cannot be modified on an existing user.', - ), - ); - } - // Build the setAccountInfo request. const request: any = deepCopy(properties); request.localId = uid; @@ -1470,4 +1474,34 @@ export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler { protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { return new TenantAwareAuthResourceUrlBuilder(this.projectId, 'v2beta1', this.tenantId); } + + /** + * Imports the list of users provided to Firebase Auth. This is useful when + * migrating from an external authentication system without having to use the Firebase CLI SDK. + * At most, 1000 users are allowed to be imported one at a time. + * When importing a list of password users, UserImportOptions are required to be specified. + * + * Overrides the superclass methods by adding an additional check to match tenant IDs of + * imported user records if present. + * + * @param {UserImportRecord[]} users The list of user records to import to Firebase Auth. + * @param {UserImportOptions=} options The user import options, required when the users provided + * include password credentials. + * @return {Promise} A promise that resolves when the operation completes + * with the result of the import. This includes the number of successful imports, the number + * of failed uploads and their corresponding errors. + */ + public uploadAccount( + users: UserImportRecord[], options?: UserImportOptions): Promise { + // Add additional check to match tenant ID of imported user records. + users.forEach((user: UserImportRecord, index: number) => { + if (validator.isNonEmptyString(user.tenantId) && + user.tenantId !== this.tenantId) { + throw new FirebaseAuthError( + AuthClientErrorCode.MISMATCHING_TENANT_ID, + `UserRecord of index "${index}" has mismatching tenant ID "${user.tenantId}"`); + } + }); + return super.uploadAccount(users, options); + } } diff --git a/src/auth/auth.ts b/src/auth/auth.ts index f598747335..d4abac9c62 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -17,7 +17,9 @@ import {UserRecord, CreateRequest, UpdateRequest} from './user-record'; import {FirebaseApp} from '../firebase-app'; import {FirebaseTokenGenerator, CryptoSigner, cryptoSignerFromApp} from './token-generator'; -import {AuthRequestHandler} from './auth-api-request'; +import { + AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, +} from './auth-api-request'; import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error'; import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; import { @@ -32,6 +34,7 @@ import { AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, } from './auth-config'; +import {deepCopy, deepExtend} from '../utils/deep-copy'; /** @@ -72,6 +75,7 @@ export interface DecodedIdToken { iat: number; iss: string; sub: string; + tenant?: string; [key: string]: any; } @@ -85,7 +89,7 @@ export interface SessionCookieOptions { /** * Base Auth class. Mainly used for user management APIs. */ -class BaseAuth { +export class BaseAuth { protected readonly tokenGenerator: FirebaseTokenGenerator; protected readonly idTokenVerifier: FirebaseTokenVerifier; protected readonly sessionCookieVerifier: FirebaseTokenVerifier; @@ -94,14 +98,14 @@ class BaseAuth { * The BaseAuth class constructor. * * @param {string} projectId The corresponding project ID. - * @param {FirebaseAuthRequestHandler} authRequestHandler The RPC request handler + * @param {AbstractAuthRequestHandler} authRequestHandler The RPC request handler * for this instance. * @param {CryptoSigner} cryptoSigner The instance crypto signer used for custom token * minting. * @constructor */ constructor(protected readonly projectId: string, - protected readonly authRequestHandler: AuthRequestHandler, + protected readonly authRequestHandler: AbstractAuthRequestHandler, cryptoSigner: CryptoSigner) { this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); this.sessionCookieVerifier = createSessionCookieVerifier(projectId); @@ -599,11 +603,126 @@ class BaseAuth { } +/** + * The tenant aware Auth class. + */ +export class TenantAwareAuth extends BaseAuth { + public readonly tenantId: string; + + /** + * The TenantAwareAuth class constructor. + * + * @param {object} app The app that created this tenant. + * @param tenantId The corresponding tenant ID. + * @constructor + */ + constructor(private readonly app: FirebaseApp, tenantId: string) { + super( + utils.getProjectId(app), + new TenantAwareAuthRequestHandler(app, tenantId), + cryptoSignerFromApp(app)); + utils.addReadonlyGetter(this, 'tenantId', tenantId); + } + + /** + * Creates a new custom token that can be sent back to a client to use with + * signInWithCustomToken(). + * + * @param {string} uid The uid to use as the JWT subject. + * @param {object=} developerClaims Optional additional claims to include in the JWT payload. + * + * @return {Promise} A JWT for the provided payload. + */ + public createCustomToken(uid: string, developerClaims?: object): Promise { + // This is not yet supported by the Auth server. It is also not yet determined how this will be + // supported. + return Promise.reject( + new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION)); + } + + /** + * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the ID token was revoked. If the corresponding + * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified + * the check is not applied. + * + * @param {string} idToken The JWT to verify. + * @param {boolean=} checkRevoked Whether to check if the ID token is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifyIdToken(idToken: string, checkRevoked: boolean = false): Promise { + return super.verifyIdToken(idToken, checkRevoked) + .then((decodedClaims) => { + // Validate tenant ID. + if (decodedClaims.firebase.tenant !== this.tenantId) { + throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + } + return decodedClaims; + }); + } + + /** + * Creates a new Firebase session cookie with the specified options that can be used for + * session management (set as a server side session cookie with custom cookie policy). + * The session cookie JWT will have the same payload claims as the provided ID token. + * + * @param {string} idToken The Firebase ID token to exchange for a session cookie. + * @param {SessionCookieOptions} sessionCookieOptions The session cookie options which includes + * custom session duration. + * + * @return {Promise} A promise that resolves on success with the created session cookie. + */ + public createSessionCookie( + idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { + // Validate arguments before processing. + if (!validator.isNonEmptyString(idToken)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN)); + } + if (!validator.isNonNullObject(sessionCookieOptions) || + !validator.isNumber(sessionCookieOptions.expiresIn)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); + } + // This will verify the ID token and then match the tenant ID before creating the session cookie. + return this.verifyIdToken(idToken) + .then((decodedIdTokenClaims) => { + return super.createSessionCookie(idToken, sessionCookieOptions); + }); + } + + /** + * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the session cookie was revoked. If the corresponding + * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not + * specified the check is not performed. + * + * @param {string} sessionCookie The session cookie to verify. + * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifySessionCookie( + sessionCookie: string, checkRevoked: boolean = false): Promise { + return super.verifySessionCookie(sessionCookie, checkRevoked) + .then((decodedClaims) => { + if (decodedClaims.firebase.tenant !== this.tenantId) { + throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + } + return decodedClaims; + }); + } +} + + /** * Auth service bound to the provided app. + * An Auth instance can have multiple tenants. */ export class Auth extends BaseAuth implements FirebaseServiceInterface { public INTERNAL: AuthInternals = new AuthInternals(); + private readonly tenantsMap: {[key: string]: TenantAwareAuth}; private readonly app_: FirebaseApp; /** @@ -632,6 +751,7 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface { new AuthRequestHandler(app), cryptoSignerFromApp(app)); this.app_ = app; + this.tenantsMap = {}; } /** @@ -642,4 +762,20 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface { get app(): FirebaseApp { return this.app_; } + + /** + * Returns a TenantAwareAuth instance for the corresponding tenant ID. + * + * @param {string} tenantId The tenant ID whose TenantAwareAuth is to be returned. + * @return {TenantAwareAuth} The corresponding TenantAwareAuth instance. + */ + public forTenant(tenantId: string): TenantAwareAuth { + if (!validator.isNonEmptyString(tenantId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + } + if (typeof this.tenantsMap[tenantId] === 'undefined') { + this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId); + } + return this.tenantsMap[tenantId]; + } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 7e0ae272ed..1846fec223 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -532,6 +532,10 @@ export class AuthClientErrorCode { code: 'missing-continue-uri', message: 'A valid continue URL must be provided in the request.', }; + public static MISSING_DISPLAY_NAME = { + code: 'missing-display-name', + message: 'The resource being created or edited is missing a valid display name.', + }; public static MISSING_IOS_BUNDLE_ID = { code: 'missing-ios-bundle-id', message: 'The request is missing an iOS Bundle ID.', @@ -607,6 +611,10 @@ export class AuthClientErrorCode { message: 'The domain of the continue URL is not whitelisted. Whitelist the domain in the ' + 'Firebase console.', }; + public static UNSUPPORTED_TENANT_OPERATION = { + code: 'unsupported-tenant-operation', + message: 'This operation is not supported in a multi-tenant context.', + }; public static USER_NOT_FOUND = { code: 'user-not-found', message: 'There is no user record corresponding to the provided identifier.', @@ -827,6 +835,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. UNAUTHORIZED_DOMAIN: 'UNAUTHORIZED_DOMAIN', + // Operation is not supported in a multi-tenant context. + UNSUPPORTED_TENANT_OPERATION: 'UNSUPPORTED_TENANT_OPERATION', // User on which action is to be performed is not found. USER_NOT_FOUND: 'USER_NOT_FOUND', // Password provided is too weak. diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 0d3690d223..2da67fa62f 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -1112,6 +1112,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { describe('uploadAccount', () => { const path = handler.path('v1', '/accounts:batchCreate', 'project_id'); + const tenantId = handler.supportsTenantManagement ? undefined : TENANT_ID; const method = 'POST'; const nowString = new Date().toUTCString(); const users = [ @@ -1138,7 +1139,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { ], customClaims: {admin: true}, // Tenant ID accepted on user batch upload. - tenantId: 'TENANT_ID', + tenantId, }, { uid: '9012', @@ -1198,6 +1199,35 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { expect(stub).to.have.not.been.called; }); + if (handler.name === 'FirebaseTenantRequestHandler') { + it('should throw when a user record with mismatching tenant ID is provided', () => { + const mismatchIndex = 34; + const mismatchTenantId = 'MISMATCHING_TENANT_ID'; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.MISMATCHING_TENANT_ID, + `UserRecord of index "${mismatchIndex}" has mismatching tenant ID "${mismatchTenantId}"`, + ); + const stub = sinon.stub(HttpClient.prototype, 'send'); + stubs.push(stub); + + const testUsers: UserImportRecord[] = []; + for (let i = 0; i < 100; i++) { + testUsers.push({ + uid: 'USER' + i.toString(), + email: 'user' + i.toString() + '@example.com', + passwordHash: Buffer.from('password'), + tenantId: i === mismatchIndex ? mismatchTenantId : undefined, + }); + } + + const requestHandler = handler.init(mockApp); + expect(() => { + requestHandler.uploadAccount(testUsers, options); + }).to.throw(expectedError.message); + expect(stub).to.have.not.been.called; + }); + } + it('should resolve successfully when 1000 UserImportRecords are provided', () => { const expectedResult = utils.responseFrom({}); const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); @@ -1728,7 +1758,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { // Expected error when a tenant ID is provided. const expectedError = new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, - 'Tenant ID cannot be modified on an existing user.', + '"tenantId" is an invalid "UpdateRequest" property.', ); const requestHandler = handler.init(mockApp); // Send update request with tenant ID. @@ -1996,8 +2026,6 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { password: 'password', phoneNumber: '+11234567890', ignoredProperty: 'value', - // Tenant ID accepted on creation and relayed to Auth server. - tenantId: 'TENANT_ID', }; const expectedValidData = { localId: uid, @@ -2008,7 +2036,6 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { photoUrl: 'http://localhost/1234/photo.png', password: 'password', phoneNumber: '+11234567890', - tenantId: 'TENANT_ID', }; const invalidData = { uid, @@ -2075,6 +2102,25 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + it('should be rejected given tenantId in CreateRequest', () => { + // Expected error when a tenantId is provided. + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"tenantId" is an invalid "CreateRequest" property.'); + const validDataWithTenantId = deepCopy(validData); + (validDataWithTenantId as any).tenantId = TENANT_ID; + + const requestHandler = handler.init(mockApp); + // Create new account with tenantId. + return requestHandler.createNewAccount(validDataWithTenantId) + .then((returnedUid: string) => { + throw new Error('Unexpected success'); + }, (error) => { + // Expected invalid argument error should be thrown. + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given invalid parameters such as phoneNumber', () => { // Expected error when an invalid phone number is provided. const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 3b344fb80b..f27c82f4c9 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -25,11 +25,13 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; -import {Auth, DecodedIdToken} from '../../../src/auth/auth'; +import {Auth, TenantAwareAuth, BaseAuth, DecodedIdToken} from '../../../src/auth/auth'; import {UserRecord} from '../../../src/auth/user-record'; import {FirebaseApp} from '../../../src/firebase-app'; import {FirebaseTokenGenerator} from '../../../src/auth/token-generator'; -import {AuthRequestHandler} from '../../../src/auth/auth-api-request'; +import { + AuthRequestHandler, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler, +} from '../../../src/auth/auth-api-request'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import * as validator from '../../../src/utils/validator'; @@ -47,6 +49,15 @@ chai.use(chaiAsPromised); const expect = chai.expect; +interface AuthTest { + name: string; + supportsTenantManagement: boolean; + Auth: new (...args: any[]) => BaseAuth; + RequestHandler: new (...args: any[]) => AbstractAuthRequestHandler; + init(app: FirebaseApp): BaseAuth; +} + + interface EmailActionTest { api: string; requestType: string; @@ -55,11 +66,12 @@ interface EmailActionTest { /** + * @param {string=} tenantId The optional tenant Id. * @return {object} A sample valid server response as returned from getAccountInfo * endpoint. */ -function getValidGetAccountInfoResponse() { - const userResponse: object = { +function getValidGetAccountInfoResponse(tenantId?: string) { + const userResponse: any = { localId: 'abcdefghijklmnopqrstuvwxyz', email: 'user@gmail.com', emailVerified: true, @@ -93,6 +105,9 @@ function getValidGetAccountInfoResponse() { lastLoginAt: '1476235905000', createdAt: '1476136676000', }; + if (typeof tenantId !== 'undefined') { + userResponse.tenantId = tenantId; + } return { kind: 'identitytoolkit#GetAccountInfoResponse', users: [userResponse], @@ -115,9 +130,10 @@ function getValidUserRecord(serverResponse: any) { * * @param {string} uid The uid corresponding to the ID token. * @param {Date} authTime The authentication time of the ID token. + * @param {string=} tenantId The optional tenant ID. * @return {DecodedIdToken} The generated decoded ID token. */ -function getDecodedIdToken(uid: string, authTime: Date): DecodedIdToken { +function getDecodedIdToken(uid: string, authTime: Date, tenantId?: string): DecodedIdToken { return { iss: 'https://securetoken.google.com/project123456789', aud: 'project123456789', @@ -128,6 +144,7 @@ function getDecodedIdToken(uid: string, authTime: Date): DecodedIdToken { firebase: { identities: {}, sign_in_provider: 'custom', + tenant: tenantId, }, }; } @@ -138,9 +155,10 @@ function getDecodedIdToken(uid: string, authTime: Date): DecodedIdToken { * * @param {string} uid The uid corresponding to the session cookie. * @param {Date} authTime The authentication time of the session cookie. + * @param {string=} tenantId The optional tenant ID. * @return {DecodedIdToken} The generated decoded session cookie. */ -function getDecodedSessionCookie(uid: string, authTime: Date): DecodedIdToken { +function getDecodedSessionCookie(uid: string, authTime: Date, tenantId?: string): DecodedIdToken { return { iss: 'https://session.firebase.google.com/project123456789', aud: 'project123456789', @@ -151,6 +169,7 @@ function getDecodedSessionCookie(uid: string, authTime: Date): DecodedIdToken { firebase: { identities: {}, sign_in_provider: 'custom', + tenant: tenantId, }, }; } @@ -200,1675 +219,1632 @@ function getSAMLConfigServerResponse(providerId: string): SAMLConfigServerRespon } -describe('Auth', () => { - let auth: Auth; - let mockApp: FirebaseApp; - let getTokenStub: sinon.SinonStub; - let oldProcessEnv: NodeJS.ProcessEnv; - let nullAccessTokenAuth: Auth; - let malformedAccessTokenAuth: Auth; - let rejectedPromiseAccessTokenAuth: Auth; - - beforeEach(() => { - mockApp = mocks.app(); - getTokenStub = utils.stubGetAccessToken(undefined, mockApp); - auth = new Auth(mockApp); - - nullAccessTokenAuth = new Auth(mocks.appReturningNullAccessToken()); - malformedAccessTokenAuth = new Auth(mocks.appReturningMalformedAccessToken()); - rejectedPromiseAccessTokenAuth = new Auth(mocks.appRejectedWhileFetchingAccessToken()); - - oldProcessEnv = process.env; - // Project ID not set in the environment. - delete process.env.GOOGLE_CLOUD_PROJECT; - delete process.env.GCLOUD_PROJECT; - }); - - afterEach(() => { - getTokenStub.restore(); - process.env = oldProcessEnv; - return mockApp.delete(); - }); - - - 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 authAny: any = Auth; - return new authAny(invalidApp); - }).to.throw('First argument passed to admin.auth() must be a valid Firebase app instance.'); - }); - }); - - it('should throw given no app', () => { - expect(() => { - const authAny: any = Auth; - return new authAny(); - }).to.throw('First argument passed to admin.auth() must be a valid Firebase app instance.'); - }); - - it('should not throw given a valid app', () => { - expect(() => { - return new Auth(mockApp); - }).not.to.throw(); - }); - }); +const TENANT_ID = 'tenantId'; +const AUTH_CONFIGS: AuthTest[] = [ + { + name: 'Auth', + Auth, + supportsTenantManagement: true, + RequestHandler: AuthRequestHandler, + init: (app: FirebaseApp) => { + return new Auth(app); + }, + }, + { + name: 'TenantAwareAuth', + Auth: TenantAwareAuth, + supportsTenantManagement: false, + RequestHandler: TenantAwareAuthRequestHandler, + init: (app: FirebaseApp) => { + return new TenantAwareAuth(app, TENANT_ID); + }, + }, +]; +AUTH_CONFIGS.forEach((testConfig) => { + describe(testConfig.name, () => { + let auth: BaseAuth; + let mockApp: FirebaseApp; + let getTokenStub: sinon.SinonStub; + let oldProcessEnv: NodeJS.ProcessEnv; + let nullAccessTokenAuth: BaseAuth; + let malformedAccessTokenAuth: BaseAuth; + let rejectedPromiseAccessTokenAuth: BaseAuth; - describe('app', () => { - it('returns the app from the constructor', () => { - // We expect referential equality here - expect(auth.app).to.equal(mockApp); - }); + beforeEach(() => { + mockApp = mocks.app(); + getTokenStub = utils.stubGetAccessToken(undefined, mockApp); + auth = testConfig.init(mockApp); - it('is read-only', () => { - expect(() => { - (auth as any).app = mockApp; - }).to.throw('Cannot set property app of # which has only a getter'); - }); - }); + nullAccessTokenAuth = testConfig.init(mocks.appReturningNullAccessToken()); + malformedAccessTokenAuth = testConfig.init(mocks.appReturningMalformedAccessToken()); + rejectedPromiseAccessTokenAuth = testConfig.init(mocks.appRejectedWhileFetchingAccessToken()); - describe('createCustomToken()', () => { - let spy: sinon.SinonSpy; - beforeEach(() => { - spy = sinon.spy(FirebaseTokenGenerator.prototype, 'createCustomToken'); + oldProcessEnv = process.env; + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; }); afterEach(() => { - spy.restore(); - }); - - it('should throw if a cert credential is not specified', () => { - const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); - - expect(() => { - mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims); - }).not.to.throw; - }); + getTokenStub.restore(); + process.env = oldProcessEnv; + return mockApp.delete(); + }); + + if (testConfig.Auth === Auth) { + // Run tests for Auth. + 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 authAny: any = Auth; + return new authAny(invalidApp); + }).to.throw('First argument passed to admin.auth() must be a valid Firebase app instance.'); + }); + }); - it('should forward on the call to the token generator\'s createCustomToken() method', () => { - return auth.createCustomToken(mocks.uid, mocks.developerClaims) - .then(() => { - expect(spy) - .to.have.been.calledOnce - .and.calledWith(mocks.uid, mocks.developerClaims); + it('should throw given no app', () => { + expect(() => { + const authAny: any = Auth; + return new authAny(); + }).to.throw('First argument passed to admin.auth() must be a valid Firebase app instance.'); }); - }); - it('should be fulfilled given an app which returns null access tokens', () => { - // createCustomToken() does not rely on an access token and therefore works in this scenario. - return nullAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) - .should.eventually.be.fulfilled; - }); + it('should not throw given a valid app', () => { + expect(() => { + return new Auth(mockApp); + }).not.to.throw(); + }); + }); - it('should be fulfilled given an app which returns invalid access tokens', () => { - // createCustomToken() does not rely on an access token and therefore works in this scenario. - return malformedAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) - .should.eventually.be.fulfilled; - }); + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect((auth as Auth).app).to.equal(mockApp); + }); - it('should be fulfilled given an app which fails to generate access tokens', () => { - // createCustomToken() does not rely on an access token and therefore works in this scenario. - return rejectedPromiseAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) - .should.eventually.be.fulfilled; - }); - }); + it('is read-only', () => { + expect(() => { + (auth as any).app = mockApp; + }).to.throw('Cannot set property app of # which has only a getter'); + }); + }); - it('verifyIdToken() should throw when project ID is not specified', () => { - const mockCredentialAuth = new Auth(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 call verifyIdToken().'; - expect(() => { - mockCredentialAuth.verifyIdToken(mocks.generateIdToken()); - }).to.throw(expected); - }); + describe('Auth.forTenant()', () => { + it('should cache the returned TenantAwareAuth', () => { + const tenantAwareAuth1 = (auth as Auth).forTenant('tenantId1'); + const tenantAwareAuth2 = (auth as Auth).forTenant('tenantId2'); + expect((auth as Auth).forTenant('tenantId1')).to.equal(tenantAwareAuth1); + expect((auth as Auth).forTenant('tenantId2')).to.equal(tenantAwareAuth2); + expect(tenantAwareAuth1).to.not.be.equal(tenantAwareAuth2); + expect(tenantAwareAuth1.tenantId).to.equal('tenantId1'); + expect(tenantAwareAuth2.tenantId).to.equal('tenantId2'); + }); + }); + } else { + // Run tests for TenantAwareAuth. + describe('forTenant()', () => { + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should throw given invalid tenant ID: ' + JSON.stringify(invalidTenantId), () => { + expect(() => { + const testAuth = new Auth(mockApp); + return testAuth.forTenant(invalidTenantId as any); + }).to.throw('The tenant ID must be a valid non-empty string.'); + }); + }); - it('verifySessionCookie() should throw when project ID is not specified', () => { - const mockCredentialAuth = new Auth(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 call verifySessionCookie().'; - expect(() => { - mockCredentialAuth.verifySessionCookie(mocks.generateSessionCookie()); - }).to.throw(expected); - }); + it('should return a TenantAwareAuth with the expected tenant ID', () => { + const testAuth = new Auth(mockApp); + expect(testAuth.forTenant(TENANT_ID).tenantId).to.equal(TENANT_ID); + }); - describe('verifyIdToken()', () => { - let stub: sinon.SinonStub; - let mockIdToken: string; - const expectedUserRecord = getValidUserRecord(getValidGetAccountInfoResponse()); - // Set auth_time of token to expected user's tokensValidAfterTime. - const validSince = new Date(expectedUserRecord.tokensValidAfterTime); - // Set expected uid to expected user's. - const uid = expectedUserRecord.uid; - // Set expected decoded ID token with expected UID and auth time. - const decodedIdToken = getDecodedIdToken(uid, validSince); - let clock: sinon.SinonFakeTimers; - - // Stubs used to simulate underlying api calls. - const stubs: sinon.SinonStub[] = []; - beforeEach(() => { - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .resolves(decodedIdToken); - stubs.push(stub); - mockIdToken = mocks.generateIdToken(); - clock = sinon.useFakeTimers(validSince.getTime()); - }); - afterEach(() => { - _.forEach(stubs, (s) => s.restore()); - clock.restore(); - }); + it('should return a TenantAwareAuth with read-only tenant ID', () => { + expect(() => { + (auth as any).tenantId = 'OTHER_TENANT_ID'; + }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); + }); + }); + } - it('should forward on the call to the token generator\'s verifyIdToken() method', () => { - // Stub getUser call. - const getUserStub = sinon.stub(Auth.prototype, 'getUser'); - stubs.push(getUserStub); - return auth.verifyIdToken(mockIdToken).then((result) => { - // Confirm getUser never called. - expect(getUserStub).not.to.have.been.called; - expect(result).to.deep.equal(decodedIdToken); - expect(stub).to.have.been.calledOnce.and.calledWith(mockIdToken); + describe('createCustomToken()', () => { + let spy: sinon.SinonSpy; + beforeEach(() => { + spy = sinon.spy(FirebaseTokenGenerator.prototype, 'createCustomToken'); }); - }); - it('should reject when underlying idTokenVerifier.verifyJWT() rejects with expected error', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, 'Decoding Firebase ID token failed'); - // Restore verifyIdToken stub. - stub.restore(); - // Simulate ID token is invalid. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .rejects(expectedError); - stubs.push(stub); - return auth.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); - }); + afterEach(() => { + spy.restore(); + }); - it('should work with a non-cert credential when the GOOGLE_CLOUD_PROJECT environment variable is present', () => { - process.env.GOOGLE_CLOUD_PROJECT = mocks.projectId; + if (testConfig.Auth === TenantAwareAuth) { + it('should reject with an unsupported tenant operation error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION); + return auth.createCustomToken(mocks.uid) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + } else { + it('should throw if a cert credential is not specified', () => { + const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); + expect(() => { + mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims); + }).not.to.throw; + }); - return mockCredentialAuth.verifyIdToken(mockIdToken).then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith(mockIdToken); - }); - }); + it('should forward on the call to the token generator\'s createCustomToken() method', () => { + const developerClaimsCopy = deepCopy(mocks.developerClaims); + return auth.createCustomToken(mocks.uid, mocks.developerClaims) + .then(() => { + expect(spy) + .to.have.been.calledOnce + .and.calledWith(mocks.uid, developerClaimsCopy); + }); + }); - it('should work with a non-cert credential when the GCLOUD_PROJECT environment variable is present', () => { - process.env.GCLOUD_PROJECT = mocks.projectId; + it('should be fulfilled given an app which returns null access tokens', () => { + // createCustomToken() does not rely on an access token and therefore works in this scenario. + return nullAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.fulfilled; + }); - const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); + it('should be fulfilled given an app which returns invalid access tokens', () => { + // createCustomToken() does not rely on an access token and therefore works in this scenario. + return malformedAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.fulfilled; + }); - return mockCredentialAuth.verifyIdToken(mockIdToken).then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith(mockIdToken); - }); + it('should be fulfilled given an app which fails to generate access tokens', () => { + // createCustomToken() does not rely on an access token and therefore works in this scenario. + return rejectedPromiseAccessTokenAuth.createCustomToken(mocks.uid, mocks.developerClaims) + .should.eventually.be.fulfilled; + }); + } }); - it('should be fulfilled given an app which returns null access tokens', () => { - // verifyIdToken() does not rely on an access token and therefore works in this scenario. - return nullAccessTokenAuth.verifyIdToken(mockIdToken) - .should.eventually.be.fulfilled; + it('verifyIdToken() should throw when project ID is not specified', () => { + const mockCredentialAuth = testConfig.init(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 call verifyIdToken().'; + expect(() => { + mockCredentialAuth.verifyIdToken(mocks.generateIdToken()); + }).to.throw(expected); }); - it('should be fulfilled given an app which returns invalid access tokens', () => { - // verifyIdToken() does not rely on an access token and therefore works in this scenario. - return malformedAccessTokenAuth.verifyIdToken(mockIdToken) - .should.eventually.be.fulfilled; - }); + it('verifySessionCookie() should throw when project ID is not specified', () => { + const mockCredentialAuth = testConfig.init(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 call verifySessionCookie().'; + expect(() => { + mockCredentialAuth.verifySessionCookie(mocks.generateSessionCookie()); + }).to.throw(expected); + }); + + describe('verifyIdToken()', () => { + let stub: sinon.SinonStub; + let mockIdToken: string; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedUserRecord = getValidUserRecord(getValidGetAccountInfoResponse(tenantId)); + // Set auth_time of token to expected user's tokensValidAfterTime. + const validSince = new Date(expectedUserRecord.tokensValidAfterTime); + // Set expected uid to expected user's. + const uid = expectedUserRecord.uid; + // Set expected decoded ID token with expected UID and auth time. + const decodedIdToken = getDecodedIdToken(uid, validSince, tenantId); + let clock: sinon.SinonFakeTimers; - it('should be fulfilled given an app which fails to generate access tokens', () => { - // verifyIdToken() does not rely on an access token and therefore works in this scenario. - return rejectedPromiseAccessTokenAuth.verifyIdToken(mockIdToken) - .should.eventually.be.fulfilled; - }); + // Stubs used to simulate underlying api calls. + const stubs: sinon.SinonStub[] = []; + beforeEach(() => { + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(decodedIdToken); + stubs.push(stub); + mockIdToken = mocks.generateIdToken(); + clock = sinon.useFakeTimers(validSince.getTime()); + }); + afterEach(() => { + _.forEach(stubs, (s) => s.restore()); + clock.restore(); + }); - it('should be fulfilled with checkRevoked set to true using an unrevoked ID token', () => { - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .resolves(expectedUserRecord); - stubs.push(getUserStub); - // Verify ID token while checking if revoked. - return auth.verifyIdToken(mockIdToken, true) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + it('should forward on the call to the token generator\'s verifyIdToken() method', () => { + // Stub getUser call. + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser'); + stubs.push(getUserStub); + return auth.verifyIdToken(mockIdToken).then((result) => { + // Confirm getUser never called. + expect(getUserStub).not.to.have.been.called; expect(result).to.deep.equal(decodedIdToken); + expect(stub).to.have.been.calledOnce.and.calledWith(mockIdToken); }); - }); + }); - it('should be rejected with checkRevoked set to true using a revoked ID token', () => { - // One second before validSince. - const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); - // Restore verifyIdToken stub. - stub.restore(); - // Simulate revoked ID token returned with auth_time one second before validSince. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .resolves(getDecodedIdToken(uid, oneSecBeforeValidSince)); - stubs.push(stub); - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .resolves(expectedUserRecord); - stubs.push(getUserStub); - // Verify ID token while checking if revoked. - return auth.verifyIdToken(mockIdToken, true) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.have.property('code', 'auth/id-token-revoked'); - }); - }); + it('should reject when underlying idTokenVerifier.verifyJWT() rejects with expected error', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, 'Decoding Firebase ID token failed'); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate ID token is invalid. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .rejects(expectedError); + stubs.push(stub); + return auth.verifyIdToken(mockIdToken) + .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); + }); - it('should be fulfilled with checkRevoked set to false using a revoked ID token', () => { - // One second before validSince. - const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); - const oneSecBeforeValidSinceDecodedIdToken = - getDecodedIdToken(uid, oneSecBeforeValidSince); - // Restore verifyIdToken stub. - stub.restore(); - // Simulate revoked ID token returned with auth_time one second before validSince. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .resolves(oneSecBeforeValidSinceDecodedIdToken); - stubs.push(stub); - // Verify ID token without checking if revoked. - // This call should succeed. - return auth.verifyIdToken(mockIdToken, false) - .then((result) => { - expect(result).to.deep.equal(oneSecBeforeValidSinceDecodedIdToken); - }); - }); + it('should work with a non-cert credential when the GOOGLE_CLOUD_PROJECT environment variable is present', () => { + process.env.GOOGLE_CLOUD_PROJECT = mocks.projectId; - it('should be rejected with checkRevoked set to true if underlying RPC fails', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .rejects(expectedError); - stubs.push(getUserStub); - // Verify ID token while checking if revoked. - // This should fail with the underlying RPC error. - return auth.verifyIdToken(mockIdToken, true) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); + const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - it('should be fulfilled with checkRevoked set to true when no validSince available', () => { - // Simulate no validSince set on the user. - const noValidSinceGetAccountInfoResponse = getValidGetAccountInfoResponse(); - delete (noValidSinceGetAccountInfoResponse.users[0] as any).validSince; - const noValidSinceExpectedUserRecord = - getValidUserRecord(noValidSinceGetAccountInfoResponse); - // Confirm null tokensValidAfterTime on user. - expect(noValidSinceExpectedUserRecord.tokensValidAfterTime).to.be.undefined; - // Simulate getUser returns the expected user with no validSince. - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .resolves(noValidSinceExpectedUserRecord); - stubs.push(getUserStub); - // Verify ID token while checking if revoked. - return auth.verifyIdToken(mockIdToken, true) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - expect(result).to.deep.equal(decodedIdToken); + return mockCredentialAuth.verifyIdToken(mockIdToken).then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith(mockIdToken); }); - }); + }); - it('should be rejected with checkRevoked set to true using an invalid ID token', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CREDENTIAL); - // Restore verifyIdToken stub. - stub.restore(); - // Simulate ID token is invalid. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .rejects(expectedError); - stubs.push(stub); - // Verify ID token while checking if revoked. - // This should fail with the underlying token generator verifyIdToken error. - return auth.verifyIdToken(mockIdToken, true) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + it('should work with a non-cert credential when the GCLOUD_PROJECT environment variable is present', () => { + process.env.GCLOUD_PROJECT = mocks.projectId; - describe('verifySessionCookie()', () => { - let stub: sinon.SinonStub; - let mockSessionCookie: string; - const expectedUserRecord = getValidUserRecord(getValidGetAccountInfoResponse()); - // Set auth_time of token to expected user's tokensValidAfterTime. - const validSince = new Date(expectedUserRecord.tokensValidAfterTime); - // Set expected uid to expected user's. - const uid = expectedUserRecord.uid; - // Set expected decoded session cookie with expected UID and auth time. - const decodedSessionCookie = getDecodedSessionCookie(uid, validSince); - let clock: sinon.SinonFakeTimers; - - // Stubs used to simulate underlying api calls. - const stubs: sinon.SinonStub[] = []; - beforeEach(() => { - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .resolves(decodedSessionCookie); - stubs.push(stub); - mockSessionCookie = mocks.generateSessionCookie(); - clock = sinon.useFakeTimers(validSince.getTime()); - }); - afterEach(() => { - _.forEach(stubs, (s) => s.restore()); - clock.restore(); - }); + const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - it('should forward on the call to the token verifier\'s verifySessionCookie() method', () => { - // Stub getUser call. - const getUserStub = sinon.stub(Auth.prototype, 'getUser'); - stubs.push(getUserStub); - return auth.verifySessionCookie(mockSessionCookie).then((result) => { - // Confirm getUser never called. - expect(getUserStub).not.to.have.been.called; - expect(result).to.deep.equal(decodedSessionCookie); - expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); + return mockCredentialAuth.verifyIdToken(mockIdToken).then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith(mockIdToken); + }); }); - }); - it('should reject when underlying sessionCookieVerifier.verifyJWT() rejects with expected error', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, 'Decoding Firebase session cookie failed'); - // Restore verifySessionCookie stub. - stub.restore(); - // Simulate session cookie is invalid. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .rejects(expectedError); - stubs.push(stub); - return auth.verifySessionCookie(mockSessionCookie) - .should.eventually.be.rejectedWith('Decoding Firebase session cookie failed'); - }); - - it('should work with a non-cert credential when the GOOGLE_CLOUD_PROJECT environment variable is present', () => { - process.env.GOOGLE_CLOUD_PROJECT = mocks.projectId; + it('should be fulfilled given an app which returns null access tokens', () => { + // verifyIdToken() does not rely on an access token and therefore works in this scenario. + return nullAccessTokenAuth.verifyIdToken(mockIdToken) + .should.eventually.be.fulfilled; + }); - const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); + it('should be fulfilled given an app which returns invalid access tokens', () => { + // verifyIdToken() does not rely on an access token and therefore works in this scenario. + return malformedAccessTokenAuth.verifyIdToken(mockIdToken) + .should.eventually.be.fulfilled; + }); - return mockCredentialAuth.verifySessionCookie(mockSessionCookie).then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); + it('should be fulfilled given an app which fails to generate access tokens', () => { + // verifyIdToken() does not rely on an access token and therefore works in this scenario. + return rejectedPromiseAccessTokenAuth.verifyIdToken(mockIdToken) + .should.eventually.be.fulfilled; }); - }); - it('should work with a non-cert credential when the GCLOUD_PROJECT environment variable is present', () => { - process.env.GCLOUD_PROJECT = mocks.projectId; + it('should be fulfilled with checkRevoked set to true using an unrevoked ID token', () => { + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecord); + stubs.push(getUserStub); + // Verify ID token while checking if revoked. + return auth.verifyIdToken(mockIdToken, true) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + expect(result).to.deep.equal(decodedIdToken); + }); + }); - const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); + it('should be rejected with checkRevoked set to true using a revoked ID token', () => { + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate revoked ID token returned with auth_time one second before validSince. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(getDecodedIdToken(uid, oneSecBeforeValidSince)); + stubs.push(stub); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecord); + stubs.push(getUserStub); + // Verify ID token while checking if revoked. + return auth.verifyIdToken(mockIdToken, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/id-token-revoked'); + }); + }); - return mockCredentialAuth.verifySessionCookie(mockSessionCookie).then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); + it('should be fulfilled with checkRevoked set to false using a revoked ID token', () => { + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + const oneSecBeforeValidSinceDecodedIdToken = + getDecodedIdToken(uid, oneSecBeforeValidSince, tenantId); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate revoked ID token returned with auth_time one second before validSince. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(oneSecBeforeValidSinceDecodedIdToken); + stubs.push(stub); + // Verify ID token without checking if revoked. + // This call should succeed. + return auth.verifyIdToken(mockIdToken, false) + .then((result) => { + expect(result).to.deep.equal(oneSecBeforeValidSinceDecodedIdToken); + }); }); - }); - it('should be fulfilled given an app which returns null access tokens', () => { - // verifySessionCookie() does not rely on an access token and therefore works in this scenario. - return nullAccessTokenAuth.verifySessionCookie(mockSessionCookie) - .should.eventually.be.fulfilled; - }); + it('should be rejected with checkRevoked set to true if underlying RPC fails', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .rejects(expectedError); + stubs.push(getUserStub); + // Verify ID token while checking if revoked. + // This should fail with the underlying RPC error. + return auth.verifyIdToken(mockIdToken, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); - it('should be fulfilled given an app which returns invalid access tokens', () => { - // verifySessionCookie() does not rely on an access token and therefore works in this scenario. - return malformedAccessTokenAuth.verifySessionCookie(mockSessionCookie) - .should.eventually.be.fulfilled; - }); + it('should be fulfilled with checkRevoked set to true when no validSince available', () => { + // Simulate no validSince set on the user. + const noValidSinceGetAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); + delete (noValidSinceGetAccountInfoResponse.users[0] as any).validSince; + const noValidSinceExpectedUserRecord = + getValidUserRecord(noValidSinceGetAccountInfoResponse); + // Confirm null tokensValidAfterTime on user. + expect(noValidSinceExpectedUserRecord.tokensValidAfterTime).to.be.undefined; + // Simulate getUser returns the expected user with no validSince. + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(noValidSinceExpectedUserRecord); + stubs.push(getUserStub); + // Verify ID token while checking if revoked. + return auth.verifyIdToken(mockIdToken, true) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + expect(result).to.deep.equal(decodedIdToken); + }); + }); - it('should be fulfilled given an app which fails to generate access tokens', () => { - // verifySessionCookie() does not rely on an access token and therefore works in this scenario. - return rejectedPromiseAccessTokenAuth.verifySessionCookie(mockSessionCookie) - .should.eventually.be.fulfilled; - }); + it('should be rejected with checkRevoked set to true using an invalid ID token', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CREDENTIAL); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate ID token is invalid. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .rejects(expectedError); + stubs.push(stub); + // Verify ID token while checking if revoked. + // This should fail with the underlying token generator verifyIdToken error. + return auth.verifyIdToken(mockIdToken, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); - it('should be fulfilled with checkRevoked set to true using an unrevoked session cookie', () => { - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .resolves(expectedUserRecord); - stubs.push(getUserStub); - // Verify ID token while checking if revoked. - return auth.verifySessionCookie(mockSessionCookie, true) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - expect(result).to.deep.equal(decodedSessionCookie); + if (testConfig.Auth === TenantAwareAuth) { + it('should be rejected with ID token missing tenant ID', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate JWT does not contain tenant ID. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .returns(Promise.resolve(getDecodedIdToken(uid, validSince))); + // Verify ID token. + return auth.verifyIdToken(mockIdToken) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.deep.equal(expectedError); + }); }); - }); - it('should be rejected with checkRevoked set to true using a revoked session cookie', () => { - // One second before validSince. - const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); - // Restore verifySessionCookie stub. - stub.restore(); - // Simulate revoked session cookie returned with auth_time one second before validSince. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .resolves(getDecodedSessionCookie(uid, oneSecBeforeValidSince)); - stubs.push(stub); - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .resolves(expectedUserRecord); - stubs.push(getUserStub); - // Verify session cookie while checking if revoked. - return auth.verifySessionCookie(mockSessionCookie, true) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.have.property('code', 'auth/session-cookie-revoked'); + it('should be rejected with ID token containing mismatching tenant ID', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate JWT does not contain matching tenant ID. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .returns(Promise.resolve(getDecodedIdToken(uid, validSince, 'otherTenantId'))); + // Verify ID token. + return auth.verifyIdToken(mockIdToken) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.deep.equal(expectedError); + }); }); + } }); - it('should be fulfilled with checkRevoked set to false using a revoked session cookie', () => { - // One second before validSince. - const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); - const oneSecBeforeValidSinceDecodedSessionCookie = - getDecodedSessionCookie(uid, oneSecBeforeValidSince); - // Restore verifySessionCookie stub. - stub.restore(); - // Simulate revoked session cookie returned with auth_time one second before validSince. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .resolves(oneSecBeforeValidSinceDecodedSessionCookie); - stubs.push(stub); - // Verify session cookie without checking if revoked. - // This call should succeed. - return auth.verifySessionCookie(mockSessionCookie, false) - .then((result) => { - expect(result).to.deep.equal(oneSecBeforeValidSinceDecodedSessionCookie); - }); - }); + describe('verifySessionCookie()', () => { + let stub: sinon.SinonStub; + let mockSessionCookie: string; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedUserRecord = getValidUserRecord(getValidGetAccountInfoResponse(tenantId)); + // Set auth_time of token to expected user's tokensValidAfterTime. + const validSince = new Date(expectedUserRecord.tokensValidAfterTime); + // Set expected uid to expected user's. + const uid = expectedUserRecord.uid; + // Set expected decoded session cookie with expected UID and auth time. + const decodedSessionCookie = getDecodedSessionCookie(uid, validSince, tenantId); + let clock: sinon.SinonFakeTimers; - it('should be rejected with checkRevoked set to true if underlying RPC fails', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .rejects(expectedError); - stubs.push(getUserStub); - // Verify session cookie while checking if revoked. - // This should fail with the underlying RPC error. - return auth.verifySessionCookie(mockSessionCookie, true) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); + // Stubs used to simulate underlying api calls. + const stubs: sinon.SinonStub[] = []; + beforeEach(() => { + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(decodedSessionCookie); + stubs.push(stub); + mockSessionCookie = mocks.generateSessionCookie(); + clock = sinon.useFakeTimers(validSince.getTime()); + }); + afterEach(() => { + _.forEach(stubs, (s) => s.restore()); + clock.restore(); + }); - it('should be fulfilled with checkRevoked set to true when no validSince available', () => { - // Simulate no validSince set on the user. - const noValidSinceGetAccountInfoResponse = getValidGetAccountInfoResponse(); - delete (noValidSinceGetAccountInfoResponse.users[0] as any).validSince; - const noValidSinceExpectedUserRecord = - getValidUserRecord(noValidSinceGetAccountInfoResponse); - // Confirm null tokensValidAfterTime on user. - expect(noValidSinceExpectedUserRecord.tokensValidAfterTime).to.be.undefined; - // Simulate getUser returns the expected user with no validSince. - const getUserStub = sinon.stub(Auth.prototype, 'getUser') - .resolves(noValidSinceExpectedUserRecord); - stubs.push(getUserStub); - // Verify session cookie while checking if revoked. - return auth.verifySessionCookie(mockSessionCookie, true) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + it('should forward on the call to the token verifier\'s verifySessionCookie() method', () => { + // Stub getUser call. + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser'); + stubs.push(getUserStub); + return auth.verifySessionCookie(mockSessionCookie).then((result) => { + // Confirm getUser never called. + expect(getUserStub).not.to.have.been.called; expect(result).to.deep.equal(decodedSessionCookie); + expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); }); - }); - - it('should be rejected with checkRevoked set to true using an invalid session cookie', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CREDENTIAL); - // Restore verifySessionCookie stub. - stub.restore(); - // Simulate session cookie is invalid. - stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') - .rejects(expectedError); - stubs.push(stub); - // Verify session cookie while checking if revoked. - // This should fail with the underlying token generator verifySessionCookie error. - return auth.verifySessionCookie(mockSessionCookie, true) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + }); - describe('getUser()', () => { - const uid = 'abcdefghijklmnopqrstuvwxyz'; - const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(); - const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + it('should reject when underlying sessionCookieVerifier.verifyJWT() rejects with expected error', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, 'Decoding Firebase session cookie failed'); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate session cookie is invalid. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .rejects(expectedError); + stubs.push(stub); + return auth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.rejectedWith('Decoding Firebase session cookie failed'); + }); - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => sinon.spy(validator, 'isUid')); - afterEach(() => { - (validator.isUid as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should work with a non-cert credential when the GOOGLE_CLOUD_PROJECT environment variable is present', () => { + process.env.GOOGLE_CLOUD_PROJECT = mocks.projectId; - it('should be rejected given no uid', () => { - return (auth as any).getUser() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); - }); + const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - it('should be rejected given an invalid uid', () => { - const invalidUid = ('a' as any).repeat(129); - return auth.getUser(invalidUid) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-uid'); - expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); + return mockCredentialAuth.verifySessionCookie(mockSessionCookie).then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.getUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.getUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should work with a non-cert credential when the GCLOUD_PROJECT environment variable is present', () => { + process.env.GCLOUD_PROJECT = mocks.projectId; - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.getUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + const mockCredentialAuth = testConfig.init(mocks.mockCredentialApp()); - it('should resolve with a UserRecord on success', () => { - // Stub getAccountInfoByUid to return expected result. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .resolves(expectedGetAccountInfoResult); - stubs.push(stub); - return auth.getUser(uid) - .then((userRecord) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected user record response returned. - expect(userRecord).to.deep.equal(expectedUserRecord); + return mockCredentialAuth.verifySessionCookie(mockSessionCookie).then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); }); - }); + }); - it('should throw an error when the backend returns an error', () => { - // Stub getAccountInfoByUid to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .rejects(expectedError); - stubs.push(stub); - return auth.getUser(uid) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + it('should be fulfilled given an app which returns null access tokens', () => { + // verifySessionCookie() does not rely on an access token and therefore works in this scenario. + return nullAccessTokenAuth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.fulfilled; + }); - describe('getUserByEmail()', () => { - const email = 'user@gmail.com'; - const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(); - const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + it('should be fulfilled given an app which returns invalid access tokens', () => { + // verifySessionCookie() does not rely on an access token and therefore works in this scenario. + return malformedAccessTokenAuth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.fulfilled; + }); - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => sinon.spy(validator, 'isEmail')); - afterEach(() => { - (validator.isEmail as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should be fulfilled given an app which fails to generate access tokens', () => { + // verifySessionCookie() does not rely on an access token and therefore works in this scenario. + return rejectedPromiseAccessTokenAuth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.fulfilled; + }); - it('should be rejected given no email', () => { - return (auth as any).getUserByEmail() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); - }); + it('should be fulfilled with checkRevoked set to true using an unrevoked session cookie', () => { + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecord); + stubs.push(getUserStub); + // Verify ID token while checking if revoked. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + expect(result).to.deep.equal(decodedSessionCookie); + }); + }); - it('should be rejected given an invalid email', () => { - const invalidEmail = 'name-example-com'; - return auth.getUserByEmail(invalidEmail) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-email'); - expect(validator.isEmail).to.have.been.calledOnce.and.calledWith(invalidEmail); - }); - }); + it('should be rejected with checkRevoked set to true using a revoked session cookie', () => { + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate revoked session cookie returned with auth_time one second before validSince. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(getDecodedSessionCookie(uid, oneSecBeforeValidSince)); + stubs.push(stub); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(expectedUserRecord); + stubs.push(getUserStub); + // Verify session cookie while checking if revoked. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/session-cookie-revoked'); + }); + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.getUserByEmail(email) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be fulfilled with checkRevoked set to false using a revoked session cookie', () => { + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + const oneSecBeforeValidSinceDecodedSessionCookie = + getDecodedSessionCookie(uid, oneSecBeforeValidSince, tenantId); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate revoked session cookie returned with auth_time one second before validSince. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .resolves(oneSecBeforeValidSinceDecodedSessionCookie); + stubs.push(stub); + // Verify session cookie without checking if revoked. + // This call should succeed. + return auth.verifySessionCookie(mockSessionCookie, false) + .then((result) => { + expect(result).to.deep.equal(oneSecBeforeValidSinceDecodedSessionCookie); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.getUserByEmail(email) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected with checkRevoked set to true if underlying RPC fails', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .rejects(expectedError); + stubs.push(getUserStub); + // Verify session cookie while checking if revoked. + // This should fail with the underlying RPC error. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.getUserByEmail(email) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be fulfilled with checkRevoked set to true when no validSince available', () => { + // Simulate no validSince set on the user. + const noValidSinceGetAccountInfoResponse = getValidGetAccountInfoResponse(tenantId); + delete (noValidSinceGetAccountInfoResponse.users[0] as any).validSince; + const noValidSinceExpectedUserRecord = + getValidUserRecord(noValidSinceGetAccountInfoResponse); + // Confirm null tokensValidAfterTime on user. + expect(noValidSinceExpectedUserRecord.tokensValidAfterTime).to.be.undefined; + // Simulate getUser returns the expected user with no validSince. + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(noValidSinceExpectedUserRecord); + stubs.push(getUserStub); + // Verify session cookie while checking if revoked. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + expect(result).to.deep.equal(decodedSessionCookie); + }); + }); - it('should resolve with a UserRecord on success', () => { - // Stub getAccountInfoByEmail to return expected result. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByEmail') - .resolves(expectedGetAccountInfoResult); - stubs.push(stub); - return auth.getUserByEmail(email) - .then((userRecord) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(email); - // Confirm expected user record response returned. - expect(userRecord).to.deep.equal(expectedUserRecord); - }); - }); + it('should be rejected with checkRevoked set to true using an invalid session cookie', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CREDENTIAL); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate session cookie is invalid. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .rejects(expectedError); + stubs.push(stub); + // Verify session cookie while checking if revoked. + // This should fail with the underlying token generator verifySessionCookie error. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); - it('should throw an error when the backend returns an error', () => { - // Stub getAccountInfoByEmail to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByEmail') - .rejects(expectedError); - stubs.push(stub); - return auth.getUserByEmail(email) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(email); - // Confirm expected error returned. - expect(error).to.equal(expectedError); + if (testConfig.Auth === TenantAwareAuth) { + it('should be rejected with session cookie missing tenant ID', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate JWT does not contain tenant ID.. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .returns(Promise.resolve(getDecodedSessionCookie(uid, validSince))); + // Verify session cookie token. + return auth.verifySessionCookie(mockSessionCookie) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.deep.equal(expectedError); + }); }); - }); - }); - - describe('getUserByPhoneNumber()', () => { - const phoneNumber = '+11234567890'; - const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(); - const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => sinon.spy(validator, 'isPhoneNumber')); - afterEach(() => { - (validator.isPhoneNumber as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); - - it('should be rejected given no phone number', () => { - return (auth as any).getUserByPhoneNumber() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-phone-number'); - }); - - it('should be rejected given an invalid phone number', () => { - const invalidPhoneNumber = 'invalid'; - return auth.getUserByPhoneNumber(invalidPhoneNumber) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-phone-number'); - expect(validator.isPhoneNumber) - .to.have.been.calledOnce.and.calledWith(invalidPhoneNumber); + it('should be rejected with ID token containing mismatching tenant ID', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + // Restore verifyIdToken stub. + stub.restore(); + // Simulate JWT does not contain matching tenant ID.. + stub = sinon.stub(FirebaseTokenVerifier.prototype, 'verifyJWT') + .returns(Promise.resolve(getDecodedSessionCookie(uid, validSince, 'otherTenantId'))); + // Verify session cookie token. + return auth.verifySessionCookie(mockSessionCookie) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.deep.equal(expectedError); + }); }); + } }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.getUserByPhoneNumber(phoneNumber) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + describe('getUser()', () => { + const uid = 'abcdefghijklmnopqrstuvwxyz'; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId); + const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.getUserByPhoneNumber(phoneNumber) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => sinon.spy(validator, 'isUid')); + afterEach(() => { + (validator.isUid as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.getUserByPhoneNumber(phoneNumber) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given no uid', () => { + return (auth as any).getUser() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); + }); - it('should resolve with a UserRecord on success', () => { - // Stub getAccountInfoByPhoneNumber to return expected result. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByPhoneNumber') - .resolves(expectedGetAccountInfoResult); - stubs.push(stub); - return auth.getUserByPhoneNumber(phoneNumber) - .then((userRecord) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(phoneNumber); - // Confirm expected user record response returned. - expect(userRecord).to.deep.equal(expectedUserRecord); - }); - }); + it('should be rejected given an invalid uid', () => { + const invalidUid = ('a' as any).repeat(129); + return auth.getUser(invalidUid) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-uid'); + expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); + }); + }); - it('should throw an error when the backend returns an error', () => { - // Stub getAccountInfoByPhoneNumber to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByPhoneNumber') - .rejects(expectedError); - stubs.push(stub); - return auth.getUserByPhoneNumber(phoneNumber) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(phoneNumber); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.getUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - describe('deleteUser()', () => { - const uid = 'abcdefghijklmnopqrstuvwxyz'; - const expectedDeleteAccountResult = {kind: 'identitytoolkit#DeleteAccountResponse'}; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.getUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => sinon.spy(validator, 'isUid')); - afterEach(() => { - (validator.isUid as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.getUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given no uid', () => { - return (auth as any).deleteUser() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); - }); + it('should resolve with a UserRecord on success', () => { + // Stub getAccountInfoByUid to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .resolves(expectedGetAccountInfoResult); + stubs.push(stub); + return auth.getUser(uid) + .then((userRecord) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected user record response returned. + expect(userRecord).to.deep.equal(expectedUserRecord); + }); + }); - it('should be rejected given an invalid uid', () => { - const invalidUid = ('a' as any).repeat(129); - return auth.deleteUser(invalidUid) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-uid'); - expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); - }); + it('should throw an error when the backend returns an error', () => { + // Stub getAccountInfoByUid to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .rejects(expectedError); + stubs.push(stub); + return auth.getUser(uid) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.deleteUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + describe('getUserByEmail()', () => { + const email = 'user@gmail.com'; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId); + const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.deleteUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => sinon.spy(validator, 'isEmail')); + afterEach(() => { + (validator.isEmail as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.deleteUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given no email', () => { + return (auth as any).getUserByEmail() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); + }); - it('should resolve with void on success', () => { - // Stub deleteAccount to return expected result. - const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteAccount') - .resolves(expectedDeleteAccountResult); - stubs.push(stub); - return auth.deleteUser(uid) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected result is undefined. - expect(result).to.be.undefined; - }); - }); + it('should be rejected given an invalid email', () => { + const invalidEmail = 'name-example-com'; + return auth.getUserByEmail(invalidEmail) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-email'); + expect(validator.isEmail).to.have.been.calledOnce.and.calledWith(invalidEmail); + }); + }); - it('should throw an error when the backend returns an error', () => { - // Stub deleteAccount to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteAccount') - .rejects(expectedError); - stubs.push(stub); - return auth.deleteUser(uid) - .then(() => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.getUserByEmail(email) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - describe('createUser()', () => { - const uid = 'abcdefghijklmnopqrstuvwxyz'; - const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(); - const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Unable to create the user record provided.'); - const unableToCreateUserError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Unable to create the user record provided.'); - const propertiesToCreate = { - displayName: expectedUserRecord.displayName, - photoURL: expectedUserRecord.photoURL, - email: expectedUserRecord.email, - emailVerified: expectedUserRecord.emailVerified, - password: 'password', - phoneNumber: expectedUserRecord.phoneNumber, - }; - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => sinon.spy(validator, 'isNonNullObject')); - afterEach(() => { - (validator.isNonNullObject as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.getUserByEmail(email) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given no properties', () => { - return (auth as any).createUser() - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.getUserByEmail(email) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given invalid properties', () => { - return auth.createUser(null) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - expect(validator.isNonNullObject).to.have.been.calledOnce.and.calledWith(null); - }); - }); + it('should resolve with a UserRecord on success', () => { + // Stub getAccountInfoByEmail to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByEmail') + .resolves(expectedGetAccountInfoResult); + stubs.push(stub); + return auth.getUserByEmail(email) + .then((userRecord) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(email); + // Confirm expected user record response returned. + expect(userRecord).to.deep.equal(expectedUserRecord); + }); + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.createUser(propertiesToCreate) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + it('should throw an error when the backend returns an error', () => { + // Stub getAccountInfoByEmail to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByEmail') + .rejects(expectedError); + stubs.push(stub); + return auth.getUserByEmail(email) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(email); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.createUser(propertiesToCreate) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + describe('getUserByPhoneNumber()', () => { + const phoneNumber = '+11234567890'; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId); + const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.createUser(propertiesToCreate) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => sinon.spy(validator, 'isPhoneNumber')); + afterEach(() => { + (validator.isPhoneNumber as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should resolve with a UserRecord on createNewAccount request success', () => { - // Stub createNewAccount to return expected uid. - const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') - .resolves(uid); - // Stub getAccountInfoByUid to return expected result. - const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .resolves(expectedGetAccountInfoResult); - stubs.push(createUserStub); - stubs.push(getUserStub); - return auth.createUser(propertiesToCreate) - .then((userRecord) => { - // Confirm underlying API called with expected parameters. - expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected user record response returned. - expect(userRecord).to.deep.equal(expectedUserRecord); - }); - }); + it('should be rejected given no phone number', () => { + return (auth as any).getUserByPhoneNumber() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-phone-number'); + }); - it('should throw an error when createNewAccount returns an error', () => { - // Stub createNewAccount to throw a backend error. - const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') - .rejects(expectedError); - stubs.push(createUserStub); - return auth.createUser(propertiesToCreate) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); + it('should be rejected given an invalid phone number', () => { + const invalidPhoneNumber = 'invalid'; + return auth.getUserByPhoneNumber(invalidPhoneNumber) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-phone-number'); + expect(validator.isPhoneNumber) + .to.have.been.calledOnce.and.calledWith(invalidPhoneNumber); + }); + }); - it('should throw an error when getUser returns a User not found error', () => { - // Stub createNewAccount to return expected uid. - const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') - .resolves(uid); - // Stub getAccountInfoByUid to throw user not found error. - const userNotFoundError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .rejects(userNotFoundError); - stubs.push(createUserStub); - stubs.push(getUserStub); - return auth.createUser(propertiesToCreate) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error.toString()).to.equal(unableToCreateUserError.toString()); - }); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.getUserByPhoneNumber(phoneNumber) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should echo getUser error if an error occurs while retrieving the user record', () => { - // Stub createNewAccount to return expected uid. - const createUserStub = sinon.stub(AuthRequestHandler.prototype, 'createNewAccount') - .resolves(uid); - // Stub getAccountInfoByUid to throw expected error. - const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .rejects(expectedError); - stubs.push(createUserStub); - stubs.push(getUserStub); - return auth.createUser(propertiesToCreate) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned (same error thrown by getUser). - expect(error).to.equal(expectedError); - }); - }); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.getUserByPhoneNumber(phoneNumber) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - describe('updateUser()', () => { - const uid = 'abcdefghijklmnopqrstuvwxyz'; - const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(); - const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const propertiesToEdit = { - displayName: expectedUserRecord.displayName, - photoURL: expectedUserRecord.photoURL, - email: expectedUserRecord.email, - emailVerified: expectedUserRecord.emailVerified, - password: 'password', - phoneNumber: expectedUserRecord.phoneNumber, - }; - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => { - sinon.spy(validator, 'isUid'); - sinon.spy(validator, 'isNonNullObject'); - }); - afterEach(() => { - (validator.isUid as any).restore(); - (validator.isNonNullObject as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.getUserByPhoneNumber(phoneNumber) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given no uid', () => { - return (auth as any).updateUser(undefined, propertiesToEdit) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); - }); + it('should resolve with a UserRecord on success', () => { + // Stub getAccountInfoByPhoneNumber to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByPhoneNumber') + .resolves(expectedGetAccountInfoResult); + stubs.push(stub); + return auth.getUserByPhoneNumber(phoneNumber) + .then((userRecord) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(phoneNumber); + // Confirm expected user record response returned. + expect(userRecord).to.deep.equal(expectedUserRecord); + }); + }); - it('should be rejected given an invalid uid', () => { - const invalidUid = ('a' as any).repeat(129); - return auth.updateUser(invalidUid, propertiesToEdit) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-uid'); - expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); - }); + it('should throw an error when the backend returns an error', () => { + // Stub getAccountInfoByPhoneNumber to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByPhoneNumber') + .rejects(expectedError); + stubs.push(stub); + return auth.getUserByPhoneNumber(phoneNumber) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(phoneNumber); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given no properties', () => { - return (auth as any).updateUser(uid) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + describe('deleteUser()', () => { + const uid = 'abcdefghijklmnopqrstuvwxyz'; + const expectedDeleteAccountResult = {kind: 'identitytoolkit#DeleteAccountResponse'}; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - it('should be rejected given invalid properties', () => { - return auth.updateUser(uid, null) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - expect(validator.isNonNullObject).to.have.been.calledOnce.and.calledWith(null); - }); - }); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => sinon.spy(validator, 'isUid')); + afterEach(() => { + (validator.isUid as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.updateUser(uid, propertiesToEdit) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given no uid', () => { + return (auth as any).deleteUser() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.updateUser(uid, propertiesToEdit) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid uid', () => { + const invalidUid = ('a' as any).repeat(129); + return auth.deleteUser(invalidUid) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-uid'); + expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); + }); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.updateUser(uid, propertiesToEdit) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.deleteUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve with a UserRecord on updateExistingAccount request success', () => { - // Stub updateExistingAccount to return expected uid. - const updateUserStub = sinon.stub(AuthRequestHandler.prototype, 'updateExistingAccount') - .resolves(uid); - // Stub getAccountInfoByUid to return expected result. - const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .resolves(expectedGetAccountInfoResult); - stubs.push(updateUserStub); - stubs.push(getUserStub); - return auth.updateUser(uid, propertiesToEdit) - .then((userRecord) => { - // Confirm underlying API called with expected parameters. - expect(updateUserStub).to.have.been.calledOnce.and.calledWith(uid, propertiesToEdit); - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected user record response returned. - expect(userRecord).to.deep.equal(expectedUserRecord); - }); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.deleteUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should throw an error when updateExistingAccount returns an error', () => { - // Stub updateExistingAccount to throw a backend error. - const updateUserStub = sinon.stub(AuthRequestHandler.prototype, 'updateExistingAccount') - .rejects(expectedError); - stubs.push(updateUserStub); - return auth.updateUser(uid, propertiesToEdit) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(updateUserStub).to.have.been.calledOnce.and.calledWith(uid, propertiesToEdit); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.deleteUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should echo getUser error if an error occurs while retrieving the user record', () => { - // Stub updateExistingAccount to return expected uid. - const updateUserStub = sinon.stub(AuthRequestHandler.prototype, 'updateExistingAccount') - .resolves(uid); - // Stub getAccountInfoByUid to throw an expected error. - const getUserStub = sinon.stub(AuthRequestHandler.prototype, 'getAccountInfoByUid') - .rejects(expectedError); - stubs.push(updateUserStub); - stubs.push(getUserStub); - return auth.updateUser(uid, propertiesToEdit) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(updateUserStub).to.have.been.calledOnce.and.calledWith(uid, propertiesToEdit); - expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned (same error thrown by getUser). - expect(error).to.equal(expectedError); - }); - }); - }); + it('should resolve with void on success', () => { + // Stub deleteAccount to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteAccount') + .resolves(expectedDeleteAccountResult); + stubs.push(stub); + return auth.deleteUser(uid) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected result is undefined. + expect(result).to.be.undefined; + }); + }); - describe('setCustomUserClaims()', () => { - const uid = 'abcdefghijklmnopqrstuvwxyz'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - const customClaims = { - admin: true, - groupId: '123456', - }; - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => { - sinon.spy(validator, 'isUid'); - sinon.spy(validator, 'isObject'); - }); - afterEach(() => { - (validator.isUid as any).restore(); - (validator.isObject as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; + it('should throw an error when the backend returns an error', () => { + // Stub deleteAccount to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteAccount') + .rejects(expectedError); + stubs.push(stub); + return auth.deleteUser(uid) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given no uid', () => { - return (auth as any).setCustomUserClaims(undefined, customClaims) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); - }); + describe('createUser()', () => { + const uid = 'abcdefghijklmnopqrstuvwxyz'; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId); + const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the user record provided.'); + const unableToCreateUserError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the user record provided.'); + const propertiesToCreate = { + displayName: expectedUserRecord.displayName, + photoURL: expectedUserRecord.photoURL, + email: expectedUserRecord.email, + emailVerified: expectedUserRecord.emailVerified, + password: 'password', + phoneNumber: expectedUserRecord.phoneNumber, + }; + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => sinon.spy(validator, 'isNonNullObject')); + afterEach(() => { + (validator.isNonNullObject as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an invalid uid', () => { - const invalidUid = ('a' as any).repeat(129); - return auth.setCustomUserClaims(invalidUid, customClaims) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-uid'); - expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); - }); - }); + it('should be rejected given no properties', () => { + return (auth as any).createUser() + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); - it('should be rejected given no custom user claims', () => { - return (auth as any).setCustomUserClaims(uid) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + it('should be rejected given invalid properties', () => { + return auth.createUser(null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + expect(validator.isNonNullObject).to.have.been.calledOnce.and.calledWith(null); + }); + }); - it('should be rejected given invalid custom user claims', () => { - return auth.setCustomUserClaims(uid, 'invalid' as any) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - expect(validator.isObject).to.have.been.calledOnce.and.calledWith('invalid'); - }); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.createUser(propertiesToCreate) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.setCustomUserClaims(uid, customClaims) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.createUser(propertiesToCreate) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.setCustomUserClaims(uid, customClaims) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.createUser(propertiesToCreate) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.setCustomUserClaims(uid, customClaims) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should resolve with a UserRecord on createNewAccount request success', () => { + // Stub createNewAccount to return expected uid. + const createUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'createNewAccount') + .resolves(uid); + // Stub getAccountInfoByUid to return expected result. + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .resolves(expectedGetAccountInfoResult); + stubs.push(createUserStub); + stubs.push(getUserStub); + return auth.createUser(propertiesToCreate) + .then((userRecord) => { + // Confirm underlying API called with expected parameters. + expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected user record response returned. + expect(userRecord).to.deep.equal(expectedUserRecord); + }); + }); - it('should resolve on setCustomUserClaims request success', () => { - // Stub setCustomUserClaims to return expected uid. - const setCustomUserClaimsStub = sinon - .stub(AuthRequestHandler.prototype, 'setCustomUserClaims') - .resolves(uid); - stubs.push(setCustomUserClaimsStub); - return auth.setCustomUserClaims(uid, customClaims) - .then((response) => { - expect(response).to.be.undefined; - // Confirm underlying API called with expected parameters. - expect(setCustomUserClaimsStub) - .to.have.been.calledOnce.and.calledWith(uid, customClaims); - }); - }); + it('should throw an error when createNewAccount returns an error', () => { + // Stub createNewAccount to throw a backend error. + const createUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'createNewAccount') + .rejects(expectedError); + stubs.push(createUserStub); + return auth.createUser(propertiesToCreate) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); - it('should throw an error when setCustomUserClaims returns an error', () => { - // Stub setCustomUserClaims to throw a backend error. - const setCustomUserClaimsStub = sinon - .stub(AuthRequestHandler.prototype, 'setCustomUserClaims') - .rejects(expectedError); - stubs.push(setCustomUserClaimsStub); - return auth.setCustomUserClaims(uid, customClaims) - .then(() => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(setCustomUserClaimsStub) - .to.have.been.calledOnce.and.calledWith(uid, customClaims); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + it('should throw an error when getUser returns a User not found error', () => { + // Stub createNewAccount to return expected uid. + const createUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'createNewAccount') + .resolves(uid); + // Stub getAccountInfoByUid to throw user not found error. + const userNotFoundError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .rejects(userNotFoundError); + stubs.push(createUserStub); + stubs.push(getUserStub); + return auth.createUser(propertiesToCreate) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error.toString()).to.equal(unableToCreateUserError.toString()); + }); + }); - describe('listUsers()', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); - const pageToken = 'PAGE_TOKEN'; - const maxResult = 500; - const downloadAccountResponse: any = { - users: [ - {localId: 'UID1'}, - {localId: 'UID2'}, - {localId: 'UID3'}, - ], - nextPageToken: 'NEXT_PAGE_TOKEN', - }; - const expectedResult: any = { - users: [ - new UserRecord({localId: 'UID1'}), - new UserRecord({localId: 'UID2'}), - new UserRecord({localId: 'UID3'}), - ], - pageToken: 'NEXT_PAGE_TOKEN', - }; - const emptyDownloadAccountResponse: any = { - users: [], - }; - const emptyExpectedResult: any = { - users: [], - }; - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => { - sinon.spy(validator, 'isNonEmptyString'); - sinon.spy(validator, 'isNumber'); - }); - afterEach(() => { - (validator.isNonEmptyString as any).restore(); - (validator.isNumber as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; + it('should echo getUser error if an error occurs while retrieving the user record', () => { + // Stub createNewAccount to return expected uid. + const createUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'createNewAccount') + .resolves(uid); + // Stub getAccountInfoByUid to throw expected error. + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .rejects(expectedError); + stubs.push(createUserStub); + stubs.push(getUserStub); + return auth.createUser(propertiesToCreate) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createUserStub).to.have.been.calledOnce.and.calledWith(propertiesToCreate); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned (same error thrown by getUser). + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given an invalid page token', () => { - const invalidToken = {}; - return auth.listUsers(undefined, invalidToken as any) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-page-token'); - expect(validator.isNonEmptyString) - .to.have.been.calledOnce.and.calledWith(invalidToken); - }); - }); + describe('updateUser()', () => { + const uid = 'abcdefghijklmnopqrstuvwxyz'; + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId); + const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const propertiesToEdit = { + displayName: expectedUserRecord.displayName, + photoURL: expectedUserRecord.photoURL, + email: expectedUserRecord.email, + emailVerified: expectedUserRecord.emailVerified, + password: 'password', + phoneNumber: expectedUserRecord.phoneNumber, + }; + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => { + sinon.spy(validator, 'isUid'); + sinon.spy(validator, 'isNonNullObject'); + }); + afterEach(() => { + (validator.isUid as any).restore(); + (validator.isNonNullObject as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an invalid max result', () => { - const invalidResults = 5000; - return auth.listUsers(invalidResults) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - expect(validator.isNumber) - .to.have.been.calledOnce.and.calledWith(invalidResults); - }); - }); + it('should be rejected given no uid', () => { + return (auth as any).updateUser(undefined, propertiesToEdit) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.listUsers(maxResult) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid uid', () => { + const invalidUid = ('a' as any).repeat(129); + return auth.updateUser(invalidUid, propertiesToEdit) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-uid'); + expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.listUsers(maxResult) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given no properties', () => { + return (auth as any).updateUser(uid) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.listUsers(maxResult) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given invalid properties', () => { + return auth.updateUser(uid, null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + expect(validator.isNonNullObject).to.have.been.calledOnce.and.calledWith(null); + }); + }); - it('should resolve on downloadAccount request success with users in response', () => { - // Stub downloadAccount to return expected response. - const downloadAccountStub = sinon - .stub(AuthRequestHandler.prototype, 'downloadAccount') - .resolves(downloadAccountResponse); - stubs.push(downloadAccountStub); - return auth.listUsers(maxResult, pageToken) - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(downloadAccountStub) - .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); - }); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.updateUser(uid, propertiesToEdit) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve on downloadAccount request success with default options', () => { - // Stub downloadAccount to return expected response. - const downloadAccountStub = sinon - .stub(AuthRequestHandler.prototype, 'downloadAccount') - .resolves(downloadAccountResponse); - stubs.push(downloadAccountStub); - return auth.listUsers() - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(downloadAccountStub) - .to.have.been.calledOnce.and.calledWith(undefined, undefined); - }); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.updateUser(uid, propertiesToEdit) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.updateUser(uid, propertiesToEdit) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve on downloadAccount request success with no users in response', () => { - // Stub downloadAccount to return expected response. - const downloadAccountStub = sinon - .stub(AuthRequestHandler.prototype, 'downloadAccount') - .resolves(emptyDownloadAccountResponse); - stubs.push(downloadAccountStub); - return auth.listUsers(maxResult, pageToken) - .then((response) => { - expect(response).to.deep.equal(emptyExpectedResult); - // Confirm underlying API called with expected parameters. - expect(downloadAccountStub) - .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); - }); - }); + it('should resolve with a UserRecord on updateExistingAccount request success', () => { + // Stub updateExistingAccount to return expected uid. + const updateUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateExistingAccount') + .resolves(uid); + // Stub getAccountInfoByUid to return expected result. + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .resolves(expectedGetAccountInfoResult); + stubs.push(updateUserStub); + stubs.push(getUserStub); + return auth.updateUser(uid, propertiesToEdit) + .then((userRecord) => { + // Confirm underlying API called with expected parameters. + expect(updateUserStub).to.have.been.calledOnce.and.calledWith(uid, propertiesToEdit); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected user record response returned. + expect(userRecord).to.deep.equal(expectedUserRecord); + }); + }); - it('should throw an error when downloadAccount returns an error', () => { - // Stub downloadAccount to throw a backend error. - const downloadAccountStub = sinon - .stub(AuthRequestHandler.prototype, 'downloadAccount') - .rejects(expectedError); - stubs.push(downloadAccountStub); - return auth.listUsers(maxResult, pageToken) - .then((results) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(downloadAccountStub) - .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); + it('should throw an error when updateExistingAccount returns an error', () => { + // Stub updateExistingAccount to throw a backend error. + const updateUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateExistingAccount') + .rejects(expectedError); + stubs.push(updateUserStub); + return auth.updateUser(uid, propertiesToEdit) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateUserStub).to.have.been.calledOnce.and.calledWith(uid, propertiesToEdit); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); - describe('revokeRefreshTokens()', () => { - const uid = 'abcdefghijklmnopqrstuvwxyz'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => { - sinon.spy(validator, 'isUid'); - }); - afterEach(() => { - (validator.isUid as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; + it('should echo getUser error if an error occurs while retrieving the user record', () => { + // Stub updateExistingAccount to return expected uid. + const updateUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateExistingAccount') + .resolves(uid); + // Stub getAccountInfoByUid to throw an expected error. + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .rejects(expectedError); + stubs.push(updateUserStub); + stubs.push(getUserStub); + return auth.updateUser(uid, propertiesToEdit) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateUserStub).to.have.been.calledOnce.and.calledWith(uid, propertiesToEdit); + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned (same error thrown by getUser). + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given no uid', () => { - return (auth as any).revokeRefreshTokens(undefined) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); - }); + describe('setCustomUserClaims()', () => { + const uid = 'abcdefghijklmnopqrstuvwxyz'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const customClaims = { + admin: true, + groupId: '123456', + }; + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => { + sinon.spy(validator, 'isUid'); + sinon.spy(validator, 'isObject'); + }); + afterEach(() => { + (validator.isUid as any).restore(); + (validator.isObject as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an invalid uid', () => { - const invalidUid = ('a' as any).repeat(129); - return auth.revokeRefreshTokens(invalidUid) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-uid'); - expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); - }); - }); + it('should be rejected given no uid', () => { + return (auth as any).setCustomUserClaims(undefined, customClaims) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.revokeRefreshTokens(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid uid', () => { + const invalidUid = ('a' as any).repeat(129); + return auth.setCustomUserClaims(invalidUid, customClaims) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-uid'); + expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.revokeRefreshTokens(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given no custom user claims', () => { + return (auth as any).setCustomUserClaims(uid) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.revokeRefreshTokens(uid) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given invalid custom user claims', () => { + return auth.setCustomUserClaims(uid, 'invalid' as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + expect(validator.isObject).to.have.been.calledOnce.and.calledWith('invalid'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.setCustomUserClaims(uid, customClaims) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.setCustomUserClaims(uid, customClaims) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve on underlying revokeRefreshTokens request success', () => { - // Stub revokeRefreshTokens to return expected uid. - const revokeRefreshTokensStub = - sinon.stub(AuthRequestHandler.prototype, 'revokeRefreshTokens') + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.setCustomUserClaims(uid, customClaims) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve on setCustomUserClaims request success', () => { + // Stub setCustomUserClaims to return expected uid. + const setCustomUserClaimsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'setCustomUserClaims') .resolves(uid); - stubs.push(revokeRefreshTokensStub); - return auth.revokeRefreshTokens(uid) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(revokeRefreshTokensStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected response returned. - expect(result).to.be.undefined; - }); - }); + stubs.push(setCustomUserClaimsStub); + return auth.setCustomUserClaims(uid, customClaims) + .then((response) => { + expect(response).to.be.undefined; + // Confirm underlying API called with expected parameters. + expect(setCustomUserClaimsStub) + .to.have.been.calledOnce.and.calledWith(uid, customClaims); + }); + }); - it('should throw when underlying revokeRefreshTokens request returns an error', () => { - // Stub revokeRefreshTokens to throw a backend error. - const revokeRefreshTokensStub = - sinon.stub(AuthRequestHandler.prototype, 'revokeRefreshTokens') + it('should throw an error when setCustomUserClaims returns an error', () => { + // Stub setCustomUserClaims to throw a backend error. + const setCustomUserClaimsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'setCustomUserClaims') .rejects(expectedError); - stubs.push(revokeRefreshTokensStub); - return auth.revokeRefreshTokens(uid) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(revokeRefreshTokensStub).to.have.been.calledOnce.and.calledWith(uid); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + stubs.push(setCustomUserClaimsStub); + return auth.setCustomUserClaims(uid, customClaims) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(setCustomUserClaimsStub) + .to.have.been.calledOnce.and.calledWith(uid, customClaims); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - }); - describe('importUsers()', () => { - const users = [ - {uid: '1234', email: 'user@example.com', passwordHash: Buffer.from('password')}, - {uid: '5678', phoneNumber: 'invalid'}, - ]; - const options = { - hash: { - algorithm: 'BCRYPT' as any, - }, - }; - const expectedUserImportResultError = - new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); - const expectedOptionsError = - new FirebaseAuthError(AuthClientErrorCode.INVALID_HASH_ALGORITHM); - const expectedServerError = - new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); - const expectedUserImportResult = { - successCount: 1, - failureCount: 1, - errors: [ - { - index: 1, - error: expectedUserImportResultError, - }, - ], - }; - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + describe('listUsers()', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const pageToken = 'PAGE_TOKEN'; + const maxResult = 500; + const downloadAccountResponse: any = { + users: [ + {localId: 'UID1'}, + {localId: 'UID2'}, + {localId: 'UID3'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }; + const expectedResult: any = { + users: [ + new UserRecord({localId: 'UID1'}), + new UserRecord({localId: 'UID2'}), + new UserRecord({localId: 'UID3'}), + ], + pageToken: 'NEXT_PAGE_TOKEN', + }; + const emptyDownloadAccountResponse: any = { + users: [], + }; + const emptyExpectedResult: any = { + users: [], + }; + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => { + sinon.spy(validator, 'isNonEmptyString'); + sinon.spy(validator, 'isNumber'); + }); + afterEach(() => { + (validator.isNonEmptyString as any).restore(); + (validator.isNumber as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.importUsers(users, options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid page token', () => { + const invalidToken = {}; + return auth.listUsers(undefined, invalidToken as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-page-token'); + expect(validator.isNonEmptyString) + .to.have.been.calledOnce.and.calledWith(invalidToken); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.importUsers(users, options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid max result', () => { + const invalidResults = 5000; + return auth.listUsers(invalidResults) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + expect(validator.isNumber) + .to.have.been.calledOnce.and.calledWith(invalidResults); + }); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.importUsers(users, options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.listUsers(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve on underlying uploadAccount request resolution', () => { - // Stub uploadAccount to return expected result. - const uploadAccountStub = - sinon.stub(AuthRequestHandler.prototype, 'uploadAccount') - .resolves(expectedUserImportResult); - stubs.push(uploadAccountStub); - return auth.importUsers(users, options) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(uploadAccountStub).to.have.been.calledOnce.and.calledWith(users, options); - // Confirm expected response returned. - expect(result).to.be.equal(expectedUserImportResult); - }); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.listUsers(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should reject when underlying uploadAccount request rejects with an error', () => { - // Stub uploadAccount to reject with expected error. - const uploadAccountStub = - sinon.stub(AuthRequestHandler.prototype, 'uploadAccount') - .rejects(expectedServerError); - stubs.push(uploadAccountStub); - return auth.importUsers(users, options) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(uploadAccountStub).to.have.been.calledOnce.and.calledWith(users, options); - // Confirm expected error returned. - expect(error).to.equal(expectedServerError); - }); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.listUsers(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should throw and fail quickly when underlying uploadAccount throws', () => { - // Stub uploadAccount to throw with expected error. - const uploadAccountStub = - sinon.stub(AuthRequestHandler.prototype, 'uploadAccount') - .throws(expectedOptionsError); - stubs.push(uploadAccountStub); - expect(() => { - return auth.importUsers(users, {hash: {algorithm: 'invalid' as any}}); - }).to.throw(expectedOptionsError); - }); - }); + it('should resolve on downloadAccount request success with users in response', () => { + // Stub downloadAccount to return expected response. + const downloadAccountStub = sinon + .stub(testConfig.RequestHandler.prototype, 'downloadAccount') + .resolves(downloadAccountResponse); + stubs.push(downloadAccountStub); + return auth.listUsers(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(downloadAccountStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); - describe('createSessionCookie()', () => { - const idToken = 'ID_TOKEN'; - const options = {expiresIn: 60 * 60 * 24 * 1000}; - const sessionCookie = 'SESSION_COOKIE'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); - // Stubs used to simulate underlying api calls. - let stubs: sinon.SinonStub[] = []; - beforeEach(() => { - sinon.spy(validator, 'isNonEmptyString'); - }); - afterEach(() => { - (validator.isNonEmptyString as any).restore(); - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should resolve on downloadAccount request success with default options', () => { + // Stub downloadAccount to return expected response. + const downloadAccountStub = sinon + .stub(testConfig.RequestHandler.prototype, 'downloadAccount') + .resolves(downloadAccountResponse); + stubs.push(downloadAccountStub); + return auth.listUsers() + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(downloadAccountStub) + .to.have.been.calledOnce.and.calledWith(undefined, undefined); + }); + }); - it('should be rejected given no ID token', () => { - return (auth as any).createSessionCookie(undefined, options) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-id-token'); - }); - it('should be rejected given an invalid ID token', () => { - const invalidIdToken = {} as any; - return auth.createSessionCookie(invalidIdToken, options) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-id-token'); - expect(validator.isNonEmptyString).to.have.been.calledOnce.and.calledWith(invalidIdToken); - }); - }); + it('should resolve on downloadAccount request success with no users in response', () => { + // Stub downloadAccount to return expected response. + const downloadAccountStub = sinon + .stub(testConfig.RequestHandler.prototype, 'downloadAccount') + .resolves(emptyDownloadAccountResponse); + stubs.push(downloadAccountStub); + return auth.listUsers(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(emptyExpectedResult); + // Confirm underlying API called with expected parameters. + expect(downloadAccountStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); - it('should be rejected given no session duration', () => { - return (auth as any).createSessionCookie(idToken, undefined) - .should.eventually.be.rejected.and.have.property( - 'code', 'auth/invalid-session-cookie-duration'); + it('should throw an error when downloadAccount returns an error', () => { + // Stub downloadAccount to throw a backend error. + const downloadAccountStub = sinon + .stub(testConfig.RequestHandler.prototype, 'downloadAccount') + .rejects(expectedError); + stubs.push(downloadAccountStub); + return auth.listUsers(maxResult, pageToken) + .then((results) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(downloadAccountStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - it('should be rejected given an invalid session duration', () => { - // Invalid object. - const invalidOptions = {} as any; - return auth.createSessionCookie(idToken, invalidOptions) - .should.eventually.be.rejected.and.have.property( - 'code', 'auth/invalid-session-cookie-duration'); - }); + describe('revokeRefreshTokens()', () => { + const uid = 'abcdefghijklmnopqrstuvwxyz'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => { + sinon.spy(validator, 'isUid'); + }); + afterEach(() => { + (validator.isUid as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - it('should be rejected given out of range session duration', () => { - // 1 minute duration. - const invalidOptions = {expiresIn: 60 * 1000}; - return auth.createSessionCookie(idToken, invalidOptions) - .should.eventually.be.rejected.and.have.property( - 'code', 'auth/invalid-session-cookie-duration'); - }); + it('should be rejected given no uid', () => { + return (auth as any).revokeRefreshTokens(undefined) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-uid'); + }); - it('should be rejected given an app which returns null access tokens', () => { - return nullAccessTokenAuth.createSessionCookie(idToken, options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid uid', () => { + const invalidUid = ('a' as any).repeat(129); + return auth.revokeRefreshTokens(invalidUid) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-uid'); + expect(validator.isUid).to.have.been.calledOnce.and.calledWith(invalidUid); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return malformedAccessTokenAuth.createSessionCookie(idToken, options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.revokeRefreshTokens(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return rejectedPromiseAccessTokenAuth.createSessionCookie(idToken, options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.revokeRefreshTokens(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve on underlying createSessionCookie request success', () => { - // Stub createSessionCookie to return expected sessionCookie. - const createSessionCookieStub = - sinon.stub(AuthRequestHandler.prototype, 'createSessionCookie') - .resolves(sessionCookie); - stubs.push(createSessionCookieStub); - return auth.createSessionCookie(idToken, options) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(createSessionCookieStub) - .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); - // Confirm expected response returned. - expect(result).to.be.equal(sessionCookie); - }); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.revokeRefreshTokens(uid) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should throw when underlying createSessionCookie request returns an error', () => { - // Stub createSessionCookie to throw a backend error. - const createSessionCookieStub = - sinon.stub(AuthRequestHandler.prototype, 'createSessionCookie') - .rejects(expectedError); - stubs.push(createSessionCookieStub); - return auth.createSessionCookie(idToken, options) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createSessionCookieStub) - .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should resolve on underlying revokeRefreshTokens request success', () => { + // Stub revokeRefreshTokens to return expected uid. + const revokeRefreshTokensStub = + sinon.stub(testConfig.RequestHandler.prototype, 'revokeRefreshTokens') + .resolves(uid); + stubs.push(revokeRefreshTokensStub); + return auth.revokeRefreshTokens(uid) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(revokeRefreshTokensStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected response returned. + expect(result).to.be.undefined; + }); + }); + + it('should throw when underlying revokeRefreshTokens request returns an error', () => { + // Stub revokeRefreshTokens to throw a backend error. + const revokeRefreshTokensStub = + sinon.stub(testConfig.RequestHandler.prototype, 'revokeRefreshTokens') + .rejects(expectedError); + stubs.push(revokeRefreshTokensStub); + return auth.revokeRefreshTokens(uid) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(revokeRefreshTokensStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - }); - const emailActionFlows: EmailActionTest[] = [ - {api: 'generatePasswordResetLink', requestType: 'PASSWORD_RESET', requiresSettings: false}, - {api: 'generateEmailVerificationLink', requestType: 'VERIFY_EMAIL', requiresSettings: false}, - {api: 'generateSignInWithEmailLink', requestType: 'EMAIL_SIGNIN', requiresSettings: true}, - ]; - emailActionFlows.forEach((emailActionFlow) => { - describe(`${emailActionFlow.api}()`, () => { - const email = 'user@example.com'; - const actionCodeSettings = { - url: 'https://www.example.com/path/file?a=1&b=2', - handleCodeInApp: true, - iOS: { - bundleId: 'com.example.ios', + describe('importUsers()', () => { + const users = [ + {uid: '1234', email: 'user@example.com', passwordHash: Buffer.from('password')}, + {uid: '5678', phoneNumber: 'invalid'}, + ]; + const options = { + hash: { + algorithm: 'BCRYPT' as any, }, - android: { - packageName: 'com.example.android', - installApp: true, - minimumVersion: '6', - }, - dynamicLinkDomain: 'custom.page.link', }; - const expectedLink = 'https://custom.page.link?link=' + - encodeURIComponent('https://projectId.firebaseapp.com/__/auth/action?oobCode=CODE') + - '&apn=com.example.android&ibi=com.example.ios'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const expectedUserImportResultError = + new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); + const expectedOptionsError = + new FirebaseAuthError(AuthClientErrorCode.INVALID_HASH_ALGORITHM); + const expectedServerError = + new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const expectedUserImportResult = { + successCount: 1, + failureCount: 1, + errors: [ + { + index: 1, + error: expectedUserImportResultError, + }, + ], + }; // Stubs used to simulate underlying api calls. let stubs: sinon.SinonStub[] = []; afterEach(() => { @@ -1876,926 +1852,1212 @@ describe('Auth', () => { stubs = []; }); - it('should be rejected given no email', () => { - return (auth as any)[emailActionFlow.api](undefined, actionCodeSettings) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); - }); - - it('should be rejected given an invalid email', () => { - return (auth as any)[emailActionFlow.api]('invalid', actionCodeSettings) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); - }); - - it('should be rejected given an invalid ActionCodeSettings object', () => { - return (auth as any)[emailActionFlow.api](email, 'invalid') - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + return nullAccessTokenAuth.importUsers(users, options) .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + return malformedAccessTokenAuth.importUsers(users, options) .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + return rejectedPromiseAccessTokenAuth.importUsers(users, options) .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - it('should resolve when called with actionCodeSettings with a generated link on success', () => { - // Stub getEmailActionLink to return expected link. - const getEmailActionLinkStub = sinon.stub(AuthRequestHandler.prototype, 'getEmailActionLink') - .resolves(expectedLink); - stubs.push(getEmailActionLinkStub); - return (auth as any)[emailActionFlow.api](email, actionCodeSettings) - .then((actualLink: string) => { + it('should resolve on underlying uploadAccount request resolution', () => { + // Stub uploadAccount to return expected result. + const uploadAccountStub = + sinon.stub(testConfig.RequestHandler.prototype, 'uploadAccount') + .resolves(expectedUserImportResult); + stubs.push(uploadAccountStub); + return auth.importUsers(users, options) + .then((result) => { // Confirm underlying API called with expected parameters. - expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( - emailActionFlow.requestType, email, actionCodeSettings); - // Confirm expected user record response returned. - expect(actualLink).to.equal(expectedLink); + expect(uploadAccountStub).to.have.been.calledOnce.and.calledWith(users, options); + // Confirm expected response returned. + expect(result).to.be.equal(expectedUserImportResult); }); }); - if (emailActionFlow.requiresSettings) { - it('should reject when called without actionCodeSettings', () => { - return (auth as any)[emailActionFlow.api](email, undefined) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - } else { - it('should resolve when called without actionCodeSettings with a generated link on success', () => { - // Stub getEmailActionLink to return expected link. - const getEmailActionLinkStub = sinon.stub(AuthRequestHandler.prototype, 'getEmailActionLink') - .resolves(expectedLink); - stubs.push(getEmailActionLinkStub); - return (auth as any)[emailActionFlow.api](email) - .then((actualLink: string) => { - // Confirm underlying API called with expected parameters. - expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( - emailActionFlow.requestType, email, undefined); - // Confirm expected user record response returned. - expect(actualLink).to.equal(expectedLink); - }); - }); - } - - it('should throw an error when getEmailAction returns an error', () => { - // Stub getEmailActionLink to throw a backend error. - const getEmailActionLinkStub = sinon.stub(AuthRequestHandler.prototype, 'getEmailActionLink') - .rejects(expectedError); - stubs.push(getEmailActionLinkStub); - return (auth as any)[emailActionFlow.api](email, actionCodeSettings) - .then((actualLink: string) => { + it('should reject when underlying uploadAccount request rejects with an error', () => { + // Stub uploadAccount to reject with expected error. + const uploadAccountStub = + sinon.stub(testConfig.RequestHandler.prototype, 'uploadAccount') + .rejects(expectedServerError); + stubs.push(uploadAccountStub); + return auth.importUsers(users, options) + .then((result) => { throw new Error('Unexpected success'); - }, (error: any) => { + }, (error) => { // Confirm underlying API called with expected parameters. - expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( - emailActionFlow.requestType, email, actionCodeSettings); + expect(uploadAccountStub).to.have.been.calledOnce.and.calledWith(users, options); // Confirm expected error returned. - expect(error).to.equal(expectedError); + expect(error).to.equal(expectedServerError); }); }); - }); - }); - describe('getProviderConfig()', () => { - let stubs: sinon.SinonStub[] = []; + it('should throw and fail quickly when underlying uploadAccount throws', () => { + // Stub uploadAccount to throw with expected error. + const uploadAccountStub = + sinon.stub(testConfig.RequestHandler.prototype, 'uploadAccount') + .throws(expectedOptionsError); + stubs.push(uploadAccountStub); + expect(() => { + return auth.importUsers(users, {hash: {algorithm: 'invalid' as any}}); + }).to.throw(expectedOptionsError); + }); - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + if (testConfig.Auth === TenantAwareAuth) { + it('should throw and fail quickly when users provided have mismatching tenant IDs', () => { + const usersCopy = deepCopy(users); + // Simulate one user with mismatching tenant ID. + (usersCopy[0] as any).tenantId = 'otherTenantId'; + expect(() => { + return auth.importUsers(usersCopy, options); + }).to.throw('UserRecord of index "0" has mismatching tenant ID "otherTenantId"'); + }); - it('should be rejected given no provider ID', () => { - return (auth as any).getProviderConfig() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); + it('should resolve when users provided have matching tenant IDs', () => { + // Stub uploadAccount to return expected result. + const uploadAccountStub = + sinon.stub(testConfig.RequestHandler.prototype, 'uploadAccount') + .returns(Promise.resolve(expectedUserImportResult)); + const usersCopy = deepCopy(users); + usersCopy.forEach((user) => { + (user as any).tenantId = TENANT_ID; + }); + stubs.push(uploadAccountStub); + return auth.importUsers(usersCopy, options) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(uploadAccountStub).to.have.been.calledOnce.and.calledWith(usersCopy, options); + // Confirm expected response returned. + expect(result).to.be.equal(expectedUserImportResult); + }); + }); + } }); - const invalidProviderIds = [ - undefined, null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { - it(`should be rejected given an invalid provider ID "${JSON.stringify(invalidProviderId)}"`, () => { - return (auth as Auth).getProviderConfig(invalidProviderId as any) + describe('createSessionCookie()', () => { + const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID; + const idToken = 'ID_TOKEN'; + const options = {expiresIn: 60 * 60 * 24 * 1000}; + const sessionCookie = 'SESSION_COOKIE'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + const expectedUserRecord = getValidUserRecord(getValidGetAccountInfoResponse(tenantId)); + // Set auth_time of token to expected user's tokensValidAfterTime. + const validSince = new Date(expectedUserRecord.tokensValidAfterTime); + // Set expected uid to expected user's. + const uid = expectedUserRecord.uid; + // Set expected decoded ID token with expected UID and auth time. + const decodedIdToken = getDecodedIdToken(uid, validSince, tenantId); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => { + // If verifyIdToken stubbed, restore it. + if (testConfig.Auth.prototype.verifyIdToken.restore) { + testConfig.Auth.prototype.verifyIdToken.restore(); + } + sinon.spy(validator, 'isNonEmptyString'); + }); + afterEach(() => { + (validator.isNonEmptyString as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no ID token', () => { + return (auth as any).createSessionCookie(undefined, options) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-id-token'); + }); + + it('should be rejected given an invalid ID token', () => { + const invalidIdToken = {} as any; + return auth.createSessionCookie(invalidIdToken, options) .then(() => { throw new Error('Unexpected success'); }) .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-provider-id'); + expect(error).to.have.property('code', 'auth/invalid-id-token'); + expect(validator.isNonEmptyString).to.have.been.calledOnce.and.calledWith(invalidIdToken); }); }); - }); - it('should be rejected given an app which returns null access tokens', () => { - const providerId = 'oidc.provider'; - return (nullAccessTokenAuth as Auth).getProviderConfig(providerId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - const providerId = 'oidc.provider'; - return (malformedAccessTokenAuth as Auth).getProviderConfig(providerId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given no session duration', () => { + // Simulate auth.verifyIdToken() succeeds if called. + stubs.push(sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.resolve(decodedIdToken))); + return (auth as any).createSessionCookie(idToken, undefined) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - const providerId = 'oidc.provider'; - return (rejectedPromiseAccessTokenAuth as Auth).getProviderConfig(providerId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an invalid session duration', () => { + // Invalid object. + const invalidOptions = {} as any; + return auth.createSessionCookie(idToken, invalidOptions) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }); - describe('using OIDC configurations', () => { - const providerId = 'oidc.provider'; - const serverResponse = { - name: `projects/project_id/oauthIdpConfigs/${providerId}`, - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const expectedConfig = new OIDCConfig(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + it('should be rejected given out of range session duration', () => { + // Simulate auth.verifyIdToken() succeeds if called. + stubs.push(sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.resolve(decodedIdToken))); + // 1 minute duration. + const invalidOptions = {expiresIn: 60 * 1000}; + return auth.createSessionCookie(idToken, invalidOptions) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }); - it('should resolve with an OIDCConfig on success', () => { - // Stub getOAuthIdpConfig to return expected result. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getOAuthIdpConfig') - .resolves(serverResponse); - stubs.push(stub); - return (auth as Auth).getProviderConfig(providerId) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected config returned. - expect(result).to.deep.equal(expectedConfig); - }); + it('should be rejected given an app which returns null access tokens', () => { + // Simulate auth.verifyIdToken() succeeds if called. + stubs.push(sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.resolve(decodedIdToken))); + return nullAccessTokenAuth.createSessionCookie(idToken, options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - it('should throw an error when the backend returns an error', () => { - // Stub getOAuthIdpConfig to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getOAuthIdpConfig') - .rejects(expectedError); - stubs.push(stub); - return (auth as Auth).getProviderConfig(providerId) - .then((config) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + stubs.push(sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.resolve(decodedIdToken))); + return malformedAccessTokenAuth.createSessionCookie(idToken, options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - }); - describe('using SAML configurations', () => { - const providerId = 'saml.provider'; - const serverResponse = { - name: `projects/project_id/inboundSamlConfigs/${providerId}`, - idpConfig: { - idpEntityId: 'IDP_ENTITY_ID', - ssoUrl: 'https://example.com/login', - signRequest: true, - idpCertificates: [ - {x509Certificate: 'CERT1'}, - {x509Certificate: 'CERT2'}, - ], - }, - spConfig: { - spEntityId: 'RP_ENTITY_ID', - callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', - }, - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - }; - const expectedConfig = new SAMLConfig(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + it('should be rejected given an app which fails to generate access tokens', () => { + stubs.push(sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.resolve(decodedIdToken))); + return rejectedPromiseAccessTokenAuth.createSessionCookie(idToken, options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve with a SAMLConfig on success', () => { - // Stub getInboundSamlConfig to return expected result. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getInboundSamlConfig') - .resolves(serverResponse); - stubs.push(stub); - return (auth as Auth).getProviderConfig(providerId) + it('should resolve on underlying createSessionCookie request success', () => { + // Simulate auth.verifyIdToken() succeeds if called. + const verifyIdTokenStub = sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.resolve(decodedIdToken)); + // Stub createSessionCookie to return expected sessionCookie. + const createSessionCookieStub = + sinon.stub(testConfig.RequestHandler.prototype, 'createSessionCookie') + .resolves(sessionCookie); + stubs.push(createSessionCookieStub); + return auth.createSessionCookie(idToken, options) .then((result) => { // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected config returned. - expect(result).to.deep.equal(expectedConfig); + expect(createSessionCookieStub) + .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); + // TenantAwareAuth should verify the ID token first. + if (testConfig.Auth === TenantAwareAuth) { + expect(verifyIdTokenStub) + .to.have.been.calledOnce.and.calledWith(idToken); + } else { + expect(verifyIdTokenStub).to.have.not.been.called; + } + // Confirm expected response returned. + expect(result).to.be.equal(sessionCookie); }); }); - it('should throw an error when the backend returns an error', () => { - // Stub getInboundSamlConfig to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'getInboundSamlConfig') - .rejects(expectedError); - stubs.push(stub); - return (auth as Auth).getProviderConfig(providerId) - .then((config) => { + it('should throw when underlying createSessionCookie request returns an error', () => { + // Simulate auth.verifyIdToken() succeeds if called. + stubs.push(sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .resolves(decodedIdToken)); + // Stub createSessionCookie to throw a backend error. + const createSessionCookieStub = + sinon.stub(testConfig.RequestHandler.prototype, 'createSessionCookie') + .rejects(expectedError); + stubs.push(createSessionCookieStub); + return auth.createSessionCookie(idToken, options) + .then((result) => { throw new Error('Unexpected success'); }, (error) => { // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + expect(createSessionCookieStub) + .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); // Confirm expected error returned. expect(error).to.equal(expectedError); }); }); + + if (testConfig.Auth === TenantAwareAuth) { + it('should be rejected when ID token provided is invalid', () => { + // Simulate auth.verifyIdToken() fails when called. + const verifyIdTokenStub = sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') + .returns(Promise.reject(expectedError)); + stubs.push(verifyIdTokenStub); + return auth.createSessionCookie(idToken, options) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(verifyIdTokenStub) + .to.have.been.calledOnce.and.calledWith(idToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + } }); - }); - describe('listProviderConfigs()', () => { - const options: AuthProviderConfigFilter = { - type: 'oidc', - }; - let stubs: sinon.SinonStub[] = []; + const emailActionFlows: EmailActionTest[] = [ + {api: 'generatePasswordResetLink', requestType: 'PASSWORD_RESET', requiresSettings: false}, + {api: 'generateEmailVerificationLink', requestType: 'VERIFY_EMAIL', requiresSettings: false}, + {api: 'generateSignInWithEmailLink', requestType: 'EMAIL_SIGNIN', requiresSettings: true}, + ]; + emailActionFlows.forEach((emailActionFlow) => { + describe(`${emailActionFlow.api}()`, () => { + const email = 'user@example.com'; + const actionCodeSettings = { + url: 'https://www.example.com/path/file?a=1&b=2', + handleCodeInApp: true, + iOS: { + bundleId: 'com.example.ios', + }, + android: { + packageName: 'com.example.android', + installApp: true, + minimumVersion: '6', + }, + dynamicLinkDomain: 'custom.page.link', + }; + const expectedLink = 'https://custom.page.link?link=' + + encodeURIComponent('https://projectId.firebaseapp.com/__/auth/action?oobCode=CODE') + + '&apn=com.example.android&ibi=com.example.ios'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should be rejected given no email', () => { + return (auth as any)[emailActionFlow.api](undefined, actionCodeSettings) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); + }); - it('should be rejected given no options', () => { - return (auth as any).listProviderConfigs() - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + it('should be rejected given an invalid email', () => { + return (auth as any)[emailActionFlow.api]('invalid', actionCodeSettings) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); + }); - it('should be rejected given an invalid AuthProviderConfigFilter type', () => { - const invalidOptions = { - type: 'unsupported', - }; - return (auth as Auth).listProviderConfigs(invalidOptions as any) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); + it('should be rejected given an invalid ActionCodeSettings object', () => { + return (auth as any)[emailActionFlow.api](email, 'invalid') + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).listProviderConfigs(options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).listProviderConfigs(options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).listProviderConfigs(options) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - describe('using OIDC type filter', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); - const pageToken = 'PAGE_TOKEN'; - const maxResults = 50; - const filterOptions: AuthProviderConfigFilter = { - type: 'oidc', - pageToken, - maxResults, - }; - const listConfigsResponse: any = { - oauthIdpConfigs : [ - getOIDCConfigServerResponse('oidc.provider1'), - getOIDCConfigServerResponse('oidc.provider2'), - ], - nextPageToken: 'NEXT_PAGE_TOKEN', - }; - const expectedResult: any = { - providerConfigs: [ - new OIDCConfig(listConfigsResponse.oauthIdpConfigs[0]), - new OIDCConfig(listConfigsResponse.oauthIdpConfigs[1]), - ], - pageToken: 'NEXT_PAGE_TOKEN', - }; - const emptyListConfigsResponse: any = { - oauthIdpConfigs: [], - }; - const emptyExpectedResult: any = { - providerConfigs: [], - }; + it('should resolve when called with actionCodeSettings with a generated link on success', () => { + // Stub getEmailActionLink to return expected link. + const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink') + .resolves(expectedLink); + stubs.push(getEmailActionLinkStub); + return (auth as any)[emailActionFlow.api](email, actionCodeSettings) + .then((actualLink: string) => { + // Confirm underlying API called with expected parameters. + expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( + emailActionFlow.requestType, email, actionCodeSettings); + // Confirm expected user record response returned. + expect(actualLink).to.equal(expectedLink); + }); + }); - it('should resolve on success with configs in response', () => { - // Stub listOAuthIdpConfigs to return expected response. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') - .resolves(listConfigsResponse); - stubs.push(listConfigsStub); - return auth.listProviderConfigs(filterOptions) - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + if (emailActionFlow.requiresSettings) { + it('should reject when called without actionCodeSettings', () => { + return (auth as any)[emailActionFlow.api](email, undefined) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); }); + } else { + it('should resolve when called without actionCodeSettings with a generated link on success', () => { + // Stub getEmailActionLink to return expected link. + const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink') + .resolves(expectedLink); + stubs.push(getEmailActionLinkStub); + return (auth as any)[emailActionFlow.api](email) + .then((actualLink: string) => { + // Confirm underlying API called with expected parameters. + expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( + emailActionFlow.requestType, email, undefined); + // Confirm expected user record response returned. + expect(actualLink).to.equal(expectedLink); + }); + }); + } + + it('should throw an error when getEmailAction returns an error', () => { + // Stub getEmailActionLink to throw a backend error. + const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink') + .rejects(expectedError); + stubs.push(getEmailActionLinkStub); + return (auth as any)[emailActionFlow.api](email, actionCodeSettings) + .then((actualLink: string) => { + throw new Error('Unexpected success'); + }, (error: any) => { + // Confirm underlying API called with expected parameters. + expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( + emailActionFlow.requestType, email, actionCodeSettings); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); + }); - it('should resolve on success with default options', () => { - // Stub listOAuthIdpConfigs to return expected response. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') - .resolves(listConfigsResponse); - stubs.push(listConfigsStub); - return (auth as Auth).listProviderConfigs({type: 'oidc'}) - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(undefined, undefined); - }); + describe('getProviderConfig()', () => { + let stubs: sinon.SinonStub[] = []; + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; }); + it('should be rejected given no provider ID', () => { + return (auth as any).getProviderConfig() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); + }); - it('should resolve on success with no configs in response', () => { - // Stub listOAuthIdpConfigs to return expected response. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') - .resolves(emptyListConfigsResponse); - stubs.push(listConfigsStub); - return auth.listProviderConfigs(filterOptions) - .then((response) => { - expect(response).to.deep.equal(emptyExpectedResult); - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); - }); + const invalidProviderIds = [ + undefined, null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidProviderIds.forEach((invalidProviderId) => { + it(`should be rejected given an invalid provider ID "${JSON.stringify(invalidProviderId)}"`, () => { + return (auth as Auth).getProviderConfig(invalidProviderId as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-provider-id'); + }); + }); }); - it('should throw an error when listOAuthIdpConfigs returns an error', () => { - // Stub listOAuthIdpConfigs to throw a backend error. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listOAuthIdpConfigs') - .rejects(expectedError); - stubs.push(listConfigsStub); - return auth.listProviderConfigs(filterOptions) - .then((results) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should be rejected given an app which returns null access tokens', () => { + const providerId = 'oidc.provider'; + return (nullAccessTokenAuth as Auth).getProviderConfig(providerId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + const providerId = 'oidc.provider'; + return (malformedAccessTokenAuth as Auth).getProviderConfig(providerId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + const providerId = 'oidc.provider'; + return (rejectedPromiseAccessTokenAuth as Auth).getProviderConfig(providerId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + describe('using OIDC configurations', () => { + const providerId = 'oidc.provider'; + const serverResponse = { + name: `projects/project_id/oauthIdpConfigs/${providerId}`, + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const expectedConfig = new OIDCConfig(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + + it('should resolve with an OIDCConfig on success', () => { + // Stub getOAuthIdpConfig to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getOAuthIdpConfig') + .resolves(serverResponse); + stubs.push(stub); + return (auth as Auth).getProviderConfig(providerId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected config returned. + expect(result).to.deep.equal(expectedConfig); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getOAuthIdpConfig to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getOAuthIdpConfig') + .rejects(expectedError); + stubs.push(stub); + return (auth as Auth).getProviderConfig(providerId) + .then((config) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('using SAML configurations', () => { + const providerId = 'saml.provider'; + const serverResponse = { + name: `projects/project_id/inboundSamlConfigs/${providerId}`, + idpConfig: { + idpEntityId: 'IDP_ENTITY_ID', + ssoUrl: 'https://example.com/login', + signRequest: true, + idpCertificates: [ + {x509Certificate: 'CERT1'}, + {x509Certificate: 'CERT2'}, + ], + }, + spConfig: { + spEntityId: 'RP_ENTITY_ID', + callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', + }, + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + }; + const expectedConfig = new SAMLConfig(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + + it('should resolve with a SAMLConfig on success', () => { + // Stub getInboundSamlConfig to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getInboundSamlConfig') + .resolves(serverResponse); + stubs.push(stub); + return (auth as Auth).getProviderConfig(providerId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected config returned. + expect(result).to.deep.equal(expectedConfig); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getInboundSamlConfig to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getInboundSamlConfig') + .rejects(expectedError); + stubs.push(stub); + return (auth as Auth).getProviderConfig(providerId) + .then((config) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); }); - describe('using SAML type filter', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); - const pageToken = 'PAGE_TOKEN'; - const maxResults = 50; - const filterOptions: AuthProviderConfigFilter = { - type: 'saml', - pageToken, - maxResults, - }; - const listConfigsResponse: any = { - inboundSamlConfigs : [ - getSAMLConfigServerResponse('saml.provider1'), - getSAMLConfigServerResponse('saml.provider2'), - ], - nextPageToken: 'NEXT_PAGE_TOKEN', - }; - const expectedResult: any = { - providerConfigs: [ - new SAMLConfig(listConfigsResponse.inboundSamlConfigs[0]), - new SAMLConfig(listConfigsResponse.inboundSamlConfigs[1]), - ], - pageToken: 'NEXT_PAGE_TOKEN', - }; - const emptyListConfigsResponse: any = { - inboundSamlConfigs: [], - }; - const emptyExpectedResult: any = { - providerConfigs: [], + describe('listProviderConfigs()', () => { + const options: AuthProviderConfigFilter = { + type: 'oidc', }; + let stubs: sinon.SinonStub[] = []; - it('should resolve on success with configs in response', () => { - // Stub listInboundSamlConfigs to return expected response. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') - .resolves(listConfigsResponse); - stubs.push(listConfigsStub); - return auth.listProviderConfigs(filterOptions) - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); - }); + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; }); - it('should resolve on success with default options', () => { - // Stub listInboundSamlConfigs to return expected response. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') - .resolves(listConfigsResponse); - stubs.push(listConfigsStub); - return (auth as Auth).listProviderConfigs({type: 'saml'}) - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(undefined, undefined); - }); + it('should be rejected given no options', () => { + return (auth as any).listProviderConfigs() + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); }); + it('should be rejected given an invalid AuthProviderConfigFilter type', () => { + const invalidOptions = { + type: 'unsupported', + }; + return (auth as Auth).listProviderConfigs(invalidOptions as any) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); - it('should resolve on success with no configs in response', () => { - // Stub listInboundSamlConfigs to return expected response. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') - .resolves(emptyListConfigsResponse); - stubs.push(listConfigsStub); - return auth.listProviderConfigs(filterOptions) - .then((response) => { - expect(response).to.deep.equal(emptyExpectedResult); - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); - }); + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).listProviderConfigs(options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - it('should throw an error when listInboundSamlConfigs returns an error', () => { - // Stub listInboundSamlConfigs to throw a backend error. - const listConfigsStub = sinon - .stub(AuthRequestHandler.prototype, 'listInboundSamlConfigs') - .rejects(expectedError); - stubs.push(listConfigsStub); - return auth.listProviderConfigs(filterOptions) - .then((results) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(listConfigsStub) - .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).listProviderConfigs(options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - }); - }); - describe('deleteProviderConfig()', () => { - let stubs: sinon.SinonStub[] = []; + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).listProviderConfigs(options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + describe('using OIDC type filter', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const pageToken = 'PAGE_TOKEN'; + const maxResults = 50; + const filterOptions: AuthProviderConfigFilter = { + type: 'oidc', + pageToken, + maxResults, + }; + const listConfigsResponse: any = { + oauthIdpConfigs : [ + getOIDCConfigServerResponse('oidc.provider1'), + getOIDCConfigServerResponse('oidc.provider2'), + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }; + const expectedResult: any = { + providerConfigs: [ + new OIDCConfig(listConfigsResponse.oauthIdpConfigs[0]), + new OIDCConfig(listConfigsResponse.oauthIdpConfigs[1]), + ], + pageToken: 'NEXT_PAGE_TOKEN', + }; + const emptyListConfigsResponse: any = { + oauthIdpConfigs: [], + }; + const emptyExpectedResult: any = { + providerConfigs: [], + }; + + it('should resolve on success with configs in response', () => { + // Stub listOAuthIdpConfigs to return expected response. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listOAuthIdpConfigs') + .resolves(listConfigsResponse); + stubs.push(listConfigsStub); + return auth.listProviderConfigs(filterOptions) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + }); + }); + + it('should resolve on success with default options', () => { + // Stub listOAuthIdpConfigs to return expected response. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listOAuthIdpConfigs') + .resolves(listConfigsResponse); + stubs.push(listConfigsStub); + return (auth as Auth).listProviderConfigs({type: 'oidc'}) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(undefined, undefined); + }); + }); - it('should be rejected given no provider ID', () => { - return (auth as any).deleteProviderConfig() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); - }); - it('should be rejected given an invalid provider ID', () => { - const invalidProviderId = ''; - return (auth as Auth).deleteProviderConfig(invalidProviderId) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-provider-id'); + it('should resolve on success with no configs in response', () => { + // Stub listOAuthIdpConfigs to return expected response. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listOAuthIdpConfigs') + .resolves(emptyListConfigsResponse); + stubs.push(listConfigsStub); + return auth.listProviderConfigs(filterOptions) + .then((response) => { + expect(response).to.deep.equal(emptyExpectedResult); + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + }); }); - }); - it('should be rejected given an app which returns null access tokens', () => { - const providerId = 'oidc.provider'; - return (nullAccessTokenAuth as Auth).deleteProviderConfig(providerId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should throw an error when listOAuthIdpConfigs returns an error', () => { + // Stub listOAuthIdpConfigs to throw a backend error. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listOAuthIdpConfigs') + .rejects(expectedError); + stubs.push(listConfigsStub); + return auth.listProviderConfigs(filterOptions) + .then((results) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - const providerId = 'oidc.provider'; - return (malformedAccessTokenAuth as Auth).deleteProviderConfig(providerId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + describe('using SAML type filter', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const pageToken = 'PAGE_TOKEN'; + const maxResults = 50; + const filterOptions: AuthProviderConfigFilter = { + type: 'saml', + pageToken, + maxResults, + }; + const listConfigsResponse: any = { + inboundSamlConfigs : [ + getSAMLConfigServerResponse('saml.provider1'), + getSAMLConfigServerResponse('saml.provider2'), + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }; + const expectedResult: any = { + providerConfigs: [ + new SAMLConfig(listConfigsResponse.inboundSamlConfigs[0]), + new SAMLConfig(listConfigsResponse.inboundSamlConfigs[1]), + ], + pageToken: 'NEXT_PAGE_TOKEN', + }; + const emptyListConfigsResponse: any = { + inboundSamlConfigs: [], + }; + const emptyExpectedResult: any = { + providerConfigs: [], + }; + + it('should resolve on success with configs in response', () => { + // Stub listInboundSamlConfigs to return expected response. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listInboundSamlConfigs') + .resolves(listConfigsResponse); + stubs.push(listConfigsStub); + return auth.listProviderConfigs(filterOptions) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + }); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - const providerId = 'oidc.provider'; - return (rejectedPromiseAccessTokenAuth as Auth).deleteProviderConfig(providerId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should resolve on success with default options', () => { + // Stub listInboundSamlConfigs to return expected response. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listInboundSamlConfigs') + .resolves(listConfigsResponse); + stubs.push(listConfigsStub); + return (auth as Auth).listProviderConfigs({type: 'saml'}) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(undefined, undefined); + }); + }); - describe('using OIDC configurations', () => { - const providerId = 'oidc.provider'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); - it('should resolve with void on success', () => { - // Stub deleteOAuthIdpConfig to resolve. - const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteOAuthIdpConfig') - .resolves(); - stubs.push(stub); - return (auth as Auth).deleteProviderConfig(providerId) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected result returned. - expect(result).to.be.undefined; - }); - }); + it('should resolve on success with no configs in response', () => { + // Stub listInboundSamlConfigs to return expected response. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listInboundSamlConfigs') + .resolves(emptyListConfigsResponse); + stubs.push(listConfigsStub); + return auth.listProviderConfigs(filterOptions) + .then((response) => { + expect(response).to.deep.equal(emptyExpectedResult); + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + }); + }); - it('should throw an error when the backend returns an error', () => { - // Stub deleteOAuthIdpConfig to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteOAuthIdpConfig') - .rejects(expectedError); - stubs.push(stub); - return (auth as Auth).deleteProviderConfig(providerId) - .then((config) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should throw an error when listInboundSamlConfigs returns an error', () => { + // Stub listInboundSamlConfigs to throw a backend error. + const listConfigsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listInboundSamlConfigs') + .rejects(expectedError); + stubs.push(listConfigsStub); + return auth.listProviderConfigs(filterOptions) + .then((results) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(listConfigsStub) + .to.have.been.calledOnce.and.calledWith(maxResults, pageToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); }); - describe('using SAML configurations', () => { - const providerId = 'saml.provider'; - const serverResponse = {}; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + describe('deleteProviderConfig()', () => { + let stubs: sinon.SinonStub[] = []; - it('should resolve with void on success', () => { - // Stub deleteInboundSamlConfig to resolve. - const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteInboundSamlConfig') - .resolves(); - stubs.push(stub); - return (auth as Auth).deleteProviderConfig(providerId) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected result returned. - expect(result).to.be.undefined; - }); + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; }); - it('should throw an error when the backend returns an error', () => { - // Stub deleteInboundSamlConfig to throw a backend error. - const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteInboundSamlConfig') - .rejects(expectedError); - stubs.push(stub); - return (auth as Auth).deleteProviderConfig(providerId) - .then((config) => { + it('should be rejected given no provider ID', () => { + return (auth as any).deleteProviderConfig() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); + }); + + it('should be rejected given an invalid provider ID', () => { + const invalidProviderId = ''; + return (auth as Auth).deleteProviderConfig(invalidProviderId) + .then(() => { throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(providerId); - // Confirm expected error returned. - expect(error).to.equal(expectedError); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-provider-id'); }); }); - }); - }); - describe('updateProviderConfig()', () => { - const oidcConfigOptions = { - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - let stubs: sinon.SinonStub[] = []; + it('should be rejected given an app which returns null access tokens', () => { + const providerId = 'oidc.provider'; + return (nullAccessTokenAuth as Auth).deleteProviderConfig(providerId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + it('should be rejected given an app which returns invalid access tokens', () => { + const providerId = 'oidc.provider'; + return (malformedAccessTokenAuth as Auth).deleteProviderConfig(providerId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given no provider ID', () => { - return (auth as any).updateProviderConfig(undefined, oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + const providerId = 'oidc.provider'; + return (rejectedPromiseAccessTokenAuth as Auth).deleteProviderConfig(providerId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should be rejected given an invalid provider ID', () => { - const invalidProviderId = ''; - return (auth as Auth).updateProviderConfig(invalidProviderId, oidcConfigOptions) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-provider-id'); + describe('using OIDC configurations', () => { + const providerId = 'oidc.provider'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + + it('should resolve with void on success', () => { + // Stub deleteOAuthIdpConfig to resolve. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteOAuthIdpConfig') + .resolves(); + stubs.push(stub); + return (auth as Auth).deleteProviderConfig(providerId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected result returned. + expect(result).to.be.undefined; + }); }); - }); - it('should be rejected given no options', () => { - const providerId = 'oidc.provider'; - return (auth as any).updateProviderConfig(providerId) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error: FirebaseAuthError) => { - expect(error).to.have.property('code', 'auth/invalid-config'); + it('should throw an error when the backend returns an error', () => { + // Stub deleteOAuthIdpConfig to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteOAuthIdpConfig') + .rejects(expectedError); + stubs.push(stub); + return (auth as Auth).deleteProviderConfig(providerId) + .then((config) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - const providerId = 'oidc.provider'; - return (nullAccessTokenAuth as Auth).updateProviderConfig(providerId, oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - const providerId = 'oidc.provider'; - return (malformedAccessTokenAuth as Auth).updateProviderConfig(providerId, oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + describe('using SAML configurations', () => { + const providerId = 'saml.provider'; + const serverResponse = {}; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.CONFIGURATION_NOT_FOUND); + + it('should resolve with void on success', () => { + // Stub deleteInboundSamlConfig to resolve. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteInboundSamlConfig') + .resolves(); + stubs.push(stub); + return (auth as Auth).deleteProviderConfig(providerId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected result returned. + expect(result).to.be.undefined; + }); + }); - it('should be rejected given an app which fails to generate access tokens', () => { - const providerId = 'oidc.provider'; - return (rejectedPromiseAccessTokenAuth as Auth).updateProviderConfig(providerId, oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + it('should throw an error when the backend returns an error', () => { + // Stub deleteInboundSamlConfig to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteInboundSamlConfig') + .rejects(expectedError); + stubs.push(stub); + return (auth as Auth).deleteProviderConfig(providerId) + .then((config) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(providerId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); }); - describe('using OIDC configurations', () => { - const providerId = 'oidc.provider'; - const configOptions = { + describe('updateProviderConfig()', () => { + const oidcConfigOptions = { displayName: 'OIDC_DISPLAY_NAME', enabled: true, clientId: 'CLIENT_ID', issuer: 'https://oidc.com/issuer', }; - const serverResponse = { - name: `projects/project_id/oauthIdpConfigs/${providerId}`, - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const expectedConfig = new OIDCConfig(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - - it('should resolve with an OIDCConfig on updateOAuthIdpConfig request success', () => { - // Stub updateOAuthIdpConfig to return expected server response. - const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateOAuthIdpConfig') - .resolves(serverResponse); - stubs.push(updateConfigStub); + let stubs: sinon.SinonStub[] = []; - return auth.updateProviderConfig(providerId, configOptions) - .then((actualConfig) => { - // Confirm underlying API called with expected parameters. - expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); - // Confirm expected config response returned. - expect(actualConfig).to.deep.equal(expectedConfig); - }); + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; }); - it('should throw an error when updateOAuthIdpConfig returns an error', () => { - // Stub updateOAuthIdpConfig to throw a backend error. - const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateOAuthIdpConfig') - .rejects(expectedError); - stubs.push(updateConfigStub); + it('should be rejected given no provider ID', () => { + return (auth as any).updateProviderConfig(undefined, oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); + }); - return auth.updateProviderConfig(providerId, configOptions) - .then((actualConfig) => { + it('should be rejected given an invalid provider ID', () => { + const invalidProviderId = ''; + return (auth as Auth).updateProviderConfig(invalidProviderId, oidcConfigOptions) + .then(() => { throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); - // Confirm expected error returned. - expect(error).to.equal(expectedError); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-provider-id'); }); }); - }); - - describe('using SAML configurations', () => { - const providerId = 'saml.provider'; - const configOptions = { - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID', - ssoURL: 'https://example.com/login', - x509Certificates: ['CERT1', 'CERT2'], - rpEntityId: 'RP_ENTITY_ID', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const serverResponse = { - name: `projects/project_id/inboundSamlConfigs/${providerId}`, - idpConfig: { - idpEntityId: 'IDP_ENTITY_ID', - ssoUrl: 'https://example.com/login', - signRequest: true, - idpCertificates: [ - {x509Certificate: 'CERT1'}, - {x509Certificate: 'CERT2'}, - ], - }, - spConfig: { - spEntityId: 'RP_ENTITY_ID', - callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', - }, - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - }; - const expectedConfig = new SAMLConfig(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - it('should resolve with a SAMLConfig on updateInboundSamlConfig request success', () => { - // Stub updateInboundSamlConfig to return expected server response. - const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateInboundSamlConfig') - .resolves(serverResponse); - stubs.push(updateConfigStub); - - return auth.updateProviderConfig(providerId, configOptions) - .then((actualConfig) => { - // Confirm underlying API called with expected parameters. - expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); - // Confirm expected config response returned. - expect(actualConfig).to.deep.equal(expectedConfig); + it('should be rejected given no options', () => { + const providerId = 'oidc.provider'; + return (auth as any).updateProviderConfig(providerId) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error: FirebaseAuthError) => { + expect(error).to.have.property('code', 'auth/invalid-config'); }); }); - it('should throw an error when updateInboundSamlConfig returns an error', () => { - // Stub updateInboundSamlConfig to throw a backend error. - const updateConfigStub = sinon.stub(AuthRequestHandler.prototype, 'updateInboundSamlConfig') - .rejects(expectedError); - stubs.push(updateConfigStub); + it('should be rejected given an app which returns null access tokens', () => { + const providerId = 'oidc.provider'; + return (nullAccessTokenAuth as Auth).updateProviderConfig(providerId, oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - return auth.updateProviderConfig(providerId, configOptions) - .then((actualConfig) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should be rejected given an app which returns invalid access tokens', () => { + const providerId = 'oidc.provider'; + return (malformedAccessTokenAuth as Auth).updateProviderConfig(providerId, oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - }); - }); - describe('createProviderConfig()', () => { - const oidcConfigOptions = { - providerId: 'oidc.provider', - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - let stubs: sinon.SinonStub[] = []; + it('should be rejected given an app which fails to generate access tokens', () => { + const providerId = 'oidc.provider'; + return (rejectedPromiseAccessTokenAuth as Auth).updateProviderConfig(providerId, oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); + describe('using OIDC configurations', () => { + const providerId = 'oidc.provider'; + const configOptions = { + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const serverResponse = { + name: `projects/project_id/oauthIdpConfigs/${providerId}`, + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const expectedConfig = new OIDCConfig(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + + it('should resolve with an OIDCConfig on updateOAuthIdpConfig request success', () => { + // Stub updateOAuthIdpConfig to return expected server response. + const updateConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateOAuthIdpConfig') + .resolves(serverResponse); + stubs.push(updateConfigStub); + + return auth.updateProviderConfig(providerId, configOptions) + .then((actualConfig) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); + // Confirm expected config response returned. + expect(actualConfig).to.deep.equal(expectedConfig); + }); + }); - it('should be rejected given no configuration options', () => { - return (auth as any).createProviderConfig() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-config'); - }); + it('should throw an error when updateOAuthIdpConfig returns an error', () => { + // Stub updateOAuthIdpConfig to throw a backend error. + const updateConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateOAuthIdpConfig') + .rejects(expectedError); + stubs.push(updateConfigStub); - it('should be rejected given an invalid provider ID', () => { - const invalidConfigOptions = deepCopy(oidcConfigOptions); - invalidConfigOptions.providerId = 'unsupported'; - return (auth as Auth).createProviderConfig(invalidConfigOptions) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-provider-id'); + return auth.updateProviderConfig(providerId, configOptions) + .then((actualConfig) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); }); - }); + }); - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).createProviderConfig(oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + describe('using SAML configurations', () => { + const providerId = 'saml.provider'; + const configOptions = { + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + idpEntityId: 'IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['CERT1', 'CERT2'], + rpEntityId: 'RP_ENTITY_ID', + callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', + enableRequestSigning: true, + }; + const serverResponse = { + name: `projects/project_id/inboundSamlConfigs/${providerId}`, + idpConfig: { + idpEntityId: 'IDP_ENTITY_ID', + ssoUrl: 'https://example.com/login', + signRequest: true, + idpCertificates: [ + {x509Certificate: 'CERT1'}, + {x509Certificate: 'CERT2'}, + ], + }, + spConfig: { + spEntityId: 'RP_ENTITY_ID', + callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', + }, + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + }; + const expectedConfig = new SAMLConfig(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + + it('should resolve with a SAMLConfig on updateInboundSamlConfig request success', () => { + // Stub updateInboundSamlConfig to return expected server response. + const updateConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateInboundSamlConfig') + .resolves(serverResponse); + stubs.push(updateConfigStub); + + return auth.updateProviderConfig(providerId, configOptions) + .then((actualConfig) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); + // Confirm expected config response returned. + expect(actualConfig).to.deep.equal(expectedConfig); + }); + }); - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).createProviderConfig(oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); + it('should throw an error when updateInboundSamlConfig returns an error', () => { + // Stub updateInboundSamlConfig to throw a backend error. + const updateConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateInboundSamlConfig') + .rejects(expectedError); + stubs.push(updateConfigStub); - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).createProviderConfig(oidcConfigOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + return auth.updateProviderConfig(providerId, configOptions) + .then((actualConfig) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateConfigStub).to.have.been.calledOnce.and.calledWith(providerId, configOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); }); - describe('using OIDC configurations', () => { - const providerId = 'oidc.provider'; - const configOptions = { - providerId, - displayName: 'OIDC_DISPLAY_NAME', - enabled: true, - clientId: 'CLIENT_ID', - issuer: 'https://oidc.com/issuer', - }; - const serverResponse = { - name: `projects/project_id/oauthIdpConfigs/${providerId}`, + describe('createProviderConfig()', () => { + const oidcConfigOptions = { + providerId: 'oidc.provider', displayName: 'OIDC_DISPLAY_NAME', enabled: true, clientId: 'CLIENT_ID', issuer: 'https://oidc.com/issuer', }; - const expectedConfig = new OIDCConfig(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); - - it('should resolve with an OIDCConfig on createOAuthIdpConfig request success', () => { - // Stub createOAuthIdpConfig to return expected server response. - const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createOAuthIdpConfig') - .resolves(serverResponse); - stubs.push(createConfigStub); + let stubs: sinon.SinonStub[] = []; - return (auth as Auth).createProviderConfig(configOptions) - .then((actualConfig) => { - // Confirm underlying API called with expected parameters. - expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); - // Confirm expected config response returned. - expect(actualConfig).to.deep.equal(expectedConfig); - }); + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; }); - it('should throw an error when createOAuthIdpConfig returns an error', () => { - // Stub createOAuthIdpConfig to throw a backend error. - const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createOAuthIdpConfig') - .rejects(expectedError); - stubs.push(createConfigStub); + it('should be rejected given no configuration options', () => { + return (auth as any).createProviderConfig() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-config'); + }); - return (auth as Auth).createProviderConfig(configOptions) - .then((actualConfig) => { + it('should be rejected given an invalid provider ID', () => { + const invalidConfigOptions = deepCopy(oidcConfigOptions); + invalidConfigOptions.providerId = 'unsupported'; + return (auth as Auth).createProviderConfig(invalidConfigOptions) + .then(() => { throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); - // Confirm expected error returned. - expect(error).to.equal(expectedError); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-provider-id'); }); }); - }); - describe('using SAML configurations', () => { - const providerId = 'saml.provider'; - const configOptions = { - providerId, - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID', - ssoURL: 'https://example.com/login', - x509Certificates: ['CERT1', 'CERT2'], - rpEntityId: 'RP_ENTITY_ID', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const serverResponse = { - name: `projects/project_id/inboundSamlConfigs/${providerId}`, - idpConfig: { - idpEntityId: 'IDP_ENTITY_ID', - ssoUrl: 'https://example.com/login', - signRequest: true, - idpCertificates: [ - {x509Certificate: 'CERT1'}, - {x509Certificate: 'CERT2'}, - ], - }, - spConfig: { - spEntityId: 'RP_ENTITY_ID', - callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', - }, - displayName: 'SAML_DISPLAY_NAME', - enabled: true, - }; - const expectedConfig = new SAMLConfig(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).createProviderConfig(oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - it('should resolve with a SAMLConfig on createInboundSamlConfig request success', () => { - // Stub createInboundSamlConfig to return expected server response. - const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createInboundSamlConfig') - .resolves(serverResponse); - stubs.push(createConfigStub); + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).createProviderConfig(oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); - return (auth as Auth).createProviderConfig(configOptions) - .then((actualConfig) => { - // Confirm underlying API called with expected parameters. - expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); - // Confirm expected config response returned. - expect(actualConfig).to.deep.equal(expectedConfig); - }); + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).createProviderConfig(oidcConfigOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); - it('should throw an error when createInboundSamlConfig returns an error', () => { - // Stub createInboundSamlConfig to throw a backend error. - const createConfigStub = sinon.stub(AuthRequestHandler.prototype, 'createInboundSamlConfig') - .rejects(expectedError); - stubs.push(createConfigStub); + describe('using OIDC configurations', () => { + const providerId = 'oidc.provider'; + const configOptions = { + providerId, + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const serverResponse = { + name: `projects/project_id/oauthIdpConfigs/${providerId}`, + displayName: 'OIDC_DISPLAY_NAME', + enabled: true, + clientId: 'CLIENT_ID', + issuer: 'https://oidc.com/issuer', + }; + const expectedConfig = new OIDCConfig(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + + it('should resolve with an OIDCConfig on createOAuthIdpConfig request success', () => { + // Stub createOAuthIdpConfig to return expected server response. + const createConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'createOAuthIdpConfig') + .resolves(serverResponse); + stubs.push(createConfigStub); + + return (auth as Auth).createProviderConfig(configOptions) + .then((actualConfig) => { + // Confirm underlying API called with expected parameters. + expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); + // Confirm expected config response returned. + expect(actualConfig).to.deep.equal(expectedConfig); + }); + }); - return (auth as Auth).createProviderConfig(configOptions) - .then((actualConfig) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); + it('should throw an error when createOAuthIdpConfig returns an error', () => { + // Stub createOAuthIdpConfig to throw a backend error. + const createConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'createOAuthIdpConfig') + .rejects(expectedError); + stubs.push(createConfigStub); + + return (auth as Auth).createProviderConfig(configOptions) + .then((actualConfig) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); }); - }); - }); - describe('INTERNAL.delete()', () => { - it('should delete Auth instance', () => { - auth.INTERNAL.delete().should.eventually.be.fulfilled; + describe('using SAML configurations', () => { + const providerId = 'saml.provider'; + const configOptions = { + providerId, + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + idpEntityId: 'IDP_ENTITY_ID', + ssoURL: 'https://example.com/login', + x509Certificates: ['CERT1', 'CERT2'], + rpEntityId: 'RP_ENTITY_ID', + callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', + enableRequestSigning: true, + }; + const serverResponse = { + name: `projects/project_id/inboundSamlConfigs/${providerId}`, + idpConfig: { + idpEntityId: 'IDP_ENTITY_ID', + ssoUrl: 'https://example.com/login', + signRequest: true, + idpCertificates: [ + {x509Certificate: 'CERT1'}, + {x509Certificate: 'CERT2'}, + ], + }, + spConfig: { + spEntityId: 'RP_ENTITY_ID', + callbackUri: 'https://projectId.firebaseapp.com/__/auth/handler', + }, + displayName: 'SAML_DISPLAY_NAME', + enabled: true, + }; + const expectedConfig = new SAMLConfig(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CONFIG); + + it('should resolve with a SAMLConfig on createInboundSamlConfig request success', () => { + // Stub createInboundSamlConfig to return expected server response. + const createConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'createInboundSamlConfig') + .resolves(serverResponse); + stubs.push(createConfigStub); + + return (auth as Auth).createProviderConfig(configOptions) + .then((actualConfig) => { + // Confirm underlying API called with expected parameters. + expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); + // Confirm expected config response returned. + expect(actualConfig).to.deep.equal(expectedConfig); + }); + }); + + it('should throw an error when createInboundSamlConfig returns an error', () => { + // Stub createInboundSamlConfig to throw a backend error. + const createConfigStub = sinon.stub(testConfig.RequestHandler.prototype, 'createInboundSamlConfig') + .rejects(expectedError); + stubs.push(createConfigStub); + + return (auth as Auth).createProviderConfig(configOptions) + .then((actualConfig) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createConfigStub).to.have.been.calledOnce.and.calledWith(configOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); }); + + if (testConfig.Auth === Auth) { + describe('INTERNAL.delete()', () => { + it('should delete Auth instance', () => { + (auth as Auth).INTERNAL.delete().should.eventually.be.fulfilled; + }); + }); + } }); }); From 5a36d396fb06a9ec0f39a55cbc5661c6be0368d0 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 12 Jun 2019 11:54:38 -0700 Subject: [PATCH 04/14] Defines tenant management API on AuthRequestHandler. (#559) * Defines tenant management API on AuthRequestHandler. * Adds unit test for confirming an error is thrown on createTenant request with no type. * Address review comments. --- src/auth/auth-api-request.ts | 180 +++++++- test/unit/auth/auth-api-request.spec.ts | 553 ++++++++++++++++++++++++ 2 files changed, 732 insertions(+), 1 deletion(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 8cf8402a15..1921972bbe 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -34,6 +34,7 @@ import { OIDCConfigServerRequest, SAMLConfigServerRequest, AuthProviderConfig, OIDCUpdateAuthProviderRequest, SAMLUpdateAuthProviderRequest, } from './auth-config'; +import {Tenant, TenantOptions, TenantServerResponse} from './tenant'; /** Firebase Auth backend host. */ @@ -87,6 +88,9 @@ const FIREBASE_AUTH_BASE_URL_FORMAT = const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace( 'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}'); +/** Maximum allowed number of tenants to download at one time. */ +const MAX_LIST_TENANT_PAGE_SIZE = 1000; + /** Defines a base utility to help with resource URL construction. */ class AuthResourceUrlBuilder { @@ -1406,6 +1410,72 @@ export abstract class AbstractAuthRequestHandler { } +/** Instantiates the getTenant endpoint settings. */ +const GET_TENANT = new ApiSettings('/tenants/{tenantId}', 'GET') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain at least the tenant name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get tenant', + ); + } + }); + +/** Instantiates the deleteTenant endpoint settings. */ +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. + .setResponseValidator((response: any) => { + // Response should always contain at least the tenant name. + if (!validator.isNonEmptyString(response.name) || + !Tenant.getTenantIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update tenant', + ); + } + }); + +/** Instantiates the listTenants endpoint settings. */ +const LIST_TENANTS = new ApiSettings('/tenants', 'GET') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate next page token. + if (typeof request.pageToken !== 'undefined' && + !validator.isNonEmptyString(request.pageToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); + } + // Validate max results. + if (!validator.isNumber(request.pageSize) || + request.pageSize <= 0 || + request.pageSize > MAX_LIST_TENANT_PAGE_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Required "maxResults" must be a positive non-zero number that does not exceed ` + + `the allowed ${MAX_LIST_TENANT_PAGE_SIZE}.`, + ); + } + }); + +/** Instantiates the createTenant endpoint settings. */ +const CREATE_TENANT = new ApiSettings('/tenants', 'POST') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain at least the tenant name. + if (!validator.isNonEmptyString(response.name) || + !Tenant.getTenantIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new tenant', + ); + } + }); + + /** * Utility for sending requests to Auth server that are Auth instance related. This includes user and * tenant management related APIs. This extends the BaseFirebaseAuthRequestHandler class and defines @@ -1440,7 +1510,115 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { return new AuthResourceUrlBuilder(this.projectId, 'v2beta1'); } - // TODO: add tenant management APIs. + /** + * Looks up a tenant by tenant ID. + * + * @param {string} tenantId The tenant identifier of the tenant to lookup. + * @return {Promise} A promise that resolves with the tenant information. + */ + public getTenant(tenantId: string): Promise { + if (!validator.isNonEmptyString(tenantId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); + } + return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, GET_TENANT, {}, {tenantId}) + .then((response: any) => { + return response as TenantServerResponse; + }); + } + + /** + * Exports the tenants (single batch only) with a size of maxResults and starting from + * the offset as specified by pageToken. + * + * @param {number=} maxResults The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param {string=} pageToken The next page token. If not specified, returns tenants starting + * without any offset. Tenants are returned in the order they were created from oldest to + * newest, relative to the page token offset. + * @return {Promise} A promise that resolves with the current batch of downloaded + * tenants and the next page token if available. For the last page, an empty list of tenants + * and no page token are returned. + */ + public listTenants( + maxResults: number = MAX_LIST_TENANT_PAGE_SIZE, + pageToken?: string): Promise<{tenants: TenantServerResponse[], nextPageToken?: string}> { + const request = { + pageSize: maxResults, + pageToken, + }; + // Remove next page token if not provided. + if (typeof request.pageToken === 'undefined') { + delete request.pageToken; + } + return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, LIST_TENANTS, request) + .then((response: any) => { + if (!response.tenants) { + response.tenants = []; + delete response.nextPageToken; + } + return response as {tenants: TenantServerResponse[], nextPageToken?: string}; + }); + } + + /** + * Deletes a tenant identified by a tenantId. + * + * @param {string} tenantId The identifier of the tenant to delete. + * @return {Promise} A promise that resolves when the tenant is deleted. + */ + public deleteTenant(tenantId: string): Promise { + if (!validator.isNonEmptyString(tenantId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); + } + return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, DELETE_TENANT, {}, {tenantId}) + .then((response: any) => { + // Return nothing. + }); + } + + /** + * Creates a new tenant with the properties provided. + * + * @param {TenantOptions} tenantOptions The properties to set on the new tenant to be created. + * @return {Promise} A promise that resolves with the newly created tenant object. + */ + public createTenant(tenantOptions: TenantOptions): Promise { + try { + // Construct backend request. + const request = Tenant.buildServerRequest(tenantOptions, true); + return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, CREATE_TENANT, request) + .then((response: any) => { + return response as TenantServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Updates an existing tenant with the properties provided. + * + * @param {string} tenantId The tenant identifier of the tenant to update. + * @param {TenantOptions} tenantOptions The properties to update on the existing tenant. + * @return {Promise} A promise that resolves with the modified tenant object. + */ + public updateTenant(tenantId: string, tenantOptions: TenantOptions): Promise { + if (!validator.isNonEmptyString(tenantId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); + } + try { + // Construct backend request. + const request = Tenant.buildServerRequest(tenantOptions, false); + const updateMask = utils.generateUpdateMask(request); + return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request, + {tenantId, updateMask: updateMask.join(',')}) + .then((response: any) => { + return response as TenantServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } } /** diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 2da67fa62f..0aabd2fd07 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -43,6 +43,7 @@ import { OIDCAuthProviderConfig, SAMLAuthProviderConfig, OIDCUpdateAuthProviderRequest, SAMLUpdateAuthProviderRequest, SAMLConfigServerResponse, } from '../../../src/auth/auth-config'; +import {TenantOptions} from '../../../src/auth/tenant'; chai.should(); chai.use(sinonChai); @@ -3549,6 +3550,558 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + if (handler.supportsTenantManagement) { + describe('getTenant', () => { + const path = '/v2beta1/projects/project_id/tenants/tenant_id'; + const method = 'GET'; + const tenantId = 'tenant_id'; + const expectedResult = utils.responseFrom({ + name: 'projects/project_id/tenants/tenant_id', + }); + + it('should be fulfilled given a valid tenant ID', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.getTenant(tenantId) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, {})); + }); + }); + + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.getTenant(invalidTenantId as any) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + }); + + it('should be rejected given a backend error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + const expectedServerError = utils.errorFrom({ + error: { + message: 'TENANT_NOT_FOUND', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.getTenant(tenantId) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, {})); + }); + }); + }); + + describe('listTenants', () => { + const path = '/v2beta1/projects/project_id/tenants'; + const method = 'GET'; + const nextPageToken = 'PAGE_TOKEN'; + const maxResults = 500; + const expectedResult = utils.responseFrom({ + tenants : [ + {name: 'projects/project_id/tenants/tenant_id1'}, + {name: 'projects/project_id/tenants/tenant_id2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }); + + it('should be fulfilled given valid parameters', () => { + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.listTenants(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + + it('should be fulfilled with empty tenant array when no tenants exist', () => { + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.listTenants(maxResults, nextPageToken) + .then((result) => { + expect(result).to.deep.equal({tenants: []}); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + + it('should be fulfilled given no parameters', () => { + // Default maxResults should be used. + const data = { + pageSize: 1000, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.listTenants() + .then((result) => { + expect(result).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + + it('should be rejected given an invalid maxResults', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Required "maxResults" must be a positive non-zero number that does not ` + + `exceed the allowed 1000.`, + ); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.listTenants(1001, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected given an invalid next page token', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_PAGE_TOKEN, + ); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.listTenants(maxResults, '') + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INVALID_PAGE_SELECTION', + }, + }); + const expectedError = FirebaseAuthError.fromServerError('INVALID_PAGE_SELECTION'); + const data = { + pageSize: maxResults, + pageToken: nextPageToken, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.listTenants(maxResults, nextPageToken) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, data)); + }); + }); + }); + + describe('deleteTenant', () => { + const path = '/v2beta1/projects/project_id/tenants/tenant_id'; + const method = 'DELETE'; + const tenantId = 'tenant_id'; + const expectedResult = utils.responseFrom({}); + + it('should be fulfilled given a valid tenant ID', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.deleteTenant(tenantId) + .then((result) => { + expect(result).to.be.undefined; + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, {})); + }); + }); + + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.deleteTenant(invalidTenantId as any) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + }); + + it('should be rejected given a backend error', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + const expectedServerError = utils.errorFrom({ + error: { + message: 'TENANT_NOT_FOUND', + }, + }); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.deleteTenant(tenantId) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, {})); + }); + }); + }); + + describe('createTenant', () => { + const path = '/v2beta1/projects/project_id/tenants'; + const postMethod = 'POST'; + const tenantId = 'tenant_id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + type: 'lightweight', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const expectedRequest = { + displayName: 'TENANT_DISPLAY_NAME', + type: 'LIGHTWEIGHT', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + const expectedResult = utils.responseFrom(deepExtend({ + name: 'projects/project_id/tenants/tenant_id', + }, expectedRequest)); + + it('should be fulfilled given valid parameters', () => { + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.createTenant(tenantOptions) + .then((actualResult) => { + expect(actualResult).to.be.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, postMethod, expectedRequest)); + }); + }); + + it('should be rejected given valid parameters with no type', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"CreateTenantRequest.type" must be either "full_service" or "lightweight".', + ); + // Initialize CreateTenantRequest with missing type. + const invalidOptions = deepCopy(tenantOptions); + delete invalidOptions.type; + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.createTenant(invalidOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected given invalid parameters', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig" must be a non-null object.', + ); + const invalidOptions = deepCopy(tenantOptions); + invalidOptions.emailSignInConfig = 'invalid' as any; + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.createTenant(invalidOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns a response missing name', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new tenant', + ); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.createTenant(tenantOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, postMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns a response missing tenant ID in response name', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new tenant', + ); + // Resource name should have /tenants/tenant_id in path. This should throw an error. + const stub = sinon.stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({name: 'projects/project_id'})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.createTenant(tenantOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, postMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedServerError = utils.errorFrom({ + error: { + message: 'INTERNAL_ERROR', + }, + }); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + `An internal error has occurred. Raw server response: ` + + `"${JSON.stringify(expectedServerError.response.data)}"`, + ); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.createTenant(tenantOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, postMethod, expectedRequest)); + }); + }); + }); + + describe('updateTenant', () => { + const path = '/v2beta1/projects/project_id/tenants/tenant_id'; + const patchMethod = 'PATCH'; + const tenantId = 'tenant_id'; + const tenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const expectedRequest = { + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + 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 stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, tenantOptions) + .then((actualResult) => { + expect(actualResult).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, patchMethod, expectedRequest)); + }); + }); + + it('should be fulfilled given partial parameters', () => { + const expectedPath = path + '?updateMask=allowPasswordSignup'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + const partialRequest = { + allowPasswordSignup: true, + }; + const partialTenantOptions = { + emailSignInConfig: {enabled: true}, + }; + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, partialTenantOptions) + .then((actualResult) => { + expect(actualResult).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, patchMethod, partialRequest)); + }); + }); + + it('should be fulfilled given a single parameter to change', () => { + const expectedPath = path + '?updateMask=displayName'; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + const partialRequest = { + displayName: 'TENANT_DISPLAY_NAME', + }; + const partialTenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + }; + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, partialTenantOptions) + .then((actualResult) => { + expect(actualResult).to.deep.equal(expectedResult.data); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, patchMethod, partialRequest)); + }); + }); + + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(invalidTenantId as any, tenantOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + }); + + it('should be rejected given invalid parameters', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig" must be a non-null object.', + ); + const invalidOptions = deepCopy(tenantOptions); + invalidOptions.emailSignInConfig = 'invalid' as any; + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, invalidOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected given an unmodifiable property', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"Tenant.type" is an immutable property.', + ); + const invalidOptions = deepCopy(tenantOptions); + (invalidOptions as TenantOptions).type = 'full_service'; + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, invalidOptions) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + + it('should be rejected when the backend returns a response missing name', () => { + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update tenant', + ); + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(utils.responseFrom({})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, tenantOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, patchMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns a response missing tenant ID in response name', () => { + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update tenant', + ); + // Resource name should have /tenants/tenant_id in path. This should throw an error. + const stub = sinon.stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({name: 'projects/project_id'})); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, tenantOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, patchMethod, expectedRequest)); + }); + }); + + it('should be rejected when the backend returns an error', () => { + const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; + const expectedServerError = utils.errorFrom({ + error: { + message: 'INTERNAL_ERROR', + }, + }); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + `An internal error has occurred. Raw server response: ` + + `"${JSON.stringify(expectedServerError.response.data)}"`, + ); + const stub = sinon.stub(HttpClient.prototype, 'send').rejects(expectedServerError); + stubs.push(stub); + + const requestHandler = handler.init(mockApp) as AuthRequestHandler; + return requestHandler.updateTenant(tenantId, tenantOptions) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + callParams(expectedPath, patchMethod, expectedRequest)); + }); + }); + }); + } + describe('non-2xx responses', () => { it('should be rejected given a simulated non-2xx response with a known error code', () => { const mockErrorResponse = utils.errorFrom({ From 5aa4dc09efb42eee25aac72d5fc97a1940bc74b0 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Fri, 14 Jun 2019 10:35:52 -0700 Subject: [PATCH 05/14] Adds tenant management APIs to developer facing Auth instance. (#567) * Adds tenant management APIs to developer facing Auth instance. This includes getTenant, deleteTenant, listTenants, createTenant and updateTenant. This expects TenantServerResponse to be returned for createTenant and updateTenant. Expected results have not been confirmed. A followup PR will add integration tests for the above. * Addresses review comments. Adds missing backend errors. * Addresses review comments. --- src/auth/auth.ts | 100 ++++++- src/utils/error.ts | 24 ++ test/unit/auth/auth.spec.ts | 516 +++++++++++++++++++++++++++++++++++- 3 files changed, 628 insertions(+), 12 deletions(-) diff --git a/src/auth/auth.ts b/src/auth/auth.ts index d4abac9c62..37d67a9b16 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -34,7 +34,7 @@ import { AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, } from './auth-config'; -import {deepCopy, deepExtend} from '../utils/deep-copy'; +import {Tenant, TenantOptions, ListTenantsResult, TenantServerResponse} from './tenant'; /** @@ -89,7 +89,7 @@ export interface SessionCookieOptions { /** * Base Auth class. Mainly used for user management APIs. */ -export class BaseAuth { +export class BaseAuth { protected readonly tokenGenerator: FirebaseTokenGenerator; protected readonly idTokenVerifier: FirebaseTokenVerifier; protected readonly sessionCookieVerifier: FirebaseTokenVerifier; @@ -98,14 +98,14 @@ export class BaseAuth { * The BaseAuth class constructor. * * @param {string} projectId The corresponding project ID. - * @param {AbstractAuthRequestHandler} authRequestHandler The RPC request handler + * @param {T} authRequestHandler The RPC request handler * for this instance. * @param {CryptoSigner} cryptoSigner The instance crypto signer used for custom token * minting. * @constructor */ constructor(protected readonly projectId: string, - protected readonly authRequestHandler: AbstractAuthRequestHandler, + protected readonly authRequestHandler: T, cryptoSigner: CryptoSigner) { this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); this.sessionCookieVerifier = createSessionCookieVerifier(projectId); @@ -606,7 +606,7 @@ export class BaseAuth { /** * The tenant aware Auth class. */ -export class TenantAwareAuth extends BaseAuth { +export class TenantAwareAuth extends BaseAuth { public readonly tenantId: string; /** @@ -720,7 +720,7 @@ export class TenantAwareAuth extends BaseAuth { * Auth service bound to the provided app. * An Auth instance can have multiple tenants. */ -export class Auth extends BaseAuth implements FirebaseServiceInterface { +export class Auth extends BaseAuth implements FirebaseServiceInterface { public INTERNAL: AuthInternals = new AuthInternals(); private readonly tenantsMap: {[key: string]: TenantAwareAuth}; private readonly app_: FirebaseApp; @@ -778,4 +778,92 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface { } return this.tenantsMap[tenantId]; } + + /** + * Looks up the tenant identified by the provided tenant ID and returns a promise that is + * fulfilled with the corresponding tenant if it is found. + * + * @param {string} tenantId The tenant ID of the tenant to look up. + * @return {Promise} A promise that resolves with the corresponding tenant. + */ + public getTenant(tenantId: string): Promise { + return this.authRequestHandler.getTenant(tenantId) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } + + /** + * Exports a batch of tenant accounts. Batch size is determined by the maxResults argument. + * Starting point of the batch is determined by the pageToken argument. + * + * @param {number=} maxResults The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param {string=} pageToken The next page token. If not specified, returns users starting + * without any offset. + * @return {Promise<{users: Tenant[], pageToken?: string}>} A promise that resolves with + * the current batch of downloaded tenants and the next page token. For the last page, an + * empty list of tenants and no page token are returned. + */ + public listTenants( + maxResults?: number, + pageToken?: string): Promise { + return this.authRequestHandler.listTenants(maxResults, pageToken) + .then((response: {tenants: TenantServerResponse[], nextPageToken?: string}) => { + // List of tenants to return. + const tenants: Tenant[] = []; + // Convert each user response to a Tenant. + response.tenants.forEach((tenantResponse: TenantServerResponse) => { + tenants.push(new Tenant(tenantResponse)); + }); + // Return list of tenants and the next page token if available. + const result = { + tenants, + pageToken: response.nextPageToken, + }; + // Delete result.pageToken if undefined. + if (typeof result.pageToken === 'undefined') { + delete result.pageToken; + } + return result; + }); + } + + /** + * Deletes the tenant identified by the provided tenant ID and returns a promise that is + * fulfilled when the tenant is found and successfully deleted. + * + * @param {string} tenantId The tenant ID of the tenant to delete. + * @return {Promise} A promise that resolves when the tenant is successfully deleted. + */ + public deleteTenant(tenantId: string): Promise { + return this.authRequestHandler.deleteTenant(tenantId); + } + + /** + * Creates a new tenant with the properties provided. + * + * @param {TenantOptions} tenantOptions The properties to set on the new tenant to be created. + * @return {Promise} A promise that resolves with the newly created tenant. + */ + public createTenant(tenantOptions: TenantOptions): Promise { + return this.authRequestHandler.createTenant(tenantOptions) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } + + /** + * Updates an existing tenant identified by the tenant ID with the properties provided. + * + * @param {string} tenantId The tenant identifier of the tenant to update. + * @param {TenantOptions} tenantOptions The properties to update on the existing tenant. + * @return {Promise} A promise that resolves with the modified tenant. + */ + public updateTenant(tenantId: string, tenantOptions: TenantOptions): Promise { + return this.authRequestHandler.updateTenant(tenantId, tenantOptions) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 1846fec223..ab24678273 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -457,6 +457,10 @@ export class AuthClientErrorCode { code: 'invalid-last-sign-in-time', message: 'The last sign-in time must be a valid UTC date string.', }; + public static INVALID_NAME = { + code: 'invalid-name', + message: 'The resource name provided is invalid.', + }; public static INVALID_OAUTH_CLIENT_ID = { code: 'invalid-oauth-client-id', message: 'The provided OAuth client ID is invalid.', @@ -486,6 +490,10 @@ export class AuthClientErrorCode { code: 'invalid-photo-url', message: 'The photoURL field must be a valid URL.', }; + public static INVALID_PROJECT_ID = { + code: 'invalid-project-id', + message: 'Invalid parent project. Either parent project doesn\'t exist or didn\'t enable multi-tenancy.', + }; public static INVALID_PROVIDER_DATA = { code: 'invalid-provider-data', message: 'The providerData must be a valid array of UserInfo objects.', @@ -503,6 +511,10 @@ export class AuthClientErrorCode { code: 'invalid-tenant-id', message: 'The tenant ID must be a valid non-empty string.', }; + public static INVALID_TENANT_TYPE = { + code: 'invalid-tenant-type', + message: 'Tenant type must be either "full_service" or "lightweight".', + }; public static INVALID_UID = { code: 'invalid-uid', message: 'The uid must be a non-empty string with at most 128 characters.', @@ -590,6 +602,10 @@ export class AuthClientErrorCode { 'https://firebase.google.com/docs/admin/setup for details on how to authenticate this SDK ' + 'with appropriate permissions.', }; + public static QUOTA_EXCEEDED = { + code: 'quota-exceeded', + message: 'The project quota for the specified operation has been exceeded.', + }; public static SESSION_COOKIE_EXPIRED = { code: 'session-cookie-expired', message: 'The Firebase session cookie is expired.', @@ -787,16 +803,22 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME', // Invalid ID token provided. INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', + // Invalid tenant/parent resource name. + INVALID_NAME: 'INVALID_NAME', // OIDC configuration has an invalid OAuth client ID. INVALID_OAUTH_CLIENT_ID: 'INVALID_OAUTH_CLIENT_ID', // Invalid page token. INVALID_PAGE_SELECTION: 'INVALID_PAGE_TOKEN', // Invalid phone number. INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER', + // Invalid agent project. Either agent project doesn't exist or didn't enable multi-tenancy. + INVALID_PROJECT_ID: 'INVALID_PROJECT_ID', // Invalid provider ID. INVALID_PROVIDER_ID: 'INVALID_PROVIDER_ID', // Invalid service account. INVALID_SERVICE_ACCOUNT: 'INVALID_SERVICE_ACCOUNT', + // Invalid tenant type. + INVALID_TENANT_TYPE: 'INVALID_TENANT_TYPE', // Missing Android package name. MISSING_ANDROID_PACKAGE_NAME: 'MISSING_ANDROID_PACKAGE_NAME', // Missing configuration. @@ -827,6 +849,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS', // Project not found. PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + // In multi-tenancy context: project creation quota exceeded. + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', // Tenant not found. TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', // Tenant ID mismatch. diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index f27c82f4c9..bebfa904a7 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -41,6 +41,7 @@ import { OIDCConfigServerResponse, SAMLConfigServerResponse, } from '../../../src/auth/auth-config'; import {deepCopy} from '../../../src/utils/deep-copy'; +import {Tenant, TenantOptions, TenantServerResponse, ListTenantsResult} from '../../../src/auth/tenant'; chai.should(); chai.use(sinonChai); @@ -52,9 +53,9 @@ const expect = chai.expect; interface AuthTest { name: string; supportsTenantManagement: boolean; - Auth: new (...args: any[]) => BaseAuth; + Auth: new (...args: any[]) => BaseAuth; RequestHandler: new (...args: any[]) => AbstractAuthRequestHandler; - init(app: FirebaseApp): BaseAuth; + init(app: FirebaseApp): BaseAuth; } @@ -242,13 +243,13 @@ const AUTH_CONFIGS: AuthTest[] = [ ]; AUTH_CONFIGS.forEach((testConfig) => { describe(testConfig.name, () => { - let auth: BaseAuth; + let auth: BaseAuth; let mockApp: FirebaseApp; let getTokenStub: sinon.SinonStub; let oldProcessEnv: NodeJS.ProcessEnv; - let nullAccessTokenAuth: BaseAuth; - let malformedAccessTokenAuth: BaseAuth; - let rejectedPromiseAccessTokenAuth: BaseAuth; + let nullAccessTokenAuth: BaseAuth; + let malformedAccessTokenAuth: BaseAuth; + let rejectedPromiseAccessTokenAuth: BaseAuth; beforeEach(() => { mockApp = mocks.app(); @@ -3052,6 +3053,509 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); + if (testConfig.supportsTenantManagement) { + describe('getTenant()', () => { + const tenantId = 'tenant_id'; + const serverResponse: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + type: 'FULL_SERVICE', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + const expectedTenant = new Tenant(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (auth as any).getTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return (auth as Auth).getTenant(invalidTenantId) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on success', () => { + // Stub getTenant to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getTenant') + .returns(Promise.resolve(serverResponse)); + stubs.push(stub); + return (auth as Auth).getTenant(tenantId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected tenant returned. + expect(result).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getTenant to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getTenant') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return (auth as Auth).getTenant(tenantId) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('listTenants()', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const pageToken = 'PAGE_TOKEN'; + const maxResult = 500; + const listTenantsResponse: any = { + tenants : [ + {name: 'projects/project_id/tenants/tenant_id1'}, + {name: 'projects/project_id/tenants/tenant_id2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }; + const expectedResult: ListTenantsResult = { + tenants: [ + new Tenant({name: 'projects/project_id/tenants/tenant_id1'}), + new Tenant({name: 'projects/project_id/tenants/tenant_id2'}), + ], + pageToken: 'NEXT_PAGE_TOKEN', + }; + const emptyListTenantsResponse: any = { + tenants: [], + }; + const emptyExpectedResult: any = { + tenants: [], + }; + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given an invalid page token', () => { + const invalidToken = {}; + return (auth as Auth).listTenants(undefined, invalidToken as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-page-token'); + }); + }); + + it('should be rejected given an invalid max result', () => { + const invalidResults = 5000; + return (auth as Auth).listTenants(invalidResults) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve on listTenants request success with tenants in response', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(listTenantsResponse)); + stubs.push(listTenantsStub); + return (auth as Auth).listTenants(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); + + it('should resolve on listTenants request success with default options', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(listTenantsResponse)); + stubs.push(listTenantsStub); + return (auth as Auth).listTenants() + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(undefined, undefined); + }); + }); + + it('should resolve on listTenants request success with no tenants in response', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(emptyListTenantsResponse)); + stubs.push(listTenantsStub); + return (auth as Auth).listTenants(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(emptyExpectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); + + it('should throw an error when listTenants returns an error', () => { + // Stub listTenants to throw a backend error. + const listTenantsStub = sinon + .stub(testConfig.RequestHandler.prototype, 'listTenants') + .returns(Promise.reject(expectedError)); + stubs.push(listTenantsStub); + return (auth as Auth).listTenants(maxResult, pageToken) + .then((results) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('deleteTenant()', () => { + const tenantId = 'tenant_id'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (auth as any).deleteTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return (auth as Auth).deleteTenant(invalidTenantId) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with void on success', () => { + // Stub deleteTenant to return expected result. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteTenant') + .returns(Promise.resolve()); + stubs.push(stub); + return (auth as Auth).deleteTenant(tenantId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected result is undefined. + expect(result).to.be.undefined; + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub deleteTenant to throw a backend error. + const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteTenant') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return (auth as Auth).deleteTenant(tenantId) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('createTenant()', () => { + const tenantId = 'tenant_id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + type: 'lightweight', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const serverResponse: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + type: 'LIGHTWEIGHT', + }; + const expectedTenant = new Tenant(serverResponse); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the tenant provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no properties', () => { + return (auth as any).createTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given invalid TenantOptions', () => { + return (auth as Auth).createTenant(null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given TenantOptions with invalid type property', () => { + // Create tenant using invalid type. This should throw an argument error. + return (auth as Auth).createTenant({type: 'invalid'} as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on createTenant request success', () => { + // Stub createTenant to return expected result. + const createTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'createTenant') + .returns(Promise.resolve(serverResponse)); + stubs.push(createTenantStub); + return (auth as Auth).createTenant(tenantOptions) + .then((actualTenant) => { + // Confirm underlying API called with expected parameters. + expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); + // Confirm expected Tenant object returned. + expect(actualTenant).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when createTenant returns an error', () => { + // Stub createTenant to throw a backend error. + const createTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'createTenant') + .returns(Promise.reject(expectedError)); + stubs.push(createTenantStub); + return (auth as Auth).createTenant(tenantOptions) + .then((actualTenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('updateTenant()', () => { + const tenantId = 'tenant_id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const serverResponse: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + type: 'FULL_SERVICE', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + const expectedTenant = new Tenant(serverResponse); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to update the tenant provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (auth as any).updateTenant(undefined, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return (auth as Auth).updateTenant(invalidTenantId, tenantOptions) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given no TenantOptions', () => { + return (auth as any).updateTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given invalid TenantOptions', () => { + return (auth as Auth).updateTenant(tenantId, null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given TenantOptions with invalid update property', () => { + // Updating the type of an existing tenant will throw an error as type is + // an immutable property. + return (auth as Auth).updateTenant(tenantId, {type: 'lightweight'}) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return (nullAccessTokenAuth as Auth).updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return (malformedAccessTokenAuth as Auth).updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return (rejectedPromiseAccessTokenAuth as Auth).updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on updateTenant request success', () => { + // Stub updateTenant to return expected result. + const updateTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateTenant') + .returns(Promise.resolve(serverResponse)); + stubs.push(updateTenantStub); + return (auth as Auth).updateTenant(tenantId, tenantOptions) + .then((actualTenant) => { + // Confirm underlying API called with expected parameters. + expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); + // Confirm expected Tenant object returned. + expect(actualTenant).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when updateTenant returns an error', () => { + // Stub updateTenant to throw a backend error. + const updateTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateTenant') + .returns(Promise.reject(expectedError)); + stubs.push(updateTenantStub); + return (auth as Auth).updateTenant(tenantId, tenantOptions) + .then((actualTenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + } + if (testConfig.Auth === Auth) { describe('INTERNAL.delete()', () => { it('should delete Auth instance', () => { From 99eef4a608a1ab902fb678d96f3532fad2c428b5 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 17 Jun 2019 17:11:41 -0700 Subject: [PATCH 06/14] Adds integration tests for tenant management APIs. (#570) * Adds integration tests for tenant management APIs. These tests are skipped by default as multi-tenancy is a paid feature on Google Cloud Identity Platform. To run these tests, --testMultiTenancy flag has to be added. Adds default email provider config when backend Auth server returns undefined for emailSignInConfig. * Updates listTenants integration test. --- src/auth/tenant.ts | 9 +- src/index.d.ts | 1 - test/integration/auth.spec.ts | 157 +++++++++++++++++++++++++++++++++- test/unit/auth/tenant.spec.ts | 15 ++++ 4 files changed, 176 insertions(+), 6 deletions(-) mode change 100644 => 100755 src/auth/tenant.ts mode change 100644 => 100755 test/integration/auth.spec.ts mode change 100644 => 100755 test/unit/auth/tenant.spec.ts diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts old mode 100644 new mode 100755 index 6d08d9cafb..2b14487e7a --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -45,8 +45,8 @@ export interface TenantServerResponse { name: string; type?: TenantServerType; displayName?: string; - allowPasswordSignup: boolean; - enableEmailLinkSignin: boolean; + allowPasswordSignup?: boolean; + enableEmailLinkSignin?: boolean; } /** The interface representing the listTenant API response. */ @@ -181,7 +181,10 @@ export class Tenant { try { this.emailSignInConfig = new EmailSignInConfig(response); } catch (e) { - this.emailSignInConfig = undefined; + // If allowPasswordSignup is undefined, it is disabled by default. + this.emailSignInConfig = new EmailSignInConfig({ + allowPasswordSignup: false, + }); } } diff --git a/src/index.d.ts b/src/index.d.ts index 3cd8de1a19..f144cd4cf7 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -507,7 +507,6 @@ declare namespace admin.auth { providerId: string; /** - * @return A JSON-serializable representation of this object. */ toJSON(): Object; diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts old mode 100644 new mode 100755 index 6cf3ba8040..4f29720292 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -23,11 +23,17 @@ import * as scrypt from 'scrypt'; import firebase from '@firebase/app'; import '@firebase/auth'; import {clone} from 'lodash'; -import {generateRandomString, projectId, apiKey, noServiceAccountApp} from './setup'; +import { + generateRandomString, projectId, apiKey, noServiceAccountApp, cmdArgs, +} from './setup'; import url = require('url'); import * as mocks from '../resources/mocks'; import { AuthProviderConfig } from '../../src/auth/auth-config'; -import { deepExtend } from '../../src/utils/deep-copy'; +import { deepExtend, deepCopy } from '../../src/utils/deep-copy'; + +/* tslint:disable:no-var-requires */ +const chalk = require('chalk'); +/* tslint:enable:no-var-requires */ chai.should(); chai.use(chaiAsPromised); @@ -428,6 +434,153 @@ describe('admin.auth', () => { }); }); + describe('Tenant management operations', () => { + // TODO: Add basic user management tests for multi-tenancy when Auth client SDK starts supporting it. + let createdTenantId: string; + const createdTenants: string[] = []; + const tenantOptions: admin.auth.CreateTenantRequest = { + displayName: 'testTenant1', + type: 'lightweight', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const expectedCreatedTenant: any = { + displayName: 'testTenant1', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + type: 'lightweight', + }; + const expectedUpdatedTenant: any = { + displayName: 'testTenantUpdated', + emailSignInConfig: { + enabled: false, + passwordRequired: true, + }, + type: 'lightweight', + }; + const expectedUpdatedTenant2: any = { + displayName: 'testTenantUpdated', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + type: 'lightweight', + }; + + // https://mochajs.org/ + // Passing arrow functions (aka "lambdas") to Mocha is discouraged. + // Lambdas lexically bind this and cannot access the Mocha context. + before(function() { + /* tslint:disable:no-console */ + if (!cmdArgs.testMultiTenancy) { + // To enable, run: npm run test:integration -- --testMultiTenancy + // By default we skip multi-tenancy as it is a Google Cloud Identity Platform + // feature only and requires to be enabled via the Cloud Console. + console.log(chalk.yellow(' Skipping multi-tenancy tests.')); + this.skip(); + } + /* tslint:enable:no-console */ + }); + + // Delete test tenants at the end of test suite. + after(() => { + const promises: Array> = []; + createdTenants.forEach((tenantId) => { + promises.push( + admin.auth().deleteTenant(tenantId).catch((error) => {/** Ignore. */})); + }); + return Promise.all(promises); + }); + + it('createTenant() should resolve with a new tenant', () => { + return admin.auth().createTenant(tenantOptions) + .then((actualTenant) => { + createdTenantId = actualTenant.tenantId; + createdTenants.push(createdTenantId); + expectedCreatedTenant.tenantId = createdTenantId; + expect(actualTenant.toJSON()).to.deep.equal(expectedCreatedTenant); + }); + }); + + it('getTenant() should resolve with expected tenant', () => { + return admin.auth().getTenant(createdTenantId) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedCreatedTenant); + }); + }); + + it('updateTenant() should resolve with the updated tenant', () => { + expectedUpdatedTenant.tenantId = createdTenantId; + expectedUpdatedTenant2.tenantId = createdTenantId; + const updatedOptions: admin.auth.UpdateTenantRequest = { + displayName: expectedUpdatedTenant.displayName, + emailSignInConfig: { + enabled: false, + }, + }; + const updatedOptions2: admin.auth.UpdateTenantRequest = { + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + }; + return admin.auth().updateTenant(createdTenantId, updatedOptions) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant); + return admin.auth().updateTenant(createdTenantId, updatedOptions2); + }) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2); + }); + }); + + it('listTenants() should resolve with expected number of tenants', () => { + const allTenantIds: string[] = []; + const tenantOptions2 = deepCopy(tenantOptions); + tenantOptions2.displayName = 'testTenant2'; + const listAllTenantIds = (tenantIds: string[], nextPageToken?: string): Promise => { + return admin.auth().listTenants(100, nextPageToken) + .then((result) => { + result.tenants.forEach((tenant) => { + tenantIds.push(tenant.tenantId); + }); + if (result.pageToken) { + return listAllTenantIds(tenantIds, result.pageToken); + } + }); + }; + return admin.auth().createTenant(tenantOptions2) + .then((actualTenant) => { + createdTenants.push(actualTenant.tenantId); + // Test listTenants returns the expected tenants. + return listAllTenantIds(allTenantIds); + }) + .then(() => { + // All created tenants should be in the list of tenants. + createdTenants.forEach((tenantId) => { + expect(allTenantIds).to.contain(tenantId); + }); + }); + }); + + it('deleteTenant() should successfully delete the provided tenant', () => { + return admin.auth().deleteTenant(createdTenantId) + .then(() => { + return admin.auth().getTenant(createdTenantId); + }) + .then((result) => { + throw new Error('unexpected success'); + }) + .catch((error) => { + expect(error.code).to.equal('auth/tenant-not-found'); + }); + }); + }); + describe('SAML configuration operations', () => { const authProviderConfig1 = { providerId: 'saml.' + generateRandomString(5), diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts old mode 100644 new mode 100755 index be1a3a576b..a803fdc414 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -247,6 +247,21 @@ describe('Tenant', () => { expect(() => new Tenant(invalidOptions)) .to.throw('INTERNAL ASSERT FAILED: Invalid tenant response'); }); + + it('should set default EmailSignInConfig when allowPasswordSignup is undefined', () => { + const serverResponse: TenantServerResponse = { + name: 'projects/project1/tenants/TENANT_ID', + displayName: 'TENANT_DISPLAY_NAME', + }; + expect(() => { + const tenantWithoutAllowPasswordSignup = new Tenant(serverResponse); + + expect(tenantWithoutAllowPasswordSignup.displayName).to.equal(serverResponse.displayName); + expect(tenantWithoutAllowPasswordSignup.tenantId).to.equal('TENANT_ID'); + expect(tenantWithoutAllowPasswordSignup.emailSignInConfig.enabled).to.be.false; + expect(tenantWithoutAllowPasswordSignup.emailSignInConfig.passwordRequired).to.be.true; + }).not.to.throw(); + }); }); describe('toJSON()', () => { From 6158b1fe6fe3cad34cccf2bcf9def35a63bbaaa4 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Fri, 21 Jun 2019 11:17:48 -0700 Subject: [PATCH 07/14] Defines Auth multi-tenancy references in index.d.ts. (#572) * Defines Auth multi-tenancy references in index.d.ts. * Addresses review comments. --- src/index.d.ts | 210 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 203 insertions(+), 7 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index f144cd4cf7..5c66511eec 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -601,6 +601,10 @@ declare namespace admin.auth { * resets, password or email updates, etc). */ tokensValidAfterTime?: string; + + /** + * The ID of the tenant the user belongs to, if available. + */ tenantId?: string | null; /** @@ -726,6 +730,10 @@ declare namespace admin.auth { * `"google.com"`, `"twitter.com"`, or `"custom"`. */ sign_in_provider: string; + + /** + * The ID of the tenant the user belongs to, if available. + */ tenant?: string; [key: string]: any; }; @@ -951,6 +959,14 @@ declare namespace admin.auth { * The buffer of bytes representing the user’s password salt. */ passwordSalt?: Buffer; + + /** + * The identifier of the tenant where user is to be imported to. + * When not provided in an `admin.auth.Auth` context, the user is uploaded to + * the default parent project. + * When not provided in an `admin.auth.TenantAwareAuth` context, the user is uploaded + * to the tenant corresponding to that `TenantAwareAuth` instance's tenant ID. + */ tenantId?: string | null; } @@ -1050,31 +1066,128 @@ declare namespace admin.auth { type TenantType = 'lightweight' | 'full_service'; + /** + * Interface representing a tenant configuration. + * + * Multi-tenancy support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the [GCIP documentation](https://cloud.google.com/identity-platform) + * + * Before multi-tenancy can be used on a Google Cloud Identity Platform project, + * tenants must be allowed on that project via the Cloud Console UI. + * + * A tenant configuration provides information such as the type of tenant (lightweight or + * full service), display name, tenant identifier and email authentication configuration. + * For OIDC/SAML provider configuration management, `TenantAwareAuth` instances should + * be used instead of a `Tenant` to retrieve the list of configured IdPs on a tenant. + * When configuring these providers, note that tenants will inherit + * whitelisted domains and authenticated redirect URIs of their parent project. + * + * All other settings of a tenant will also be inherited. These will need to be managed + * from the Cloud Console UI. + */ interface Tenant { + + /** + * The tenant identifier. + */ tenantId: string; + + /** + * The tenant type: `lightweight` or `full_service`. + * Tenants that use separate billing and quota will require their own project and + * must be defined as `full_service`. + * `full_service` tenants may be subject to quota creation limits. + * For additional project quota increases, refer to + * [project quota requests](https://support.google.com/cloud/answer/6330231?hl=en). + * In addition, deleted `full_service` tenants may take 30 days after deletion + * before they are completely removed. + */ type?: admin.auth.TenantType; + + /** + * The tenant display name. + */ displayName?: string; + + /** + * The email sign in provider configuration. + */ emailSignInConfig?: { + + /** + * Whether email provider is enabled. + */ enabled: boolean; + + /** + * Whether password is required for email sign-in. When not required, + * email sign-in can be performed with password or via email link sign-in. + */ passwordRequired?: boolean }; + + /** + * @return A JSON-serializable representation of this object. + */ toJSON(): Object; } + /** + * Interface representing the properties to update on the provided tenant. + */ interface UpdateTenantRequest { - displayName: string; + + /** + * The tenant display name. + */ + displayName?: string; + + /** + * The email sign in configuration. + */ emailSignInConfig?: { + + /** + * Whether email provider is enabled. + */ enabled: boolean; + + /** + * Whether password is required for email sign-in. When not required, + * email sign-in can be performed with password or via email link sign-in. + */ passwordRequired?: boolean; }; } + /** + * Interface representing the properties to set on a new tenant. + */ interface CreateTenantRequest extends UpdateTenantRequest { + + /** + * The newly created tenant type. This can be `lightweight` or `full_service`. + */ type: admin.auth.TenantType; } + /** + * Interface representing the object returned from a + * {@link https://firebase.google.com/docs/reference/admin/node/admin.auth.Auth#listTenants `listTenants()`} + * operation. + * Contains the list of tenants for the current batch and the next page token if available. + */ interface ListTenantsResult { + + /** + * The list of {@link admin.auth.Tenant `Tenant`} objects for the downloaded batch. + */ tenants: admin.auth.Tenant[]; + + /** + * The next page token if available. This is needed for the next batch download. + */ pageToken?: string; } @@ -1124,7 +1237,7 @@ declare namespace admin.auth { displayName: string; /** - * Whether the current provider configuration is enabled or disabled. A user + * Whether the provider configuration is enabled or disabled. A user * cannot sign in using a disabled provider. */ enabled: boolean; @@ -1742,7 +1855,7 @@ declare namespace admin.auth { * * SAML and OIDC provider support requires Google Cloud's Identity Platform * (GCIP). To learn more about GCIP, including pricing and features, - * see the [GCIP documentation](https://cloud.google.com/identity-cp). + * see the [GCIP documentation](https://cloud.google.com/identity-platform). * * @param options The provider config filter to apply. * @return A promise that resolves with the list of provider configs meeting the @@ -1760,7 +1873,7 @@ declare namespace admin.auth { * * SAML and OIDC provider support requires Google Cloud's Identity Platform * (GCIP). To learn more about GCIP, including pricing and features, - * see the [GCIP documentation](https://cloud.google.com/identity-cp). + * see the [GCIP documentation](https://cloud.google.com/identity-platform). * * @param providerId The provider ID corresponding to the provider * config to return. @@ -1776,7 +1889,7 @@ declare namespace admin.auth { * * SAML and OIDC provider support requires Google Cloud's Identity Platform * (GCIP). To learn more about GCIP, including pricing and features, - * see the [GCIP documentation](https://cloud.google.com/identity-cp). + * see the [GCIP documentation](https://cloud.google.com/identity-platform). * * @param providerId The provider ID corresponding to the provider * config to delete. @@ -1792,7 +1905,7 @@ declare namespace admin.auth { * * SAML and OIDC provider support requires Google Cloud's Identity Platform * (GCIP). To learn more about GCIP, including pricing and features, - * see the [GCIP documentation](https://cloud.google.com/identity-cp). + * see the [GCIP documentation](https://cloud.google.com/identity-platform). * * @param providerId The provider ID corresponding to the provider * config to update. @@ -1809,7 +1922,7 @@ declare namespace admin.auth { * * SAML and OIDC provider support requires Google Cloud's Identity Platform * (GCIP). To learn more about GCIP, including pricing and features, - * see the [GCIP documentation](https://cloud.google.com/identity-cp). + * see the [GCIP documentation](https://cloud.google.com/identity-platform). * * @param config The provider configuration to create. * @return A promise that resolves with the created provider configuration. @@ -1819,18 +1932,101 @@ declare namespace admin.auth { ): Promise; } + /** + * Tenant-aware `Auth` interface used for managing users, configuring SAML/OIDC providers, + * generating email links for password reset, email verification, etc for specific tenants. + * + * Multi-tenancy support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the [GCIP documentation](https://cloud.google.com/identity-platform) + * + * Each tenant contains its own identity providers, settings and sets of users. + * Using `TenantAwareAuth`, users for a specific tenant and corresponding OIDC/SAML + * configurations can also be managed, ID tokens for users signed in to a specific tenant + * can be verified, and email action links can also be generated for users belonging to the + * tenant. + * + * `TenantAwareAuth` instances for a specific `tenantId` can be instantiated by calling + * `auth.forTenant(tenantId)`. + */ interface TenantAwareAuth extends BaseAuth { + + /** + * The tenant identifier corresponding to this `TenantAwareAuth` instance. + * All calls to the user management APIs, OIDC/SAML provider management APIs, email link + * generation APIs, etc will only be applied within the scope of this tenant. + */ tenantId: string; } interface Auth extends admin.auth.BaseAuth { app: admin.app.App; + /** + * @param tenantId The tenant ID whose `TenantAwareAuth` instance is to be returned. + * + * @return The `TenantAwareAuth` instance corresponding to this tenant identifier. + */ forTenant(tenantId: string): admin.auth.TenantAwareAuth; + + /** + * Gets the tenant configuration for the tenant corresponding to a given `tenantId`. + * + * @param tenantId The tenant identifier corresponding to the tenant whose data to fetch. + * + * @return A promise fulfilled with the tenant configuration to the provided `tenantId`. + */ getTenant(tenantId: string): Promise; + + /** + * Retrieves a list of tenants (single batch only) with a size of `maxResults` + * starting from the offset as specified by `pageToken`. This is used to + * retrieve all the tenants of a specified project in batches. + * + * @param maxResults The page size, 1000 if undefined. This is also + * the maximum allowed limit. + * @param pageToken The next page token. If not specified, returns + * tenants starting without any offset. + * + * @return A promise that resolves with + * a batch of downloaded tenants and the next page token. + */ listTenants(maxResults?: number, pageToken?: string): Promise; + + /** + * Deletes an existing tenant. + * + * @param tenantId The `tenantId` corresponding to the tenant to delete. + * + * @return An empty promise fulfilled once the tenant has been deleted. + */ deleteTenant(tenantId: string): Promise; + + /** + * Creates a new tenant. + * When creating new tenants, tenants that use separate billing and quota will require their + * own project and must be defined as `full_service`. + * + * @param tenantOptions The properties to set on the new tenant configuration to be created. + * + * @return A promise fulfilled with the tenant configuration corresponding to the newly + * created tenant. + */ createTenant(tenantOptions: admin.auth.CreateTenantRequest): Promise; + + /** + * Updates an existing tenant configuration. + * + * Tenant types cannot be modified after creation. + * If a tenant type needs to be changed after creation, a new tenant with the expected + * type needs to be created and the users/configurations of existing tenant copied to the + * new tenant. + * + * @param tenantId The `tenantId` corresponding to the tenant to delete. + * @param tenantOptions The properties to update on the provided tenant. + * + * @return A promise fulfilled with the update tenant data. + */ updateTenant(tenantId: string, tenantOptions: admin.auth.UpdateTenantRequest): Promise; } } From 3f37111d1584bde7b7c9400c2d71433758ab8295 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 27 Jun 2019 10:41:06 -0700 Subject: [PATCH 08/14] Adds basic integration tests for the following in multi-tenancy context: (#584) * Adds basic integration tests for the following in multi-tenancy context: - User management - Custom claims - Import users - List users - Token revocation - Email link generation (skipped due to backend bug) - OIDC/SAML management APIs. * Removes email link sign-in generation test for now. --- test/integration/auth.spec.ts | 288 +++++++++++++++++++++++++++++++++- 1 file changed, 281 insertions(+), 7 deletions(-) diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 4f29720292..0120b4c0ee 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -50,7 +50,7 @@ const sessionCookieUids = [ const testPhoneNumber = '+11234567890'; const testPhoneNumber2 = '+16505550101'; const nonexistentPhoneNumber = '+18888888888'; -const updatedEmail = generateRandomString(20) + '@example.com'; +const updatedEmail = generateRandomString(20).toLowerCase() + '@example.com'; const updatedPhone = '+16505550102'; const customClaims: {[key: string]: any} = { admin: true, @@ -58,7 +58,7 @@ const customClaims: {[key: string]: any} = { }; const uids = [newUserUid + '-1', newUserUid + '-2', newUserUid + '-3']; const mockUserData = { - email: newUserUid + '@example.com', + email: newUserUid.toLowerCase() + '@example.com', emailVerified: false, phoneNumber: testPhoneNumber, password: 'password', @@ -99,14 +99,14 @@ describe('admin.auth', () => { it('createUser() creates a new user when called without a UID', () => { const newUserData = clone(mockUserData); - newUserData.email = generateRandomString(20) + '@example.com'; + newUserData.email = generateRandomString(20).toLowerCase() + '@example.com'; newUserData.phoneNumber = testPhoneNumber2; return admin.auth().createUser(newUserData) .then((userRecord) => { uidFromCreateUserWithoutUid = userRecord.uid; expect(typeof userRecord.uid).to.equal('string'); // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email.toLowerCase()); + expect(userRecord.email).to.equal(newUserData.email); // Confirm expected phone number. expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); }); @@ -119,7 +119,7 @@ describe('admin.auth', () => { .then((userRecord) => { expect(userRecord.uid).to.equal(newUserUid); // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email.toLowerCase()); + expect(userRecord.email).to.equal(newUserData.email); // Confirm expected phone number. expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); }); @@ -278,7 +278,7 @@ describe('admin.auth', () => { expect(userRecord.emailVerified).to.be.true; expect(userRecord.displayName).to.equal(updatedDisplayName); // Confirm expected email. - expect(userRecord.email).to.equal(updatedEmail.toLowerCase()); + expect(userRecord.email).to.equal(updatedEmail); // Confirm expected phone number. expect(userRecord.phoneNumber).to.equal(updatedPhone); }); @@ -435,7 +435,6 @@ describe('admin.auth', () => { }); describe('Tenant management operations', () => { - // TODO: Add basic user management tests for multi-tenancy when Auth client SDK starts supporting it. let createdTenantId: string; const createdTenants: string[] = []; const tenantOptions: admin.auth.CreateTenantRequest = { @@ -506,6 +505,270 @@ describe('admin.auth', () => { }); }); + // Sanity check user management + email link generation + custom attribute APIs. + // TODO: Confirm behavior in client SDK when it starts supporting it. + describe('supports user management, email link generation, custom attribute and token revocation APIs', () => { + let tenantAwareAuth: admin.auth.TenantAwareAuth; + let createdUserUid: string; + let lastValidSinceTime: number; + const newUserData = clone(mockUserData); + newUserData.email = generateRandomString(20).toLowerCase() + '@example.com'; + newUserData.phoneNumber = testPhoneNumber; + const importOptions: any = { + hash: { + algorithm: 'HMAC_SHA256', + key: Buffer.from('secret'), + }, + }; + const rawPassword = 'password'; + const rawSalt = 'NaCl'; + + before(() => { + tenantAwareAuth = admin.auth().forTenant(createdTenantId); + }); + + // Delete test user at the end of test suite. + after(() => { + // If user successfully created, make sure it is deleted at the end of the test suite. + if (createdUserUid) { + return tenantAwareAuth.deleteUser(createdUserUid) + .catch((error) => { + // Ignore error. + }); + } + }); + + it('createUser() should create a user in the expected tenant', () => { + return tenantAwareAuth.createUser(newUserData) + .then((userRecord) => { + createdUserUid = userRecord.uid; + expect(userRecord.tenantId).to.equal(createdTenantId); + expect(userRecord.email).to.equal(newUserData.email); + expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); + }); + }); + + it('setCustomUserClaims() should set custom attributes on the tenant specific user', () => { + return tenantAwareAuth.setCustomUserClaims(createdUserUid, customClaims) + .then(() => { + return tenantAwareAuth.getUser(createdUserUid); + }) + .then((userRecord) => { + expect(userRecord.uid).to.equal(createdUserUid); + expect(userRecord.tenantId).to.equal(createdTenantId); + // Confirm custom claims set on the UserRecord. + expect(userRecord.customClaims).to.deep.equal(customClaims); + }); + }); + + it('updateUser() should update the tenant specific user', () => { + return tenantAwareAuth.updateUser(createdUserUid, { + email: updatedEmail, + phoneNumber: updatedPhone, + }) + .then((userRecord) => { + expect(userRecord.uid).to.equal(createdUserUid); + expect(userRecord.tenantId).to.equal(createdTenantId); + expect(userRecord.email).to.equal(updatedEmail); + expect(userRecord.phoneNumber).to.equal(updatedPhone); + }); + }); + + // Ignore email action link tests for now as there is a bug for lightweight tenants: + // expected '1085102361755-testTenant1-6rjsn' to equal 'testTenant1-6rjsn' + xit('generateEmailVerificationLink() should generate the link for tenant specific user', () => { + // Generate email verification link to confirm it is generated in the expected + // tenant context. + return tenantAwareAuth.generateEmailVerificationLink(updatedEmail, actionCodeSettings) + .then((link) => { + // Confirm tenant ID set in link. + expect(getTenantId(link)).equal(createdTenantId); + }); + }); + + xit('generatePasswordResetLink() should generate the link for tenant specific user', () => { + // Generate password reset link to confirm it is generated in the expected + // tenant context. + return tenantAwareAuth.generatePasswordResetLink(updatedEmail, actionCodeSettings) + .then((link) => { + // Confirm tenant ID set in link. + expect(getTenantId(link)).equal(createdTenantId); + }); + }); + + it('revokeRefreshTokens() should revoke the tokens for the tenant specific user', () => { + // Revoke refresh tokens. + // On revocation, tokensValidAfterTime will be updated to current time. All tokens issued + // before that time will be rejected. As the underlying backend field is rounded to the nearest + // second, we are subtracting one second. + lastValidSinceTime = new Date().getTime() - 1000; + return tenantAwareAuth.revokeRefreshTokens(createdUserUid) + .then(() => { + return tenantAwareAuth.getUser(createdUserUid); + }) + .then((userRecord) => { + expect(new Date(userRecord.tokensValidAfterTime).getTime()) + .to.be.greaterThan(lastValidSinceTime); + }); + }); + + it('listUsers() should list tenant specific users', () => { + return tenantAwareAuth.listUsers(100) + .then((listUsersResult) => { + // Confirm expected user returned in the list and all users returned + // belong to the expected tenant. + const allUsersBelongToTenant = + listUsersResult.users.every((user) => user.tenantId === createdTenantId); + expect(allUsersBelongToTenant).to.be.true; + const knownUserInTenant = + listUsersResult.users.some((user) => user.uid === createdUserUid); + expect(knownUserInTenant).to.be.true; + }); + }); + + it('deleteUser() should delete the tenant specific user', () => { + return tenantAwareAuth.deleteUser(createdUserUid) + .then(() => { + return tenantAwareAuth.getUser(createdUserUid) + .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); + }); + }); + + it('importUsers() should upload a user to the specified tenant', () => { + const currentHashKey = importOptions.hash.key.toString('utf8'); + const passwordHash = + crypto.createHmac('sha256', currentHashKey).update(rawPassword + rawSalt).digest(); + const importUserRecord: any = { + uid: createdUserUid, + email: createdUserUid + '@example.com', + passwordHash, + passwordSalt: Buffer.from(rawSalt), + }; + return tenantAwareAuth.importUsers([importUserRecord], importOptions) + .then(() => { + return tenantAwareAuth.getUser(createdUserUid); + }) + .then((userRecord) => { + // Confirm user uploaded successfully. + expect(userRecord.tenantId).to.equal(createdTenantId); + expect(userRecord.uid).to.equal(createdUserUid); + }); + }); + }); + + // Sanity check OIDC/SAML config management API. + describe('SAML management APIs', () => { + let tenantAwareAuth: admin.auth.TenantAwareAuth; + const authProviderConfig = { + providerId: 'saml.' + generateRandomString(5), + displayName: 'SAML_DISPLAY_NAME1', + enabled: true, + idpEntityId: 'IDP_ENTITY_ID1', + ssoURL: 'https://example.com/login1', + x509Certificates: [mocks.x509CertPairs[0].public], + rpEntityId: 'RP_ENTITY_ID1', + callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', + enableRequestSigning: true, + }; + const modifiedConfigOptions = { + displayName: 'SAML_DISPLAY_NAME3', + enabled: false, + idpEntityId: 'IDP_ENTITY_ID3', + ssoURL: 'https://example.com/login3', + x509Certificates: [mocks.x509CertPairs[1].public], + rpEntityId: 'RP_ENTITY_ID3', + callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', + enableRequestSigning: false, + }; + + before(() => { + tenantAwareAuth = admin.auth().forTenant(createdTenantId); + }); + + // Delete SAML configuration at the end of test suite. + after(() => { + return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) + .catch((error) => { + // Ignore error. + }); + }); + + it('should support CRUD operations', () => { + return tenantAwareAuth.createProviderConfig(authProviderConfig) + .then((config) => { + assertDeepEqualUnordered(authProviderConfig, config); + return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId); + }) + .then((config) => { + assertDeepEqualUnordered(authProviderConfig, config); + return tenantAwareAuth.updateProviderConfig( + authProviderConfig.providerId, modifiedConfigOptions); + }) + .then((config) => { + const modifiedConfig = deepExtend( + {providerId: authProviderConfig.providerId}, modifiedConfigOptions); + assertDeepEqualUnordered(modifiedConfig, config); + return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId); + }) + .then(() => { + return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId) + .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); + }); + }); + }); + + describe('OIDC management APIs', () => { + let tenantAwareAuth: admin.auth.TenantAwareAuth; + const authProviderConfig = { + providerId: 'oidc.' + generateRandomString(5), + displayName: 'OIDC_DISPLAY_NAME1', + enabled: true, + issuer: 'https://oidc.com/issuer1', + clientId: 'CLIENT_ID1', + }; + const modifiedConfigOptions = { + displayName: 'OIDC_DISPLAY_NAME3', + enabled: false, + issuer: 'https://oidc.com/issuer3', + clientId: 'CLIENT_ID3', + }; + + before(() => { + tenantAwareAuth = admin.auth().forTenant(createdTenantId); + }); + + // Delete OIDC configuration at the end of test suite. + after(() => { + return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) + .catch((error) => { + // Ignore error. + }); + }); + + it('should support CRUD operations', () => { + return tenantAwareAuth.createProviderConfig(authProviderConfig) + .then((config) => { + assertDeepEqualUnordered(authProviderConfig, config); + return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId); + }) + .then((config) => { + assertDeepEqualUnordered(authProviderConfig, config); + return tenantAwareAuth.updateProviderConfig( + authProviderConfig.providerId, modifiedConfigOptions); + }) + .then((config) => { + const modifiedConfig = deepExtend( + {providerId: authProviderConfig.providerId}, modifiedConfigOptions); + assertDeepEqualUnordered(modifiedConfig, config); + return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId); + }) + .then(() => { + return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId) + .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); + }); + }); + }); + it('getTenant() should resolve with expected tenant', () => { return admin.auth().getTenant(createdTenantId) .then((actualTenant) => { @@ -1286,6 +1549,17 @@ function getContinueUrl(link: string): string { return parsedUrl.searchParams.get('continueUrl'); } +/** + * Returns the tenant ID corresponding to the link. + * + * @param {string} link The link to parse for the tenant ID. + * @return {string} The link's corresponding tenant ID. + */ +function getTenantId(link: string): string { + const parsedUrl = new url.URL(link); + return parsedUrl.searchParams.get('tenantId'); +} + /** * Safely deletes a specificed user identified by uid. This API chains all delete * requests and throttles them as the Auth backend rate limits this endpoint. From f5d81637de33822c3d1336f79800585b7f93d7d6 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 7 Aug 2019 11:03:31 -0700 Subject: [PATCH 09/14] Removes all usage of tenant types. (#611) All tenants are now created in lightweight state by default. --- src/auth/tenant.ts | 31 --------------------- src/index.d.ts | 28 ++----------------- test/integration/auth.spec.ts | 6 +--- test/unit/auth/auth-api-request.spec.ts | 37 ------------------------- test/unit/auth/auth.spec.ts | 8 ++---- test/unit/auth/tenant.spec.ts | 36 ------------------------ 6 files changed, 5 insertions(+), 141 deletions(-) diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 2b14487e7a..923b4a2771 100755 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -21,29 +21,20 @@ import { EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, } from './auth-config'; -/** The server side tenant type enum. */ -export type TenantServerType = 'LIGHTWEIGHT' | 'FULL_SERVICE' | 'TYPE_UNSPECIFIED'; - -/** The client side tenant type enum. */ -export type TenantType = 'lightweight' | 'full_service' | 'type_unspecified'; - /** The TenantOptions interface used for create/read/update tenant operations. */ export interface TenantOptions { displayName?: string; - type?: TenantType; emailSignInConfig?: EmailSignInProviderConfig; } /** The corresponding server side representation of a TenantOptions object. */ export interface TenantOptionsServerRequest extends EmailSignInConfigServerRequest { displayName?: string; - type?: TenantServerType; } /** The tenant server response interface. */ export interface TenantServerResponse { name: string; - type?: TenantServerType; displayName?: string; allowPasswordSignup?: boolean; enableEmailLinkSignin?: boolean; @@ -61,7 +52,6 @@ export interface ListTenantsResult { */ export class Tenant { public readonly tenantId: string; - public readonly type?: TenantType; public readonly displayName?: string; public readonly emailSignInConfig?: EmailSignInConfig; @@ -82,9 +72,6 @@ export class Tenant { if (typeof tenantOptions.displayName !== 'undefined') { request.displayName = tenantOptions.displayName; } - if (typeof tenantOptions.type !== 'undefined') { - request.type = tenantOptions.type.toUpperCase() as TenantServerType; - } return request; } @@ -112,7 +99,6 @@ export class Tenant { private static validate(request: any, createRequest: boolean) { const validKeys = { displayName: true, - type: true, emailSignInConfig: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; @@ -139,21 +125,6 @@ export class Tenant { `"${label}.displayName" must be a valid non-empty string.`, ); } - // Validate type if provided. - if (typeof request.type !== 'undefined' && !createRequest) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"Tenant.type" is an immutable property.', - ); - } - if (createRequest && - request.type !== 'full_service' && - request.type !== 'lightweight') { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"${label}.type" must be either "full_service" or "lightweight".`, - ); - } // Validate emailSignInConfig type if provided. if (typeof request.emailSignInConfig !== 'undefined') { // This will throw an error if invalid. @@ -177,7 +148,6 @@ export class Tenant { } this.tenantId = tenantId; this.displayName = response.displayName; - this.type = (response.type && response.type.toLowerCase()) || undefined; try { this.emailSignInConfig = new EmailSignInConfig(response); } catch (e) { @@ -193,7 +163,6 @@ export class Tenant { return { tenantId: this.tenantId, displayName: this.displayName, - type: this.type, emailSignInConfig: this.emailSignInConfig && this.emailSignInConfig.toJSON(), }; } diff --git a/src/index.d.ts b/src/index.d.ts index 5c66511eec..3817208c71 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1064,8 +1064,6 @@ declare namespace admin.auth { dynamicLinkDomain?: string; } - type TenantType = 'lightweight' | 'full_service'; - /** * Interface representing a tenant configuration. * @@ -1076,8 +1074,8 @@ declare namespace admin.auth { * Before multi-tenancy can be used on a Google Cloud Identity Platform project, * tenants must be allowed on that project via the Cloud Console UI. * - * A tenant configuration provides information such as the type of tenant (lightweight or - * full service), display name, tenant identifier and email authentication configuration. + * A tenant configuration provides information such as the display name, tenant + * identifier and email authentication configuration. * For OIDC/SAML provider configuration management, `TenantAwareAuth` instances should * be used instead of a `Tenant` to retrieve the list of configured IdPs on a tenant. * When configuring these providers, note that tenants will inherit @@ -1093,18 +1091,6 @@ declare namespace admin.auth { */ tenantId: string; - /** - * The tenant type: `lightweight` or `full_service`. - * Tenants that use separate billing and quota will require their own project and - * must be defined as `full_service`. - * `full_service` tenants may be subject to quota creation limits. - * For additional project quota increases, refer to - * [project quota requests](https://support.google.com/cloud/answer/6330231?hl=en). - * In addition, deleted `full_service` tenants may take 30 days after deletion - * before they are completely removed. - */ - type?: admin.auth.TenantType; - /** * The tenant display name. */ @@ -1165,11 +1151,6 @@ declare namespace admin.auth { * Interface representing the properties to set on a new tenant. */ interface CreateTenantRequest extends UpdateTenantRequest { - - /** - * The newly created tenant type. This can be `lightweight` or `full_service`. - */ - type: admin.auth.TenantType; } /** @@ -2016,11 +1997,6 @@ declare namespace admin.auth { /** * Updates an existing tenant configuration. - * - * Tenant types cannot be modified after creation. - * If a tenant type needs to be changed after creation, a new tenant with the expected - * type needs to be created and the users/configurations of existing tenant copied to the - * new tenant. * * @param tenantId The `tenantId` corresponding to the tenant to delete. * @param tenantOptions The properties to update on the provided tenant. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 0120b4c0ee..1fbe2d81f7 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -439,7 +439,6 @@ describe('admin.auth', () => { const createdTenants: string[] = []; const tenantOptions: admin.auth.CreateTenantRequest = { displayName: 'testTenant1', - type: 'lightweight', emailSignInConfig: { enabled: true, passwordRequired: true, @@ -451,7 +450,6 @@ describe('admin.auth', () => { enabled: true, passwordRequired: true, }, - type: 'lightweight', }; const expectedUpdatedTenant: any = { displayName: 'testTenantUpdated', @@ -459,7 +457,6 @@ describe('admin.auth', () => { enabled: false, passwordRequired: true, }, - type: 'lightweight', }; const expectedUpdatedTenant2: any = { displayName: 'testTenantUpdated', @@ -467,7 +464,6 @@ describe('admin.auth', () => { enabled: true, passwordRequired: false, }, - type: 'lightweight', }; // https://mochajs.org/ @@ -574,7 +570,7 @@ describe('admin.auth', () => { }); }); - // Ignore email action link tests for now as there is a bug for lightweight tenants: + // Ignore email action link tests for now as there is a bug in the returned tenant ID: // expected '1085102361755-testTenant1-6rjsn' to equal 'testTenant1-6rjsn' xit('generateEmailVerificationLink() should generate the link for tenant specific user', () => { // Generate email verification link to confirm it is generated in the expected diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 0aabd2fd07..0770f47d69 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -3783,7 +3783,6 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { const tenantId = 'tenant_id'; const tenantOptions: TenantOptions = { displayName: 'TENANT_DISPLAY_NAME', - type: 'lightweight', emailSignInConfig: { enabled: true, passwordRequired: true, @@ -3791,7 +3790,6 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }; const expectedRequest = { displayName: 'TENANT_DISPLAY_NAME', - type: 'LIGHTWEIGHT', allowPasswordSignup: true, enableEmailLinkSignin: false, }; @@ -3811,24 +3809,6 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); - it('should be rejected given valid parameters with no type', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"CreateTenantRequest.type" must be either "full_service" or "lightweight".', - ); - // Initialize CreateTenantRequest with missing type. - const invalidOptions = deepCopy(tenantOptions); - delete invalidOptions.type; - - const requestHandler = handler.init(mockApp) as AuthRequestHandler; - return requestHandler.createTenant(invalidOptions) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); - it('should be rejected given invalid parameters', () => { const expectedError = new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -4015,23 +3995,6 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); - it('should be rejected given an unmodifiable property', () => { - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"Tenant.type" is an immutable property.', - ); - const invalidOptions = deepCopy(tenantOptions); - (invalidOptions as TenantOptions).type = 'full_service'; - - const requestHandler = handler.init(mockApp) as AuthRequestHandler; - return requestHandler.updateTenant(tenantId, invalidOptions) - .then((result) => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.deep.equal(expectedError); - }); - }); - it('should be rejected when the backend returns a response missing name', () => { const expectedPath = path + '?updateMask=allowPasswordSignup,enableEmailLinkSignin,displayName'; const expectedError = new FirebaseAuthError( diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index bebfa904a7..f9520cc4e2 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -3058,7 +3058,6 @@ AUTH_CONFIGS.forEach((testConfig) => { const tenantId = 'tenant_id'; const serverResponse: TenantServerResponse = { name: 'projects/project_id/tenants/tenant_id', - type: 'FULL_SERVICE', displayName: 'TENANT_DISPLAY_NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, @@ -3343,7 +3342,6 @@ AUTH_CONFIGS.forEach((testConfig) => { const tenantId = 'tenant_id'; const tenantOptions: TenantOptions = { displayName: 'TENANT_DISPLAY_NAME', - type: 'lightweight', emailSignInConfig: { enabled: true, passwordRequired: true, @@ -3354,7 +3352,6 @@ AUTH_CONFIGS.forEach((testConfig) => { displayName: 'TENANT_DISPLAY_NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, - type: 'LIGHTWEIGHT', }; const expectedTenant = new Tenant(serverResponse); const expectedError = new FirebaseAuthError( @@ -3450,7 +3447,6 @@ AUTH_CONFIGS.forEach((testConfig) => { }; const serverResponse: TenantServerResponse = { name: 'projects/project_id/tenants/tenant_id', - type: 'FULL_SERVICE', displayName: 'TENANT_DISPLAY_NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, @@ -3498,9 +3494,9 @@ AUTH_CONFIGS.forEach((testConfig) => { }); it('should be rejected given TenantOptions with invalid update property', () => { - // Updating the type of an existing tenant will throw an error as type is + // Updating the tenantId of an existing tenant will throw an error as tenantId is // an immutable property. - return (auth as Auth).updateTenant(tenantId, {type: 'lightweight'}) + return (auth as Auth).updateTenant(tenantId, {tenantId: 'unmodifiable'} as any) .then(() => { throw new Error('Unexpected success'); }) diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index a803fdc414..0558670614 100755 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -83,13 +83,6 @@ describe('Tenant', () => { }).to.throw('"EmailSignInConfig.enabled" must be a boolean.'); }); - it('should throw when type is specified in an update request', () => { - const tenantOptionsClientRequest: TenantOptions = deepCopy(tenantOptions); - tenantOptionsClientRequest.type = 'lightweight'; - expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) - .to.throw('"Tenant.type" is an immutable property.'); - }); - it('should not throw on valid client request object', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); expect(() => { @@ -129,29 +122,16 @@ describe('Tenant', () => { describe('for a create request', () => { it('should return the expected server request', () => { const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequest); - tenantOptionsClientRequest.type = 'lightweight'; const tenantOptionsServerRequest: TenantServerResponse = deepCopy(serverRequest); delete tenantOptionsServerRequest.name; - tenantOptionsServerRequest.type = 'LIGHTWEIGHT'; expect(Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) .to.deep.equal(tenantOptionsServerRequest); }); - const invalidTypes = [undefined, 'invalid', null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; - invalidTypes.forEach((invalidType) => { - it('should throw on invalid type ' + JSON.stringify(invalidType), () => { - const tenantOptionsClientRequest: TenantOptions = deepCopy(tenantOptions); - tenantOptionsClientRequest.type = invalidType as any; - expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) - .to.throw(`"CreateTenantRequest.type" must be either "full_service" or "lightweight".`); - }); - }); - it('should throw on invalid EmailSignInConfig', () => { const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequest); tenantOptionsClientRequest.emailSignInConfig = null; - tenantOptionsClientRequest.type = 'full_service'; expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) .to.throw('"EmailSignInConfig" must be a non-null object.'); @@ -184,15 +164,6 @@ describe('Tenant', () => { }).to.throw('"CreateTenantRequest.displayName" must be a valid non-empty string.'); }); }); - - invalidTypes.forEach((invalidType) => { - it('should throw on creation with invalid type ' + JSON.stringify(invalidType), () => { - const tenantOptionsClientRequest: TenantOptions = deepCopy(tenantOptions); - tenantOptionsClientRequest.type = invalidType as any; - expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) - .to.throw(`"CreateTenantRequest.type" must be either "full_service" or "lightweight".`); - }); - }); }); }); @@ -214,7 +185,6 @@ describe('Tenant', () => { describe('constructor', () => { const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); - serverRequestCopy.type = 'LIGHTWEIGHT'; const tenant = new Tenant(serverRequestCopy); it('should not throw on valid initialization', () => { expect(() => new Tenant(serverRequest)).not.to.throw(); @@ -228,10 +198,6 @@ describe('Tenant', () => { expect(tenant.displayName).to.equal('TENANT_DISPLAY_NAME'); }); - it('should set readonly property type', () => { - expect(tenant.type).to.equal('lightweight'); - }); - it('should set readonly property emailSignInConfig', () => { const expectedEmailSignInConfig = new EmailSignInConfig({ allowPasswordSignup: true, @@ -266,11 +232,9 @@ describe('Tenant', () => { describe('toJSON()', () => { const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); - serverRequestCopy.type = 'LIGHTWEIGHT'; it('should return the expected object representation of a tenant', () => { expect(new Tenant(serverRequestCopy).toJSON()).to.deep.equal({ tenantId: 'TENANT_ID', - type: 'lightweight', displayName: 'TENANT_DISPLAY_NAME', emailSignInConfig: { enabled: true, From 904e118b894b5603f15362dc31a9c614ba95b142 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 8 Aug 2019 16:49:03 -0700 Subject: [PATCH 10/14] Defines the TenantManager class and its underlying methods. (#617) * Defines the TenantManager class and its underlying methods. Adds unit tests for this new class. Unit tests were copied from the exising auth.spec.ts file. --- src/auth/tenant-manager.ts | 149 +++++++ test/unit/auth/tenant-manager.spec.ts | 579 ++++++++++++++++++++++++++ test/unit/index.spec.ts | 1 + 3 files changed, 729 insertions(+) create mode 100644 src/auth/tenant-manager.ts create mode 100644 test/unit/auth/tenant-manager.spec.ts diff --git a/src/auth/tenant-manager.ts b/src/auth/tenant-manager.ts new file mode 100644 index 0000000000..5af985a04a --- /dev/null +++ b/src/auth/tenant-manager.ts @@ -0,0 +1,149 @@ +/*! + * Copyright 2019 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 {AuthRequestHandler} from './auth-api-request'; +import {FirebaseApp} from '../firebase-app'; +import {TenantAwareAuth} from './auth'; +import { + Tenant, TenantServerResponse, ListTenantsResult, TenantOptions, +} from './tenant'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import * as validator from '../utils/validator'; + +/** + * Data structure used to help manage tenant related operations. + * This includes: + * - The ability to create, update, list, get and delete tenants for the underlying project. + * - Getting a TenantAwareAuth instance for running Auth related operations (user mgmt, provider config mgmt, etc) + * in the context of a specified tenant. + */ +export class TenantManager { + private readonly authRequestHandler: AuthRequestHandler; + private readonly tenantsMap: {[key: string]: TenantAwareAuth}; + + /** + * Initializes a TenantManager instance for a specified FirebaseApp. + * @param app The app for this TenantManager instance. + */ + constructor(private readonly app: FirebaseApp) { + this.authRequestHandler = new AuthRequestHandler(app); + this.tenantsMap = {}; + } + + /** + * Returns a TenantAwareAuth instance for the corresponding tenant ID. + * + * @param tenantId The tenant ID whose TenantAwareAuth is to be returned. + * @return The corresponding TenantAwareAuth instance. + */ + public authForTenant(tenantId: string): TenantAwareAuth { + if (!validator.isNonEmptyString(tenantId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + } + if (typeof this.tenantsMap[tenantId] === 'undefined') { + this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId); + } + return this.tenantsMap[tenantId]; + } + + /** + * Looks up the tenant identified by the provided tenant ID and returns a promise that is + * fulfilled with the corresponding tenant if it is found. + * + * @param tenantId The tenant ID of the tenant to look up. + * @return A promise that resolves with the corresponding tenant. + */ + public getTenant(tenantId: string): Promise { + return this.authRequestHandler.getTenant(tenantId) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } + + /** + * Exports a batch of tenant accounts. Batch size is determined by the maxResults argument. + * Starting point of the batch is determined by the pageToken argument. + * + * @param maxResults The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param pageToken The next page token. If not specified, returns users starting + * without any offset. + * @return A promise that resolves with + * the current batch of downloaded tenants and the next page token. For the last page, an + * empty list of tenants and no page token are returned. + */ + public listTenants( + maxResults?: number, + pageToken?: string): Promise { + return this.authRequestHandler.listTenants(maxResults, pageToken) + .then((response: {tenants: TenantServerResponse[], nextPageToken?: string}) => { + // List of tenants to return. + const tenants: Tenant[] = []; + // Convert each user response to a Tenant. + response.tenants.forEach((tenantResponse: TenantServerResponse) => { + tenants.push(new Tenant(tenantResponse)); + }); + // Return list of tenants and the next page token if available. + const result = { + tenants, + pageToken: response.nextPageToken, + }; + // Delete result.pageToken if undefined. + if (typeof result.pageToken === 'undefined') { + delete result.pageToken; + } + return result; + }); + } + + /** + * Deletes the tenant identified by the provided tenant ID and returns a promise that is + * fulfilled when the tenant is found and successfully deleted. + * + * @param tenantId The tenant ID of the tenant to delete. + * @return A promise that resolves when the tenant is successfully deleted. + */ + public deleteTenant(tenantId: string): Promise { + return this.authRequestHandler.deleteTenant(tenantId); + } + + /** + * Creates a new tenant with the properties provided. + * + * @param tenantOptions The properties to set on the new tenant to be created. + * @return A promise that resolves with the newly created tenant. + */ + public createTenant(tenantOptions: TenantOptions): Promise { + return this.authRequestHandler.createTenant(tenantOptions) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } + + /** + * Updates an existing tenant identified by the tenant ID with the properties provided. + * + * @param tenantId The tenant identifier of the tenant to update. + * @param tenantOptions The properties to update on the existing tenant. + * @return A promise that resolves with the modified tenant. + */ + public updateTenant(tenantId: string, tenantOptions: TenantOptions): Promise { + return this.authRequestHandler.updateTenant(tenantId, tenantOptions) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } +} diff --git a/test/unit/auth/tenant-manager.spec.ts b/test/unit/auth/tenant-manager.spec.ts new file mode 100644 index 0000000000..40144443e9 --- /dev/null +++ b/test/unit/auth/tenant-manager.spec.ts @@ -0,0 +1,579 @@ +/*! + * Copyright 2019 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 sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import {FirebaseApp} from '../../../src/firebase-app'; +import {AuthRequestHandler} from '../../../src/auth/auth-api-request'; +import {Tenant, TenantOptions, TenantServerResponse, ListTenantsResult} from '../../../src/auth/tenant'; +import {TenantManager} from '../../../src/auth/tenant-manager'; +import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('TenantManager', () => { + const TENANT_ID = 'tenant-id'; + let mockApp: FirebaseApp; + let tenantManager: TenantManager; + let nullAccessTokenTenantManager: TenantManager; + let malformedAccessTokenTenantManager: TenantManager; + let rejectedPromiseAccessTokenTenantManager: TenantManager; + const GET_TENANT_RESPONSE: TenantServerResponse = { + name: 'projects/project-id/tenants/tenant-id', + displayName: 'TENANT-DISPLAY-NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + + before(() => { + mockApp = mocks.app(); + tenantManager = new TenantManager(mockApp); + nullAccessTokenTenantManager = new TenantManager( + mocks.appReturningNullAccessToken()); + malformedAccessTokenTenantManager = new TenantManager( + mocks.appReturningMalformedAccessToken()); + rejectedPromiseAccessTokenTenantManager = new TenantManager( + mocks.appRejectedWhileFetchingAccessToken()); + + }); + + after(() => { + return mockApp.delete(); + }); + + describe('authForTenant()', () => { + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should throw given invalid tenant ID: ' + JSON.stringify(invalidTenantId), () => { + expect(() => { + return tenantManager.authForTenant(invalidTenantId as any); + }).to.throw('The tenant ID must be a valid non-empty string.'); + }); + }); + + it('should return a TenantAwareAuth with the expected tenant ID', () => { + expect(tenantManager.authForTenant(TENANT_ID).tenantId).to.equal(TENANT_ID); + }); + + it('should return a TenantAwareAuth with read-only tenant ID', () => { + expect(() => { + (tenantManager.authForTenant(TENANT_ID) as any).tenantId = 'OTHER-TENANT-ID'; + }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); + }); + + it('should cache the returned TenantAwareAuth', () => { + const tenantAwareAuth1 = tenantManager.authForTenant('tenantId1'); + const tenantAwareAuth2 = tenantManager.authForTenant('tenantId2'); + expect(tenantManager.authForTenant('tenantId1')).to.equal(tenantAwareAuth1); + expect(tenantManager.authForTenant('tenantId2')).to.equal(tenantAwareAuth2); + expect(tenantAwareAuth1).to.not.be.equal(tenantAwareAuth2); + expect(tenantAwareAuth1.tenantId).to.equal('tenantId1'); + expect(tenantAwareAuth2.tenantId).to.equal('tenantId2'); + }); + }); + + describe('getTenant()', () => { + const tenantId = 'tenant-id'; + const expectedTenant = new Tenant(GET_TENANT_RESPONSE); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (tenantManager as any).getTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return tenantManager.getTenant(invalidTenantId) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenTenantManager.getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on success', () => { + // Stub getTenant to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getTenant') + .returns(Promise.resolve(GET_TENANT_RESPONSE)); + stubs.push(stub); + return tenantManager.getTenant(tenantId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected tenant returned. + expect(result).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getTenant to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getTenant') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return tenantManager.getTenant(tenantId) + .then((tenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('listTenants()', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const pageToken = 'PAGE_TOKEN'; + const maxResult = 500; + const listTenantsResponse: any = { + tenants : [ + {name: 'projects/project-id/tenants/tenant-id1'}, + {name: 'projects/project-id/tenants/tenant-id2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }; + const expectedResult: ListTenantsResult = { + tenants: [ + new Tenant({name: 'projects/project-id/tenants/tenant-id1'}), + new Tenant({name: 'projects/project-id/tenants/tenant-id2'}), + ], + pageToken: 'NEXT_PAGE_TOKEN', + }; + const emptyListTenantsResponse: any = { + tenants: [], + }; + const emptyExpectedResult: any = { + tenants: [], + }; + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given an invalid page token', () => { + const invalidToken = {}; + return tenantManager.listTenants(undefined, invalidToken as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-page-token'); + }); + }); + + it('should be rejected given a maxResults greater than the allowed max', () => { + const moreThanMax = 1000 + 1; + return tenantManager.listTenants(moreThanMax) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenTenantManager.listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve on listTenants request success with tenants in response', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(listTenantsResponse)); + stubs.push(listTenantsStub); + return tenantManager.listTenants(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); + + it('should resolve on listTenants request success with default options', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(listTenantsResponse)); + stubs.push(listTenantsStub); + return tenantManager.listTenants() + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(undefined, undefined); + }); + }); + + it('should resolve on listTenants request success with no tenants in response', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(emptyListTenantsResponse)); + stubs.push(listTenantsStub); + return tenantManager.listTenants(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(emptyExpectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); + + it('should throw an error when listTenants returns an error', () => { + // Stub listTenants to throw a backend error. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.reject(expectedError)); + stubs.push(listTenantsStub); + return tenantManager.listTenants(maxResult, pageToken) + .then((results) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('deleteTenant()', () => { + const tenantId = 'tenant-id'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (tenantManager as any).deleteTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { + return tenantManager.deleteTenant(invalidTenantId as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenTenantManager.deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with void on success', () => { + // Stub deleteTenant to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteTenant') + .returns(Promise.resolve()); + stubs.push(stub); + return tenantManager.deleteTenant(tenantId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected result is undefined. + expect(result).to.be.undefined; + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub deleteTenant to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteTenant') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return tenantManager.deleteTenant(tenantId) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('createTenant()', () => { + const tenantId = 'tenant-id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT-DISPLAY-NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const expectedTenant = new Tenant(GET_TENANT_RESPONSE); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the tenant provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no properties', () => { + return (tenantManager as any).createTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given invalid TenantOptions', () => { + return tenantManager.createTenant(null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given TenantOptions with invalid type property', () => { + // Create tenant using invalid type. This should throw an argument error. + return tenantManager.createTenant({type: 'invalid'} as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenTenantManager.createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on createTenant request success', () => { + // Stub createTenant to return expected result. + const createTenantStub = sinon.stub(AuthRequestHandler.prototype, 'createTenant') + .returns(Promise.resolve(GET_TENANT_RESPONSE)); + stubs.push(createTenantStub); + return tenantManager.createTenant(tenantOptions) + .then((actualTenant) => { + // Confirm underlying API called with expected parameters. + expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); + // Confirm expected Tenant object returned. + expect(actualTenant).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when createTenant returns an error', () => { + // Stub createTenant to throw a backend error. + const createTenantStub = sinon.stub(AuthRequestHandler.prototype, 'createTenant') + .returns(Promise.reject(expectedError)); + stubs.push(createTenantStub); + return tenantManager.createTenant(tenantOptions) + .then((actualTenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('updateTenant()', () => { + const tenantId = 'tenant-id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT-DISPLAY-NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const expectedTenant = new Tenant(GET_TENANT_RESPONSE); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to update the tenant provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (tenantManager as any).updateTenant(undefined, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return tenantManager.updateTenant(invalidTenantId, tenantOptions) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given no TenantOptions', () => { + return (tenantManager as any).updateTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given invalid TenantOptions', () => { + return tenantManager.updateTenant(tenantId, null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given TenantOptions with invalid update property', () => { + // Updating the tenantId of an existing tenant will throw an error as tenantId is + // an immutable property. + return tenantManager.updateTenant(tenantId, {tenantId: 'unmodifiable'} as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenTenantManager.updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on updateTenant request success', () => { + // Stub updateTenant to return expected result. + const updateTenantStub = sinon.stub(AuthRequestHandler.prototype, 'updateTenant') + .returns(Promise.resolve(GET_TENANT_RESPONSE)); + stubs.push(updateTenantStub); + return tenantManager.updateTenant(tenantId, tenantOptions) + .then((actualTenant) => { + // Confirm underlying API called with expected parameters. + expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); + // Confirm expected Tenant object returned. + expect(actualTenant).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when updateTenant returns an error', () => { + // Stub updateTenant to throw a backend error. + const updateTenantStub = sinon.stub(AuthRequestHandler.prototype, 'updateTenant') + .returns(Promise.reject(expectedError)); + stubs.push(updateTenantStub); + return tenantManager.updateTenant(tenantId, tenantOptions) + .then((actualTenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index bebe719b3b..a0474786d8 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -36,6 +36,7 @@ import './auth/user-import-builder.spec'; import './auth/action-code-settings-builder.spec'; import './auth/auth-config.spec'; import './auth/tenant.spec'; +import './auth/tenant-manager.spec'; // Database import './database/database.spec'; From 11243484bfd5274076ed0b9acd2d7b9dd6f5a7bf Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 12 Aug 2019 18:30:50 -0700 Subject: [PATCH 11/14] Removes tenant management methods and `forTenant` from Auth class. (#619) * Removes tenant management methods and `forTenant` from Auth class. Defines Auth#tenantManager(). Updates unit and integration tests. Updates index.d.ts type definitions. --- src/auth/auth.ts | 111 +------ src/index.d.ts | 22 +- test/integration/auth.spec.ts | 25 +- test/unit/auth/auth.spec.ts | 541 +--------------------------------- 4 files changed, 49 insertions(+), 650 deletions(-) diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 37d67a9b16..95078fabfd 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -34,7 +34,7 @@ import { AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, } from './auth-config'; -import {Tenant, TenantOptions, ListTenantsResult, TenantServerResponse} from './tenant'; +import {TenantManager} from './tenant-manager'; /** @@ -722,7 +722,7 @@ export class TenantAwareAuth extends BaseAuth { */ export class Auth extends BaseAuth implements FirebaseServiceInterface { public INTERNAL: AuthInternals = new AuthInternals(); - private readonly tenantsMap: {[key: string]: TenantAwareAuth}; + private readonly tenantManager_: TenantManager; private readonly app_: FirebaseApp; /** @@ -751,7 +751,7 @@ export class Auth extends BaseAuth implements FirebaseServic new AuthRequestHandler(app), cryptoSignerFromApp(app)); this.app_ = app; - this.tenantsMap = {}; + this.tenantManager_ = new TenantManager(app); } /** @@ -763,107 +763,8 @@ export class Auth extends BaseAuth implements FirebaseServic return this.app_; } - /** - * Returns a TenantAwareAuth instance for the corresponding tenant ID. - * - * @param {string} tenantId The tenant ID whose TenantAwareAuth is to be returned. - * @return {TenantAwareAuth} The corresponding TenantAwareAuth instance. - */ - public forTenant(tenantId: string): TenantAwareAuth { - if (!validator.isNonEmptyString(tenantId)) { - throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); - } - if (typeof this.tenantsMap[tenantId] === 'undefined') { - this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId); - } - return this.tenantsMap[tenantId]; - } - - /** - * Looks up the tenant identified by the provided tenant ID and returns a promise that is - * fulfilled with the corresponding tenant if it is found. - * - * @param {string} tenantId The tenant ID of the tenant to look up. - * @return {Promise} A promise that resolves with the corresponding tenant. - */ - public getTenant(tenantId: string): Promise { - return this.authRequestHandler.getTenant(tenantId) - .then((response: TenantServerResponse) => { - return new Tenant(response); - }); - } - - /** - * Exports a batch of tenant accounts. Batch size is determined by the maxResults argument. - * Starting point of the batch is determined by the pageToken argument. - * - * @param {number=} maxResults The page size, 1000 if undefined. This is also the maximum - * allowed limit. - * @param {string=} pageToken The next page token. If not specified, returns users starting - * without any offset. - * @return {Promise<{users: Tenant[], pageToken?: string}>} A promise that resolves with - * the current batch of downloaded tenants and the next page token. For the last page, an - * empty list of tenants and no page token are returned. - */ - public listTenants( - maxResults?: number, - pageToken?: string): Promise { - return this.authRequestHandler.listTenants(maxResults, pageToken) - .then((response: {tenants: TenantServerResponse[], nextPageToken?: string}) => { - // List of tenants to return. - const tenants: Tenant[] = []; - // Convert each user response to a Tenant. - response.tenants.forEach((tenantResponse: TenantServerResponse) => { - tenants.push(new Tenant(tenantResponse)); - }); - // Return list of tenants and the next page token if available. - const result = { - tenants, - pageToken: response.nextPageToken, - }; - // Delete result.pageToken if undefined. - if (typeof result.pageToken === 'undefined') { - delete result.pageToken; - } - return result; - }); - } - - /** - * Deletes the tenant identified by the provided tenant ID and returns a promise that is - * fulfilled when the tenant is found and successfully deleted. - * - * @param {string} tenantId The tenant ID of the tenant to delete. - * @return {Promise} A promise that resolves when the tenant is successfully deleted. - */ - public deleteTenant(tenantId: string): Promise { - return this.authRequestHandler.deleteTenant(tenantId); - } - - /** - * Creates a new tenant with the properties provided. - * - * @param {TenantOptions} tenantOptions The properties to set on the new tenant to be created. - * @return {Promise} A promise that resolves with the newly created tenant. - */ - public createTenant(tenantOptions: TenantOptions): Promise { - return this.authRequestHandler.createTenant(tenantOptions) - .then((response: TenantServerResponse) => { - return new Tenant(response); - }); - } - - /** - * Updates an existing tenant identified by the tenant ID with the properties provided. - * - * @param {string} tenantId The tenant identifier of the tenant to update. - * @param {TenantOptions} tenantOptions The properties to update on the existing tenant. - * @return {Promise} A promise that resolves with the modified tenant. - */ - public updateTenant(tenantId: string, tenantOptions: TenantOptions): Promise { - return this.authRequestHandler.updateTenant(tenantId, tenantOptions) - .then((response: TenantServerResponse) => { - return new Tenant(response); - }); + /** @return The current Auth instance's tenant manager. */ + public tenantManager(): TenantManager { + return this.tenantManager_; } } diff --git a/src/index.d.ts b/src/index.d.ts index 3817208c71..1a72e8bc35 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1928,7 +1928,7 @@ declare namespace admin.auth { * tenant. * * `TenantAwareAuth` instances for a specific `tenantId` can be instantiated by calling - * `auth.forTenant(tenantId)`. + * `auth.tenantManager().authForTenant(tenantId)`. */ interface TenantAwareAuth extends BaseAuth { @@ -1943,12 +1943,30 @@ declare namespace admin.auth { interface Auth extends admin.auth.BaseAuth { app: admin.app.App; + /** + * @return The tenant manager instance associated with the current project. + */ + tenantManager(): admin.auth.TenantManager; + } + + /** + * Defines the tenant manager used to help manage tenant related operations. + * This includes: + *
    + *
  • The ability to create, update, list, get and delete tenants for the underlying + * project.
  • + *
  • Getting a `TenantAwareAuth` instance for running Auth related operations + * (user management, provider configuration management, token verification, + * email link generation, etc) in the context of a specified tenant.
  • + *
+ */ + interface TenantManager { /** * @param tenantId The tenant ID whose `TenantAwareAuth` instance is to be returned. * * @return The `TenantAwareAuth` instance corresponding to this tenant identifier. */ - forTenant(tenantId: string): admin.auth.TenantAwareAuth; + authForTenant(tenantId: string): admin.auth.TenantAwareAuth; /** * Gets the tenant configuration for the tenant corresponding to a given `tenantId`. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 1fbe2d81f7..b326dc5685 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -486,13 +486,14 @@ describe('admin.auth', () => { const promises: Array> = []; createdTenants.forEach((tenantId) => { promises.push( - admin.auth().deleteTenant(tenantId).catch((error) => {/** Ignore. */})); + admin.auth().tenantManager().deleteTenant(tenantId) + .catch((error) => {/** Ignore. */})); }); return Promise.all(promises); }); it('createTenant() should resolve with a new tenant', () => { - return admin.auth().createTenant(tenantOptions) + return admin.auth().tenantManager().createTenant(tenantOptions) .then((actualTenant) => { createdTenantId = actualTenant.tenantId; createdTenants.push(createdTenantId); @@ -520,7 +521,7 @@ describe('admin.auth', () => { const rawSalt = 'NaCl'; before(() => { - tenantAwareAuth = admin.auth().forTenant(createdTenantId); + tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); }); // Delete test user at the end of test suite. @@ -678,7 +679,7 @@ describe('admin.auth', () => { }; before(() => { - tenantAwareAuth = admin.auth().forTenant(createdTenantId); + tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); }); // Delete SAML configuration at the end of test suite. @@ -730,7 +731,7 @@ describe('admin.auth', () => { }; before(() => { - tenantAwareAuth = admin.auth().forTenant(createdTenantId); + tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); }); // Delete OIDC configuration at the end of test suite. @@ -766,7 +767,7 @@ describe('admin.auth', () => { }); it('getTenant() should resolve with expected tenant', () => { - return admin.auth().getTenant(createdTenantId) + return admin.auth().tenantManager().getTenant(createdTenantId) .then((actualTenant) => { expect(actualTenant.toJSON()).to.deep.equal(expectedCreatedTenant); }); @@ -787,10 +788,10 @@ describe('admin.auth', () => { passwordRequired: false, }, }; - return admin.auth().updateTenant(createdTenantId, updatedOptions) + return admin.auth().tenantManager().updateTenant(createdTenantId, updatedOptions) .then((actualTenant) => { expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant); - return admin.auth().updateTenant(createdTenantId, updatedOptions2); + return admin.auth().tenantManager().updateTenant(createdTenantId, updatedOptions2); }) .then((actualTenant) => { expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2); @@ -802,7 +803,7 @@ describe('admin.auth', () => { const tenantOptions2 = deepCopy(tenantOptions); tenantOptions2.displayName = 'testTenant2'; const listAllTenantIds = (tenantIds: string[], nextPageToken?: string): Promise => { - return admin.auth().listTenants(100, nextPageToken) + return admin.auth().tenantManager().listTenants(100, nextPageToken) .then((result) => { result.tenants.forEach((tenant) => { tenantIds.push(tenant.tenantId); @@ -812,7 +813,7 @@ describe('admin.auth', () => { } }); }; - return admin.auth().createTenant(tenantOptions2) + return admin.auth().tenantManager().createTenant(tenantOptions2) .then((actualTenant) => { createdTenants.push(actualTenant.tenantId); // Test listTenants returns the expected tenants. @@ -827,9 +828,9 @@ describe('admin.auth', () => { }); it('deleteTenant() should successfully delete the provided tenant', () => { - return admin.auth().deleteTenant(createdTenantId) + return admin.auth().tenantManager().deleteTenant(createdTenantId) .then(() => { - return admin.auth().getTenant(createdTenantId); + return admin.auth().tenantManager().getTenant(createdTenantId); }) .then((result) => { throw new Error('unexpected success'); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index f9520cc4e2..1e2db9cff7 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -41,7 +41,7 @@ import { OIDCConfigServerResponse, SAMLConfigServerResponse, } from '../../../src/auth/auth-config'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {Tenant, TenantOptions, TenantServerResponse, ListTenantsResult} from '../../../src/auth/tenant'; +import { TenantManager } from '../../../src/auth/tenant-manager'; chai.should(); chai.use(sinonChai); @@ -312,39 +312,17 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); - describe('Auth.forTenant()', () => { - it('should cache the returned TenantAwareAuth', () => { - const tenantAwareAuth1 = (auth as Auth).forTenant('tenantId1'); - const tenantAwareAuth2 = (auth as Auth).forTenant('tenantId2'); - expect((auth as Auth).forTenant('tenantId1')).to.equal(tenantAwareAuth1); - expect((auth as Auth).forTenant('tenantId2')).to.equal(tenantAwareAuth2); - expect(tenantAwareAuth1).to.not.be.equal(tenantAwareAuth2); - expect(tenantAwareAuth1.tenantId).to.equal('tenantId1'); - expect(tenantAwareAuth2.tenantId).to.equal('tenantId2'); - }); - }); - } else { - // Run tests for TenantAwareAuth. - describe('forTenant()', () => { - const invalidTenantIds = [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; - invalidTenantIds.forEach((invalidTenantId) => { - it('should throw given invalid tenant ID: ' + JSON.stringify(invalidTenantId), () => { - expect(() => { - const testAuth = new Auth(mockApp); - return testAuth.forTenant(invalidTenantId as any); - }).to.throw('The tenant ID must be a valid non-empty string.'); - }); - }); - - it('should return a TenantAwareAuth with the expected tenant ID', () => { - const testAuth = new Auth(mockApp); - expect(testAuth.forTenant(TENANT_ID).tenantId).to.equal(TENANT_ID); + describe('tenantManager()', () => { + it('should return a TenantManager with the expected attributes', () => { + const tenantManager1 = (auth as Auth).tenantManager(); + const tenantManager2 = new TenantManager(mockApp); + expect(tenantManager1).to.deep.equal(tenantManager2); }); - it('should return a TenantAwareAuth with read-only tenant ID', () => { - expect(() => { - (auth as any).tenantId = 'OTHER_TENANT_ID'; - }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); + it('should return the same cached instance', () => { + const tenantManager1 = (auth as Auth).tenantManager(); + const tenantManager2 = (auth as Auth).tenantManager(); + expect(tenantManager1).to.equal(tenantManager2); }); }); } @@ -3053,505 +3031,6 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); - if (testConfig.supportsTenantManagement) { - describe('getTenant()', () => { - const tenantId = 'tenant_id'; - const serverResponse: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT_DISPLAY_NAME', - allowPasswordSignup: true, - enableEmailLinkSignin: false, - }; - const expectedTenant = new Tenant(serverResponse); - const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); - // Stubs used to simulate underlying API calls. - let stubs: sinon.SinonStub[] = []; - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); - - it('should be rejected given no tenant ID', () => { - return (auth as any).getTenant() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); - }); - - it('should be rejected given an invalid tenant ID', () => { - const invalidTenantId = ''; - return (auth as Auth).getTenant(invalidTenantId) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-tenant-id'); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).getTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).getTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).getTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should resolve with a Tenant on success', () => { - // Stub getTenant to return expected result. - const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getTenant') - .returns(Promise.resolve(serverResponse)); - stubs.push(stub); - return (auth as Auth).getTenant(tenantId) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); - // Confirm expected tenant returned. - expect(result).to.deep.equal(expectedTenant); - }); - }); - - it('should throw an error when the backend returns an error', () => { - // Stub getTenant to throw a backend error. - const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getTenant') - .returns(Promise.reject(expectedError)); - stubs.push(stub); - return (auth as Auth).getTenant(tenantId) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); - - describe('listTenants()', () => { - const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); - const pageToken = 'PAGE_TOKEN'; - const maxResult = 500; - const listTenantsResponse: any = { - tenants : [ - {name: 'projects/project_id/tenants/tenant_id1'}, - {name: 'projects/project_id/tenants/tenant_id2'}, - ], - nextPageToken: 'NEXT_PAGE_TOKEN', - }; - const expectedResult: ListTenantsResult = { - tenants: [ - new Tenant({name: 'projects/project_id/tenants/tenant_id1'}), - new Tenant({name: 'projects/project_id/tenants/tenant_id2'}), - ], - pageToken: 'NEXT_PAGE_TOKEN', - }; - const emptyListTenantsResponse: any = { - tenants: [], - }; - const emptyExpectedResult: any = { - tenants: [], - }; - // Stubs used to simulate underlying API calls. - let stubs: sinon.SinonStub[] = []; - - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); - - it('should be rejected given an invalid page token', () => { - const invalidToken = {}; - return (auth as Auth).listTenants(undefined, invalidToken as any) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-page-token'); - }); - }); - - it('should be rejected given an invalid max result', () => { - const invalidResults = 5000; - return (auth as Auth).listTenants(invalidResults) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).listTenants(maxResult) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).listTenants(maxResult) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).listTenants(maxResult) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should resolve on listTenants request success with tenants in response', () => { - // Stub listTenants to return expected response. - const listTenantsStub = sinon - .stub(testConfig.RequestHandler.prototype, 'listTenants') - .returns(Promise.resolve(listTenantsResponse)); - stubs.push(listTenantsStub); - return (auth as Auth).listTenants(maxResult, pageToken) - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(listTenantsStub) - .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); - }); - }); - - it('should resolve on listTenants request success with default options', () => { - // Stub listTenants to return expected response. - const listTenantsStub = sinon - .stub(testConfig.RequestHandler.prototype, 'listTenants') - .returns(Promise.resolve(listTenantsResponse)); - stubs.push(listTenantsStub); - return (auth as Auth).listTenants() - .then((response) => { - expect(response).to.deep.equal(expectedResult); - // Confirm underlying API called with expected parameters. - expect(listTenantsStub) - .to.have.been.calledOnce.and.calledWith(undefined, undefined); - }); - }); - - it('should resolve on listTenants request success with no tenants in response', () => { - // Stub listTenants to return expected response. - const listTenantsStub = sinon - .stub(testConfig.RequestHandler.prototype, 'listTenants') - .returns(Promise.resolve(emptyListTenantsResponse)); - stubs.push(listTenantsStub); - return (auth as Auth).listTenants(maxResult, pageToken) - .then((response) => { - expect(response).to.deep.equal(emptyExpectedResult); - // Confirm underlying API called with expected parameters. - expect(listTenantsStub) - .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); - }); - }); - - it('should throw an error when listTenants returns an error', () => { - // Stub listTenants to throw a backend error. - const listTenantsStub = sinon - .stub(testConfig.RequestHandler.prototype, 'listTenants') - .returns(Promise.reject(expectedError)); - stubs.push(listTenantsStub); - return (auth as Auth).listTenants(maxResult, pageToken) - .then((results) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(listTenantsStub) - .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); - - describe('deleteTenant()', () => { - const tenantId = 'tenant_id'; - const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); - // Stubs used to simulate underlying API calls. - let stubs: sinon.SinonStub[] = []; - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); - - it('should be rejected given no tenant ID', () => { - return (auth as any).deleteTenant() - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); - }); - - it('should be rejected given an invalid tenant ID', () => { - const invalidTenantId = ''; - return (auth as Auth).deleteTenant(invalidTenantId) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-tenant-id'); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).deleteTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).deleteTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).deleteTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should resolve with void on success', () => { - // Stub deleteTenant to return expected result. - const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteTenant') - .returns(Promise.resolve()); - stubs.push(stub); - return (auth as Auth).deleteTenant(tenantId) - .then((result) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); - // Confirm expected result is undefined. - expect(result).to.be.undefined; - }); - }); - - it('should throw an error when the backend returns an error', () => { - // Stub deleteTenant to throw a backend error. - const stub = sinon.stub(testConfig.RequestHandler.prototype, 'deleteTenant') - .returns(Promise.reject(expectedError)); - stubs.push(stub); - return (auth as Auth).deleteTenant(tenantId) - .then((userRecord) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); - - describe('createTenant()', () => { - const tenantId = 'tenant_id'; - const tenantOptions: TenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', - emailSignInConfig: { - enabled: true, - passwordRequired: true, - }, - }; - const serverResponse: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT_DISPLAY_NAME', - allowPasswordSignup: true, - enableEmailLinkSignin: false, - }; - const expectedTenant = new Tenant(serverResponse); - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Unable to create the tenant provided.'); - // Stubs used to simulate underlying API calls. - let stubs: sinon.SinonStub[] = []; - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); - - it('should be rejected given no properties', () => { - return (auth as any).createTenant() - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - it('should be rejected given invalid TenantOptions', () => { - return (auth as Auth).createTenant(null) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - }); - }); - - it('should be rejected given TenantOptions with invalid type property', () => { - // Create tenant using invalid type. This should throw an argument error. - return (auth as Auth).createTenant({type: 'invalid'} as any) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).createTenant(tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).createTenant(tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).createTenant(tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should resolve with a Tenant on createTenant request success', () => { - // Stub createTenant to return expected result. - const createTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'createTenant') - .returns(Promise.resolve(serverResponse)); - stubs.push(createTenantStub); - return (auth as Auth).createTenant(tenantOptions) - .then((actualTenant) => { - // Confirm underlying API called with expected parameters. - expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); - // Confirm expected Tenant object returned. - expect(actualTenant).to.deep.equal(expectedTenant); - }); - }); - - it('should throw an error when createTenant returns an error', () => { - // Stub createTenant to throw a backend error. - const createTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'createTenant') - .returns(Promise.reject(expectedError)); - stubs.push(createTenantStub); - return (auth as Auth).createTenant(tenantOptions) - .then((actualTenant) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); - - describe('updateTenant()', () => { - const tenantId = 'tenant_id'; - const tenantOptions: TenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', - emailSignInConfig: { - enabled: true, - passwordRequired: true, - }, - }; - const serverResponse: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT_DISPLAY_NAME', - allowPasswordSignup: true, - enableEmailLinkSignin: false, - }; - const expectedTenant = new Tenant(serverResponse); - const expectedError = new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Unable to update the tenant provided.'); - // Stubs used to simulate underlying API calls. - let stubs: sinon.SinonStub[] = []; - afterEach(() => { - _.forEach(stubs, (stub) => stub.restore()); - stubs = []; - }); - - it('should be rejected given no tenant ID', () => { - return (auth as any).updateTenant(undefined, tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); - }); - - it('should be rejected given an invalid tenant ID', () => { - const invalidTenantId = ''; - return (auth as Auth).updateTenant(invalidTenantId, tenantOptions) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-tenant-id'); - }); - }); - - it('should be rejected given no TenantOptions', () => { - return (auth as any).updateTenant(tenantId) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - it('should be rejected given invalid TenantOptions', () => { - return (auth as Auth).updateTenant(tenantId, null) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - }); - }); - - it('should be rejected given TenantOptions with invalid update property', () => { - // Updating the tenantId of an existing tenant will throw an error as tenantId is - // an immutable property. - return (auth as Auth).updateTenant(tenantId, {tenantId: 'unmodifiable'} as any) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/argument-error'); - }); - }); - - it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as Auth).updateTenant(tenantId, tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as Auth).updateTenant(tenantId, tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as Auth).updateTenant(tenantId, tenantOptions) - .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); - }); - - it('should resolve with a Tenant on updateTenant request success', () => { - // Stub updateTenant to return expected result. - const updateTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateTenant') - .returns(Promise.resolve(serverResponse)); - stubs.push(updateTenantStub); - return (auth as Auth).updateTenant(tenantId, tenantOptions) - .then((actualTenant) => { - // Confirm underlying API called with expected parameters. - expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); - // Confirm expected Tenant object returned. - expect(actualTenant).to.deep.equal(expectedTenant); - }); - }); - - it('should throw an error when updateTenant returns an error', () => { - // Stub updateTenant to throw a backend error. - const updateTenantStub = sinon.stub(testConfig.RequestHandler.prototype, 'updateTenant') - .returns(Promise.reject(expectedError)); - stubs.push(updateTenantStub); - return (auth as Auth).updateTenant(tenantId, tenantOptions) - .then((actualTenant) => { - throw new Error('Unexpected success'); - }, (error) => { - // Confirm underlying API called with expected parameters. - expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); - // Confirm expected error returned. - expect(error).to.equal(expectedError); - }); - }); - }); - } - if (testConfig.Auth === Auth) { describe('INTERNAL.delete()', () => { it('should delete Auth instance', () => { From 83aea9ba4b849d54a234f11b4f4a731a74e17907 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 20 Aug 2019 11:47:29 -0700 Subject: [PATCH 12/14] Updates multi-tenancy integration tests. (#623) - Enables email action link generation tests after backend bug was fixed. - Adds email link sign-in test case. - Fixes issues with nested multi-tenancy tests not being skipped when they are supposed to. --- test/integration/auth.spec.ts | 60 ++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index b326dc5685..17e81b3a41 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -520,8 +520,12 @@ describe('admin.auth', () => { const rawPassword = 'password'; const rawSalt = 'NaCl'; - before(() => { - tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); + before(function() { + if (!createdTenantId) { + this.skip(); + } else { + tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); + } }); // Delete test user at the end of test suite. @@ -571,9 +575,7 @@ describe('admin.auth', () => { }); }); - // Ignore email action link tests for now as there is a bug in the returned tenant ID: - // expected '1085102361755-testTenant1-6rjsn' to equal 'testTenant1-6rjsn' - xit('generateEmailVerificationLink() should generate the link for tenant specific user', () => { + it('generateEmailVerificationLink() should generate the link for tenant specific user', () => { // Generate email verification link to confirm it is generated in the expected // tenant context. return tenantAwareAuth.generateEmailVerificationLink(updatedEmail, actionCodeSettings) @@ -583,7 +585,7 @@ describe('admin.auth', () => { }); }); - xit('generatePasswordResetLink() should generate the link for tenant specific user', () => { + it('generatePasswordResetLink() should generate the link for tenant specific user', () => { // Generate password reset link to confirm it is generated in the expected // tenant context. return tenantAwareAuth.generatePasswordResetLink(updatedEmail, actionCodeSettings) @@ -593,6 +595,16 @@ describe('admin.auth', () => { }); }); + it('generateSignInWithEmailLink() should generate the link for tenant specific user', () => { + // Generate link for sign-in to confirm it is generated in the expected + // tenant context. + return tenantAwareAuth.generateSignInWithEmailLink(updatedEmail, actionCodeSettings) + .then((link) => { + // Confirm tenant ID set in link. + expect(getTenantId(link)).equal(createdTenantId); + }); + }); + it('revokeRefreshTokens() should revoke the tokens for the tenant specific user', () => { // Revoke refresh tokens. // On revocation, tokensValidAfterTime will be updated to current time. All tokens issued @@ -678,16 +690,22 @@ describe('admin.auth', () => { enableRequestSigning: false, }; - before(() => { - tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); + before(function() { + if (!createdTenantId) { + this.skip(); + } else { + tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); + } }); // Delete SAML configuration at the end of test suite. after(() => { - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) - .catch((error) => { - // Ignore error. - }); + if (tenantAwareAuth) { + return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) + .catch((error) => { + // Ignore error. + }); + } }); it('should support CRUD operations', () => { @@ -730,16 +748,22 @@ describe('admin.auth', () => { clientId: 'CLIENT_ID3', }; - before(() => { - tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); + before(function() { + if (!createdTenantId) { + this.skip(); + } else { + tenantAwareAuth = admin.auth().tenantManager().authForTenant(createdTenantId); + } }); // Delete OIDC configuration at the end of test suite. after(() => { - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) - .catch((error) => { - // Ignore error. - }); + if (tenantAwareAuth) { + return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) + .catch((error) => { + // Ignore error. + }); + } }); it('should support CRUD operations', () => { From e67d4ebc21724273371027056a391a4f1086bb1a Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 20 Aug 2019 21:29:10 -0700 Subject: [PATCH 13/14] Addresses review comments. --- test/unit/auth/auth-api-request.spec.ts | 46 +++++++++++----------- test/unit/auth/tenant.spec.ts | 30 +++++++------- test/unit/auth/user-import-builder.spec.ts | 4 +- test/unit/auth/user-record.spec.ts | 12 +++--- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 0770f47d69..7d75947c71 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -1203,7 +1203,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { if (handler.name === 'FirebaseTenantRequestHandler') { it('should throw when a user record with mismatching tenant ID is provided', () => { const mismatchIndex = 34; - const mismatchTenantId = 'MISMATCHING_TENANT_ID'; + const mismatchTenantId = 'MISMATCHING-TENANT-ID'; const expectedError = new FirebaseAuthError( AuthClientErrorCode.MISMATCHING_TENANT_ID, `UserRecord of index "${mismatchIndex}" has mismatching tenant ID "${mismatchTenantId}"`, @@ -1755,7 +1755,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { it('should be rejected given a tenant ID to modify', () => { const dataWithModifiedTenantId = deepCopy(validData); - (dataWithModifiedTenantId as any).tenantId = 'MODIFIED_TENANT_ID'; + (dataWithModifiedTenantId as any).tenantId = 'MODIFIED-TENANT-ID'; // Expected error when a tenant ID is provided. const expectedError = new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -3552,11 +3552,11 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { if (handler.supportsTenantManagement) { describe('getTenant', () => { - const path = '/v2beta1/projects/project_id/tenants/tenant_id'; + const path = '/v2beta1/projects/project_id/tenants/tenant-id'; const method = 'GET'; - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const expectedResult = utils.responseFrom({ - name: 'projects/project_id/tenants/tenant_id', + name: 'projects/project_id/tenants/tenant-id', }); it('should be fulfilled given a valid tenant ID', () => { @@ -3571,7 +3571,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); - const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop]; invalidTenantIds.forEach((invalidTenantId) => { it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); @@ -3614,8 +3614,8 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { const maxResults = 500; const expectedResult = utils.responseFrom({ tenants : [ - {name: 'projects/project_id/tenants/tenant_id1'}, - {name: 'projects/project_id/tenants/tenant_id2'}, + {name: 'projects/project_id/tenants/tenant-id1'}, + {name: 'projects/project_id/tenants/tenant-id2'}, ], nextPageToken: 'NEXT_PAGE_TOKEN', }); @@ -3724,9 +3724,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); describe('deleteTenant', () => { - const path = '/v2beta1/projects/project_id/tenants/tenant_id'; + const path = '/v2beta1/projects/project_id/tenants/tenant-id'; const method = 'DELETE'; - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const expectedResult = utils.responseFrom({}); it('should be fulfilled given a valid tenant ID', () => { @@ -3741,7 +3741,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); - const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop]; invalidTenantIds.forEach((invalidTenantId) => { it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); @@ -3780,21 +3780,21 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { describe('createTenant', () => { const path = '/v2beta1/projects/project_id/tenants'; const postMethod = 'POST'; - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const tenantOptions: TenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: true, }, }; const expectedRequest = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, }; const expectedResult = utils.responseFrom(deepExtend({ - name: 'projects/project_id/tenants/tenant_id', + name: 'projects/project_id/tenants/tenant-id', }, expectedRequest)); it('should be fulfilled given valid parameters', () => { @@ -3849,7 +3849,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to create new tenant', ); - // Resource name should have /tenants/tenant_id in path. This should throw an error. + // Resource name should have /tenants/tenant-id in path. This should throw an error. const stub = sinon.stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({name: 'projects/project_id'})); stubs.push(stub); @@ -3890,23 +3890,23 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); describe('updateTenant', () => { - const path = '/v2beta1/projects/project_id/tenants/tenant_id'; + const path = '/v2beta1/projects/project_id/tenants/tenant-id'; const patchMethod = 'PATCH'; - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const tenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: true, }, }; const expectedRequest = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, }; const expectedResult = utils.responseFrom(deepExtend({ - name: 'projects/project_id/tenants/tenant_id', + name: 'projects/project_id/tenants/tenant-id', }, expectedRequest)); it('should be fulfilled given full parameters', () => { @@ -3963,7 +3963,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); - const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop]; invalidTenantIds.forEach((invalidTenantId) => { it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); @@ -4021,7 +4021,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to update tenant', ); - // Resource name should have /tenants/tenant_id in path. This should throw an error. + // Resource name should have /tenants/tenant-id in path. This should throw an error. const stub = sinon.stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom({name: 'projects/project_id'})); stubs.push(stub); diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 0558670614..806a56901d 100755 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -34,14 +34,14 @@ const expect = chai.expect; describe('Tenant', () => { const serverRequest = { - name: 'projects/project1/tenants/TENANT_ID', - displayName: 'TENANT_DISPLAY_NAME', + name: 'projects/project1/tenants/TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: true, }; const clientRequest = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: false, @@ -49,7 +49,7 @@ describe('Tenant', () => { }; const tenantOptions: TenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: false, @@ -169,13 +169,13 @@ describe('Tenant', () => { describe('getTenantIdFromResourceName()', () => { it('should return the expected tenant ID from resource name', () => { - expect(Tenant.getTenantIdFromResourceName('projects/project1/tenants/TENANT_ID')) - .to.equal('TENANT_ID'); + expect(Tenant.getTenantIdFromResourceName('projects/project1/tenants/TENANT-ID')) + .to.equal('TENANT-ID'); }); it('should return the expected tenant ID from resource name whose project ID contains "tenants" substring', () => { - expect(Tenant.getTenantIdFromResourceName('projects/projecttenants/tenants/TENANT_ID')) - .to.equal('TENANT_ID'); + expect(Tenant.getTenantIdFromResourceName('projects/projecttenants/tenants/TENANT-ID')) + .to.equal('TENANT-ID'); }); it('should return null when no tenant ID is found', () => { @@ -191,11 +191,11 @@ describe('Tenant', () => { }); it('should set readonly property tenantId', () => { - expect(tenant.tenantId).to.equal('TENANT_ID'); + expect(tenant.tenantId).to.equal('TENANT-ID'); }); it('should set readonly property displayName', () => { - expect(tenant.displayName).to.equal('TENANT_DISPLAY_NAME'); + expect(tenant.displayName).to.equal('TENANT-DISPLAY-NAME'); }); it('should set readonly property emailSignInConfig', () => { @@ -216,14 +216,14 @@ describe('Tenant', () => { it('should set default EmailSignInConfig when allowPasswordSignup is undefined', () => { const serverResponse: TenantServerResponse = { - name: 'projects/project1/tenants/TENANT_ID', - displayName: 'TENANT_DISPLAY_NAME', + name: 'projects/project1/tenants/TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', }; expect(() => { const tenantWithoutAllowPasswordSignup = new Tenant(serverResponse); expect(tenantWithoutAllowPasswordSignup.displayName).to.equal(serverResponse.displayName); - expect(tenantWithoutAllowPasswordSignup.tenantId).to.equal('TENANT_ID'); + expect(tenantWithoutAllowPasswordSignup.tenantId).to.equal('TENANT-ID'); expect(tenantWithoutAllowPasswordSignup.emailSignInConfig.enabled).to.be.false; expect(tenantWithoutAllowPasswordSignup.emailSignInConfig.passwordRequired).to.be.true; }).not.to.throw(); @@ -234,8 +234,8 @@ describe('Tenant', () => { const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); it('should return the expected object representation of a tenant', () => { expect(new Tenant(serverRequestCopy).toJSON()).to.deep.equal({ - tenantId: 'TENANT_ID', - displayName: 'TENANT_DISPLAY_NAME', + tenantId: 'TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: false, diff --git a/test/unit/auth/user-import-builder.spec.ts b/test/unit/auth/user-import-builder.spec.ts index bd753ac915..ec99ca10cb 100644 --- a/test/unit/auth/user-import-builder.spec.ts +++ b/test/unit/auth/user-import-builder.spec.ts @@ -66,7 +66,7 @@ describe('UserImportBuilder', () => { }, ], customClaims: {admin: true}, - tenantId: 'TENANT_ID', + tenantId: 'TENANT-ID', }, { uid: '9012', @@ -97,7 +97,7 @@ describe('UserImportBuilder', () => { }, ], customAttributes: JSON.stringify({admin: true}), - tenantId: 'TENANT_ID', + tenantId: 'TENANT-ID', }, { localId: '9012', diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index 21c93934c4..c3f5d07442 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -639,16 +639,16 @@ describe('UserRecord', () => { }); it('should return expected tenantId', () => { - const resp = deepCopy(getValidUserResponse('TENANT_ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')); const tenantUserRecord = new UserRecord(resp); - expect(tenantUserRecord.tenantId).to.equal('TENANT_ID'); + expect(tenantUserRecord.tenantId).to.equal('TENANT-ID'); }); it('should throw when modifying readonly tenantId property', () => { expect(() => { - const resp = deepCopy(getValidUserResponse('TENANT_ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')); const tenantUserRecord = new UserRecord(resp); - (tenantUserRecord as any).tenantId = 'OTHER_TENANT_ID'; + (tenantUserRecord as any).tenantId = 'OTHER-TENANT-ID'; }).to.throw(Error); }); }); @@ -667,9 +667,9 @@ describe('UserRecord', () => { }); it('should return expected JSON object with tenant ID when available', () => { - const resp = deepCopy(getValidUserResponse('TENANT_ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')); const tenantUserRecord = new UserRecord(resp); - expect(tenantUserRecord.toJSON()).to.deep.equal(getUserJSON('TENANT_ID')); + expect(tenantUserRecord.toJSON()).to.deep.equal(getUserJSON('TENANT-ID')); }); }); }); From 9110c8a48872db813a8f664377ad04cece7716db Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 4 Sep 2019 11:22:54 -0700 Subject: [PATCH 14/14] Sync to head. --- package-lock.json | 133 +++++++++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94adb36e1b..2ca3e0bbe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -333,7 +333,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": "3.0.2", @@ -434,27 +435,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" @@ -463,27 +469,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", @@ -634,7 +645,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/marked": { "version": "0.4.2", @@ -751,6 +763,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" } @@ -781,6 +794,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "optional": true, "requires": { "es6-promisify": "^5.0.0" } @@ -1801,7 +1815,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", @@ -2892,12 +2907,14 @@ "es6-promise": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.6.tgz", - "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==" + "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==", + "optional": true }, "es6-promisify": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "optional": true, "requires": { "es6-promise": "^4.0.3" } @@ -2979,7 +2996,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", @@ -3178,7 +3196,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", @@ -3497,7 +3516,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -3518,12 +3538,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" @@ -3538,17 +3560,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", @@ -3665,7 +3690,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -3677,6 +3703,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3691,6 +3718,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3698,12 +3726,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" @@ -3722,6 +3752,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -3809,7 +3840,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -3821,6 +3853,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -3906,7 +3939,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -3942,6 +3976,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", @@ -3961,6 +3996,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4004,12 +4040,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 } } }, @@ -4029,6 +4067,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.0.1.tgz", "integrity": "sha512-c1NXovTxkgRJTIgB2FrFmOFg4YIV6N/bAa4f/FZ4jIw13Ql9ya/82x69CswvotJhbV3DiGnlTZwoq2NVXk2Irg==", + "optional": true, "requires": { "abort-controller": "^3.0.0", "extend": "^3.0.2", @@ -4040,6 +4079,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-2.0.0.tgz", "integrity": "sha512-BN6KUUWo6WLkDRst+Y7bqpXq1PYMrKUecNLRdZESp7oYtMjWcZdAM0UYvcip8wb0GXNO/j8Z8HTccK4iYtMvyQ==", + "optional": true, "requires": { "gaxios": "^2.0.0", "json-bigint": "^0.3.0" @@ -4277,6 +4317,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-4.0.0.tgz", "integrity": "sha512-yyxl74G16GjKLevccXK3/DYEXphtI9Q2Qw3Eh7y8scjBKNL0IbAZF1mi999gC0tkfG6J23sCbd9tMEbNYeWfJQ==", + "optional": true, "requires": { "arrify": "^2.0.0", "base64-js": "^1.3.0", @@ -4292,12 +4333,14 @@ "arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "optional": true }, "semver": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz", - "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==" + "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==", + "optional": true } } }, @@ -4332,6 +4375,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.0.tgz", "integrity": "sha512-n8eGSKzWOb9/EmSBIh81sPvsQM939QlpHMXahTZDzuRIpCu09x3Oaqz+mXGjL4TeCvSbcnOC0YZRvjkJ9s9lnA==", + "optional": true, "requires": { "node-forge": "^0.8.0" }, @@ -4339,7 +4383,8 @@ "node-forge": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.3.tgz", - "integrity": "sha512-5lv9UKmvTBog+m4AWL8XpZnr3WbNKxYL2M77i903ylY/huJIooSTDHyUWQ/OppFuKQpAGMk6qNtDymSJNRIEIg==" + "integrity": "sha512-5lv9UKmvTBog+m4AWL8XpZnr3WbNKxYL2M77i903ylY/huJIooSTDHyUWQ/OppFuKQpAGMk6qNtDymSJNRIEIg==", + "optional": true } } }, @@ -4358,6 +4403,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-3.0.0.tgz", "integrity": "sha512-IY9HVi78D4ykVHn+ThI7rlcpdFtKyo9e9YLim9S9T3rp6fEnfeTexcrqzSpExVshPofsdauLKIa8dEnzX7ZLfQ==", + "optional": true, "requires": { "gaxios": "^2.0.0", "google-p12-pem": "^2.0.0", @@ -4369,7 +4415,8 @@ "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "optional": true } } }, @@ -4934,6 +4981,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "optional": true, "requires": { "agent-base": "^4.1.0", "debug": "^3.1.0" @@ -5533,6 +5581,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" } @@ -5992,12 +6041,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" } @@ -6157,7 +6208,8 @@ "mime": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.3.tgz", - "integrity": "sha512-QgrPRJfE+riq5TPZMcHZOtm8c6K/yYrMbKIoRfapfiGLxS8OTeIfRhUGW5LU7MlRa52KOAGCfUNruqLrIBvWZw==" + "integrity": "sha512-QgrPRJfE+riq5TPZMcHZOtm8c6K/yYrMbKIoRfapfiGLxS8OTeIfRhUGW5LU7MlRa52KOAGCfUNruqLrIBvWZw==", + "optional": true }, "mime-db": { "version": "1.37.0", @@ -6430,7 +6482,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.4", @@ -7130,6 +7183,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", @@ -7149,7 +7203,8 @@ "@types/node": { "version": "10.14.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.7.tgz", - "integrity": "sha512-on4MmIDgHXiuJDELPk1NFaKVUxxCFr37tm8E9yN6rAiF5Pzp/9bBfBHkoexqRiY+hk/Z04EJU9kKEb59YqJ82A==" + "integrity": "sha512-on4MmIDgHXiuJDELPk1NFaKVUxxCFr37tm8E9yN6rAiF5Pzp/9bBfBHkoexqRiY+hk/Z04EJU9kKEb59YqJ82A==", + "optional": true } } }, @@ -8153,6 +8208,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" } @@ -8233,7 +8289,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", @@ -9293,7 +9350,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", @@ -9327,7 +9385,8 @@ "yallist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "optional": true }, "yargs": { "version": "13.2.4",