Bagisto 集成 Xero 时 Authorization Server 返回无效响应
结论
这个异常通常不是 getAccessToken() 的解析逻辑出了问题,而是请求没有从 Xero 的 OAuth 2.0 Token Endpoint 收到预期的 JSON 响应。实际返回的内容可能是 HTML 错误页、重定向页面、代理或 WAF 页面,也可能是空响应。
排查时不要只盯着这行代码:
$response = $this->getParsedResponse($request);
它只是发现异常的位置。真正需要确认的是:
- 请求最终发送到了哪个 URL;
- HTTP 状态码和响应头是什么;
- 未经解析的原始响应体是什么;
- Token Endpoint、grant type、redirect URI 和客户端认证方式是否符合 Xero OAuth 2.0 的要求。
最常见的原因
Token Endpoint 配置错误
Xero OAuth 2.0 的授权地址和 Token Endpoint 是两个不同的地址。常规配置类似:
Authorization Endpoint:
https://login.xero.com/identity/connect/authorize
Token Endpoint:
https://identity.xero.com/connect/token
如果误把授权地址、旧版 OAuth 地址、回调地址或普通 API 地址设为 Token Endpoint,服务器往往会返回 HTML 页面,随后触发 “Expected JSON”。
还要检查 URL 是否存在以下问题:
- 拼写错误;
- 多余的路径;
- 使用了
http; - 环境变量中混入空格或引号;
- 配置缓存仍保留旧值。
在 Laravel 或 Bagisto 中修改 .env 后,还要留意配置缓存。如果项目允许执行 Artisan,可以重新加载配置:
php artisan config:clear
php artisan cache:clear
不要在生产环境中直接清理缓存。执行前应先确认部署方式和影响范围。
OAuth 流程或 grant type 使用错误
普通的 Xero OAuth 2.0 集成通常使用 Authorization Code Flow:
- 将用户重定向到 Xero 授权页面;
- Xero 回调 Bagisto,并提供一次性
code; - Bagisto 使用该
code请求 access token; - access token 过期后,使用 refresh token 换取新 token。
交换授权码时,请求参数应包含:
grant_type=authorization_code
code=...
redirect_uri=...
刷新 token 时应使用:
grant_type=refresh_token
refresh_token=...
普通应用不能直接改用 client_credentials。Xero 的 Custom Connection 是否支持这种模式,取决于账户、应用类型和当前产品条件,不能只靠修改 grant_type 替代正常的授权流程。
redirect_uri 不一致
交换授权码时提交的 redirect_uri 必须与以下地址一致:
- Xero 开发者后台登记的 Redirect URI;
- 发起授权请求时使用的 Redirect URI;
- 请求 token 时提交的 Redirect URI。
协议、域名、端口、路径和末尾斜杠都可能影响匹配。例如,不能假定下面两个地址等价:
https://shop.example.com/xero/callback
https://shop.example.com/xero/callback/
如果 Bagisto 部署在反向代理、负载均衡器或 CDN 后面,还要确认 Laravel 能否正确识别外部 HTTPS。否则应用可能生成 http:// 回调地址,而 Xero 后台登记的却是 https://。
Client ID、Client Secret 或认证方式不正确
Token Endpoint 一般要求客户端通过 HTTP Basic Authentication 发送凭据,格式如下:
Authorization: Basic base64(client_id:client_secret)
请求体使用:
Content-Type: application/x-www-form-urlencoded
如果使用兼容 Xero 的 OAuth provider 或官方 SDK,通常应由库生成请求,不要再手工添加另一套认证头。
常见问题有:
- 复制了另一个应用的 Client ID;
- Client Secret 已重新生成,但 Bagisto 仍在使用旧值;
- Secret 前后带有不可见空格;
- 环境变量包含额外引号;
- 同时在请求体和 Basic Auth 中传递凭据,造成重复或冲突。
授权码已失效或被重复使用
Authorization Code 是短期有效的一次性凭据。下面这些情况都可能导致交换失败:
- 同一授权码被使用两次;
- 用户停留太久后才进入回调;
- 回调接口发生重试;
- 多个请求同时消费同一个授权码;
- 把 URL 编码后的完整回调参数误当成
code。
正常情况下,OAuth 错误也可能以 JSON 返回。但如果请求经过了错误的网关、代理或端点,最终收到的仍可能是 HTML,因此必须检查原始响应。
请求被代理、WAF 或网络环境拦截
如果原始响应中出现以下内容,请求可能根本没有到达 Xero:
<html>
<title>Access Denied</title>
</html>
或者:
502 Bad Gateway
504 Gateway Timeout
SSL certificate problem
此时需要检查:
- 服务器能否访问
identity.xero.com:443; - 企业代理是否返回了认证页面;
- WAF、防火墙或 CDN 是否拦截外部 OAuth 请求;
- PHP/cURL 使用的 CA 证书是否有效;
- DNS 能否正常解析;
- 响应是否被重定向到登录页或安全验证页。
不要通过关闭 TLS 证书验证来解决问题。这样会掩盖证书链或代理配置错误,还会引入安全风险。
先记录原始 HTTP 响应
当前库在解析失败后只给出 “Expected JSON”,提供的信息不够。应在开发或测试环境中记录:
- 最终请求 URL;
- HTTP 状态码;
Content-Type;Location响应头;- 响应体前几百个字符;
- 是否发生重定向。
以下敏感信息绝对不能写入日志:
client_secret;- authorization code;
- access token;
- refresh token;
- 完整的
Authorization请求头。
如果能访问底层 PSR-7 Response,可以临时使用类似下面的代码进行检查:
$statusCode = $response->getStatusCode();
$contentType = $response->getHeaderLine('Content-Type');
$location = $response->getHeaderLine('Location');
$rawBody = (string) $response->getBody();
logger()->debug('Xero token response', [
'status' => $statusCode,
'content_type' => $contentType,
'location' => $location,
'body_preview' => mb_substr($rawBody, 0, 500),
]);
这段代码需要放在 JSON 解析之前,具体位置取决于所使用的 OAuth 客户端库。不建议长期直接修改 vendor 目录,更稳妥的方式是通过 HTTP client middleware、事件钩子或自定义 provider 记录经过脱敏的响应。
拿到原始响应后,通常很快就能判断排查方向:
| HTTP 状态或响应内容 | 优先检查 |
|---|---|
200,但正文是 HTML | URL 配错、被重定向到登录页、代理返回页面 |
301、302、307、308 | Token Endpoint、协议或代理配置 |
400 且正文是 JSON | code、grant_type、redirect_uri 等 OAuth 参数 |
401 | Client ID、Client Secret、Basic Auth |
403 | WAF、代理、应用权限或网络策略 |
404 | Token Endpoint 路径错误 |
429 | 请求过于频繁,需要退避和限流 |
500、502、503、504 | 上游服务、代理或网络故障 |
| 空响应 | TLS、超时、连接中断或日志读取方式有误 |
用最小请求验证 Token Endpoint
可以在安全的测试环境中绕过 Bagisto,直接验证 token 交换请求。下面只展示请求结构,不要把真实凭据写进脚本、提交到 Git 或粘贴到公开日志:
curl --request POST \
--url 'https://identity.xero.com/connect/token' \
--user 'CLIENT_ID:CLIENT_SECRET' \
--header 'Accept: application/json' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code=AUTHORIZATION_CODE' \
--data-urlencode 'redirect_uri=https://shop.example.com/xero/callback'
authorization code 只能使用一次。不要先用 curl 成功消费授权码,再让 Bagisto 使用同一个 code。测试 Bagisto 时应重新发起授权,获取新的 code。
如果最小请求返回 JSON,而 Bagisto 返回 HTML,问题通常出在应用配置、HTTP 客户端、中间代理或旧缓存。如果最小请求也返回非 JSON,应重点检查 endpoint、网络和客户端认证。
PHP 请求示例
不依赖具体 OAuth provider 时,token 请求的核心结构如下:
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://identity.xero.com',
'timeout' => 15,
'connect_timeout' => 5,
'http_errors' => false,
'allow_redirects' => false,
]);
$response = $client->post('/connect/token', [
'auth' => [
config('services.xero.client_id'),
config('services.xero.client_secret'),
],
'headers' => [
'Accept' => 'application/json',
],
'form_params' => [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'redirect_uri' => config('services.xero.redirect_uri'),
],
]);
$status = $response->getStatusCode();
$contentType = $response->getHeaderLine('Content-Type');
$rawBody = (string) $response->getBody();
if ($status < 200 || $status >= 300) {
throw new RuntimeException(
sprintf(
'Xero token request failed: HTTP %d, Content-Type: %s, Body: %s',
$status,
$contentType,
mb_substr($rawBody, 0, 500)
)
);
}
$data = json_decode($rawBody, true);
if (!is_array($data) || json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(
sprintf(
'Xero returned a non-JSON token response. Content-Type: %s, Body: %s',
$contentType,
mb_substr($rawBody, 0, 500)
)
);
}
实际项目中的异常消息和日志必须脱敏。如果使用 Xero SDK 或 league/oauth2-client 的 Xero provider,应优先修正 provider 配置,不要重复实现 OAuth 协议。
检查 OAuth provider 配置
配置结构可以整理为:
return [
'xero' => [
'client_id' => env('XERO_CLIENT_ID'),
'client_secret' => env('XERO_CLIENT_SECRET'),
'redirect_uri' => env('XERO_REDIRECT_URI'),
],
];
回调处理至少还应验证 state,以防止登录 CSRF:
if (
!$request->filled('state') ||
!hash_equals((string) session('xero_oauth_state'), (string) $request->input('state'))
) {
abort(403, 'Invalid OAuth state.');
}
if ($request->filled('error')) {
throw new RuntimeException(
'Xero authorization failed: ' . $request->input('error')
);
}
$authorizationCode = $request->input('code');
if (!$authorizationCode) {
throw new RuntimeException('Xero callback did not contain an authorization code.');
}
如果集成需要长期在后台创建 invoice,授权时通常要请求能够获取 refresh token 的相应 scope,例如 offline_access。具体业务 scope 应根据实际调用的 Xero API 确定,不要申请无关权限。
Refresh token 在刷新后可能发生轮换。应用需要原子地保存响应中最新的 access token、refresh token 和过期时间,避免继续使用旧 refresh token。如果多个队列任务可能同时刷新,还应加锁,防止并发刷新导致凭据相互覆盖。
Bagisto 集成还需要注意的边界
获取 access token 只是第一步。同步订单、发票和付款时,还需要处理好以下事项:
- 在 Bagisto 中保存订单与 Xero invoice ID 的映射;
- 使用幂等键或本地同步状态,避免队列重试时重复创建 invoice;
- 不要只根据 webhook 就把订单标记为可发货;
- 校验 Xero webhook 签名;
- 收到通知后,通过 Xero API 再次查询 invoice/payment 的当前状态;
- 校验金额、币种、invoice ID 和租户关系;
- webhook 应快速返回成功,后续处理交给队列;
- 对超时、
429和暂时性5xx执行有限次数的指数退避; - 对永久性
4xx记录可操作的错误信息,不要无限重试。
Xero 的回调通知不能直接当作可以无条件信任的”已收款证明”。是否发货,应以服务端重新查询到的权威状态和业务规则为准。
推荐排查顺序
- 输出脱敏后的最终 Token Endpoint,确认它是
https://identity.xero.com/connect/token。 - 捕获原始 HTTP 状态码、响应头和响应体,确认收到的是 HTML、重定向还是空内容。
- 确认请求使用
POST和application/x-www-form-urlencoded。 - 核对 Client ID、Client Secret 和 HTTP Basic Authentication。
- 核对
grant_type、authorization code,以及完全一致的redirect_uri。 - 确认 authorization code 没有过期或被重复消费。
- 使用最小
curl请求,区分 Xero/OAuth 配置问题与 Bagisto 内部问题。 - 检查反向代理、WAF、DNS、TLS 和服务器出站网络。
- 清理或重新加载 Laravel 配置缓存。
- 修复后,再补充 token 加密存储、refresh token 轮换、并发锁、webhook 验签和幂等处理。
必须看到原始 HTTP 响应,才能确定这次错误的具体原因。仅凭 Invalid response received from Authorization Server. Expected JSON.,无法判断是 Xero 拒绝了请求参数,还是请求根本没有到达正确的 Xero Token Endpoint。
备注:内容仅供参考。
