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';