一次 Sign in with Apple 接入,连续踩了六个坑。服务端日志从头到尾都是「登录成功」,用户看到的却是「没登录」。记录下来,省下下次的半夜。
Flutter + OpenHarmony 项目,接入苹果登录。鸿蒙 / Android 没有苹果原生 SDK,只能走 Web 授权流程(OAuth2 + form_post)。
架构一句话:App 内 WebView 加载苹果授权页 → 用户授权 → 苹果把 code / id_token 以跨域 POST 提交到服务端 callback → 服务端验签、建号、签发 JWT → 把登录态交回 App → App 写本地会话、跳主页。
看起来平平无奇,实际每一环都断过一次。
| # | 现象 | 真因 | 修法 |
|---|---|---|---|
| 1 | 服务端报「JWKS 中找不到对应公钥」,刷新到的公钥数量是 0 | 苹果把 id_token 签名密钥轮换成了 RSA / RS256,而代码只收 EC / P-256,RSA key 全被过滤掉 |
JWKS 解析同时支持 RSA(n/e)与 EC(x/y) |
| 2 | 服务端只收到一个没有参数的 GET | redirect_uri 用了不带 www 的域名,nginx 301 跳 www,POST 被降级成 GET 并丢弃 body | redirect_uri 必须是最终处理请求的地址,中间不能有跳转 |
| 3 | 改用系统浏览器后,服务端登录全成功,App 拿不到登录态 | 服务端 302 到自定义 scheme 后,登录态能否带回 App 取决于系统浏览器 / openLink 行为,不可控 | 改回 App 内 WebView |
| 4 | WebView 路线下,登录态依然丢失 | 服务端 302 到自定义 scheme 时,WebView 导航拦截器完全不回调(详见下文) | 服务端改返回 200 HTML 结果页,数据内嵌在页面里 |
| 5 | 结果页数据取到了(参数齐全),点「确认」却没反应 | 确认动作放在网页按钮上,要靠 JS 通道 / 深链拦截把点击传回 App,这两条通道在鸿蒙 Web 组件上都不可靠 | 确认动作改为 Flutter 原生 UI |
| 6 | 终于跳到主页,但所有接口全部调不通 | 装的是 debug 包,kReleaseMode == false → baseUrl 走 http://localhost,手机上根本没有本地后端 |
真机验收改用 release 构建 |
这是本次最大的认知收获,按可靠性排序:
✅ 可用 —— App 主动执行 JS 取页面数据
final raw = await controller.runJavaScriptReturningResult(
"(function(){var el=document.getElementById('apple-login-result');"
"return el?el.textContent:'';})()");
方向是 App → 网页,App 掌握主动权,什么时候取、取几次都能控。本次登录数据就是靠它拿到的,真机验证稳定。
⚠️ 不可靠 —— JS 通道(addJavaScriptChannel + 网页 postMessage)
注册依赖原生 WebView 实例已经创建,动态注册的时机并不总有保障。
⚠️ 不可靠 —— 网页 location.href = 自定义scheme 再由 App 拦截
对自定义 scheme 的导航是否回调 onNavigationRequest,行为不确定。
❌ 完全不行 —— 服务端 HTTP 302 到自定义 scheme
HTTP/1.1 302
Location: myapp://auth/apple?success=true...
这一跳不会触发 onNavigationRequest / onPageStarted(Android 的 shouldOverrideUrlLoading 同理)。这两个回调只对「页面发起的导航」生效——链接点击、JS 跳转——对服务端重定向不回调。登录态就是在这一环丢的,代码从头到尾没机会执行。
正确做法:自定义 scheme 回跳不要直接 302,改成返回一个 200 HTML 结果页,把登录数据内嵌进页面(比如放在 <script type="application/json"> 里),由 App 用 runJavaScriptReturningResult 主动取走。
第 5 个坑值得单独说。当时我在结果页放了一个「确认返回 App」按钮,onclick 里同时做了两件事:调 JS 通道通知 App、跳自定义 scheme 深链。看起来是双保险,实际上两条通道都不可靠,点了没反应。
原则:凡是 WebView 里能取到的数据,一律用 runJavaScriptReturningResult 取到 App 侧;确认、提交、跳转这类关键交互,必须用原生 UI 完成,不要交给网页按钮再想办法把结果传回来。
改完之后,确认卡片是 Flutter 原生 Widget,数据早就在 App 手里了,点一下直接写会话、跳主页,不再依赖任何跨端通道。
第 6 个坑最气人——症状是「跳到主页了但所有接口调不通」,极其容易误判成「登录态没写进去 / token 无效」,然后在会话代码里反复白查。
真相是构建模式:
static String get baseUrl {
if (kReleaseMode) return 'https://<生产域名>';
else return 'http://localhost'; // debug 包走这条
}
PC 上「开发态」意味着连我本机的服务;真机上「开发态」意味着连一个不存在的服务。手机上没有本地后端,请求打的是手机自己的 localhost。
通用原则:凡是按 kReleaseMode / kDebugMode 分支的配置——baseUrl、日志级别、埋点开关、mock 数据——debug 包装到真机上都会走「开发态」分支,语义和 PC 完全不同。真机验收前先确认构建模式,别只看 UI 跑不跑得通。
但不要为了省事把 dev baseUrl 改成生产地址:localhost 在 Web 本地调试时是刚需(flutter run -d chrome 与本地后端同机)。正确姿势是加编译期开关(--dart-define 之类),保持默认行为不变。
判别包是不是真 release(鸿蒙):解压 hap 看 libs/arm64-v8a/libflutter.so,release 引擎约 16MB、debug 引擎约 39MB;release 包有 libapp.so(AOT)、没有 kernel_blob.bin。
第 2 个坑导致了一条错误结论。当时现象是「服务端只收到无参 GET」,我据此判断「鸿蒙 Web 组件不支持 form_post」,改走系统浏览器;系统浏览器路线又因为深链回传不可控而失败——两个 bug 叠加,产生了第三次误判,绕了好几圈。
教训两条:
排查期间踩到的观测障碍:
print / developer.log 不进鸿蒙 hilog。抓全量 hilog -x 只有 Flutter 引擎噪音,别指望从这里看 Dart 侧链路。auth_page_loaded → callback_navigated → result_page_finished → result_json_extracted),肉眼可读,不依赖任何日志系统。联调期很有用,上线前记得摘掉。六个断点全部修完,链路打通。几个遗留的小优化(头像用了 http 明文地址、另一个第三方登录漏写了两个字段)不影响主流程,留着慢慢收拾。
最大的收获不是某个具体 API,而是三条方法论:跨端通道要分级信任、关键交互别放网页侧、真机验收先确认构建模式。
还没有评论,登录后来说两句。