PAYATHON 2026

Expo Web Google OAuth 为何立即返回 dismiss,且回调后弹窗不关闭?

数据小陈

结论

先排查 Web 弹窗会话是否完成,以及主窗口与弹窗能否正常通信。后端 authorize 和 callback API 被访问,只说明请求到达了接口,无法证明 Expo 已收到授权结果。

现有代码已经调用 WebBrowser.maybeCompleteAuthSession(),不能直接归因于漏写了这个调用。需要确认它是否在最终回调页面执行、返回了什么,以及回调地址是否符合当前会话的要求。对于“弹窗仍然打开,主窗口却立即收到 dismiss”的现象,还应检查 Cross-Origin-Opener-Policy(COOP)。由于缺少依赖版本、响应头和回调参数,目前无法确定唯一原因。

AuthSession 返回结果的判断字段是 type,代码中的 response?.type === "success" 用法正确。

先检查弹窗为何被判定为 dismiss

Expo 的 Web 实现会检查弹窗是否关闭,检测到关闭后可以返回 { type: "dismiss" }。相关逻辑见 Expo WebBrowser 源码。实际行为仍需结合项目安装的版本判断。

如果弹窗肉眼可见地保持打开,却已经返回 dismiss,请查看主页面和重定向链涉及的响应头,尤其是:

Cross-Origin-Opener-Policy: same-origin

COOP 导致窗口进入不同的 browsing context group 时,主窗口持有的窗口引用可能显示 closed === true,即使弹窗实际上仍然打开。这与所描述的现象相符,值得排查,但还不能认定是本次故障的原因。参见 MDN 的 window.open() 说明。

如果确认当前隔离策略切断了窗口联系,可以根据部署环境评估使用:

Cross-Origin-Opener-Policy: same-origin-allow-popups

这是 HTTP 响应头,需要在实际提供页面的服务器或托管平台配置。它能否解决问题取决于整个导航过程的策略。如果应用还依赖 cross-origin isolation,调整前也要评估对相关功能的影响。有关窗口策略之间的关系,参见 COOP 文档。

确认最终回调页面能够完成会话

maybeCompleteAuthSession() 应在弹窗最终返回的页面执行。Expo 会检查本地保存的会话信息和回调 URL;同源要求包括协议、主机名和端口。例如,从 http://localhost:8081 发起的会话,不应最终返回 http://127.0.0.1:8081 或其他端口。参见 Expo WebBrowser 文档。

当前 Web 回调地址写死为:

http://localhost:8081/login

先确认主窗口确实从这个 origin 打开,再在弹窗的开发者工具中检查:

  1. 最后加载的是否是预期的 /login 页面。
  2. 该页面是否成功执行了 JavaScript。
  3. 是否被路由守卫或 isAuthenticated 对应的跳转提前带到了其他页面。
  4. 主窗口是否在授权过程中刷新,导致原来的监听和请求对象丢失。

为减少登录页其他逻辑的干扰,可以使用独立回调路由。例如新增 app/oauth/callback.tsx:

import { useEffect, useState } from "react";
import { Text } from "react-native";
import * as WebBrowser from "expo-web-browser";

export default function OAuthCallbackScreen() {
  const [message, setMessage] = useState("正在完成登录…");

  useEffect(() => {
    const result = WebBrowser.maybeCompleteAuthSession();
    console.info("OAuth completion:", result);
    setMessage(result.message);
  }, []);

  return <Text>{message}</Text>;
}

这个示例用于 Web 回调页。该路由应允许未登录用户访问,并避免在完成会话前跳转。采用独立路由后,需要把前端请求和后端最终跳转目标一并改到对应地址。

查看 result.message:找不到会话时,检查 origin、存储以及会话是否已被清理;URL 不匹配时,核对实际落地路径。maybeCompleteAuthSession() 自身返回的 success 表示完成会话的步骤成功,不等于后端已经建立登录态。

不要通过手动 window.close() 或调用 dismissAuthSession() 来替代结果传递,以免把关闭弹窗和完成授权混在一起。

核对后端是否把结果返回给了正确的一次请求

当前 authorizationEndpoint 指向自己的后端,至少需要区分两段流程:

主窗口 → 后端 /api/auth/authorize → Google
Google → 后端 callback → 前端回调页面 → 主窗口

由于没有提供后端实现,无法确定返回的是 Google 原始授权码,还是后端签发的一次性兑换码。需要明确以下约定:

  • 返回前端的 state 必须对应最初的 Expo 请求。如果后端为 Google 授权另外生成了 state,应维护两者的关联。
  • 前端按授权码流程调用 auth/token 时,最终回调需要提供可供该接口兑换的 code。
  • code_verifier 应与签发该 code 时使用的 code_challenge 对应。如果后端独立管理 Google 的 PKCE,不能直接假定前端 verifier 就是 Google 所需的 verifier。
  • 如果 Google 授权码已经在后端兑换,不应把同一个码再次交给前端重复兑换。

所描述的 /login?state=.... 可能只是 URL 的简写。如果完整 URL 确实只有 state,现有的 const { code } = response.params 就拿不到授权码。即使弹窗会话完成,也无法按当前逻辑兑换 token,state 不能充当 code。

clientId: "google" 也需要根据后端协议判断,它可能是后端接受的标识。这里请求的是自有授权接口,不能仅凭这个字符串断定配置错误。

在主窗口验证结果,再兑换 code

可以在配置中明确写出授权码流程。以下片段用于替换 Web 登录相关配置,discovery 沿用现有后端端点:

const redirectUri = makeRedirectUri({
  path: "oauth/callback",
});

const [request, response, promptAsync] = useAuthRequest(
  {
    clientId: "google",
    redirectUri,
    scopes: ["openid", "profile", "email"],
    responseType: ResponseType.Code,
    usePKCE: true,
  },
  discovery,
);

ResponseType.Code 和 usePKCE: true 在当前文档中已有默认值。显式写出主要是为了方便检查协议,不能保证解决 dismiss 问题。参见 Expo AuthSession 参数文档。原生端的 scheme 配置仍需按平台单独保留。

登录按钮继续在点击事件中直接触发授权,并在 request 未就绪时禁用:

<SSOButton
  provider="google"
  disabled={!request}
  loading={!request}
  onPress={() => {
    void promptAsync();
  }}
/>

原代码的点击处理已经符合这个基本形式。Web 上不要在打开弹窗之前插入耗时的异步步骤,相关要求见 Expo OAuth 指南。

在现有响应处理中,先检查结果和 code,再按后端协议调用接口:

const handleAuthResponse = async (
  result: AuthSessionResult | null,
) => {
  if (!result) return;

  if (result.type !== "success") {
    console.info("OAuth result:", result.type);
    return;
  }

  const code = result.params.code;
  if (!code) {
    console.error("OAuth callback did not include code");
    return;
  }

  try {
    const data: {
      code: string;
      platform: string;
      code_verifier?: string;
    } = {
      code,
      platform: "web",
    };

    if (request?.codeVerifier) {
      data.code_verifier = request.codeVerifier;
    }

    await httpClient.post("auth/token", data);
    // 根据后端响应更新 AuthContext 中的登录状态。
  } catch (error) {
    console.error("OAuth token exchange failed:", error);
  }
};

这里需要从 expo-auth-session 导入 AuthSessionResult 类型。请求体沿用了现有接口字段,后端是否要求额外参数以实际协议为准。生产环境应移除原代码中打印完整回调参数和 token 响应的日志。

如何判断修复到了哪一步

按顺序验证:主窗口保持原会话,弹窗返回预期地址,回调页完成会话,主窗口收到 response.type === "success",响应含有效 code,最后再验证 token 兑换和登录态更新。

原代码只打印了 tokenResponse,没有展示更新 AuthContext 的步骤。因此,即使已经进入 success,也需要确认 isAuthenticated 是否正确更新,才能触发现有的登录后跳转。

所贴代码还包含 \>、\[ 等转义、不完整的 JSX,以及未展示的声明,需要在实际源码中核对。但仅凭粘贴片段,无法断定它们造成了已经观察到的运行时 OAuth 故障。

备注:内容仅供参考。