1. 先说清楚:苹果登录为什么必须在后台再验一次 token
很多人第一次接苹果登录时,会有一个直觉:用户在 App 里点了苹果登录,苹果返回了用户信息,我用这些信息直接创建账号不就行了吗?表面看没问题,但放到真实业务里,这是接第三方登录最容易埋雷的做法。
客户端拿到的“用户信息”本质上是一个 JSON Web Token(JWT),也就是我们常说的identityToken。这个 token 是苹果签发的,但它落在客户端手里,就意味着任何人只要抓包、脱壳、或者干脆写个脚本伪造请求,都能把一段“看起来合法”的数据甩给你的服务器。服务端如果不验证签名,只凭字段里的 email、sub 就去建账号,就等于开门让攻击者随意冒用别人身份。这不是危言耸听,我在排查一个模拟项目 X 的越权漏洞时,就见过攻击者直接改 JWT 里的 sub 字段后重放请求,后端愣是没拦住——因为那段代码只解析了 Base64 负载,完全没验签。
所以苹果登录的正确姿势必须是三段式:客户端弹出苹果授权页、拿到identityToken和authorizationCode,然后把这两个东西交给后端;后端负责对 identityToken 做签名验证、字段校验,用 authorizationCode 去苹果的 token 端点换真实的用户会话;最后才在自己的用户体系里创建或绑定账号。这篇文章就把后端这一段用 Java 怎么写得完整、写得稳,一步一步拆开讲。
这套流程不仅适用于 iOS App,也适用于安卓、网页端接 Sign in with Apple 的场景。只要客户端能拿到 identityToken,后端验签的逻辑就是同一套。适合正在接苹果登录、或者已经接了但想补上安全漏洞的开发者参考。
2. 完整的苹果登录流程:客户端、苹果、后端三个角色的分工
2.1 一次登录请求到底经过了哪几个节点
要理解后端为什么要验签,先把整条链路画一遍:
- 客户端调用苹果的授权接口,用户在苹果的弹窗里输入 Apple ID 密码,或者直接用 Face ID / Touch ID 确认。
- 苹果返回给客户端一组凭证,核心是两个字段:
identityToken和authorizationCode,还有可选的user信息(姓、名、邮箱)。 - 客户端把这三个字段打包发给自己的后端。
- 后端拿着
identityToken验签,拿着authorizationCode调用苹果的token端点换取access_token和refresh_token。 - 验签通过、换 token 成功,后端在自建用户表里执行“找用户、建用户、绑关系”的逻辑,然后给自己的 App 签发登录态。
很多人对第 4 步有误解:以为authorizationCode换来的access_token才是验签用的。其实不是。identityToken本身就是一个完整的 JWT,签名信息都在里面,可以独立完成身份验证;authorizationCode的作用更偏向“授权码换令牌”,拿它调 token 接口后,你才能获得真正用于调用苹果用户信息接口的访问令牌,也可以换取refresh_token实现“撤销登录”。
2.2 为什么 authorizationCode 不能省
identityToken的有效期通常很短(苹果文档里建议视为 10 分钟内有效),它主要是给后端做即时身份确认用的。而authorizationCode的有效期也很短,但它的价值在于:只有通过它换到苹果签发的access_token,你才能在后端主动调用苹果接口(比如查询用户是否还在授权状态、实现“从服务器侧撤销登录”)。
换句话说,如果只验identityToken不换access_token,那么遇到“用户在苹果设置里关掉了对 App 的授权”这种场景,你的后端没有任何途径主动感知。反过来,拿着authorizationCode换了access_token并妥善存储,后续就能做到主动校验和撤销。所以在生产环境里,这两个动作必须一起做,少一个都会在某个角落留下坑。
2.3 后端验证的职责边界
一句话总结:验身份是后端的责任,不是客户端的责任。
- 验签:确认 token 真的是苹果签发的,不是伪造的。
- 验时效:确认 token 没过期,不是重放攻击。
- 验受众:确认 token 是给当前这个 App 签发的,不是给别的应用。
- 验用户:从 token 里读出稳定的
sub,用它作为用户的唯一标识。
这四点做完,才算建立了一次可信的第三方身份。至于“这个用户是不是坏人”“要不要风控”那是另外一层逻辑,不属于验签范畴,但验签是地基,地基不牢上层全白搭。
3. identityToken 结构拆解:JWT 的 Header、Payload、Signature
3.1 三个点的故事
苹果返回的identityToken是一个标准的 JWT,长得像这样:
eyJhbGciOiJSUzI1NiIsImtpZCI6IjZZSjRKV1hYVCJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmV4YW1wbGUuYXBwIiwiZXhwIjoxNzE3MDAwMDAwLCJpYXQiOjE3MTY5OTk0MDAsInN1YiI6IjEwMDAwMS4xMjM0NTY3ODkwQUJDREVGIn0.pUwQX2Ix2WJc...用点号分成三段:
| 段 | 名称 | 内容 | 是否明文可见 |
|---|---|---|---|
| 第一段 | Header | 签名算法alg、密钥 IDkid | 是(Base64URL) |
| 第二段 | Payload | 用户声明信息、过期时间等 | 是(Base64URL) |
| 第三段 | Signature | 苹果用私钥对前两段做的签名 | 否(二进制签名值) |
注意:JWT 的 Payload 只是 Base64URL 编码,不是加密,任何人都能直接解码看到内容。所谓“验签”,是确认第三段签名值确实是用苹果私钥生成的,从而证明前两段内容没有被篡改。
3.2 Payload 里的关键字段与业务含义
苹果签发的 JWT 里,能解出来的重要字段一般有这些:
| 字段 | 含义 | 业务用途 |
|---|---|---|
iss | 签发者,固定为https://appleid.apple.com | 确认 token 来源 |
aud | 受众,即你的 App 的 client_id | 确认 token 是发给你这个应用的 |
exp | 过期时间戳 | 拒绝过期 token |
iat | 签发时间戳 | 辅证有效期判断 |
sub | 用户的唯一标识符,形如100001.1234567890ABCDEF | 用它做业务主键 |
email | 用户邮箱 | 用于展示或通知,不能当主键 |
email_verified | 邮箱是否已验证 | 判断邮箱可信度 |
is_private_email | 是否为苹果隐藏邮箱转发 | 用户选“隐藏邮箱”时为 true |
auth_time | 用户授权的时间戳 | 可选 |
这里最核心的是sub。它是苹果针对当前client_id给用户生成的稳定唯一标识,同一个人在你这个 App 下永远一样。但注意,同一个用户在不同 App(不同client_id)下的sub是不同的,这点在后面踩坑部分细说。
3.3 identityToken 和 authorizationCode 的区别别搞混
identityToken:JWT 格式,包含身份信息,用于验签确认身份。authorizationCode:一段短时效的授权码,用于换取 access_token / refresh_token。
客户端回调里两个字段经常同时出现,但用途完全不同。后端接参时一定要分开处理:identityToken走 JWT 验证流程,authorizationCode走 token 端点交换流程。
4. 苹果公钥服务:JWKS 是怎么来的、怎么用
4.1 Apple 的公钥端点与 JWKS 机制
苹果签 JWT 用的是一对 RSA 密钥,私钥在苹果手里,公钥通过一个标准端点对外公开:
https://appleid.apple.com/auth/keys这个地址返回的是一个 JWKS(JSON Web Key Set)文档。所谓 JWKS,就是一组 JSON 格式的公钥集合。苹果会不定期轮换这批密钥,所以你不能把公钥写死在代码里,必须动态获取并缓存。
响应内容大致长这样:
{ "keys": [ { "kty": "RSA", "kid": "6YJ4JWXXT", "use": "sig", "alg": "RS256", "n": "oE7WztM...", "e": "AQAB" } ] }其中kid是密钥标识,n是 RSA 模数,e是公钥指数,拼起来就是完整的 RSA 公钥。alg通常是RS256,也就是 RSA-SHA256 签名。
4.2 为什么必须先看 Header 里的 kid 再取公钥
JWT 的 Header 里有个kid字段,它对应着苹果哪把私钥签的这个 JWT。验签的第一步应该是:解码 JWT 的 Header,取出kid,然后去刚才拉回来的 keys 列表里找kid相同的公钥,用这把公钥做验签。
为什么不直接拿 keys 里的第一把公钥试?因为苹果会轮换密钥,旧 token 是用旧私钥签的。如果你硬要用新公钥去验旧 token,验签必然失败。而正确的做法就是不看时间、不看缓存顺序,只看kid是否匹配。找到匹配的kid,基本等于拿到了正确的验签钥匙。
4.3 公钥缓存与刷新策略
每次验签都去拉一次appleid.apple.com不现实,网络开销和稳定性都扛不住。公钥变化频率又很低,典型做法是:启动时拉取一次,放入本地内存缓存,设定一个刷新周期。
我自己在项目里的策略是:缓存 12 小时,每小时用异步任务悄悄刷新一次,刷新失败时继续用旧缓存,不阻塞业务。因为苹果公钥轮换不是高频事件,但如果长时间不刷新,万一遇到轮换,老用户请求会突然验签失败。等到失败才去拉新公钥,那几百毫秒的延迟足够让一堆请求超时了。
有的团队会用 Redis 缓存公钥,多实例部署时共享一份,避免每个节点都去拉一次苹果接口。如果只是单机小服务,本地内存缓存完全够用。
5. Java 侧完整验签实现:依赖选型、核心代码、异常处理
5.1 依赖选型:四个主流 JWT 库怎么选
Java 生态里处理 JWT 的库不少,接苹果登录验签,推荐优先考虑下面几个:
| 库 | 坐标 | 特点 | 推荐度 |
|---|---|---|---|
| jose4j | org.bitbucket.b_c:jose4j | 对 JOSE/JWKS 支持完整,自带 HttpsJwks 公钥自动管理 | 高 |
| Nimbus JOSE + JWT | com.nimbusds:nimbus-jose-jwt | 功能强大,Spring 系常用 | 高 |
| Java JWT | com.auth0:java-jwt | API 简洁,需要自己拉公钥 | 中 |
| JJWT | io.jsonwebtoken:jjwt | 文档友好,JWKS 支持逐步完善 | 中 |
如果你的需求是尽量少写样板代码,我比较推荐 jose4j。它内置了HttpsJwks和HttpsJwksVerificationKeyResolver,只要把公钥端点 URL 丢给它,它就能自动拉取、缓存、按kid匹配公钥,省去自己解析 JWKS 的一堆代码。
5.2 核心代码:用 jose4j 完成验签全流程
先加依赖:
<dependency> <groupId>org.bitbucket.b_c</groupId> <artifactId>jose4j</artifactId> <version>0.9.6</version> </dependency>然后定义一个验签服务:
import org.jose4j.jwk.HttpsJwks; import org.jose4j.jwt.JwtClaims; import org.jose4j.jwt.consumer.JwtConsumer; import org.jose4j.jwt.consumer.JwtConsumerBuilder; import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; public class AppleIdTokenValidator { private static final String APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"; private static final String APPLE_ISSUER = "https://appleid.apple.com"; private final HttpsJwks httpsJwks; private final HttpsJwksVerificationKeyResolver keyResolver; public AppleIdTokenValidator() throws Exception { this.httpsJwks = new HttpsJwks(APPLE_JWKS_URL); // 默认 refresh 间隔 3600 秒(1 小时) this.httpsJwks.setRefreshCacheLifetimeMillis(60 * 60 * 1000L); this.httpsJwks.setRetainCacheOnRefreshFailure(true); this.keyResolver = new HttpsJwksVerificationKeyResolver(httpsJwks); } public JwtClaims validate(String identityToken, String clientId) throws Exception { JwtConsumer consumer = new JwtConsumerBuilder() .setVerificationKeyResolver(keyResolver) .setRequireExpirationTime() .setAllowedClockSkewInSeconds(30) .setRequireSubject() .setExpectedIssuer(APPLE_ISSUER) .setExpectedAudience(clientId) .build(); // processToClaims 内部完成:验签 -> 校验 iss/aud/exp -> 返回 claims JwtClaims claims = consumer.processToClaims(identityToken); return claims; } }调用方式:
AppleIdTokenValidator validator = new AppleIdTokenValidator(); JwtClaims claims = validator.validate(identityToken, "com.example.app"); String sub = claims.getSubject(); String email = (String) claims.getClaimValue("email"); Boolean emailVerified = (Boolean) claims.getClaimValue("email_verified"); Boolean isPrivateEmail = (Boolean) claims.getClaimValue("is_private_email");processToClaims这个方法做的事比表面看起来多得多:它会把 JWT 三段拆开,按 Header 里的kid去 JWKS 里匹配公钥,然后做 RSA-SHA256 签名验证;验证通过后再检查exp、iss、aud、sub这些关键声明。只要它没抛异常,基本可以认为这个 token 是苹果签发且未被篡改的。
5.3 不依赖库内置公钥管理:手动实现 JWKS 拉取与匹配
有的团队不想引入太多库,或者想自己控制公钥刷新逻辑,那就手动解析 JWKS。核心代码可以这样写:
import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.Verification; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.RSAPublicKeySpec; import java.util.Base64; import java.util.List; import java.util.Map; public class AppleManualValidator { private static final String APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"; // 第一步:拿 header 里 kid DecodedJWT jwt = JWT.decode(identityToken); String kid = jwt.getHeaderClaim("kid").asString(); // 第二步:拉取 JWKS,找到匹配 kid 的 key Map<String, Object> jwks = fetchJwks(); List<Map<String, Object>> keys = (List<Map<String, Object>>) jwks.get("keys"); Map<String, Object> matchedKey = keys.stream() .filter(k -> kid.equals(k.get("kid"))) .findFirst() .orElseThrow(() -> new RuntimeException("kid not found: " + kid)); // 第三步:把 n/e 拼成 RSA 公钥 byte[] modulusBytes = Base64.getUrlDecoder().decode((String) matchedKey.get("n")); byte[] exponentBytes = Base64.getUrlDecoder().decode((String) matchedKey.get("e")); RSAPublicKeySpec spec = new RSAPublicKeySpec( new java.math.BigInteger(1, modulusBytes), new java.math.BigInteger(1, exponentBytes)); PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(spec); // 第四步:用公钥验签,并校验 iss/aud/exp Verification verification = JWT.require(Algorithm.RSA256(publicKey)) .withIssuer("https://appleid.apple.com") .withAudience(clientId) .acceptExpiresAt(30); DecodedJWT verified = verification.build().verify(identityToken); }这段代码的思路和 jose4j 版殊途同归,适合想精细控制每一环的场景。注意n和e是 Base64URL 编码的无符号大数,不是普通 Base64,解码时要用Base64.getUrlDecoder()。
5.4 异常处理与返回值设计
验签过程中的异常要分门别类处理,不能一把 catch 吞掉。常见异常类型和业务含义:
| 异常类型 | 可能原因 | 业务处理建议 |
|---|---|---|
JwtExpiredException | token 过期 | 提示客户端重新走苹果登录 |
InvalidJwtSignatureException | 签名不匹配,token 被篡改 | 拒绝,记录日志,防刷 |
InvalidJwtIssuerException | iss 不是苹果 | 拒绝,可能带了错误的 token 类型 |
InvalidJwtAudienceException | aud 不是当前 client_id | 配置错误,或 token 是别的 App 的 |
| kid 找不到 | 公钥缓存过期或 token 伪造 | 刷新公钥缓存后重试一次 |
更好的做法是把验签结果封装成一个对象:
public record AppleLoginResult( boolean valid, String message, String sub, String email, boolean emailVerified, boolean isPrivateEmail ) { public static AppleLoginResult success(JwtClaims claims) { return new AppleLoginResult(true, "ok", claims.getSubject(), (String) claims.getClaimValue("email"), Boolean.TRUE.equals(claims.getClaimValue("email_verified")), Boolean.TRUE.equals(claims.getClaimValue("is_private_email"))); } public static AppleLoginResult fail(String message) { return new AppleLoginResult(false, message, null, null, false, false); } }接口层永远只返回这个结果,不把异常细节透传给客户端,避免攻击者利用报错信息做试探。
5.5 authorizationCode 换取 access_token 的后端实现
验完identityToken后,紧接着要用authorizationCode调苹果 token 端点:
POST https://appleid.apple.com/auth/token Content-Type: application/x-www-form-urlencoded参数:
| 参数 | 值 |
|---|---|
grant_type | authorization_code |
code | 客户端传给你的authorizationCode |
client_id | 你的 App 的 client_id |
client_secret | 后端生成的 JWT,签名密钥是苹果开发者后台创建的 Key |
这里的client_secret是一个很关键也容易被忽略的点,它本质是后端用自己的私钥签一个 JWT 发给苹果,证明“我是这个 App 的合法后端”。生成逻辑大致是:Header 里alg用ES256,Payload 里放iss(你的 Team ID)、iat、exp、aud(固定https://appleid.apple.com/)、sub(client_id),然后用苹果开发者后台下载下来的.p8私钥做签名。
生成client_secret的代码片段:
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.time.Instant; import java.util.Base64; import java.util.Date; public class AppleClientSecretGenerator { public static String generate(String teamId, String clientId, String p8FilePath, String keyId) throws Exception { String p8Content = new String(java.nio.file.Files.readAllBytes( java.nio.file.Path.of(p8FilePath))); // 去掉 p8 文件的 PEM 头尾和换行 p8Content = p8Content .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(p8Content); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); PrivateKey privateKey = KeyFactory.getInstance("EC").generatePrivate(spec); Instant now = Instant.now(); return Jwts.builder() .setHeaderParam("alg", "ES256") .setHeaderParam("kid", keyId) .setIssuer(teamId) .setIssuedAt(Date.from(now)) .setExpiration(Date.from(now.plusSeconds(5 * 60))) .setAudience("https://appleid.apple.com/") .setSubject(clientId) .signWith(privateKey, SignatureAlgorithm.ES256) .compact(); } }拿到client_secret后,用 HTTP 客户端组装表单请求,换取access_token、refresh_token、id_token等。这个环节网络依赖较重,务必配置合理的超时时间和重试策略。
6. 在正式环境验证身份:测试思路与常用验证工具
6.1 怎么才能拿到比较真实的 identityToken 来测试
苹果登录验签逻辑写完,马上要回答的问题是:我手上没有真实的 token,怎么测?两种途径:
第一种是走真实的客户端流程。在 Xcode 里用模拟器或者真机,配置好 Sign in with Apple 的 Capability 和 Apple 开发者账号,点击登录后在回调里把identityToken打印出来,拿它来测后端接口。这是最接近线上环境的验证方式。
第二种是处理不了真实 token 时的替代方案。可以在苹果的 jwt.io 网站上把一个已知真实的identityToken粘贴进去查看 Header 和 Payload,确认字段结构。也可以用苹果返回的authorizationCode配上自己生成的client_secret调 token 端点,拿到返回的id_token做进一步验签测试。
6.2 用命令行快速核对 JWT 内容
测试时不想写额外代码,可以直接用命令行解码 JWT 的 Payload:
# 假设 TOKEN 环境变量里存了完整的 identityToken echo "$TOKEN" | cut -d '.' -f2 | base64 -d 2>/dev/null | jq .用 Base64URL 编码的中段内容做解码,就能看到sub、email、iss、aud、exp这些字段。配合 jq 格式化后检查一目了然。
6.3 动手实测全流程的步骤
我建议在联调阶段把整个流程整理成一份自测清单,逐项打勾:
- 用有效 token 调验签接口,返回
valid=true,sub与自己拿到的用户标识一致。 - 手动把 token 的
iss字段改掉,验签必须失败。 - 手动改 Payload 里的
sub字段,验签必须失败(签名校验会拦截)。 - 用一个过了
exp的 token 验签,返回明确过期错误。 - 换一个其他 App 的
aud的 token 验签,返回 audience 不匹配。 - 拿同一个用户在不同
client_id下登录的 token 对比,确认sub不同。 - 连续验签同一 token,确认幂等,无副作用。
- 模拟公钥刷新失败场景,旧缓存还在时业务不中断。
这些用例覆盖了大部分线上问题,值得在接口写完当天就跑一遍。
7. 我踩过的坑:时钟、sub、缓存、库版本与登录态一致性
7.1 服务器时钟不准导致 exp 校验失败
某次联调时,测试环境一切正常,上了预发布环境之后只要请求一多,就零星出现“token expired”的报错。查了半天发现,预发布那台服务器的系统时钟偏慢了近两分钟,导致本来还有余量的exp被判成过期。
解决方式分两层:运维层配置 NTP 自动校时;代码层通过setAllowedClockSkewInSeconds(30)允许最大 30 秒时钟偏差。两层都做了之后,这类误报基本绝迹。但要注意偏差时间不能设太大,否则过期 token 的重放窗口也变长了,安全性和便利性要平衡。
7.2 多人共用 Apple ID 时 sub 的表现
家庭共享 Apple 账号是一个很容易让人掉坑的场景。家庭组织里用户共享一个 Apple ID 支付或者下载,但 Sign in with Apple 的sub是按真实 Apple ID 账号维度生成的。也就是说,家庭成员即使共用 Apple ID,每个人的授权会话绑定的是同一个账号实体,后端拿到的sub是一样的,email也一样。
如果业务把“苹果账号”当自然人维度,那家庭共享账号会导致多个真实用户被映射成同一个账号。我的建议是:sub只能作为第三方平台的账号标识,落到自己的用户体系里,仍然要再生成一层内部用户 ID,并做好多账号绑定的设计。
7.3 用户选“隐藏邮箱”时 email 字段的坑
很多 App 希望把 Apple 登录用户的邮箱当作用户名或者找回密码的通道。但苹果有个选项叫“隐藏我的邮箱”,用户一旦勾选,后端拿到的email字段会被替换成类似abc123xyz@privaterelay.appleid.apple.com的随机转发邮箱,is_private_email字段为true。
这种情况下如果你还按普通邮箱去发通知、做唯一索引,就会出大问题:同一用户每次登录拿到的转发邮箱可能不一样,也就没法稳定匹配用户。经验是,永远用sub做用户唯一键,email只作为辅助展示字段,绝不能做主键。这也是我在代码里特意把emailVerified、isPrivateEmail单独提炼出来的原因。
7.4 缓存公钥时忘了考虑签名算法切换
有段时间alg字段表现得很稳定,默认都是RS256,我就没对alg做任何校验。后来排查一个验签失败问题时发现,苹果在个别的 token 里切换使用了其他签名算法组合,而我的代码还在按 RSA 固定逻辑处理,导致匹配公钥的步骤直接报错。
改进的方法是校验 Header 里的alg,如果不在自己支持的白名单里(比如只接受RS256和ES256),直接拒绝。不要让库默认接受alg=none之类的降级请求,这在行业安全测试里是高危项。
7.5 验签通过不等于账号可以登录:登录态一致性
还有一类问题是验收单里最容易漏的:后端验证 token 全成功,但用户实际使用时还是出现了“明明登录成功,一刷新就掉线”的诡异现象。排查到最后,发现是前端并没有把苹果登录的sub正确映射到内部 session,而是用email做了 key;用户选了隐藏邮箱后,两边 key 对不上,自然每次都像是新用户。
所以在设计对接层时,我强烈建议把苹果登录返回结果抽象为统一的AppleLoginResult,只保留sub、email、emailVerified、isPrivateEmail这几个必要字段,内部跟自建用户关联时只认sub。这样数据流清晰,后续无论换什么第三方登录,改动面都局限在一个适配器里。
7.6 Java 库版本差异:同一段代码在不同版本间的行为不一致
最后提醒一句:JWT 库的小版本升级也可能引入不兼容变化。我遇到过升级 jose4j 后,默认不再接受某些不带过期时间的 token,导致之前能通过的请求开始报异常。还有一次是换了新版本之后,HttpsJwks的缓存刷新行为变了,测试时经常出现第一次失败、第二次成功的“怪象”。
我的建议是:锁死 JWT 相关依赖的版本号,升级时把验签服务的自动化测试完整跑一遍,至少覆盖正常验签、篡改验签、过期验签、错误 audience 验签这四个场景,避免被库的升级悄悄改了行为。
虽然苹果登录到处都有文档,但真正落到 Java 后端,把公钥缓存、验签、异常处理、测试方案串起来做扎实,还是要花不少功夫的。尤其是把上面这些边界场景都照顾到了之后,再回头看那段验签代码,才算真正写完。