tva
← 洞察

Docker 认证导致移动端登录失败:跨平台故障排查

工单在一个星期四下午到来:“Google 登录在移动端无法使用。”支持讨论很短:三名用户,全都使用 iOS,全都反映 OAuth 登录一直显示加载动画,却无法完成。网页端正常,桌面端正常。只有移动端失败,而且仅限 OAuth 登录;邮箱和密码认证始终不受影响。

这类故障报告乍看很快就能修好,却让人困惑了一个下午。单个平台的认证流程失败这一表面症状,同时指向许多可能原因:重定向 URI、CORS、令牌交换、会话存储、客户端配置。每一项都合理,但最终都不是问题所在。

技术栈

应用运行在自托管 Supabase 实例上,完整的 Docker Compose 技术栈部署在专用服务器中。Supabase 将认证拆分为独立的微服务 GoTrue,负责 OAuth 流程、令牌签发和用户管理。GoTrue 通过直接连接 PostgreSQL,读写 auth 数据模式。前端是 React Native 应用,通过标准 JavaScript 客户端库与 Supabase 通信。

对希望掌控数据的团队,这是一种较常见的配置。自托管 Supabase 提供完整的 PostgreSQL 数据库、认证层、实时订阅和存储服务,全都运行在自行管理的容器中。代价是,托管服务通常在后台处理的配置细节,自行管理时可能变成显性的故障,而本次事件恰好说明了这一点。

第一个假设:重定向 URI 不匹配

移动端 OAuth 错误的排查几乎总是从重定向 URI 开始。原生应用使用自定义 URL scheme,例如 myapp://auth/callback,而不是 HTTPS URL。注册的回调地址与 OAuth 提供商预期不一致,就会产生我们所见的静默失败:OAuth 流程启动,浏览器打开,用户完成认证,但回调始终无法完成。

我们核查了 Google Cloud Console 中的重定向 URI 配置、Supabase 项目设置,以及 React Native 应用的深度链接配置。全部一致,双方的 URI 注册都正确。OAuth 授权同意页面也正常完成,说明 Google 已接受登录。失败发生在回调之后,令牌交换的某个环节。

第二个假设:CORS

跨源资源共享(CORS)响应头,是排查移动端 OAuth 时第二个常想到的原因。移动应用向自定义域名上的后端发起请求时,CORS 配置决定浏览器的安全模型是否允许该请求。CORS 策略配置不当会产生一类看起来与认证失败相同的错误。

但实际上,CORS 由浏览器强制执行;原生应用通过自身 HTTP 客户端发送的请求不受其限制。邮箱认证正常、OAuth 认证失败这一症状,与 CORS 故障并不一致。两者使用相同的 GoTrue 端点。如果 CORS 拒绝请求,很可能会影响两者,而非仅影响 OAuth。我们为移动客户端加入详细日志后,确认请求已经到达 GoTrue 服务,服务也有响应,状态码为 500。

查看实际日志

查看容器日志,是自托管排障与托管服务差异最明显的地方。Supabase Cloud 的仪表盘会展示各服务的结构化日志;自托管 Docker 则需要直接提取单个容器的日志:

docker logs supabase-auth --since 1h 2>&1 | grep -i error

GoTrue 容器一直在记录错误。相关日志如下:

level=error msg="Error creating oauth state" error="ERROR: function gen_random_uuid() does not exist (SQLSTATE 42883)"

这不是 OAuth 重定向错误,而是 PostgreSQL 函数解析失败。在 PostgreSQL 13 之前的版本中,gen_random_uuid() 仅由标准 PostgreSQL 扩展 pgcrypto 提供;Supabase 将它安装在 extensions 数据模式中,而非默认的 public 数据模式。从 PostgreSQL 13 起,该函数也内置于核心中,始终能够被解析,因此这一具体错误指向较早的主版本。GoTrue 调用没有数据模式限定的 gen_random_uuid(),期待 PostgreSQL 通过搜索路径找到函数,但没有找到。

search_path 的作用

PostgreSQL 按顺序搜索一系列数据模式,解析没有限定的数据表和函数名称。默认值是 "$user", public:先查找以当前数据库用户名命名的数据模式,再查找 public。如果函数位于其他数据模式,且调用没有显式加上数据模式名称,例如使用 extensions.gen_random_uuid() 而非 gen_random_uuid(),即使函数实际存在,PostgreSQL 也会返回“does not exist”错误。

Supabase 的托管部署会配置 PostgreSQL 的 search_path,加入 extensions、auth 和 public。这项配置在 Supabase 代码库中有记录,却未在自托管安装指南中突出说明。托管 Supabase 会自动应用它;自托管 Docker 则取决于已应用的环境变量和初始化脚本。我们的部署两者都没有应用。

为何移动端失败,网页端却正常

日志所揭示的原因并没有立即回答一个问题:为什么网页端认证正常,移动端却失败?答案在于不同环境中的 OAuth 流程不同。

使用 Supabase OAuth 的网页应用默认采用隐式授权流程:OAuth 回调后,授权服务器在 URL 片段中直接返回令牌。客户端从 URL 中提取并存储令牌,无需单独的令牌交换步骤就能建立会话,而这一代码路径恰好没有调用 gen_random_uuid()。

遵循当前安全最佳实践的移动应用使用 PKCE,即 Proof Key for Code Exchange。在 PKCE 中,客户端生成随机的代码验证值,将其哈希处理为代码挑战值,再把挑战值发送给授权服务器。回调后,客户端将原始代码验证值发送到令牌端点;端点校验成功后才签发令牌。失败的正是这个 PKCE 流程的代码路径。GoTrue 为 PKCE 流程生成 OAuth 状态时调用了 gen_random_uuid(),而 extensions 数据模式不在搜索路径中。

网页端的隐式授权流程绕过了这一特定代码路径,移动端的 PKCE 则没有。OAuth 实现上的细微差异,暴露了从部署时就存在于 Docker 技术栈中的配置错误;只是在移动客户端尝试 PKCE 流程之前,这条路径一直未被触发。

修复方法

正确修复是确保 PostgreSQL 的 search_path 包含 GoTrue 和 PostgREST 依赖的所有数据模式。在 Docker Compose 环境中,最直接的做法是在数据库层应用设置,这样无论哪个容器连接,配置都能持续生效:

-- Run once against the database
ALTER DATABASE postgres SET search_path TO extensions, auth, public, storage;
ALTER ROLE authenticator SET search_path TO extensions, auth, public, storage;
ALTER ROLE supabase_auth_admin SET search_path TO extensions, auth, public, storage;

另一种方式是通过 Docker Compose 中的 PostgreSQL 服务配置传入设置:

services:
  db:
    image: supabase/postgres:15.1.0.117
    command:
      - postgres
      - -c
      - search_path=extensions,auth,public,storage

我们同时应用了两种配置。重启 GoTrue 容器后,移动端 OAuth 正常完成。此前排查过程中我们一直没有积极测试的 Apple 登录,也立即恢复,确认问题与流程有关,而非某个登录提供商特有的问题。

这次事件揭示的自托管排障经验

从“移动端认证失败”定位到“PostgreSQL 的 search_path 错误”,耗时本可更短。以下经验值得总结。

容器日志是事实依据。我们在读取 GoTrue 日志之前,先追查了两个错误假设。日志的第一条相关记录就包含确切错误。在自托管 Docker 技术栈中,读日志应该是第一步,而非穷尽其他猜测后才采用的最后手段。

共享后端只在某个平台失败,几乎总是说明客户端触发的代码路径不同,而不是后端对该客户端的处理方式不同。后端并不知道自己服务的是移动应用还是浏览器,它遵循的是请求参数所决定的代码路径。如果理解 PKCE 相比隐式授权流程激活了哪条代码路径,就能立即将注意力转向 PKCE 流程状态。

托管服务与自托管服务的配置并不等同。Supabase Cloud 会应用一些已有文档记录、却不会在 Docker Compose 环境中自动应用的配置细节。当某个功能在 Supabase Cloud 中正常、自托管时却失败,差距几乎总是在这些配置中。官方自托管仓库的问题追踪系统,往往是定位具体缺失设置的最快途径。

所谓“只影响一个平台”的缺陷,往往一直存在,只是直到第一个客户端触发特定代码路径时才暴露。本例中的 search_path 错误,从 Docker 技术栈部署当天就存在,数月后接入移动端 OAuth 才显现。

相关洞察

延伸阅读