← 返回
AI技术

如何在用户离职时通过 Kinde Webhook 自动撤销 Claude Agent 的访问权限

✍️ zhirenhun 📅 2026/9/17 👁 8 阅读 ⏱ 22 分钟
如何在用户离职时通过 Kinde Webhook 自动撤销 Claude Agent 的访问权限

想象一下这种情形:团队里有人在离职时,他们的 AI 代理仍在执行任务中途。没有人记得代理还在运行。它只是按照之前的指令行事,代表的是一分钟前还在公司或团队工作的人。

它会停下来吗?

嗯,我构建了一个小应用来测试这种情形,使用 Kinde 处理登录并保存谁仍然活跃的记录。我让一个真实用户登录,把任务交给他们的代理,而在代理仍在执行过程中,我在 Kinde 的仪表盘中暂停了同一用户,以观察代理接下来会做什么。

毫不意外,代理继续在执行任务。

你看,暂停或删除一个人只会改变 Kinde 对该用户的看法,但不会触及其代理已经持有的访问令牌,因为暂停不会追溯到已经签发并发出的令牌。令牌的验证方式与暂停前完全相同,因此代理无法知道发生了任何变化。

本文接下来要讨论的内容,是如何弥合这一差距,以及我在实践中实际发现的东西:实时测量的 webhook 交付、一个可能导致离职用户记录永远显示为活跃的生产环境 bug,以及一个确切的数字——离职用户的代理在被发现之前能继续行动多久。

暂停用户的令牌为何仍然有效

让我们先来看看访问令牌实际上是什么,因为整个差距正是由此而生。OAuth 访问令牌并不是你交回去对账本核对的收据。它是一个带有加密签名的已签名声明,一个小型 JSON 有效载荷,检查它的任何东西只会用公钥验证该签名,而不是向服务器询问令牌是否仍然有效。这种设计的全部吸引力在于:API 可以在不每次请求都进行数据库往返的情况下确认令牌的真实性。

这意味着在 Kinde 中暂停用户仅仅会修改 Kinde 自身数据库中的一行。它根本触及不到令牌,因为没有可触及的对象:令牌已经被发出、已经签名、并且在其签发时设定的过期日期之前一直有效。要正确地撤销它,意味着必须在某个查找表中跟踪每一个已签发的令牌,这会彻底丧失最初签发令牌的意义;或者只能等待令牌自然过期。

我在会话进行中暂停了一个已登录的测试用户,而应用自身的检查仍在几秒后报告该用户的访问令牌有效,尽管 Kinde 已经将其暂停。

因此,这个差距既不是 Kinde 的错误,也不是 OAuth 的错误。它只是无状态凭据本身的设计特性,真正的问题是你在其之上构建了什么。

修复的形态

两个部分弥补了这一差距。Webhook 会在 Kinde 对用户的视图发生变化时通知应用;而在每个代理操作执行前都会运行一次检查,读取应用对该用户的自身记录,而不是依赖会话开始时的状态。

这张图展示了一个流程图,描述了如何使用 Kinde 和 Webhook 来实现用户身份验证和时间戳验证

该代理本身是一个小型 Claude Messages API 循环,针对一组演示内部资源的三个工具(封闭集合)进行操作:list_resourcesread_resourcewrite_resource。以下内容与代理具体执行的操作无关。它特指每个工具调用在被允许运行之前必须经过的唯一环节。

构建衔接

首先,我们从定义这三个操作的注册表开始,因为它是通过构造而非约定来封闭的。此表中不存在的操作不会在代码中半途存在、等待被意外调用。它根本就不存在:

export const ACTION_REGISTRY: Record<ActionName, ActionDefinition> = {
  list_resources: { name: "list_resources", destructive: false, params: {} },
  read_resource: {
    name: "read_resource",
    destructive: false,
    params: { resourceId: { type: "string", required: true } },
  },
  write_resource: {
    name: "write_resource",
    destructive: true,
    params: {
      resourceId: { type: "string", required: true },
      title: { type: "string", required: false },
      body: { type: "string", required: false },
    },
  },
};

Claude 所使用的工具模式和执行检查均来源于同一张表,因此它们不会像通常的模式和权限列表那样在有人忘记更新其中一方时悄然脱节。

模型所做的每一次工具调用都会经过同一个函数 enforceToolCall,该函数会查询当前操作用户的状态,并将其交给其下方的一个小型纯净决策函数:

export function decideAccess(input: {
  mode: EnforcementMode;
  userStatus: UserStatus;
}): { decision: SeamDecision; reason: SeamReason } {
  if (input.mode === "naive") {
    return { decision: "allow", reason: "naive_mode_no_check" };
  }
  if (input.userStatus === "active") {
    return { decision: "allow", reason: "user_active" };
  }
  if (input.userStatus === "offboarded") {
    return { decision: "refuse", reason: "user_offboarded" };
  }
  return { decision: "refuse", reason: "user_unknown" };
}

天真模式会在不检查该状态的情况下放行所有调用,这正是本文所讨论的漏洞。我们故意这样复现,使两种模式能够在完全相同的代码上并行运行,从而清晰地证明观点。严格模式在关键方面更为严格:它仅允许一种情况——已确认的活跃用户,其他所有情况均被拒绝,即便是连接 Convex 读取失败导致无法解析的状态也会被拒绝。未知状态不会得到任何疑罪从宽的待遇。

该状态来源于一个 webhook。Kinde sends a signed event on user.updated and user.deleted, the receiver verifies the signature, and then it does one more thing that has nothing to do with the signature at all:

export function isFreshWebhookEvent(
  event: WebhookEvent,
  now: number = Date.now(),
): boolean {
  const eventTime = Date.parse(event.timestamp);
  if (Number.isNaN(eventTime)) return false;
  return Math.abs(now - eventTime) <= MAX_CLOCK_SKEW_MS;
}

有效的签名仅证明 Kinde 在某个时刻对该有效载荷进行了签名。它并未说明具体时间,因此如果不进行此检查,捕获的事件在数月后重放也会直接通过签名验证,因为签名本身永不过期,且过去的去重也不起作用——去重只能拦截已经见过的事件 ID。MAX_CLOCK_SKEW_MS 的值为五分钟。超过这个时长的事件将与伪造的签名一样被拒绝。

构建实际发现的错误

加固此接收器时发现了一个真实的顺序错误。最初的版本在为去重记录 webhook 投递之后,才应用实际的“将用户标记为离线”效果。这种顺序存在一种隐蔽的失效模式:如果在投递已经被记录后,效果写入失败,重试的 webhook 就会看起来像是已处理过的重复事件而被跳过。用户实际上永远不会离线,系统也不会再尝试。

修复只需调整一行顺序,但它之所以有效,依赖于一个底层属性:效果 markOffboarded 是幂等的,因此两次执行始终是安全的。正是因此,它现在会在记账之前无条件地先执行,而记账的全部职责就是阻止它第三次或第四次运行。先记录投递再应用效果的顺序在编写时感觉更自然,但这也是较不安全的做法。

实际验证

scripts/e2e-narrative.ts 会对一个真实的 Kinde 测试用户执行两次同一任务:一次采用 naive 模式的接合点,一次采用 enforced 模式,在运行过程中暂停该用户的真实状态。此脚本中没有任何内容是模拟的。它会驱动一个真实的代理循环,向 Kinde 发送一次真实的暂停调用,并在通过隧道收到实际的 webhook 之后,再检查实际发生了什么。

naive enforced
离线后允许的操作 2 0
运行停止的位置 未停止,运行至完成 步骤 2,原因 user_offboarded

同样的情况也在操作员控制台中出现,因此我在编写此文时再次运行了它,故意在任务进行过程中让已登录的用户离线:

第一步在用户仍然活跃时落地。离职请求被发出。第二步拒绝,user_offboarded,截止延迟为 2697ms,测量起点是离职本身落地到数据库的时间,而不是运行开始的时间。所有这些决策都会在一个共享的关联 ID 下记录到审计日志中,因此可以在事后拉取完整的运行时间线,而不仅仅是实时观察。

比演示更重要的数字

在此构建的每一次实时 webhook 交付中,延迟在 652ms 到 2058ms 之间变化,适用于暂停、恢复、删除和角色变更等操作。这并不是瞬时的。大多数人认为它是瞬时的。

执行检查本身不属于该数字的任何部分。它只是对 Convex 的一次索引读取,相对于 webhook 交付来说,开销足够小,以至于不会影响总体延迟。

因此,更诚实地描述总撤销速度的方式是:它是 webhook 交付延迟,加上代理直到进行下一次真实操作所需的时间,而这第二部分根本不是一个固定的系统数字。如果代理移动得更快,在步骤之间没有人工间隔,或者在同一轮中请求了多次工具调用,那么它将在其非常接下来的调用中同样快速地被捕获,而不需要任何额外机制来更早地捕获它。这里的下限大约是一次模型往返时间,而不是位于其下方的执行检查。

webhook 也可能会被错过或延迟,因为这实际上是“尽力交付”的意思。因此,我在其之上构建了一个 reconciliation sweep:一个 cron 作业,每五分钟直接针对 Kinde 检查每个活跃用户的实时状态。我在一个强制场景下进行了测试:暂停一个真实用户,然后手动将应用自身的记录推回活跃状态,模拟一个根本没有到达的 webhook。cron 检测到了漂移并自行纠正,在我触发手动运行之前就已经完成了。

我想说的

"撤销" 是这里所做的任何事情的错误说法,我认为这一点比表面看起来更重要。这里实际上并没有撤销任何东西。真正发生的情况是,对一个本来就不可撤销的凭证,在其上额外添加了一个实时检查,我宁愿这样描述,也不愿假装否则,因为这更接近该领域所有系统在底层实际上所做的事情,不管它们在营销文案中如何称呼它。

"我也认为这个检查不应该在演示已经能够运行后,作为加固阶段的补丁被临时加上——而这正是在这个构建中发生的顺序。裂缝早在早期就存在,但和解回退以及时间戳检查后来才作为生产打磨加入,我认为对于任何无人值守运行的系统来说,这种顺序是倒置的。浏览器会话在每次页面加载时,基本上会因为有人一直在产生新请求而意外地重新验证。代理循环不会自动获得这种好处。它只持有一个凭证,在启动时验证一次,然后在无人实时监控的循环中使用该凭证。如果这个检查不是从第一行就内置到该循环中,之后就不会有意外情况把它补回来。"

通常对这一点的反对意见是成本:每次工具调用都进行一次数据库读取,在代理任务意味着几十次调用而不是一次时,这听起来很昂贵。但此构建自身的数据表明,这种反对意见站不住脚——至少在使用像这里这样的索引查找时是这样。该检查在 webhook 延迟面前可以忽略不计,而 webhook 延迟又在人们实际上需要多久才能注意到有人离开并去点击暂停的时间面前可以忽略不计。这个系统中真正昂贵的部分是有人离开后到别人发现之间的五到十五分钟。尽管这个数字实际上决定了你暴露的程度,但没有人会为它进行优化。

这并未解决的问题

本文开头提到的令牌仍然有效的差距是真实存在的,而这里的任何内容都无法弥补它。此构建通过在每次调用时检查活性来规避这一问题,而不是试图让令牌本身失效。真正需要撤销令牌的系统需要完全不同的机制,例如使用短寿命令牌或在每次使用时调用内省端点,而这两种方式都会放弃使签名令牌最初值得使用的确切无状态验证优势。

和解扫描是一个五分钟的后备措施,而不是主要路径;如果 webhook 正常工作,则该扫描永远不会发现需要纠正的内容。

runs.timeline, runs.get, 以及此构建中的审计查询也是未经身份验证的 Convex 读取。仅凭一个 run ID 或 correlation ID 就足以读取该运行的完整时间线,这对单用户演示控制台来说没问题,但对任何包含不可信用户的系统来说完全不行。

此处的操作注册表仅覆盖演示资源上的恰好三个读写操作。这足以证明该模式成立。但要证明该模式能够扩展到具有真实资源之间真实权限边界的真实授权模型,这远远不够。

这对代理意味着什么

回到开场场景:有人被离职,而他们的代理正在执行任务中。其接下来的几次工具调用是否能通过,从来都不是关于令牌的问题。令牌一直到自然过期之前都会继续工作,无论是否离职,因为这就是它的本质。真正的问题一直是:在代理即将执行的操作与其之间的任何东西,是否在那一刻检查了背后的人是否仍然在岗。

代码与来源

完整的构建、执行接合点、Webhook 处理程序、调和 Cron 以及 scripts/e2e-narrative.ts 位于 GitHub:sholajegede/离职撤销演示

Webhook 签名和交付模型来源于 Kinde 自己的 webhooks 文档,而演示中使用的暂停和恢复操作遵循 Kinde 的 管理 API 及其关于 暂停和删除用户 的说明。

调和扫 sweep 运行在 Convex 的 cron 作业 上,而代理循环遵循 Anthropic 的 工具使用文档,用于 Messages API。本文中的每个数字都来自同一 Convex 部署中的 runs.timelineauditLog,直接从实时运行中读取。

克隆它,按照你自己的 Kinde 租户进行连接,并在运行过程中中途下线一个测试用户。你的 webhook 延迟可能与我不匹配,你的对账扫描可能捕捉到不同的漂移,这正是重点。请在评论中填写你的数字,并告诉我你发现了什么。

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

ai kinde webdev claude