PAYATHON 2026

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:

  1. 将用户重定向到 Xero 授权页面;
  2. Xero 回调 Bagisto,并提供一次性 code;
  3. Bagisto 使用该 code 请求 access token;
  4. 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,但正文是 HTMLURL 配错、被重定向到登录页、代理返回页面
301、302、307、308Token Endpoint、协议或代理配置
400 且正文是 JSONcode、grant_type、redirect_uri 等 OAuth 参数
401Client ID、Client Secret、Basic Auth
403WAF、代理、应用权限或网络策略
404Token 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 的回调通知不能直接当作可以无条件信任的”已收款证明”。是否发货,应以服务端重新查询到的权威状态和业务规则为准。

推荐排查顺序

  1. 输出脱敏后的最终 Token Endpoint,确认它是 https://identity.xero.com/connect/token。
  2. 捕获原始 HTTP 状态码、响应头和响应体,确认收到的是 HTML、重定向还是空内容。
  3. 确认请求使用 POST 和 application/x-www-form-urlencoded。
  4. 核对 Client ID、Client Secret 和 HTTP Basic Authentication。
  5. 核对 grant_type、authorization code,以及完全一致的 redirect_uri。
  6. 确认 authorization code 没有过期或被重复消费。
  7. 使用最小 curl 请求,区分 Xero/OAuth 配置问题与 Bagisto 内部问题。
  8. 检查反向代理、WAF、DNS、TLS 和服务器出站网络。
  9. 清理或重新加载 Laravel 配置缓存。
  10. 修复后,再补充 token 加密存储、refresh token 轮换、并发锁、webhook 验签和幂等处理。

必须看到原始 HTTP 响应,才能确定这次错误的具体原因。仅凭 Invalid response received from Authorization Server. Expected JSON.,无法判断是 Xero 拒绝了请求参数,还是请求根本没有到达正确的 Xero Token Endpoint。

备注:内容仅供参考。