Xero API PKCE 报 invalid_client?原因分析与5步解决
2025-04-27 11:34:46
解决 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 里也带上了 code
和 state
参数。
看起来一切顺利,但当你拿着这个 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 流程中,导致这个错误的常见原因有:
- Client ID 确实错了: 最直接的原因,可能只是简单的复制粘贴失误,或者配置了错误环境的 Client ID。
- Redirect URI 不匹配: 这是个非常常见且隐蔽的坑!你在 Xero App 配置里登记的
redirect_uri
、发起授权请求时用的redirect_uri
、以及在请求 Token 时提供的redirect_uri
,这三者必须一字不差地完全一致 。 - Xero App 配置问题: 你在 Xero 开发者中心创建的应用配置可能有误,比如应用类型不支持 PKCE,或者
redirect_uri
没有正确添加。 - 请求格式或参数问题: 虽然不太可能直接报
invalid_client
(更可能是invalid_request
或invalid_grant
),但在某些边缘情况下,请求/connect/token
的方式,比如 HTTP 方法、Content-Type 或某些必需参数的缺失/错误,也可能被解释为客户端问题。
咱们挨个来排查。
排查和解决步骤
下面我们分步骤来定位并解决问题。请务必 严格按照顺序 检查,因为 Redirect URI 的问题最为普遍。
1. 核对 Client ID
别嫌烦,这是第一步。最简单的错误往往最容易被忽略。
- 原理:
client_id
是你在 Xero 平台上注册应用时获得的唯一标识符。Xero 通过它来识别是哪个应用在请求访问。如果这个 ID 不对,Xero 自然不认识你。 - 操作步骤:
- 登录 Xero 开发者中心。
- 进入 "My Apps",找到你正在使用的那个应用。
- 仔细核对应用详情页显示的 "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
是常见的报错之一。
- 请求 token 时提供的
- 操作步骤:
- 检查 Xero App 配置: 登录 Xero 开发者中心,在你的应用配置里找到 "OAuth 2.0 redirect URI" 或类似名称的设置。记录下你登记的所有 URI。
- 检查代码中的
redirect_uri
:- 在你发起授权请求的代码中(用户访问的第一个URL,如
https://login.xero.com/identity/connect/authorize?redirect_uri=...
),确认使用的$redirectUri
变量值。 - 在你用 cURL 请求
/connect/token
的代码中,确认CURLOPT_POSTFIELDS
数组里'redirect_uri'
键对应的值。
- 在你发起授权请求的代码中(用户访问的第一个URL,如
- 进行三方比对: 确保以上三个地方的
redirect_uri
完全相同 。注意:- 大小写敏感:
http://localhost/Callback
和http://localhost/callback
是不同的。 - 尾部斜杠:
http://localhost/myapp
和http://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 中明确注册了它。
- 生产环境中 必须 使用 HTTPS 协议的
3. 确认 Xero App 配置
检查你的应用在 Xero 开发者中心是否正确配置为支持 PKCE 流程。
- 原理: Xero 需要知道你的应用类型以及它将如何进行身份验证。如果你的应用配置与 PKCE 流程不符,或者没有正确添加回调地址,也可能导致验证失败。
- 操作步骤:
- 登录 Xero 开发者中心,进入 "My Apps"。
- 选择你的应用。
- 检查 "App type"。通常,需要是 "Web app" 类型才能较好地支持 PKCE。确保没有错误地选择了仅支持 Client Credentials Flow 或其他不相关类型的选项。
- 再次确认 "OAuth 2.0 redirect URI" 列表中包含了你正在使用的、精确匹配的 URI。如果需要添加或修改,记得保存更改。更改后可能需要一点时间生效。
4. 验证 Code Verifier 和 Code Challenge
虽然 invalid_grant
是 PKCE 中 code_verifier
不匹配时更典型的错误,但也不能完全排除它间接导致 invalid_client
的可能性,特别是如果 Xero 的实现比较特殊。更重要的是,你的原始代码在处理 code_verifier
上存在严重隐患 。
-
原理: PKCE 的核心在于:
- 客户端生成一个随机的、高熵的字符串,称为
code_verifier
。 - 客户端对
code_verifier
进行 SHA-256 哈希运算,然后进行 Base64URL 编码,得到code_challenge
。 - 在发起授权请求时,带上
code_challenge
和code_challenge_method=S256
。 - 在收到
code
并请求 Token 时,带上原始的code_verifier
。 - Xero 服务器会用收到的
code_verifier
执行同样的哈希和编码操作,与之前收到的code_challenge
比对。匹配成功,才证明是同一个客户端发起了授权和令牌请求。
你的示例代码中,$codeVerifier
是硬编码的。这在实际应用中是绝对错误 的。code_verifier
必须为每个授权流程动态生成 ,并且在回调时能够取回同一个verifier
。通常使用 Session 来存储。
- 客户端生成一个随机的、高熵的字符串,称为
-
操作步骤与代码修正:
- 实现正确的
base64url_encode
: PHP 没有内置此函数。你需要自己实现,确保它替换了 Base64 中的+
和/
,并移除了尾部的=
。
function base64url_encode($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
- 动态生成和存储
code_verifier
: 在发起授权请求之前,生成code_verifier
并存入 Session。 - 从 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 头。
- 操作步骤:
- 确保使用 POST 方法:
curl_setopt($ch, CURLOPT_POST, true);
确保了这点。 - 设置 Content-Type Header: 明确告知服务器你发送的数据是
application/x-www-form-urlencoded
格式,这与http_build_query
的输出格式一致。虽然 cURL 经常能自动处理,但显式设置是好习惯。 - 检查 Body 格式: 确保
CURLOPT_POSTFIELDS
使用的是http_build_query
生成的字符串,或者是一个关联数组(cURL 会自动处理为multipart/form-data
或x-www-form-urlencoded
,通常后者是令牌端点期望的)。使用http_build_query
是标准做法。 - 详细调试输出: 在 cURL 选项中加入
CURLOPT_VERBOSE
和CURLINFO_HEADER_OUT
可以看到完整的请求头和响应信息,有助于诊断。上面修正代码示例中增加了详细日志输出。
- 确保使用 POST 方法:
- 代码示例 (已包含在步骤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 看。按以下顺序排查通常能快速定位问题:
- Redirect URI (重定向 URI): 检查 Xero App 配置、发起授权请求、请求 Token 三处的 URI 是否完全、绝对、一模一样 。这是最常见的罪魁祸首。
- Client ID: 仔细核对代码中的 ID 和 Xero 开发者中心提供的是否一致,无空格、无拼写错误。
- Xero App 配置: 确认应用类型正确,且 Redirect URI 已被正确添加并保存。
- PKCE 实现细节: 确保
code_verifier
是动态生成且通过 Session 等方式正确传递的,base64url_encode
函数实现无误。使用state
参数进行 CSRF 防护。 - HTTP 请求细节: 确认
/connect/token
请求是 POST 方法,Content-Type
正确,参数都按要求传递了。
通过这几步细致的排查,你应该就能揪出那个导致 invalid_client
的“内鬼”,顺利完成 Xero 的 PKCE 认证流程。