Skip to content

feat(auth): Adds ability to enable MFA on a Google Cloud Identity Platform tenant #930

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Aug 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docgen/content-sources/node/toc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ toc:
path: /docs/reference/admin/node/admin.auth.ListProviderConfigResults
- title: "ListTenantsResult"
path: /docs/reference/admin/node/admin.auth.ListTenantsResult
- title: "MultiFactorConfig"
path: /docs/reference/admin/node/admin.auth.MultiFactorConfig
- title: "MultiFactorCreateSettings"
path: /docs/reference/admin/node/admin.auth.MultiFactorCreateSettings
- title: "MultiFactorInfo"
Expand Down
49 changes: 49 additions & 0 deletions src/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,12 +971,50 @@ export namespace admin.auth {
passwordRequired?: boolean;
};

/**
* The multi-factor auth configuration on the current tenant.
*/
multiFactorConfig?: admin.auth.MultiFactorConfig;

/**
* The map containing the test phone number / code pairs for the tenant.
*/
testPhoneNumbers?: {[phoneNumber: string]: string};

/**
* @return A JSON-serializable representation of this object.
*/
toJSON(): Object;
}

/**
* Identifies a second factor type.
*/
type AuthFactorType = 'phone';

/**
* Identifies a multi-factor configuration state.
*/
type MultiFactorConfigState = 'ENABLED' | 'DISABLED';

/**
* Interface representing a multi-factor configuration.
* This can be used to define whether multi-factor authentication is enabled
* or disabled and the list of second factor challenges that are supported.
*/
interface MultiFactorConfig {
/**
* The multi-factor config state.
*/
state: admin.auth.MultiFactorConfigState;

/**
* The list of identifiers for enabled second factors.
* Currently only ‘phone’ is supported.
*/
factorIds?: admin.auth.AuthFactorType[];
}

/**
* Interface representing the properties to update on the provided tenant.
*/
Expand All @@ -1003,6 +1041,17 @@ export namespace admin.auth {
*/
passwordRequired?: boolean;
};

/**
* The multi-factor auth configuration to update on the tenant.
*/
multiFactorConfig?: admin.auth.MultiFactorConfig;

/**
* The updated map containing the test phone number / code pairs for the tenant.
* Passing null clears the previously save phone number / code pairs.
*/
testPhoneNumbers?: {[phoneNumber: string]: string} | null;
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1801,7 +1801,7 @@ const DELETE_TENANT = new ApiSettings('/tenants/{tenantId}', 'DELETE');

/** Instantiates the updateTenant endpoint settings. */
const UPDATE_TENANT = new ApiSettings('/tenants/{tenantId}?updateMask={updateMask}', 'PATCH')
// Set response validator.
// Set response validator.
.setResponseValidator((response: any) => {
// Response should always contain at least the tenant name.
if (!validator.isNonEmptyString(response.name) ||
Expand Down Expand Up @@ -1982,7 +1982,9 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler {
try {
// Construct backend request.
const request = Tenant.buildServerRequest(tenantOptions, false);
const updateMask = utils.generateUpdateMask(request);
// Do not traverse deep into testPhoneNumbers. The entire content should be replaced
// and not just specific phone numbers.
const updateMask = utils.generateUpdateMask(request, ['testPhoneNumbers']);
return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request,
{ tenantId, updateMask: updateMask.join(',') })
.then((response: any) => {
Expand Down
208 changes: 208 additions & 0 deletions src/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import * as validator from '../utils/validator';
import { deepCopy } from '../utils/deep-copy';
import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error';

/** A maximum of 10 test phone number / code pairs can be configured. */
export const MAXIMUM_TEST_PHONE_NUMBERS = 10;

/** The filter interface used for listing provider configurations. */
export interface AuthProviderConfigFilter {
Expand Down Expand Up @@ -160,6 +162,212 @@ export interface EmailSignInConfigServerRequest {
enableEmailLinkSignin?: boolean;
}

/** Identifies the public second factor type. */
export type AuthFactorType = 'phone';

/** Identifies the server side second factor type. */
export type AuthFactorServerType = 'PHONE_SMS';

/** Client Auth factor type to server auth factor type mapping. */
export const AUTH_FACTOR_CLIENT_TO_SERVER_TYPE: {[key: string]: AuthFactorServerType} = {
phone: 'PHONE_SMS',
};

/** Server Auth factor type to client auth factor type mapping. */
export const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: {[key: string]: AuthFactorType} =
Object.keys(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE)
.reduce((res: {[key: string]: AuthFactorType}, key) => {
res[AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[key]] = key as AuthFactorType;
return res;
}, {});

/** Identifies a multi-factor configuration state. */
export type MultiFactorConfigState = 'ENABLED' | 'DISABLED';

/**
* Public API interface representing a multi-factor configuration.
*/
export interface MultiFactorConfig {
/**
* The multi-factor config state.
*/
state: MultiFactorConfigState;

/**
* The list of identifiers for enabled second factors.
* Currently only ‘phone’ is supported.
*/
factorIds?: AuthFactorType[];
}

/** Server side multi-factor configuration. */
export interface MultiFactorAuthServerConfig {
state?: MultiFactorConfigState;
enabledProviders?: AuthFactorServerType[];
}


/**
* Defines the multi-factor config class used to convert client side MultiFactorConfig
* to a format that is understood by the Auth server.
*/
export class MultiFactorAuthConfig implements MultiFactorConfig {
public readonly state: MultiFactorConfigState;
public readonly factorIds: AuthFactorType[];

/**
* Static method to convert a client side request to a MultiFactorAuthServerConfig.
* Throws an error if validation fails.
*
* @param options The options object to convert to a server request.
* @return The resulting server request.
*/
public static buildServerRequest(options: MultiFactorConfig): MultiFactorAuthServerConfig {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this need to be a top-level function in this module. Doesn't look like it belongs in this class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the same pattern as other config classes here. Personally, I think this is an adequate place for it. If you don't agree, I think we should then refactor the other config classes here in a separate PR to be consistent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Let's do that refactor in a future PR.

const request: MultiFactorAuthServerConfig = {};
MultiFactorAuthConfig.validate(options);
if (Object.prototype.hasOwnProperty.call(options, 'state')) {
request.state = options.state;
}
if (Object.prototype.hasOwnProperty.call(options, 'factorIds')) {
(options.factorIds || []).forEach((factorId) => {
if (typeof request.enabledProviders === 'undefined') {
request.enabledProviders = [];
}
request.enabledProviders.push(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId]);
});
// In case an empty array is passed. Ensure it gets populated so the array is cleared.
if (options.factorIds && options.factorIds.length === 0) {
request.enabledProviders = [];
}
}
return request;
}

/**
* Validates the MultiFactorConfig options object. Throws an error on failure.
*
* @param options The options object to validate.
*/
private static validate(options: MultiFactorConfig): void {
const validKeys = {
state: true,
factorIds: true,
};
if (!validator.isNonNullObject(options)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"MultiFactorConfig" must be a non-null object.',
);
}
// Check for unsupported top level attributes.
for (const key in options) {
if (!(key in validKeys)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
`"${key}" is not a valid MultiFactorConfig parameter.`,
);
}
}
// Validate content.
if (typeof options.state !== 'undefined' &&
options.state !== 'ENABLED' &&
options.state !== 'DISABLED') {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".',
);
}

if (typeof options.factorIds !== 'undefined') {
if (!validator.isArray(options.factorIds)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".',
);
}

// Validate content of array.
options.factorIds.forEach((factorId) => {
if (typeof AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId] === 'undefined') {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
`"${factorId}" is not a valid "AuthFactorType".`,
);
}
});
}
}

/**
* The MultiFactorAuthConfig constructor.
*
* @param response The server side response used to initialize the
* MultiFactorAuthConfig object.
* @constructor
*/
constructor(response: MultiFactorAuthServerConfig) {
if (typeof response.state === 'undefined') {
throw new FirebaseAuthError(
AuthClientErrorCode.INTERNAL_ERROR,
'INTERNAL ASSERT FAILED: Invalid multi-factor configuration response');
}
this.state = response.state;
this.factorIds = [];
(response.enabledProviders || []).forEach((enabledProvider) => {
// Ignore unsupported types. It is possible the current admin SDK version is
// not up to date and newer backend types are supported.
if (typeof AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider] !== 'undefined') {
this.factorIds.push(AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider]);
}
})
}

/** @return The plain object representation of the multi-factor config instance. */
public toJSON(): object {
return {
state: this.state,
factorIds: this.factorIds,
};
}
}


/**
* Validates the provided map of test phone number / code pairs.
* @param testPhoneNumbers The phone number / code pairs to validate.
*/
export function validateTestPhoneNumbers(
testPhoneNumbers: {[phoneNumber: string]: string},
): void {
if (!validator.isObject(testPhoneNumbers)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'"testPhoneNumbers" must be a map of phone number / code pairs.',
);
}
if (Object.keys(testPhoneNumbers).length > MAXIMUM_TEST_PHONE_NUMBERS) {
throw new FirebaseAuthError(AuthClientErrorCode.MAXIMUM_TEST_PHONE_NUMBER_EXCEEDED);
}
for (const phoneNumber in testPhoneNumbers) {
// Validate phone number.
if (!validator.isPhoneNumber(phoneNumber)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER,
`"${phoneNumber}" is not a valid E.164 standard compliant phone number.`
);
}

// Validate code.
if (!validator.isString(testPhoneNumbers[phoneNumber]) ||
!/^[\d]{6}$/.test(testPhoneNumbers[phoneNumber])) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER,
`"${testPhoneNumbers[phoneNumber]}" is not a valid 6 digit code string.`
);
}
}
}


/**
* Defines the email sign-in config class used to convert client side EmailSignInConfig
Expand Down
Loading