get(self::POLICY); if (!$row) { return ['enabled'=>false,'epoch'=>'','_revision'=>0]; } if (($row['_kind'] ?? '') !== 'browser_policy' || !is_bool($row['enabled'] ?? null) || !is_string($row['epoch'] ?? null) || !preg_match('/^[a-f0-9]{32}$/D', $row['epoch'])) { throw new \RuntimeException('The browser-protection policy could not be read. Restore its record or use host recovery.'); } return $row; } private static function record(int $uid): array { $row = (new Store())->get('trusted_browsers_' . $uid); if (!$row) { return ['devices'=>[],'_revision'=>0]; } if (($row['_kind'] ?? '') !== 'trusted_browsers' || !is_array($row['devices'] ?? null) || !array_is_list($row['devices']) || count($row['devices']) > self::LIMIT) { throw new \RuntimeException('Saved browser approvals could not be read safely.'); } $ids=[]; foreach ($row['devices'] as $device) { if (!is_array($device) || count($device)!==7 || !is_string($device['id']??null) || !preg_match('/^[a-f0-9]{32}$/D',$device['id']) || isset($ids[$device['id']]) || !is_string($device['verifier']??null) || !preg_match('/^[a-f0-9]{64}$/D',$device['verifier']) || !is_string($device['epoch']??null) || !preg_match('/^[a-f0-9]{32}$/D',$device['epoch']) || !is_string($device['password']??null) || !preg_match('/^[a-f0-9]{64}$/D',$device['password']) || !is_int($device['generation']??null) || $device['generation']<0 || !is_int($device['created']??null) || !is_int($device['expires']??null) || $device['created']<1 || $device['expires']<=$device['created'] || $device['expires']-$device['created']>self::DAYS*86400) { throw new \RuntimeException('An invalid browser approval was refused.'); } $ids[$device['id']]=true; } return $row; } private static function token(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Inspect credential type and byte length before explicit sanitation and exact hexadecimal validation immediately below. if (!isset($_COOKIE[self::COOKIE]) || !is_string($_COOKIE[self::COOKIE]) || strlen($_COOKIE[self::COOKIE]) !== 64) { return ''; } $token = sanitize_text_field(wp_unslash($_COOKIE[self::COOKIE])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only reject a transformed cookie by comparing the already sanitized token with its original bytes; only the validated token is hashed or used. if (!hash_equals($_COOKIE[self::COOKIE], $token)) { return ''; } return preg_match('/^[a-f0-9]{64}$/D', $token) ? $token : ''; } private static function verifier(string $token): string { return hash('sha256', 'antivirus.ai.trusted-browser-v1|' . $token); } private static function password(int $uid): string { clean_user_cache($uid); $user = get_userdata($uid); if (!$user instanceof \WP_User) { throw new \RuntimeException('The account no longer exists.'); } return hash('sha256', $user->user_pass); } private static function active(array $device, array $policy, array $account, string $password): bool { return $device['epoch'] === $policy['epoch'] && $device['generation'] === (int)$account['generation'] && hash_equals($device['password'],$password) && $device['expires'] > time() && $device['created'] <= time(); } public static function status(int $uid): array { $policy = self::settings(); $targeted = $uid > 0 && AccountPolicy::targeted($uid); $state = ['enabled'=>$policy['enabled'],'targeted'=>$targeted,'trusted'=>false,'restricted'=>false,'expires'=>0,'host_recovery'=>self::recovery()]; if (!$targeted || !$policy['enabled'] || self::recovery()) { return $state; } $state['restricted']=true; if (!is_ssl() || $uid!==get_current_user_id()) { return $state; } $token=self::token(); if ($token==='') { return $state; } $account=AccountSecurity::account($uid); if (empty($account['totp']) && empty($account['keys'])) { return $state; } $password=self::password($uid);$verifier=self::verifier($token); foreach (self::record($uid)['devices'] as $device) { if (self::active($device,$policy,$account,$password) && hash_equals($device['verifier'],$verifier)) { $state['trusted']=true;$state['restricted']=false;$state['expires']=$device['expires'];break; } } return $state; } public static function restricted(int $uid): bool { if ($uid<1 || self::recovery() || !AccountPolicy::targeted($uid)) { return false; } try { return self::status($uid)['restricted']; } catch (\Throwable $error) { return true; } } public static function capabilities(array $allcaps, array $caps, array $args, \WP_User $user): array { if (!self::restricted((int)$user->ID)) { return $allcaps; } foreach ($allcaps as $name=>$allowed) { $allcaps[$name] = in_array($name,['read','exist'],true) && $allowed; } $allcaps['read']=true;$allcaps['exist']=true;$allcaps['do_not_allow']=false; return $allcaps; } public static function rest($result) { if (is_wp_error($result)) { return $result; } return self::restricted(get_current_user_id()) ? new \WP_Error('antivirus_ai_browser_approval_required','Verify your second factor and approve this browser on Account protection before using the API.',['status'=>403]) : $result; } /** Recovery routing grants no action: mutations still require endpoint authorization and reauthentication. */ public static function recoveryRequest(): bool { if (!is_user_logged_in() || !current_user_can('read')) { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Bound the raw server path before unslashing; only its sanitized exact basename is used for routing below. if (!isset($_SERVER['SCRIPT_NAME']) || !is_string($_SERVER['SCRIPT_NAME']) || strlen($_SERVER['SCRIPT_NAME']) > 8192) { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Directory bytes are never stored, displayed or trusted. Unslash once, bound the path, then sanitize only the endpoint filename so valid quoted or encoded directories remain usable. $scriptPath = wp_unslash($_SERVER['SCRIPT_NAME']); if (strlen($scriptPath) > 4096 || str_contains($scriptPath, "\0")) { return false; } $basename = basename($scriptPath); $script = sanitize_file_name($basename); if ($script !== $basename) { return false; } $method = isset($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; if (!isset($_SERVER['REQUEST_METHOD']) || $method !== $_SERVER['REQUEST_METHOD']) { return false; } if ($method === 'GET' && in_array($script, ['profile.php', 'users.php'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only recovery-page routing does not change state or restore administrative capabilities. $page = isset($_GET['page']) && is_string($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject normalization by comparing the already sanitized allowlisted selector with its original unslashed bytes. return $page === 'antivirus-ai-account' && $page === wp_unslash($_GET['page']); } if ($method !== 'POST') { return false; } // Only the route and its nonce are read before origin verification. No operation runs here. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Select the fixed action whose nonce is immediately verified below; this routing function never mutates state. $action = isset($_POST['action']) && is_string($_POST['action']) ? sanitize_key(wp_unslash($_POST['action'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only reject normalization by comparing the already sanitized allowlisted selector with its original unslashed bytes. if ($action === '' || $action !== wp_unslash($_POST['action'])) { return false; } if ($script === 'admin-post.php' && $action === 'antivirus_ai_device_trust') { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce']) || strlen($_POST['_wpnonce']) > 40) { return false; } $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['_wpnonce'])) { return false; } if (!wp_verify_nonce($nonce, 'antivirus_ai_device_trust')) { return false; } $operation = self::value($_POST, 'operation', 30); return in_array($operation, ['policy', 'trust', 'revoke', 'revoke_all'], true); } if ($script === 'admin-post.php' && $action === 'antivirus_ai_account') { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce']) || strlen($_POST['_wpnonce']) > 40) { return false; } $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['_wpnonce'])) { return false; } if (!wp_verify_nonce($nonce, 'antivirus_ai_account')) { return false; } $operation = self::value($_POST, 'operation', 30); return in_array($operation, ['totp_begin', 'totp_finish', 'totp_remove', 'key_remove', 'recovery', 'disable', 'sessions_revoke'], true); } if ($script !== 'admin-ajax.php' || $action !== 'antivirus_ai_passkey') { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['nonce']) || !is_string($_POST['nonce']) || strlen($_POST['nonce']) > 40) { return false; } $nonce = sanitize_text_field(wp_unslash($_POST['nonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['nonce'])) { return false; } if (!wp_verify_nonce($nonce, 'antivirus_ai_account')) { return false; } $operation = self::value($_POST, 'operation', 30); return in_array($operation, ['reauth_begin', 'reauth_finish', 'register_begin', 'register_finish'], true); } public static function adminGate(): void { if (!self::restricted(get_current_user_id()) || self::recoveryRequest()) { return; } $method = isset($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Compare the already sanitized method with original routing bytes only to reject transformation; no original value is stored, displayed or used as an action. if (wp_doing_ajax() || $method !== 'GET' || $method !== ($_SERVER['REQUEST_METHOD'] ?? null)) { wp_die(esc_html__('Approve this browser on Account protection before continuing.','shieldlume-security'),'', ['response'=>403]); } wp_safe_redirect(AccountSecurity::pageUrl());exit; } public static function loginRedirect(string $redirect, string $requested, $user): string { return $user instanceof \WP_User && self::restricted((int)$user->ID) ? AccountSecurity::pageUrl() : $redirect; } public static function notice(): void { if (!self::restricted(get_current_user_id())) { return; } echo '

Administrator actions are restricted in this browser. Verify your second factor and explicitly approve this browser on Account protection. Existing WordPress sessions are retained.

'; } public static function recoveryAssets(): void { if (!self::restricted(get_current_user_id()) || !self::recoveryRequest()) { return; } // The WordPress command palette fetches protected REST data and offers unavailable // administrative navigation. Keep the local factor/recovery controls usable instead. wp_dequeue_script('wp-core-commands'); wp_dequeue_script('wp-commands'); wp_dequeue_style('wp-commands'); } /** Ordinary form settings only; endpoint nonce checks remain explicit at their call sites. */ private static function value(array $values, string $name, int $maximum=100): string { if (!isset($values[$name]) || !is_string($values[$name]) || strlen($values[$name]) > $maximum * 2) { return ''; } $value = sanitize_text_field(wp_unslash($values[$name])); return strlen($value) <= $maximum && $value === wp_unslash($values[$name]) ? $value : ''; } private static function write(int $uid,array $record): void { (new Store())->put('trusted_browsers','trusted_browsers_'.$uid,['devices'=>array_values($record['devices'])],'active',$record['_revision']>0?$record['_revision']:null); } private static function cookie(string $token,int $expires): void { if (!setcookie(self::COOKIE,$token,['expires'=>$expires,'path'=>'/','secure'=>true,'httponly'=>true,'samesite'=>'Strict'])) { throw new \RuntimeException('The browser cookie could not be saved. Protection remains enforced; retry from Account protection.'); } if ($token==='') { unset($_COOKIE[self::COOKIE]); } else { $_COOKIE[self::COOKIE]=$token; } } private static function trust(int $uid,array $account,array $policy,array $record): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private browser operation reached only through manage() after its fixed POST nonce and AccountSecurity management reauthentication; current actor and revision checks remain enforced. if (self::value($_POST, 'trust_browser',2)!=='1' || empty($account['recovery']) || (empty($account['totp']) && empty($account['keys'])) || self::recovery() || !is_ssl()) { throw new \RuntimeException('Verify an enrolled second factor, retain unused recovery codes, and explicitly approve this browser over HTTPS. Host recovery must be off.'); } AccountSecurity::origin(); $password=self::password($uid);$current=self::token();$currentVerifier=$current!==''?self::verifier($current):''; $record['devices']=array_values(array_filter($record['devices'],static fn(array $device):bool=>self::active($device,$policy,$account,$password) && !hash_equals($device['verifier'],$currentVerifier))); if (count($record['devices'])>=self::LIMIT) { throw new \RuntimeException('This account has ten active browser approvals. Revoke one before adding another.'); } $token=bin2hex(random_bytes(32));$expires=time()+self::DAYS*86400; $record['devices'][]=['id'=>bin2hex(random_bytes(16)),'verifier'=>self::verifier($token),'epoch'=>$policy['epoch'],'password'=>$password,'generation'=>(int)$account['generation'],'created'=>time(),'expires'=>$expires]; self::write($uid,$record); self::cookie($token,$expires); } /** Called only from the existing authenticated account-management grant. */ private static function perform(int $uid,array $account): void { if ($uid!==get_current_user_id() || !AccountPolicy::targeted($uid) || !is_user_logged_in()) { throw new \RuntimeException('Only your verified administrator account can manage browser protection.'); } if (headers_sent()) { throw new \RuntimeException('The response already started. Reload Account protection and retry.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private browser operation reached only through manage() after its fixed POST nonce and AccountSecurity management reauthentication; current actor and revision checks remain enforced. $operation=self::value($_POST, 'operation',20);$revision=self::value($_POST, 'policy_revision',12);$deviceRevision=self::value($_POST, 'devices_revision',12); if (!in_array($operation,['policy','trust','revoke','revoke_all'],true) || !preg_match('/^[0-9]{1,10}$/D',$revision) || !preg_match('/^[0-9]{1,10}$/D',$deviceRevision)) { throw new \RuntimeException('The browser protection form is incomplete. Reload and retry.'); } $store=new Store();$lock=$store->lock('device_trust_policy',30); if (!$lock) { throw new \RuntimeException('Browser protection is being updated. Reload and retry.'); } try { $policy=self::settings();$record=self::record($uid); if ((int)$policy['_revision']!==(int)$revision || (int)$record['_revision']!==(int)$deviceRevision) { throw new \RuntimeException('Browser protection changed in another request. Reload before making a new choice.'); } if ($operation==='policy') { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private browser operation reached only through manage() after its fixed POST nonce and AccountSecurity management reauthentication; current actor and revision checks remain enforced. $mode=self::value($_POST, 'browser_mode',5); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private browser operation reached only through manage() after its fixed POST nonce and AccountSecurity management reauthentication; current actor and revision checks remain enforced. if (!in_array($mode,['on','off'],true) || self::value($_POST, 'acknowledge',2)!=='1') { throw new \RuntimeException('Choose and explicitly confirm the administrator browser policy.'); } if (($mode==='on')===$policy['enabled']) { throw new \RuntimeException('That policy choice is already saved. Use Approve this browser to renew its approval.'); } $next=['enabled'=>$mode==='on','epoch'=>bin2hex(random_bytes(16))]; if ($next['enabled']) { self::trust($uid,$account,$next,$record); } $store->put('browser_policy',self::POLICY,$next,'active',$policy['_revision']>0?$policy['_revision']:null); if (!$next['enabled']) { self::cookie('',time()-3600); } } elseif ($operation==='trust') { if (!$policy['enabled']) { throw new \RuntimeException('Browser protection is off. Enable its separate policy first.'); } self::trust($uid,$account,$policy,$record); } else { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private browser operation reached only through manage() after its fixed POST nonce and AccountSecurity management reauthentication; current actor and revision checks remain enforced. if (self::value($_POST, 'acknowledge',2)!=='1') { throw new \RuntimeException('Confirm removal of the selected browser approval.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Private browser operation reached only through manage() after its fixed POST nonce and AccountSecurity management reauthentication; current actor and revision checks remain enforced. $id=self::value($_POST, 'browser_id',32);$old=$record['devices'];$revokeCurrent=false;$verifier=self::token()!==''?self::verifier(self::token()):''; if ($operation==='revoke' && !preg_match('/^[a-f0-9]{32}$/D',$id)) { throw new \RuntimeException('Choose a saved browser approval.'); } $record['devices']=array_values(array_filter($old,static function(array $device)use($id,$operation,$verifier,&$revokeCurrent):bool { $remove=$operation==='revoke_all' || $device['id']===$id;if($remove && hash_equals($device['verifier'],$verifier)){$revokeCurrent=true;}return !$remove; })); if ($operation==='revoke' && count($record['devices'])===count($old)) { throw new \RuntimeException('That browser approval is no longer saved for your account.'); } self::write($uid,$record); if ($revokeCurrent || $operation==='revoke_all') { self::cookie('',time()-3600); } } AccountPolicy::audit('trusted_browser_protection_changed',['operation'=>$operation,'account_id'=>$uid]); } finally { $store->unlock('device_trust_policy',$lock); } } public static function manage(): void { try { if (!is_user_logged_in() || !current_user_can('read')) { throw new \RuntimeException('Sign in to manage your browser protection.'); } $method = isset($_SERVER['REQUEST_METHOD']) && is_string($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Compare the already sanitized method with original routing bytes only to reject transformation; no original value is stored, displayed or used as an action. if ($method !== 'POST' || $method !== ($_SERVER['REQUEST_METHOD'] ?? null)) { throw new \RuntimeException('Use the Account protection form.'); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only inspect nonce type and byte length before sanitation and the explicit verification immediately below; no action input or state is processed. if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce']) || strlen($_POST['_wpnonce']) > 40) { throw new \RuntimeException('Refresh this form and try again.'); } $nonce = sanitize_text_field(wp_unslash($_POST['_wpnonce'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the already sanitized nonce with its original unslashed bytes only to reject transformation; only the sanitized value reaches wp_verify_nonce. if ($nonce === '' || strlen($nonce) > 20 || $nonce !== wp_unslash($_POST['_wpnonce'])) { throw new \RuntimeException('Refresh this form and try again.'); } if (!wp_verify_nonce($nonce, 'antivirus_ai_device_trust')) { throw new \RuntimeException('Refresh this form and try again.'); } AccountSecurity::management(static function(int $uid,array $account):void {self::perform($uid,$account);}); wp_safe_redirect(AccountSecurity::pageUrl().'&browser_saved=1');exit; } catch (\Throwable $error) { wp_die(esc_html($error->getMessage()),esc_html__('Browser protection','shieldlume-security'),['response'=>400,'back_link'=>true]); } } private static function form(string $operation,array $policy,array $record): void { echo '
'; wp_nonce_field('antivirus_ai_device_trust'); } private static function proofFields(): void { echo '

A recent second-factor verification is accepted for five minutes. For a passkey, use Verify existing passkey in the Passkeys panel, then submit this form with your current password.

'; } public static function render(): void { $uid=get_current_user_id();if(!AccountPolicy::targeted($uid)){return;} $policy=self::settings();$record=self::record($uid);$account=AccountSecurity::account($uid);$state=self::status($uid); echo '

Administrator browser protection

This optional policy asks administrators to approve each browser after verifying their password and second factor. Until approved, a signed-in administrator can use Account protection and recovery but cannot change site content, settings, users or plugins, or use the REST API. Existing WordPress sessions are retained.

'.esc_html(self::recovery()?'Host recovery is active; browser restrictions are bypassed.':(!$policy['enabled']?'Off — no browser approval is required.':($state['trusted']?'This browser is approved until '.wp_date('M j, Y H:i T',$state['expires']).'.':'This browser needs your approval.'))).'

Approval lasts 30 days and uses a Secure, HttpOnly, SameSite cookie. Only a hash is saved on this site. No email, IP address, browser fingerprint or external service is used. A copied cookie is not proof of a physical device; this does not protect a compromised browser or host. Clearing cookies, changing your password or changing your factors requires a new approval. Keep recovery codes outside this site.

'; if(!$policy['enabled']) { self::form('policy',$policy,$record);echo '';self::proofFields(); echo ''; } else { self::form('trust',$policy,$record);self::proofFields();echo ''; self::form('policy',$policy,$record);echo '';self::proofFields();echo ''; } $password=self::password($uid);$verifier=self::token()!==''?self::verifier(self::token()):'';$visible=0; foreach($record['devices'] as $device){if(!$policy['enabled'] || !self::active($device,$policy,$account,$password)){continue;}++$visible; echo '
'.esc_html(hash_equals($device['verifier'],$verifier)?'This browser approval':'Browser approval').' · '.esc_html(wp_date('M j, Y H:i T',$device['created'])).'

Expires '.esc_html(wp_date('M j, Y H:i T',$device['expires'])).'.

';self::form('revoke',$policy,$record);echo '';self::proofFields();echo '
'; } if($visible){self::form('revoke_all',$policy,$record);self::proofFields();echo '';} echo '

Keep up to ten browser approvals per account. If recovery is needed, a trusted host administrator can temporarily set ANTIVIRUS_AI_DISABLE_DEVICE_TRUST to true in wp-config.php, reset this policy, and remove the switch. The existing account-security recovery switch also bypasses this restriction.

'; } public static function deleted(int $uid): void {try{(new Store())->delete('trusted_browsers_'.$uid);}catch(\Throwable $error){/* A deleted account cannot authenticate. */}} }