每一次令牌验证都会打到我们的源站。如今 JWKS 住在了边缘。
为了验证我们的一个 JWT,一个服务会从签发方抓取两份公开文档:位于 /.well-known/openid-configuration 的 discovery 文档,以及它所指向的 JWKS——也就是给令牌签名所用的那套公钥。这些是我们所提供的东西里请求量最大、也最不需要保密的。它们是公钥,按定义本就是公开的;它们是只有在我们轮换时才会变的元数据。而直到不久之前,这些抓取里的每一次,都要一路回到我们的源站。而因为我们是多租户的,实情比这句话听上去还要糟。
源站在做它根本没理由做的活儿
每一个租户都是它自己的签发方,有它自己的 .well-known 文档和它自己的 JWKS。而每一个依赖方、每一个资源服务器、每一个验证令牌的 SDK 都会去拉取这些文档——在冷启动时、在它自己那份短命缓存过期时、每个服务实例都会拉一次。把“按租户计的签发方”乘以“按服务计的验证方”再乘以“缓存未命中”,你得到的就是一股铺天盖地的请求洪流,全都涌向应用作为终点,而且全都是为了那些对每一个调用方来说逐字节都一模一样、只有在我们轮换密钥时才会变的文档。
这正是教科书里“应当被缓存”的典型特征:公开、一致、极少变化。可实际上,应用却在动态地、在别人做令牌验证的热路径上,一次次地生成公钥。这些密钥根本没道理按每个请求去计算。
把它们放到边缘上
改动本身很小:给 discovery 和 JWKS 的响应设上诚实的 Cache-Control 头,好让 Cloudflare 把它们缓存在边缘。如今一个验证方打到的是最近的 Cloudflare 接入点(PoP),而源站为每份文档提供服务的频率,大约是“每个 PoP 每个 TTL 一次”,而不再是“每次验证一次”。discovery 是轻而易举就能缓存的——它几乎从不变化。JWKS 才是你必须认真琢磨的那一个,因为 JWKS 正是这样一份文档:它的全部职责就是恰恰在你轮换密钥的那一刻发生变化——而去缓存一样“必须在它变化的那一刻保持新鲜”的东西,正是人们把自己害惨的地方。
被缓存的 JWKS 会怎样反咬你一口
密钥轮换加上一份被缓存的密钥集,是一个陷阱,而且它会朝着最糟糕的那个方向失败。你轮换到一把新的签名密钥,开始用它签发令牌,而一个在新密钥出现之前就抓过 JWKS 的验证方,手里攥的是一份陈旧的副本。一个携带着新密钥 id 的令牌到达了;验证方去查找它,没找到,于是拒绝了这个令牌。这个令牌完全有效。签名是真的。用户什么错都没犯。是你自己的边缘缓存,刚刚对一次合法登录返回了一个 401。
请留意它是朝哪个方向失败的。一把陈旧的私钥根本算不上什么事——不过是你还没开始用那把新的而已。而一套陈旧的公钥集,则是一场自己捅给自己的宕机;更何况,造成它的那个缓存,还是你为了省下源站流量而特意引进来的。整套方案的安全与否,全都系于轮换这一环,成也在此,败也在此。
对轮换安全的模式
安全性完全在于执行次序,而这个次序恰恰是原子交换的反面。三条规则:
- 先发布,再签名。 新密钥先进入 JWKS,而在这份 JWKS——连同新的密钥 id 一起——已经在边缘上线满至少一整个缓存 TTL 之前,你一个令牌都别用它来签。这样一来,等到任何一个带着新密钥的令牌能抵达某个验证方时,那个验证方缓存的密钥集里早已包含了这把密钥。
- 在过期之后再退役,而不是在轮换时退役。 旧密钥会一直发布在 JWKS 里,直到它曾签过的最后一个令牌也过期为止。在整个重叠期里,新旧密钥并存。你要是一轮换就把旧密钥拔掉,就会把它签过、却仍然有效的每一个令牌全都晾在半路。
- TTL 要短于重叠期。 边缘缓存的
max-age,再加上一段留给时钟偏移的余量,必须短于密钥的重叠窗口。缓存可以陈旧;但它绝不允许陈旧得超过那个“两把密钥都有效”的窗口。正是这一个不等式,让一份被激进缓存的 JWKS 变得安全。
把这些合起来,轮换就成了一次有着固定执行次序的淡入淡出交叉过渡:加入新密钥,等上一个 TTL 好让边缘和每一个验证方都看到它,开始用它签名,等旧令牌自然老去、逐渐消退,然后再撤下旧密钥。每一步都能安然应对一个落后了一个 TTL 的缓存,因为这个 TTL 从一开始就被选得比缓存可能落后的每一个窗口都要小。
提炼出来的教训
缓存通常是一个关乎延迟的决定。而缓存公钥,是一个关乎正确性的决定,因为你所缓存的东西,正是那个决定“哪些令牌是真的”的东西。你当然可以把它放到边缘上——你也理应如此,因为它既是公开的又是热点——但前提是:轮换要被表达成一段有次序的重叠,而不是一次交换;而且缓存 TTL 要被证明确实短于那段重叠。把这一点做对,你认证系统里最繁忙、最适合缓存的那些文档就再也不用碰你的源站了,而没有任何人会察觉到有什么不同——而这,恰恰就是整件事的意义所在。