Skip to content

Tapo MFA callback is not triggered in v5.2.1 because MFA error -20677 is nested in result.errorCode #120

Description

@Royal19888

Description

Hi,

I believe I found a reproducible issue with Tapo MFA authentication in tplink-cloud-api 5.2.1.

When authenticating against the Tapo cloud with MFA enabled, TPLinkDeviceManager does not invoke the supplied mfa_callback.

Instead, Tapo authentication silently fails and TPLinkDeviceManager continues with the Kasa cloud only.

After investigating the installed v5.2.1 source and the actual API responses, it appears that the Tapo API returns the MFA requirement inside result.errorCode, while TPLinkApi.login() only checks the top-level response.error_code.


Environment

  • tplink-cloud-api: 5.2.1
  • Python: 3.14
  • OS: macOS
  • TP-Link account region: CZ / eu-west-1
  • MFA / 2FA: enabled
  • Tapo cloud endpoint discovered:
    https://n-euw1-wap-gw.tplinkcloud.com

Devices on the account include Tapo P110, P110M, H100, cameras and bulbs.


Expected behavior

Based on the README, this should call the MFA callback:

def handle_mfa(mfa_type, email):
    return input(f"Enter MFA code sent to {email}: ")

manager = TPLinkDeviceManager(
    username=username,
    password=password,
    mfa_callback=handle_mfa,
)

I expected:

  1. Tapo login
  2. MFA challenge detected
  3. mfa_callback() called
  4. MFA verification completed
  5. Tapo token obtained
  6. Tapo devices queried

Actual behavior

The callback is never called.

TPLinkDeviceManager.get_devices() returns only the devices obtained through the Kasa cloud path.

Using verbose=True, I found that the Tapo login itself returns an MFA challenge successfully.

Simplified/redacted response:

{
  "error_code": 0,
  "result": {
    "errorCode": "-20677",
    "MFAProcessId": "<redacted>",
    "supportedMFATypes": [
      2,
      1
    ],
    "mfaEmail": "<redacted>",
    "errorMsg": "MFA feature enabled"
  }
}

The important detail seems to be:

response.error_code == 0

while:

response.result["errorCode"] == "-20677"

Suspected cause

In the installed TPLinkApi.login() implementation:

response = self._request_post_v2(
    regional_url, _PATH_LOGIN, login_body
)

error_code = response.error_code

if error_code == 0:
    return response.result

if error_code == _ERR_MFA_REQUIRED:
    ...

Because the top-level error code is 0, the method immediately returns response.result.

Therefore this block is never reached:

if error_code == _ERR_MFA_REQUIRED:

As a result, mfa_callback is never called.

The returned dictionary does not contain a token, so in TPLinkDeviceManager._login_all():

self._tapo_token = tapo_result.get("token")

results in:

self._tapo_token = None

and Tapo device discovery is subsequently skipped.


Additional MFA issue: requesting the verification code

I also tested the MFA flow manually.

The initial Tapo login successfully returns:

MFAProcessId: present
supportedMFATypes: [2, 1]

Calling:

/api/v2/account/getEmailVC4TerminalMFA

with my initially attempted payload returned:

error_code: -20615

However, using the push MFA endpoint:

/api/v2/account/getPushVC4TerminalMFA

with:

{
    "appType": api._app_type,
    "cloudPassword": password,
    "cloudUserName": username,
    "terminalUUID": api._term_id,
}

successfully returned:

error_code: 0

and a verification code was delivered through the Tapo app.

I then verified the code through:

/api/v2/account/checkMFACodeAndLogin

using:

{
    "appType": api._app_type,
    "cloudUserName": username,
    "code": mfa_code,
    "MFAProcessId": mfa_process_id,
    "MFAType": 1,
    "terminalBindEnabled": True,
}

This completed successfully:

MFA LOGIN SUCCESSFUL
Token received: YES

So the Tapo MFA flow itself works against the current API; the main issue appears to be how v5.2.1 detects and handles the MFA challenge.


Minimal reproduction

The following demonstrates that login() returns the MFA challenge as a normal result rather than invoking the callback:

from tplinkcloud import TPLinkDeviceManager


def handle_mfa(mfa_type, email):
    print("MFA CALLBACK CALLED")
    return input("Code: ")


username = input("TP-Link email: ")
password = input("TP-Link password: ")

manager = TPLinkDeviceManager(
    include_tapo=True,
    verbose=False,
)

result = manager._tapo_api.login(
    username,
    password,
    mfa_callback=handle_mfa,
)

print("Keys:", list(result.keys()))
print("Has token:", "token" in result)
print("Inner errorCode:", result.get("errorCode"))

Result:

MFA CALLBACK CALLED
# <-- never printed

Keys:
[
    'lockedMinutes',
    'mfaEmail',
    'failedAttempts',
    'remainAttempts',
    'errorCode',
    'MFAProcessId',
    'supportedMFATypes',
    'email',
    'errorMsg'
]

Has token: False
Inner errorCode: -20677

Possible fix

It seems TPLinkApi.login() may need to inspect the nested login result before treating top-level error_code == 0 as a successful authentication.

Something conceptually like:

response = self._request_post_v2(
    regional_url,
    _PATH_LOGIN,
    login_body,
)

result = response.result or {}

inner_error_code = str(result.get("errorCode", "0"))

if response.error_code == 0 and inner_error_code == "0":
    return result

if response.error_code == 0 and inner_error_code == str(_ERR_MFA_REQUIRED):
    # perform MFA flow
    ...

The exact MFA request implementation may also need adjustment because the current _verify_mfa() does not appear to perform the preceding push/email verification-code request.


Verification after manual workaround

After completing the push MFA flow manually, I received a valid Tapo token and was able to call the Tapo cloud device-list API directly.

It returned all 15 top-level Tapo devices on my account, including:

  • P110
  • P110M
  • H100
  • Tapo bulbs
  • Tapo cameras

So Tapo cloud discovery itself works once authentication has successfully completed.


I can provide additional sanitized API responses or test a patch if useful.

Thanks!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions