返回

Xero API PKCE 报 invalid_client?原因分析与5步解决

php

解决 Xero API PKCE 流程中的 invalid_client 错误

对接 Xero API 时,使用 PKCE (Proof Key for Code Exchange) 流程是一种常见的安全实践,特别是对于没有后端安全存储客户端密钥(Client Secret)的应用,比如纯前端应用或者移动应用。但有时候,在这个流程的最后一步——用授权码(Authorization Code)换取访问令牌(Access Token)时,会遇到一个让人困惑的 invalid_client 错误。

明明感觉 Client ID 是对的,授权跳转也能成功,为什么到了换 Token 就失败了呢?这篇文章就来帮你捋一捋,彻底搞定这个 invalid_client 问题。

问题现象

你按照 Xero 的 PKCE 文档指南,成功地将用户引导到了 Xero 的授权页面,用户也同意了授权。接着,Xero 把用户重定向回你的 redirect_uri,URL 里也带上了 codestate 参数。

看起来一切顺利,但当你拿着这个 code,连同其他必要信息(client_id, redirect_uri, code_verifier等)去请求 Xero 的 /connect/token 接口时,服务器却冷冰冰地返回了一个 JSON 响应:

{
  "error": "invalid_client"
}

这就像是拿着演唱会门票(code)准备进场,门口保安(Xero 服务器)却说你的身份证明(client_id 相关信息)无效,不让你进。问题到底出在哪儿?

为什么会出现 "invalid_client"?

在 OAuth 2.0 的世界里,invalid_client 这个错误通常意味着授权服务器无法识别或验证发出请求的客户端 。虽然名字是 "invalid_client"(无效客户端),但根源不一定只在 client_id 本身。在 Xero PKCE 流程中,导致这个错误的常见原因有:

  1. Client ID 确实错了: 最直接的原因,可能只是简单的复制粘贴失误,或者配置了错误环境的 Client ID。
  2. Redirect URI 不匹配: 这是个非常常见且隐蔽的坑!你在 Xero App 配置里登记的 redirect_uri、发起授权请求时用的 redirect_uri、以及在请求 Token 时提供的 redirect_uri,这三者必须一字不差地完全一致
  3. Xero App 配置问题: 你在 Xero 开发者中心创建的应用配置可能有误,比如应用类型不支持 PKCE,或者 redirect_uri 没有正确添加。
  4. 请求格式或参数问题: 虽然不太可能直接报 invalid_client (更可能是 invalid_requestinvalid_grant),但在某些边缘情况下,请求 /connect/token 的方式,比如 HTTP 方法、Content-Type 或某些必需参数的缺失/错误,也可能被解释为客户端问题。

咱们挨个来排查。

排查和解决步骤

下面我们分步骤来定位并解决问题。请务必 严格按照顺序 检查,因为 Redirect URI 的问题最为普遍。

1. 核对 Client ID

别嫌烦,这是第一步。最简单的错误往往最容易被忽略。

  • 原理: client_id 是你在 Xero 平台上注册应用时获得的唯一标识符。Xero 通过它来识别是哪个应用在请求访问。如果这个 ID 不对,Xero 自然不认识你。
  • 操作步骤:
    1. 登录 Xero 开发者中心
    2. 进入 "My Apps",找到你正在使用的那个应用。
    3. 仔细核对应用详情页显示的 "Client ID",确保与你代码中使用的 $clientId 变量的值完全一致 。注意检查有没有多余的空格、特殊字符,或者是不是复制错了测试环境/生产环境的 ID。
  • 代码示例(对照检查):
    <?php
    // ...
    $clientId='BFFD3**** **** **** *B848C70'; // 确认这个值和 Xero 平台上的 Client ID 完全一样
    // ...
    ?>
    
  • 安全建议:
    虽然 Client ID 通常不被视为像 Client Secret 那样高度敏感,但最好还是通过环境变量或配置文件来管理,而不是直接硬编码在代码里。这样更方便在不同环境(开发、测试、生产)中切换,也更安全。

2. 检查 Redirect URI (重点!)

这是 最常见invalid_client 错误来源,没有之一!必须高度重视。

  • 原理: Redirect URI (重定向 URI) 是 OAuth 2.0 流程中的一个关键安全机制。授权服务器(Xero)只会将用户和授权码发送到预先注册的、完全匹配的 URI。在 PKCE 流程中,不仅是初始授权跳转时需要 redirect_uri,在用 code 换取 token 的请求中,同样需要 提供这个 redirect_uri 作为验证。Xero 服务器会校验:
    • 请求 token 时提供的 redirect_uri 是否与初始请求授权码时用的 redirect_uri 一致。
    • 这个 redirect_uri 是否在你的 Xero App 配置中被注册和允许。
      任何不一致都会导致验证失败,invalid_client 是常见的报错之一。
  • 操作步骤:
    1. 检查 Xero App 配置: 登录 Xero 开发者中心,在你的应用配置里找到 "OAuth 2.0 redirect URI" 或类似名称的设置。记录下你登记的所有 URI。
    2. 检查代码中的 redirect_uri
      • 在你发起授权请求的代码中(用户访问的第一个URL,如 https://login.xero.com/identity/connect/authorize?redirect_uri=...),确认使用的 $redirectUri 变量值。
      • 在你用 cURL 请求 /connect/token 的代码中,确认 CURLOPT_POSTFIELDS 数组里 'redirect_uri' 键对应的值。
    3. 进行三方比对: 确保以上三个地方的 redirect_uri 完全相同 。注意:
      • 大小写敏感: http://localhost/Callbackhttp://localhost/callback 是不同的。
      • 尾部斜杠: http://localhost/myapphttp://localhost/myapp/ 是不同的。
      • HTTP vs HTTPS: http://https:// 是不同的。
      • 端口号: 如果你的本地开发环境使用了特定端口(如 http://localhost:8000/callback.php),那么这个端口号也必须包含在内,并且和 Xero App 中注册的一致。
      • localhost 的处理: 检查你的代码和 Xero App 注册的是否都是 http://localhost,或者都是 http://127.0.0.1。两者需要统一。
  • 代码示例(对照检查):
    <?php
    session_start();
    
    // *** 关键点 1: 这里的 $redirectUri ** *
    $redirectUri='http://localhost/starter/beta.php'; // 假设你在 Xero App 中注册的就是这个精确的字符串
    
    // ...
    
    // callback from xero
    if($_GET['code'] /* ... */){
        // ...
        $tokenUrl = 'https://identity.xero.com/connect/token';
        $ch = curl_init($tokenUrl);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
            'grant_type' => 'authorization_code',
            'client_id' => $clientId,
            'code' => $code,
            // *** 关键点 2: 这里的 redirect_uri 必须和上面的 $redirectUri 完全一致 ** *
            'redirect_uri'=>$redirectUri,
            'code_verifier' =>$codeVerifier // 我们稍后会讨论这个
        ]));
        // ...
        curl_close($ch);
        print_r($response);
        exit();
    } else {
        // initial auth
        // ...
        // *** 关键点 3: 生成授权 URL 时,这里的 $redirectUri 也必须完全一致 ** *
        $url = "https://login.xero.com/identity/connect/authorize?response_type=code&client_id=$clientId&redirect_uri=$redirectUri&scope=openid profile email accounting.transactions&state=$state&code_challenge=$codeChallenge&code_challenge_method=S256";
        header("Location:$url");
        exit();
    }
    
    // Helper function (如果你的代码里没有定义)
    function base64url_encode($data) {
       return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
    ?>
    
  • 安全建议:
    • 生产环境中 必须 使用 HTTPS 协议的 redirect_uri
    • 避免在 Xero App 配置中注册过于宽泛的 redirect_uri,比如只注册根域名。精确到具体的回调路径。
    • 对于本地开发使用 http://localhost,确保只在开发模式下使用,并且在 Xero App 中明确注册了它。

3. 确认 Xero App 配置

检查你的应用在 Xero 开发者中心是否正确配置为支持 PKCE 流程。

  • 原理: Xero 需要知道你的应用类型以及它将如何进行身份验证。如果你的应用配置与 PKCE 流程不符,或者没有正确添加回调地址,也可能导致验证失败。
  • 操作步骤:
    1. 登录 Xero 开发者中心,进入 "My Apps"。
    2. 选择你的应用。
    3. 检查 "App type"。通常,需要是 "Web app" 类型才能较好地支持 PKCE。确保没有错误地选择了仅支持 Client Credentials Flow 或其他不相关类型的选项。
    4. 再次确认 "OAuth 2.0 redirect URI" 列表中包含了你正在使用的、精确匹配的 URI。如果需要添加或修改,记得保存更改。更改后可能需要一点时间生效。

4. 验证 Code Verifier 和 Code Challenge

虽然 invalid_grant 是 PKCE 中 code_verifier 不匹配时更典型的错误,但也不能完全排除它间接导致 invalid_client 的可能性,特别是如果 Xero 的实现比较特殊。更重要的是,你的原始代码在处理 code_verifier 上存在严重隐患

  • 原理: PKCE 的核心在于:

    1. 客户端生成一个随机的、高熵的字符串,称为 code_verifier
    2. 客户端对 code_verifier 进行 SHA-256 哈希运算,然后进行 Base64URL 编码,得到 code_challenge
    3. 在发起授权请求时,带上 code_challengecode_challenge_method=S256
    4. 在收到 code 并请求 Token 时,带上原始的 code_verifier
    5. Xero 服务器会用收到的 code_verifier 执行同样的哈希和编码操作,与之前收到的 code_challenge 比对。匹配成功,才证明是同一个客户端发起了授权和令牌请求。
      你的示例代码中,$codeVerifier 是硬编码的。这在实际应用中是绝对错误 的。code_verifier 必须为每个授权流程动态生成 ,并且在回调时能够取回同一个 verifier。通常使用 Session 来存储。
  • 操作步骤与代码修正:

    1. 实现正确的 base64url_encode PHP 没有内置此函数。你需要自己实现,确保它替换了 Base64 中的 +/,并移除了尾部的 =
    function base64url_encode($data) {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
    
    1. 动态生成和存储 code_verifier 在发起授权请求之前,生成 code_verifier 并存入 Session。
    2. 从 Session 恢复 code_verifier 在回调处理中,从 Session 取出之前存储的 code_verifier 用于 Token 请求。
  • 修正后的代码示例:

    <?php
    session_start(); // 必须在所有输出之前调用
    
    // 确保 base64url_encode 函数存在
    if (!function_exists('base64url_encode')) {
        function base64url_encode($data) {
            return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
        }
    }
    
    $clientId = 'BFFD3**** **** **** *B848C70'; // 从配置或环境变量读取更佳
    $redirectUri = 'http://localhost/starter/beta.php'; // 确保与Xero配置及后面请求一致
    $state = bin2hex(random_bytes(16)); // 每次请求都生成随机 state
    
    // Callback from Xero
    if (isset($_GET['code'])) {
        // 验证 state 防止 CSRF 攻击
        if (!isset($_GET['state']) || !isset($_SESSION['oauth2_state']) || $_GET['state'] !== $_SESSION['oauth2_state']) {
            unset($_SESSION['oauth2_state']);
            unset($_SESSION['pkce_code_verifier']); // 清理 verifier
            exit('Invalid state parameter');
        }
    
        // 从 Session 中取出 code_verifier
        if (!isset($_SESSION['pkce_code_verifier'])) {
             exit('Code verifier not found in session. Authentication flow broken.');
        }
        $codeVerifier = $_SESSION['pkce_code_verifier'];
    
        // 清理 session 中的临时数据
        unset($_SESSION['oauth2_state']);
        unset($_SESSION['pkce_code_verifier']);
    
        $code = $_GET['code'];
        $tokenUrl = 'https://identity.xero.com/connect/token';
    
        $postData = [
            'grant_type' => 'authorization_code',
            'client_id' => $clientId,
            'code' => $code,
            'redirect_uri' => $redirectUri, // 再次确认与Xero配置、初始请求一致
            'code_verifier' => $codeVerifier // 使用从 Session 恢复的值
        ];
    
        $ch = curl_init($tokenUrl);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/x-www-form-urlencoded',
            // Xero 可能不需要 Authorization header for PKCE token request, 
            // unlike flows with client secret. Check their docs if unsure.
        ]);
        // 增加详细的错误输出
        curl_setopt($ch, CURLOPT_FAILONERROR, false); // 不要让 curl 在 4xx/5xx 时直接失败
        curl_setopt($ch, CURLOPT_VERBOSE, true); // 打开详细日志(调试用)
        $streamVerbose = fopen('php://temp', 'w+');
        curl_setopt($ch, CURLOPT_STDERR, $streamVerbose);
    
    
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    
        rewind($streamVerbose);
        $verboseLog = stream_get_contents($streamVerbose);
    
    
        if (curl_errno($ch)) {
            echo 'cURL Error: ' . curl_error($ch);
        } else {
            echo "HTTP Status Code: " . $httpCode . "\n";
            echo "Response Body:\n";
            print_r($response); // 直接打印 Xero 的原始响应
            echo "\ncURL Verbose Log:\n";
            echo htmlspecialchars($verboseLog); // 显示 cURL 详细日志
        }
        curl_close($ch);
        exit();
    
    } else {
        // Initial auth request generation
    
        // 生成 code_verifier (建议长度在 43-128 字符之间)
        $codeVerifier = bin2hex(random_bytes(64)); // 生成 128 字符的 verifier
    
        // 存储到 Session
        $_SESSION['pkce_code_verifier'] = $codeVerifier;
        $_SESSION['oauth2_state'] = $state; // 存储 state
    
        // 计算 code_challenge
        $codeChallenge = base64url_encode(hash('sha256', $codeVerifier, true));
    
        $scope = 'openid profile email accounting.transactions'; // 根据需要调整 scope
        $authUrlParams = [
            'response_type' => 'code',
            'client_id' => $clientId,
            'redirect_uri' => $redirectUri,
            'scope' => $scope,
            'state' => $state,
            'code_challenge' => $codeChallenge,
            'code_challenge_method' => 'S256'
        ];
    
        $url = "https://login.xero.com/identity/connect/authorize?" . http_build_query($authUrlParams);
    
        header("Location: " . $url);
        exit();
    }
    ?>
    
  • 进阶技巧:

    • 使用更健壮的随机数生成器(random_bytes 是不错的选择)。
    • state 参数也应该动态生成并存储在 Session 中,用于回调时验证,防止 CSRF 攻击。上面的修正代码已包含此实践。

5. 检查 cURL 请求细节

最后,检查一下发送到 /connect/token 的 HTTP 请求本身。

  • 原理: 虽然你的 cURL 请求看起来基本正确,但有些细节可能影响服务器的处理。比如 Content-Type 头。
  • 操作步骤:
    1. 确保使用 POST 方法: curl_setopt($ch, CURLOPT_POST, true); 确保了这点。
    2. 设置 Content-Type Header: 明确告知服务器你发送的数据是 application/x-www-form-urlencoded 格式,这与 http_build_query 的输出格式一致。虽然 cURL 经常能自动处理,但显式设置是好习惯。
    3. 检查 Body 格式: 确保 CURLOPT_POSTFIELDS 使用的是 http_build_query 生成的字符串,或者是一个关联数组(cURL 会自动处理为 multipart/form-datax-www-form-urlencoded,通常后者是令牌端点期望的)。使用 http_build_query 是标准做法。
    4. 详细调试输出: 在 cURL 选项中加入 CURLOPT_VERBOSECURLINFO_HEADER_OUT 可以看到完整的请求头和响应信息,有助于诊断。上面修正代码示例中增加了详细日志输出。
  • 代码示例 (已包含在步骤4的修正代码中):
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/x-www-form-urlencoded'
    ]);
    // ... 增加 verbose 输出设置 ...
    
  • 进阶技巧:
    • 考虑使用成熟的 HTTP 客户端库(如 Guzzle for PHP)来替代原生 cURL。这些库通常封装了更多细节,提供了更友好的接口和错误处理机制。

总结关键点

遇到 Xero API PKCE 流程中的 invalid_client 错误时,不要只盯着 Client ID 看。按以下顺序排查通常能快速定位问题:

  1. Redirect URI (重定向 URI): 检查 Xero App 配置、发起授权请求、请求 Token 三处的 URI 是否完全、绝对、一模一样 。这是最常见的罪魁祸首。
  2. Client ID: 仔细核对代码中的 ID 和 Xero 开发者中心提供的是否一致,无空格、无拼写错误。
  3. Xero App 配置: 确认应用类型正确,且 Redirect URI 已被正确添加并保存。
  4. PKCE 实现细节: 确保 code_verifier 是动态生成且通过 Session 等方式正确传递的,base64url_encode 函数实现无误。使用 state 参数进行 CSRF 防护。
  5. HTTP 请求细节: 确认 /connect/token 请求是 POST 方法,Content-Type 正确,参数都按要求传递了。

通过这几步细致的排查,你应该就能揪出那个导致 invalid_client 的“内鬼”,顺利完成 Xero 的 PKCE 认证流程。