服务多个租户的 BFF,以及那个不带 cookie 到达的请求
单页应用被教会自己保管令牌。应用在浏览器里跑完 OAuth 流程,拿回一个访问令牌,通常还有一个刷新令牌,把它们存在 JavaScript 里的某个地方,再附加到每一次 API 调用上。这有完善的文档,多数教程都这么教,而它偏偏把你寿命最长的凭据放在了你唯一无法守护的地方:一个乐于执行任何设法混上页面的东西的运行时。一次成功的注入,构建里某处一个被污染的依赖,谁都不需要再去钓谁的鱼。他们读走令牌然后离开,而它会一直有效,直到过期。
backend-for-frontend 模式把凭据挪到够不着的地方。浏览器与一个属于你应用的小型服务器对话,而那个服务器就是保密的 OAuth 客户端。它执行 code 交换,持有访问令牌和刷新令牌,只交给浏览器一个不透明的 cookie。这是一篇关于我们如何构建自己那一套的说明,也是关于其中唯一真正有意思的那部分:让它服务不止一个租户。
浏览器最终手里剩下什么
一个名为 __Host-agbff 的 cookie,标记为 HttpOnly、SameSite=Lax,作用域覆盖整个路径,没有过期时间,所以它随浏览器会话一同消亡。它的值是 256 位的随机数,别无其他。它不是令牌,也解码不出任何东西,而想在链路上把它偷走,对于一个走 TLS 的 __Host- cookie 来说,是你做不到的事。
一切真正要紧的东西都待在服务器端,在一个分布式缓存里的会话记录中:访问令牌、刷新令牌、id 令牌、访问令牌何时过期,以及这个会话属于哪个租户。登录本身是一个普通的带 PKCE 的授权码流程,由一个保密客户端执行,该客户端用自己的 secret 向令牌端点做认证。id 令牌在回来的路上被妥善校验:签发者、受众、对照已发布密钥的签名、有效期,然后是把 nonce 与重定向前藏好的值做一次常量时间比较。只有在这一切之后,会话才存在,cookie 才被设置。
一个只检查其是否存在、别无其他的 header
浏览器调用 BFF 来获取自己的用户信息,以及去访问被代理的 API,这些调用都带着一个自定义 header,x-authagonal-bff。它的值无关紧要。它是否存在,就是全部检查。
这看着偷懒,其实不然。整整一类跨站请求伪造,靠的都是一次表单提交、一个图片标签,或者一次由另一来源触发、而你的 cookie 顺带跟着走的导航,而这些统统设置不了自定义 header。攻击性 JavaScript 一旦试图加上一个,它就不再是简单请求,而变成一个带预检的请求,会被你的 CORS 策略拒绝。这个 header 不是一个需要去猜的秘密,它是一份证明,证明请求来自代码而非来自标记。
它在由脚本发起的调用上是必需的,而在那些本质上属于顶层导航的调用上则被刻意省去:开始一次登录、从身份提供方返回、点开一个登出链接。在一次浏览器导航上强求一个自定义 header,只会把导航弄坏。
刷新,恰好一次
每一个被代理的请求都可能发现访问令牌快要过期了,而一个繁忙的页面会一次发出好几个请求。若天真地把它们全都拿去刷新,你会得到一场小灾难:好几次并发的刷新,配上一个会轮换的刷新令牌,彼此互相作废,最后用户被自己的流量给登出了。
所以刷新是单飞的,按会话来。第一个通过的请求会拿下一道以会话 id 为键的门,其余的都等着。微妙之处在于,那些等待者轮到自己时会做什么:它们在决定任何事情之前,先从存储里重新读取会话,因为最先把住那道门的请求很可能已经刷新过了,而它们排队时手里攥着的值早已过时。刷新一次,然后大家都用这同一个结果。
它也懂得轮换,就是那种无聊却必要的意义上。如果令牌端点回传一个新的刷新令牌,它就替换掉已存的那一个。如果刷新以一个令牌错误告终,会话会被删除,用户会被登出,因为一个已经被拒绝的刷新令牌,在下一次尝试里也不会被接受,而重试不过是一种更慢的被登出方式。
一个 BFF,多个租户
一个单租户的 BFF 在启动时从配置里把一切都解析好:一个 authority、一个 client id、一个 secret。用一套部署去服务多个租户会打破这一点,因为现在 BFF 得弄清楚一次登录属于哪个签发者,而且它必须在任何会话存在之前就把这事弄清楚。
选择发生在登录端点,通过一个你可以自己命名的查询参数,所以它可以是 ?slug=,也可以是 ?org=,或者随便你的产品本来管一个客户叫什么。一个解析器把那个键变成一份租户配置:authority、client id、secret、scope。如果它解析不出任何东西,登录会当场被拒。
接下来这个键得挺过一趟往返:去一个对它一无所知的身份提供方那里,再回来。此时还没有会话来保管它,于是它搭在关联 cookie 里,就是那个短寿的加密 cookie,正是出于这个原因,它已经携带着 PKCE verifier、state 和 nonce。十五分钟,每次登录尝试一个。回来的路上,cookie 被解密,租户再次从它里面的键解析出来,code 拿去对着那个租户的令牌端点做交换。只有到这时,租户键才被写进会话,在那里成为持久的答案。
从那一刻起,每条路径都从会话里重新解析,而不是从浏览器说的任何东西里解析:刷新、登出、代理。而当一个租户不再能解析出来,因为它被删除或被停用了,会话会被销毁,而不是悄悄退回到某个默认租户。一个无法解析的租户意味着登出,而不是耸耸肩了事。
那个不带 cookie 到达的请求
后端通道登出正是这套设计证明自己价值的地方。当一个会话在别处结束时,身份提供方会把一个登出令牌直接 POST 到你的服务器。没有 cookie。没有会话。完全没有浏览器参与。在一个单租户的 BFF 里这不足为奇,因为它只可能来自唯一一个签发者。而在一个多租户的 BFF 里,你得先回答「这是给哪个租户的?」,才能回答「这东西究竟是不是真的?」,而唯一可用的材料就是令牌本身,那正是你尚未验证、因而无法信任的东西。
让这一切安全的次序,值得精确地讲清楚。从未经验证的令牌里读出签发者声明,只把它用于一件事:选择加载哪个租户的配置。然后对照那个租户已发布的密钥来验证令牌的签名,并以它的 client id 作为受众。一个伪造的签发者会挑中它假装成为的那个租户,随后在对照该租户密钥的验证里失败,因为攻击者并没有那些密钥。从来没有任何东西是凭着那个不可信声明的分量被接受的。那个声明只是决定了由谁来评判它,而评判者是一个我们本就已经拥有的信任锚。
在那之后,才轮到那些使一个登出令牌成其为登出令牌的检查:它不能携带 nonce,因为它并不在认证任何人;它必须携带后端通道登出事件;并且它必须标识出要么一个具体会话,要么一个 subject。一个 session id 只杀掉一个会话。一个 subject 会杀掉那个用户拥有的每一个会话,而这正是我们所发出的形式,因为人们点下「在所有地方登出我」时,真正想说的就是这个意思。这就是为什么会话存储会维护一个按 session id 建立的索引,再维护另一个按 subject 建立的索引,而不是只按 cookie 值来建。
单租户依旧无趣
对于常见情形,这一切都是不动声色的。默认解析器不管被递进来哪个键,都返回同一份配置,而它按签发者的解析则无条件地返回那份配置,因为压根只有一个。一个单租户的 BFF 表现得与这道接缝存在之前一模一样:没有租户参数,cookie 里没有多出任何东西,没有新的失败模式。多租户那套机关是选择性启用的,也是隐形的,直到你去指定一个查询参数。
把选择与信任分开
当你把一个东西改造成多租户时,值得做的功课是把每一个入口点都列出来,逐一追问:在这一个上,是什么在标识租户。浏览器导航带着一个由你签发并掌控的 cookie。重定向回调带着由你签名的 state。而服务器到服务器的回调,只带着调用方选择发送的东西,而调用方也许在撒谎。
那个别扭的入口点,那个身上没有任何可信之物的入口点,恰恰是其设计决定了整个系统是否稳固的那一个,而它通常也是没人会想到、直到很晚才想起的那一个。带我们闯过它的那套模式可以推而广之:把选择与信任分开。基于不可信的数据来做路由是没问题的,只要路由所做的仅仅是选出允许由哪个信任锚做出最终裁决,而最终裁决由某个攻击者无法伪造的东西来做出。
如果你宁可不亲自把这一切都写一遍,Authagonal 为 .NET 和 Node 都提供了现成的 BFF,而门户会一键把它所需的保密客户端配置好,重定向 URI、后端通道登出端点,全都在内。