Skip to content

Create/Update tenant with ReCAPTCHA Config #1586

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 14 commits into from
Mar 7, 2022
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
30 changes: 30 additions & 0 deletions etc/firebase-admin.auth.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,34 @@ export interface ProviderIdentifier {
providerUid: string;
}

// @public
export type RecaptchaAction = 'BLOCK';

// @public
export interface RecaptchaConfig {
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
managedRules?: RecaptchaManagedRule[];
recaptchaKeys?: RecaptchaKey[];
}

// @public
export interface RecaptchaKey {
key: string;
type?: RecaptchaKeyClientType;
}

// @public
export type RecaptchaKeyClientType = 'WEB';

// @public
export interface RecaptchaManagedRule {
action?: RecaptchaAction;
endScore: number;
}

// @public
export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE';

// @public
export interface SAMLAuthProviderConfig extends BaseAuthProviderConfig {
callbackURL?: string;
Expand Down Expand Up @@ -296,6 +324,7 @@ export class Tenant {
readonly displayName?: string;
get emailSignInConfig(): EmailSignInProviderConfig | undefined;
get multiFactorConfig(): MultiFactorConfig | undefined;
get recaptchaConfig(): RecaptchaConfig | undefined;
readonly tenantId: string;
readonly testPhoneNumbers?: {
[phoneNumber: string]: string;
Expand Down Expand Up @@ -358,6 +387,7 @@ export interface UpdateTenantRequest {
displayName?: string;
emailSignInConfig?: EmailSignInProviderConfig;
multiFactorConfig?: MultiFactorConfig;
recaptchaConfig?: RecaptchaConfig;
testPhoneNumbers?: {
[phoneNumber: string]: string;
} | null;
Expand Down
156 changes: 145 additions & 11 deletions src/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,22 +1497,156 @@ export interface RecaptchaKey {
/**
* The reCAPTCHA site key.
*/
key: string;
key: string;
}

/**
* The request interface for updating a reCAPTCHA Config.
*/
export interface RecaptchaConfig {
/**
/**
* The enforcement state of email password provider.
*/
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
/**
* The reCAPTCHA managed rules.
*/
managedRules?: RecaptchaManagedRule[];

/**
* The reCAPTCHA managed rules.
*/
managedRules: RecaptchaManagedRule[];
/**
* The reCAPTCHA keys.
*/
recaptchaKeys?: RecaptchaKey[];
}

/**
* The reCAPTCHA keys.
*/
recaptchaKeys?: RecaptchaKey[];
export class RecaptchaAuthConfig implements RecaptchaConfig {
public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
public readonly managedRules?: RecaptchaManagedRule[];
public readonly recaptchaKeys?: RecaptchaKey[];

constructor(recaptchaConfig: RecaptchaConfig) {
this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState;
this.managedRules = recaptchaConfig.managedRules;
this.recaptchaKeys = recaptchaConfig.recaptchaKeys;
}

/**
* Validates the RecaptchaConfig options object. Throws an error on failure.
* @param options - The options object to validate.
*/
public static validate(options: RecaptchaConfig): void {
const validKeys = {
emailPasswordEnforcementState: true,
managedRules: true,
recaptchaKeys: true,
};

if (!validator.isNonNullObject(options)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"RecaptchaConfig" must be a non-null object.',
);
}

for (const key in options) {
if (!(key in validKeys)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
`"${key}" is not a valid RecaptchaConfig parameter.`,
);
}
}

// Validation
if (typeof options.emailPasswordEnforcementState !== undefined) {
if (!validator.isNonEmptyString(options.emailPasswordEnforcementState)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.',
);
}

if (options.emailPasswordEnforcementState !== 'OFF' &&
options.emailPasswordEnforcementState !== 'AUDIT' &&
options.emailPasswordEnforcementState !== 'ENFORCE') {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".',
);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Since emailPasswordEnforcementState values are restricted, I think you can combine all this to a single if statement.

if (typeof options. emailPasswordEnforcementState !== 'undefined' &&
        options. emailPasswordEnforcementState !== 'OFF' &&
        options. emailPasswordEnforcementState !== 'AUDIT' &&
        options. emailPasswordEnforcementState !== 'ENFORCE') {
      throw new FirebaseAuthError(
....

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason for this separation is to align with the backend error-code and keep consistency with the existing error message. E.g. empty string is an INVALID_ARGUMENT but invalid string is an INVALID_CONFIG.

Copy link
Member

Choose a reason for hiding this comment

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

The SDK shouldn't allow empty strings in the request to the BE service so this error technically should not occur (from the backend). I felt like the check is redundant because you check for a restricted set of strings here regardless. Not a big issue so I will leave it up to you to decide the best path here.


if (typeof options.managedRules !== 'undefined') {
// Validate array
if (!validator.isArray(options.managedRules)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".',
);
}
// Validate each rule of the array
options.managedRules.forEach((managedRule) => {
RecaptchaAuthConfig.validateManagedRule(managedRule);
});
}
}

/**
* Validate each element in ManagedRule array
* @param options - The options object to validate.
*/
private static validateManagedRule(options: RecaptchaManagedRule): void {
const validKeys = {
endScore: true,
action: true,
}
if (!validator.isNonNullObject(options)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"RecaptchaManagedRule" 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 RecaptchaManagedRule parameter.`,
);
}
}

// Validate content.
if (typeof options.action !== 'undefined' &&
options.action !== 'BLOCK') {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"RecaptchaManagedRule.action" must be "BLOCK".',
);
}
}

/**
* Returns a JSON-serializable representation of this object.
* @returns The JSON-serializable object representation of the ReCaptcha config instance
*/
public toJSON(): object {
const json: any = {
emailPasswordEnforcementState: this.emailPasswordEnforcementState,
managedRules: deepCopy(this.managedRules),
recaptchaKeys: deepCopy(this.recaptchaKeys)
}

if (typeof json.emailPasswordEnforcementState === 'undefined') {
delete json.emailPasswordEnforcementState;
}
if (typeof json.managedRules === 'undefined') {
delete json.managedRules;
}
if (typeof json.recaptchaKeys === 'undefined') {
delete json.recaptchaKeys;
}

return json;
}
}
6 changes: 6 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export {
OAuthResponseType,
OIDCAuthProviderConfig,
OIDCUpdateAuthProviderRequest,
RecaptchaAction,
RecaptchaConfig,
RecaptchaKey,
RecaptchaKeyClientType,
RecaptchaManagedRule,
RecaptchaProviderEnforcementState,
SAMLAuthProviderConfig,
SAMLUpdateAuthProviderRequest,
UserProvider,
Expand Down
35 changes: 34 additions & 1 deletion src/auth/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error';
import {
EmailSignInConfig, EmailSignInConfigServerRequest, MultiFactorAuthServerConfig,
MultiFactorConfig, validateTestPhoneNumbers, EmailSignInProviderConfig,
MultiFactorAuthConfig,
MultiFactorAuthConfig, RecaptchaAuthConfig, RecaptchaConfig
} from './auth-config';

/**
Expand Down Expand Up @@ -54,6 +54,11 @@ export interface UpdateTenantRequest {
* Passing null clears the previously save phone number / code pairs.
*/
testPhoneNumbers?: { [phoneNumber: string]: string } | null;

/**
* The recaptcha configuration to update on the tenant.
*/
recaptchaConfig?: RecaptchaConfig;
}

/**
Expand All @@ -68,6 +73,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque
enableAnonymousUser?: boolean;
mfaConfig?: MultiFactorAuthServerConfig;
testPhoneNumbers?: {[key: string]: string};
recaptchaConfig?: RecaptchaConfig;
}

/** The tenant server response interface. */
Expand All @@ -79,6 +85,7 @@ export interface TenantServerResponse {
enableAnonymousUser?: boolean;
mfaConfig?: MultiFactorAuthServerConfig;
testPhoneNumbers?: {[key: string]: string};
recaptchaConfig? : RecaptchaConfig;
}

/**
Expand Down Expand Up @@ -123,6 +130,10 @@ export class Tenant {
private readonly emailSignInConfig_?: EmailSignInConfig;
private readonly multiFactorConfig_?: MultiFactorAuthConfig;

/*
* The map conatining the reCAPTCHA config.
*/
private readonly recaptchaConfig_?: RecaptchaAuthConfig;
/**
* Builds the corresponding server request for a TenantOptions object.
*
Expand Down Expand Up @@ -152,6 +163,9 @@ export class Tenant {
// null will clear existing test phone numbers. Translate to empty object.
request.testPhoneNumbers = tenantOptions.testPhoneNumbers ?? {};
}
if (typeof tenantOptions.recaptchaConfig !== 'undefined') {
request.recaptchaConfig = tenantOptions.recaptchaConfig;
}
return request;
}

Expand Down Expand Up @@ -185,6 +199,7 @@ export class Tenant {
anonymousSignInEnabled: true,
multiFactorConfig: true,
testPhoneNumbers: true,
recaptchaConfig: true,
};
const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest';
if (!validator.isNonNullObject(request)) {
Expand Down Expand Up @@ -231,6 +246,10 @@ export class Tenant {
// This will throw an error if invalid.
MultiFactorAuthConfig.buildServerRequest(request.multiFactorConfig);
}
// Validate reCAPTCHAConfig type if provided.
if (typeof request.recaptchaConfig !== 'undefined') {
RecaptchaAuthConfig.validate(request.recaptchaConfig);
}
}

/**
Expand Down Expand Up @@ -265,6 +284,9 @@ export class Tenant {
if (typeof response.testPhoneNumbers !== 'undefined') {
this.testPhoneNumbers = deepCopy(response.testPhoneNumbers || {});
}
if (typeof response.recaptchaConfig !== 'undefined') {
this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig);
}
}

/**
Expand All @@ -281,6 +303,13 @@ export class Tenant {
return this.multiFactorConfig_;
}

/**
* The recaptcha config auth configuration of the current tenant.
*/
get recaptchaConfig(): RecaptchaConfig | undefined {
return this.recaptchaConfig_;
}

/**
* Returns a JSON-serializable representation of this object.
*
Expand All @@ -294,13 +323,17 @@ export class Tenant {
multiFactorConfig: this.multiFactorConfig_?.toJSON(),
anonymousSignInEnabled: this.anonymousSignInEnabled,
testPhoneNumbers: this.testPhoneNumbers,
recaptchaConfig: this.recaptchaConfig_?.toJSON(),
};
if (typeof json.multiFactorConfig === 'undefined') {
delete json.multiFactorConfig;
}
if (typeof json.testPhoneNumbers === 'undefined') {
delete json.testPhoneNumbers;
}
if (typeof json.recaptchaConfig === 'undefined') {
delete json.recaptchaConfig;
}
return json;
}
}
Expand Down
Loading