Skip to content
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

Create/Update tenant with ReCAPTCHA Config #1586

Merged
merged 14 commits into from
Mar 7, 2022
Merged
31 changes: 31 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 (undocumented)
export interface RecaptchaConfig {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
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,8 @@ export interface UpdateTenantRequest {
displayName?: string;
emailSignInConfig?: EmailSignInProviderConfig;
multiFactorConfig?: MultiFactorConfig;
// (undocumented)
recaptchaConfig?: RecaptchaConfig;
testPhoneNumbers?: {
[phoneNumber: string]: string;
} | null;
Expand Down
150 changes: 146 additions & 4 deletions src/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,54 @@ export interface RecaptchaManagedRule {
action?: RecaptchaAction;
}

export class ManagedRuleAuth implements RecaptchaManagedRule{
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
public readonly endScore: number;
public readonly action: RecaptchaAction;

public static validate(options: RecaptchaManagedRule): void {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
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".',
);
}
}

constructor(response: RecaptchaManagedRule) {
if (typeof response.action === 'undefined') {
throw new FirebaseAuthError(
AuthClientErrorCode.INTERNAL_ERROR,
'INTERNAL ASSERT FAILED: Invalid provider-reCAPTCHA configuration response');
}
this.action = response.action;
if (response.endScore !== undefined) {
this.endScore = response.endScore;
}
}
}

/**
* The key's platform type: only web supported now.
*/
Expand All @@ -1497,22 +1545,116 @@ export interface RecaptchaKey {
/**
* The reCAPTCHA site key.
*/
key: string;
key: string;
}

export interface RecaptchaConfig {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
/**
* The enforcement state of email password provider.
*/
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
lahirumaramba marked this conversation as resolved.
Show resolved Hide resolved

/**
* The reCAPTCHA managed rules.
*/
managedRules: RecaptchaManagedRule[];
managedRules?: RecaptchaManagedRule[];

/**
* The reCAPTCHA keys.
*/
recaptchaKeys?: RecaptchaKey[];
recaptchaKeys?: RecaptchaKey[];
}

export class RecaptchaAuthConfig implements RecaptchaConfig {
public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
public readonly managedRules?: RecaptchaManagedRule[];
public readonly recaptchaKeys?: RecaptchaKey[];

constructor(recaptchaConfig: RecaptchaConfig | undefined) {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
if (recaptchaConfig?.emailPasswordEnforcementState !== undefined) {
this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState;
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
}
if (recaptchaConfig?.managedRules !== undefined) {
this.managedRules = recaptchaConfig.managedRules;
}
if (recaptchaConfig?.recaptchaKeys !== undefined) {
this.recaptchaKeys = recaptchaConfig.recaptchaKeys;
}
}

public static validate(options: RecaptchaConfig): void {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
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 (options.emailPasswordEnforcementState !== undefined){
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
if (!validator.isNonEmptyString(options.emailPasswordEnforcementState)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.',)
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
}

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) => {
ManagedRuleAuth.validate(managedRule);
});
lahirumaramba marked this conversation as resolved.
Show resolved Hide resolved
}
}

public toJSON(): object {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
const json: any = {
emailPasswordEnforcementState: this.emailPasswordEnforcementState,
managedRules: deepCopy(this.managedRules),
recaptchaKeys: deepCopy(this.recaptchaKeys)
}

if (typeof json.emailPasswordEnforcementState === 'undefined') {
delete json.emailPasswordEnforcementState;
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
}
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
32 changes: 31 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,8 @@ export interface UpdateTenantRequest {
* Passing null clears the previously save phone number / code pairs.
*/
testPhoneNumbers?: { [phoneNumber: string]: string } | null;

recaptchaConfig?: RecaptchaConfig;
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -68,6 +70,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque
enableAnonymousUser?: boolean;
mfaConfig?: MultiFactorAuthServerConfig;
testPhoneNumbers?: {[key: string]: string};
recaptchaConfig? : RecaptchaConfig;
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
}

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

/**
Expand Down Expand Up @@ -123,6 +127,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 +160,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;
lahirumaramba marked this conversation as resolved.
Show resolved Hide resolved
}
return request;
}

Expand Down Expand Up @@ -185,6 +196,7 @@ export class Tenant {
anonymousSignInEnabled: true,
multiFactorConfig: true,
testPhoneNumbers: true,
recaptchaConfig: true
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
};
const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest';
if (!validator.isNonNullObject(request)) {
Expand Down Expand Up @@ -231,6 +243,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);
lahirumaramba marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
Expand Down Expand Up @@ -265,6 +281,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 +300,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 +320,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