定制侠 iOS 版过审上架时,Sign in with Apple 是 4.8 条款的硬门槛。真正花时间的不是「调通一次登录」,而是三件事:
下面按链路拆开写,代码只留关键片段,标识符一律占位。
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 送进来。后面的修改都是单点的。
配置本身不难,难的是概念多、名字像。要分清四样东西:
| 东西 | 用在哪 | 易错点 |
|---|---|---|
| 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
三条硬性要求,贴在这免得反复踩:
www 就报 invalid redirect url;?error=...。最后一条是下一节的主角。
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。文案别写成「系统异常」。
这是整个接入里最贵的一个坑,因为它伪装得太好。
现象:苹果授权确认后,回调页显示「登录失败:回调缺少登录凭证(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 到 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(传了才校验)
这里有三个坑,都是「上线后才发作」的类型。
苹果 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 能让所有登录挂掉。
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 才是真的没有
前面说过:原生给 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 处理,别顺手升级。
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
三个实践点:
KeyFactory.getInstance("EC"),不要自己手撸 ASN.1。私钥本身存哪?存数据库配置表(TEXT 字段)比起写进代码强,但仍然要保证:备份加密、不进仓库、不进日志、后台页面永远只回显「已配置」而不是明文。
这是最容易被忽略的一条。用户第一次点「使用 Apple 登录」时,苹果会返回 user 字段(姓名、邮箱);第二次、换设备、重装后再登录,这个字段就是空的。
如果当时没落库,用户第二次登录后昵称就变成「苹果4821」,而且永远补不回来。
所以:首次授权的姓名邮箱必须在同一次请求里写进库。原生流程尤其要注意——它不是表单提交,姓名是插件单独给的,需要拼成与 Web 回调同构的 JSON 再喂给同一份登录逻辑:
{"name":{"firstName":"三","lastName":"张"},"email":"..."}
插件给的是 "Given Family" 拼接串,按最后一个空格拆。中文姓名没有空格,整体当 firstName 处理。拆错了只影响昵称展示,不影响身份——身份只认验签后的 sub。
苹果不提供头像,任何「拿到头像」的方案都不存在。直接固定一张默认图,别为这个功能纠结。
用户选择「隐藏邮件」时,你拿到的是 xxx@privaterelay.appleid.com。这带来两件事:
is_private_email 落库,后面判断「这个用户能不能直接发邮件」靠它;另外别把邮箱当账号唯一键——它可能为空、可能是中继、可能用户换 Apple ID 就变了。唯一键用(app_id, sub)复合最稳。
用户可以在「设置 → Apple ID → 使用 Apple 登录」里对某个 App 撤销授权,或者干脆注销 Apple ID。苹果会把这类事件推给你的服务端:
POST /api/apple/{appId}/notify (payload 参数是 JWS)
要做的四件事:
consent-revoked 停用账号、account-delete 标记删除、email-enabled / email-disabled 更新中继标记;有个格式细节踩过:新版把事件放在 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) | 透传后端文案 |
| 网络异常 | 网络请求失败,请检查网络后重试 |
还有两条日志纪律:
JSON.stringify(Error),输出是 {}。要打 e.toString() 加上下文。我们的实现里没有传 nonce:客户端不生成,服务端「传了才校验」。
这在「identityToken 走 TLS 直达自己服务端」的场景下风险有限,但严格讲,identityToken 一旦被截获,在 exp 窗口内是可以重放的。标准做法是:客户端生成随机串 → 作为 nonce 传给苹果 → 服务端比对 token 里的 nonce 字段。
写在这里是提醒自己,也提醒照抄的人:能补就补,补的时候三端一起补,别只补一半——半开状态比全不开更难排查。
苹果后台
客户端
com.apple.developer.applesigninString?,为空先挡掉再发请求服务端
踩坑自检
<TEAM_ID>、<SERVICES_ID>、<你的域名> 占位。技术细节写多细都行,标识符一个都别带。Sign in with Apple 的难点,从来不是「调不通」——它几乎总能调通一次。难的是这四种情况:
这四种都不在「正常路径」上,所以第一次自测全绿。它们的共同点是:症状指向的地方,往往不是真正出错的地方。405 看着像授权失败,其实是路由没写;「缺凭证」看着像组件不支持,其实是一次 301;「请重试」看着像网络问题,其实是接口没实现。
能举证就别推测:curl 看状态码、解包看产物、抓日志看 HTTP 码和 body。这一条比任何一篇接入文档都管用。
还没有评论,登录后来说两句。