Sign in with Apple 接入复盘:三条链路、五个死结,和一句骗了自己半个月的错误文案

xiezitai · 2026-11-03 16:48 · 11 次阅读

定制侠 iOS 版过审上架时,Sign in with Apple 是 4.8 条款的硬门槛。真正花时间的不是「调通一次登录」,而是三件事:

  1. 同一个 App 在三种运行形态下,要走三条不同的授权链路;
  2. 每条链路都有一处「看起来能跑、其实一直在丢数据」的陷阱;
  3. 服务端验签只要漏一道,前面所有工作都白做。

下面按链路拆开写,代码只留关键片段,标识符一律占位。


一、为什么必须做原生,而不是套个网页

4.8 的原文大意是:如果你的 App 使用了第三方或社交登录(微信、QQ、微博、Google、Facebook……),就必须同时提供 Sign in with Apple,并且体验要对等。

我们当时已经接了 QQ、微信、B站,所以这条是硬性的。而「体验对等」这三个字把捷径全堵死了:

曾经想过的做法 结果
WebView 套苹果授权网页,回调里拿 code 被拒。4.8 要求用官方 SDK(AuthenticationServices),网页套壳不算
只在 Web 端支持,App 里引导去浏览器 被拒。登录必须能在 App 内完成
加个按钮,但排在最后、颜色发灰 被拒。显著性要求「不弱于其他第三方登录」

所以 iOS 必须走原生。这也是「一个 App 三条链路」的由来。


二、三条链路:差异只有一个

三条链路的区别其实只有一句话——谁去换 id_token。

形态 client_id 用哪个 怎么拿凭证 谁验签
iOS 原生 Bundle ID AuthenticationServices 直接把 identityToken 交给 App App POST 给服务端,服务端验
Android / 鸿蒙 Services ID App 内 WebView 加载授权页,response_mode=form_post 苹果 POST 到服务端 callback,服务端用 code 换 token 再验
手机 Web Services ID 整页跳转苹果授权页,form_post 回服务端 同上,成功后 302 回落地页交接会话

有个地方必须记住:苹果后台的 Bundle ID 与 Services ID 是两套配置,Bundle ID 管原生,Services ID 管 Web 与安卓。

原生流程拿到的 aud 是 Bundle ID,Web 流程是 Services ID。服务端校验 aud 时必须两个都放行,否则会出现「iOS 能登、安卓登不了」这种偏科故障,而且报错只有一句 audience 校验失败。

三条链路不用写三套登录逻辑。把「拿到 id_token 之后」的全部处理抽成一份(取配置 → 验签 → 合并首次授权信息 → 建号/登录 → 发业务 token),前面三端只负责把 id_token 送进来。后面的修改都是单点的。


三、苹果后台:四个 ID、一把钥、两个 URL

配置本身不难,难的是概念多、名字像。要分清四样东西:

东西 用在哪 易错点
Team ID client_secret 的 iss 后台 Membership 里的 10 位串,不是账号名
App ID(Bundle ID) 原生流程的 client_id、AASA、entitlements 建 Services ID 时要选它作 Primary App ID
Services ID Web / 安卓 / 鸿蒙的 client_id 名字像 bundle id,但是另一个东西,别填混
Key(.p8)+ Key ID 签 client_secret 私钥只能下载一次,关掉页面就没了,只能重新建

Return URLs 有两条要登记,分别对应两条 Web 链路:

https://<你的域名>/api/auth/apple/<APP_ID>/callback     # App 内 WebView(安卓/鸿蒙)
https://m.<你的域名>/api/auth/apple/<APP_ID>/callback   # 手机浏览器 Web

三条硬性要求,贴在这免得反复踩:

最后一条是下一节的主角。


四、死结之一:回调只映射了 POST,用户一取消就 405

Web 链路最常见的第一坑,而且只在「用户点了取消」时出现——正常授权走 form_post 是 POST,一切正常;一取消,苹果改成 GET 302 回 redirect_uri,把 error 放在 query 里。

如果你的 controller 只写了 POST:

405 Request method 'GET' is not supported

而用户看到的是「登录失败」,你以为是授权环节出了问题,其实是自己的接口不接受 GET。

修法很短,两个分支都要有:

@RequestMapping(value = "/{appId}/callback", method = {GET, POST})
public void callback(...) {
    String appleError = request.getParameter("error");
    if (StringUtils.hasText(appleError)) {
        // 用户取消 / 授权失败:不要把 error 当成系统异常,按正常回跳处理
        sendRedirect(response, returnUrl, false, "苹果授权失败: " + appleError, ...);
        return;
    }
    if (!hasText(code) && !hasText(idToken)) {
        // 人工直接访问 / 异常 GET,明确报「缺凭证」,别落到后面报难懂的错误
        sendRedirect(response, returnUrl, false, "回调缺少登录凭证", ...);
        return;
    }
    // ...正常登录链路
}

顺手提醒:error=user_cancelled_authorize 不是错误,是正常 UX。文案别写成「系统异常」。


五、死结之二:一次 nginx 301 把 POST 变成 GET

这是整个接入里最贵的一个坑,因为它伪装得太好。

现象:苹果授权确认后,回调页显示「登录失败:回调缺少登录凭证(code/id_token)」。日志里能看到请求确实到了,但参数全空。

当时的判断是「鸿蒙的 WebView 组件不支持 form_post」,为此还改成调用系统浏览器,结果一样。

真正的原因:代码里 redirect_uri 写的是裸域:

https://<你的裸域>/api/auth/apple/<APP_ID>/callback

而站点配置把裸域 301 跳到了 www。浏览器(和 WebView)遇到 301 会重发请求,但 POST 会被降级成 GET,body 直接丢掉。苹果 form_post 提交的 code、id_token、user 全在 body 里,于是在服务端看来就是「来了一次没有参数的 GET」。

一条铁律,值得贴在墙上:

redirect_uri 必须是最终处理请求的那个地址,中间不能有任何跳转。http→https、裸域→www、末尾补斜杠,全都算跳转。

排查方法很简单,一条 curl 就够,别靠猜:

curl -sSI "https://<你的裸域>/api/auth/apple/<APP_ID>/callback" | head -5
# 看到 301 / 308 就是它

顺带一个反例:改成 GET 也不解决问题。苹果规定 response_type=code id_token 必须配 response_mode=form_post,而且只有 form_post 才会在首次授权带回姓名。想绕开 POST 等于放弃「首登拿姓名」。


六、死结之三:服务端 302 到自定义 scheme,App 收不到

登录成功后,服务端通常想 302 到 myapp://auth/apple?token=...,让 App 直接接住。这条路上有两个坑,一个是客户端的,一个是系统的。

第一坑:WebView 的导航拦截器(shouldOverrideUrlLoading / onOverrideUrlLoading)只对「页面自己发起的导航」生效。服务端返回的 302 不算页面发起的导航,拦截器根本不会触发,登录态就这么丢了。2026-10 真机实测,服务端日志显示登录成功,App 端一片空白。

第二坑:改成走系统浏览器,靠系统「打开 App」来唤回。这条路能不能成,取决于系统浏览器与 openLink 的具体行为,不可控,同样出现过「服务端成功、App 没拿到」。

最后采用的方案是:服务端不回 302,返回一个 200 的结果页,页面上三条通道并存。

<!-- 主通道:内嵌 JSON,App 用 JS 取走写会话 -->
<script id="apple-login-result" type="application/json">{"success":"true","token":"..."}</script>

<!-- 副通道:页面发起的导航能触发拦截器 -->
<button onclick="window.location.href='myapp://auth/apple?token=...'">确认返回 App</button>

第三通道是给人看的:把参数列成表格,token 做脱敏(只留首尾 + 长度),让真机联调时肉眼能确认「服务端到底给了什么」。这一屏在排查阶段救过好几次。

注意两个安全细节:JSON 里的 < 要转成 \u003c,否则一旦值里出现 </script> 就逃逸了;深链写进 HTML 属性前要转义引号。


七、服务端验签:五道校验,一道都不能省

原生流程里 identityToken 是从客户端手里送来的,可能被替换或重放。所以身份必须以验签后的 sub 为准,客户端另外传的 userIdentifier 只记日志、不采信。

标准校验链:

拆 header 取 kid
  → 按 kid 从 JWKS 取公钥
  → 验签名
  → iss == https://appleid.apple.com
  → aud ∈ {Bundle ID, Services ID}
  → exp 未过期(库自动)
  → nonce(传了才校验)

这里有三个坑,都是「上线后才发作」的类型。

7.1 苹果把签名密钥换成了 RSA

苹果 id_token 的签名 key 早年是 EC P-256(ES256),后来轮换为 RSA(RS256)。JWKS 里现在可能两种都有,也可能某天只剩一种。

只实现 EC 的实现,会在某次轮换后突然全量登录失败,而且错误信息是「JWKS 中找不到公钥」,看起来完全不像密钥算法问题。

按 kty 分流,两种都支持,才叫稳:

if ("EC".equals(kty) && "P-256".equals(crv)) {
    cache.put(kid, buildEcPublicKey(x, y));   // 硬编码 secp256r1 曲线参数
} else if ("RSA".equals(kty)) {
    cache.put(kid, buildRsaPublicKey(n, e)); // BigInteger(1, base64url(n))
}

单个 key 构造失败要跳过、不能中断整个刷新,否则一个畸形 key 能让所有登录挂掉。

7.2 缓存刷新时机,决定了「偶发失败」

JWKS 缓存 24 小时是常见做法。但苹果轮换或多 key 灰度时会出现:缓存没过期,可这个 kid 不在缓存里。

如果只判断「缓存是否过期」,表现就是「偶发登录失败,重启服务就好」——这种 bug 能查一整周。

正确顺序是:缓存过期则刷;没过期但按 kid 找不到,强刷一次再找;两次都找不到才报错。

if (now > expire) refresh();
PublicKey key = cache.get(kid);
if (key == null && now <= expire) { refresh(); key = cache.get(kid); }
return key;  // null 才是真的没有

7.3 aud 不是一个值

前面说过:原生给 Bundle ID,Web 给 Services ID。校验要写成集合求交,不能写等号:

Set<String> allowed = new HashSet<>();
allowed.add(setting.getServicesId());
allowed.add(setting.getBundleId());
if (Collections.disjoint(audienceOf(claims), allowed)) throw new Exception("audience 校验失败");

还有一个版本坑:jjwt 0.11.x 的 claims.getAudience() 返回单个 String,0.12+ 改成 Set。看网上示例时会看到两种写法,照抄会编译不过。我们锁在 0.11.5,就一律按 String 处理,别顺手升级。


八、client_secret:必须现签,不能存成配置

Web 链路用 code 换 token 时,需要一个 client_secret。很多人第一反应是去后台生成一个字符串存起来——这不是苹果给的,苹果要的是一个 JWT。

它的结构:

header : { alg: ES256, kid: <Key ID> }
payload: { iss: <Team ID>, sub: <Services ID>, aud: https://appleid.apple.com, iat, exp }
签名   : 用 .p8 私钥做 ES256

三个实践点:

私钥本身存哪?存数据库配置表(TEXT 字段)比起写进代码强,但仍然要保证:备份加密、不进仓库、不进日志、后台页面永远只回显「已配置」而不是明文。


九、苹果不给你什么:三个必须自己补的坑

9.1 姓名和邮箱只在第一次授权时给

这是最容易被忽略的一条。用户第一次点「使用 Apple 登录」时,苹果会返回 user 字段(姓名、邮箱);第二次、换设备、重装后再登录,这个字段就是空的。

如果当时没落库,用户第二次登录后昵称就变成「苹果4821」,而且永远补不回来。

所以:首次授权的姓名邮箱必须在同一次请求里写进库。原生流程尤其要注意——它不是表单提交,姓名是插件单独给的,需要拼成与 Web 回调同构的 JSON 再喂给同一份登录逻辑:

{"name":{"firstName":"三","lastName":"张"},"email":"..."}

插件给的是 "Given Family" 拼接串,按最后一个空格拆。中文姓名没有空格,整体当 firstName 处理。拆错了只影响昵称展示,不影响身份——身份只认验签后的 sub。

9.2 没有头像

苹果不提供头像,任何「拿到头像」的方案都不存在。直接固定一张默认图,别为这个功能纠结。

9.3 邮箱可能是中继地址

用户选择「隐藏邮件」时,你拿到的是 xxx@privaterelay.appleid.com。这带来两件事:

另外别把邮箱当账号唯一键——它可能为空、可能是中继、可能用户换 Apple ID 就变了。唯一键用(app_id, sub)复合最稳。


十、服务端通知:撤销授权和删号,不接会出事

用户可以在「设置 → Apple ID → 使用 Apple 登录」里对某个 App 撤销授权,或者干脆注销 Apple ID。苹果会把这类事件推给你的服务端:

POST /api/apple/{appId}/notify     (payload 参数是 JWS)

要做的四件事:

  1. 用同一套 JWKS 验签 payload,并校验 iss;
  2. 按事件类型处理:consent-revoked 停用账号、account-delete 标记删除、email-enabled / email-disabled 更新中继标记;
  3. 幂等去重。苹果会重试,同一事件会来多次,用(app_id, jti)做唯一键;
  4. 无论如何都返回 HTTP 200。返回非 200 会触发退避重试,越堆越多。

有个格式细节踩过:新版把事件放在 events 字段里,而且值可能是「JSON 字符串」而不是对象;旧版则是顶层的 type 和 sub。稳妥写法是两处都读,先试 events 再兜底顶层。

还有一个业务上的必要性:如果账号没有状态机,用户撤销授权之后仍然能用业务 token 继续访问,等于授权撤销形同虚设。我们的做法是给账号加 status(正常 / 已停用 / 已删除),登录前先查状态。


十一、一句「请重试」,骗了自己半个月

这是全篇最想写的一节,和技术无关,和判断力有关。

原生流程第一次上真机测试,点「Apple 账号登录」弹一句:

苹果登录失败,请重试

授权弹窗明明完成了,人脸也过了。于是开始怀疑证书、怀疑 entitlement、怀疑网络。

真相是:服务端那个 native 端点压根没实现。Spring 返回了默认的 404 JSON:

{"timestamp":"...","status":404,"error":"Not Found","path":"/api/auth/apple/<APP_ID>/native"}

而这个 body 里没有 message 字段,客户端那句 result['message'] ?? '苹果登录失败,请重试' 就命中兜底文案。用户重试了一周。

后来把失败分成四类,每类文案不同、都上云日志(含 HTTP 码和响应前 300 字符):

类型 文案
HTTP 非 200 苹果登录服务暂不可用(HTTP xxx),请更换手机号等其他方式登录
200 但响应不是 JSON 对象 苹果登录服务响应异常,请更换其他方式登录
业务拒绝(success=false 且有 message) 透传后端文案
网络异常 网络请求失败,请检查网络后重试

还有两条日志纪律:


十二、两个关于「环境」的错觉


十三、一个已知的可改进项:nonce

我们的实现里没有传 nonce:客户端不生成,服务端「传了才校验」。

这在「identityToken 走 TLS 直达自己服务端」的场景下风险有限,但严格讲,identityToken 一旦被截获,在 exp 窗口内是可以重放的。标准做法是:客户端生成随机串 → 作为 nonce 传给苹果 → 服务端比对 token 里的 nonce 字段。

写在这里是提醒自己,也提醒照抄的人:能补就补,补的时候三端一起补,别只补一半——半开状态比全不开更难排查。


十四、可复用清单

苹果后台

客户端

服务端

踩坑自检


十五、安全边界:哪些东西永远不进仓库、不进文章


结语

Sign in with Apple 的难点,从来不是「调不通」——它几乎总能调通一次。难的是这四种情况:

这四种都不在「正常路径」上,所以第一次自测全绿。它们的共同点是:症状指向的地方,往往不是真正出错的地方。405 看着像授权失败,其实是路由没写;「缺凭证」看着像组件不支持,其实是一次 301;「请重试」看着像网络问题,其实是接口没实现。

能举证就别推测:curl 看状态码、解包看产物、抓日志看 HTTP 码和 body。这一条比任何一篇接入文档都管用。

评论

还没有评论,登录后来说两句。