Jamdesk Documentation logo

JWT 身份验证

在 docs.json 中启用 JWT 身份验证,为每位用户提供短期会话

JWT 身份验证需要付费方案,以及一个已连接 Git 仓库的 Jamdesk 项目。配置位于 docs.json 中,因此会随常规构建与部署流程一起生效。

如果您的产品已有自己的登录系统,JWT 身份验证可以让您使用该系统保护文档,而不必分发共享密码短语。登录用户点击进入文档时,您的后端会签发一个短期令牌。Jamdesk 验证令牌一次、创建会话,此后访客即可正常浏览。访客不需要 Jamdesk 账户或共享密码。

这与密码保护有何不同

密码保护为每位访客提供相同的共享密码短语,适用于内部文档、暂存预览或单个合作伙伴受众。JWT 身份验证则按用户区分:每位访客的身份、会话时长和页面访问权限,都来自由您的后端签发的令牌。文档访问权限可以遵循现有的客户账户、方案或角色,而不是使用一个共享密钥。

这两种模式互斥:auth.password 和 auth.jwt 不能同时启用。如果要从一种模式切换到另一种模式,请参阅下方的从密码保护迁移。

设置步骤

1
在 docs.json 中启用 auth.jwt
docs.json
{
  "$schema": "https://jamdesk.com/docs.json",
  "name": "Acme Docs",
  "theme": "jam",
  "auth": {
    "jwt": {
      "enabled": true,
      "loginUrl": "https://app.example.com/docs-login",
      "public": ["/changelog/*"]
    }
  }
}

enabled: true 时必须设置 loginUrl,且它必须是绝对的 https:// URL。未通过身份验证的访客会被重定向到此 URL,并附带 ?redirect=<path>,以便您的登录流程知道应将访客送回哪里。public 是可选的:其中可以填写无需登录即可访问的路径或 glob(* 表示一个分段,** 表示任意深度)。

2
生成签名密钥

在仪表板中打开 Project Settings,找到 JWT authentication 卡片。点击 Generate signing key。

Jamdesk 会创建一个 Ed25519 密钥对,仅保留公钥,并且只显示一次私钥。请立即将其复制到密钥管理器中。Jamdesk 永不存储或通过电子邮件发送私钥;如果您丢失私钥,也无法恢复。如果发生这种情况,请轮换密钥。轮换会立即切换密钥,因此请先阅读轮换签名密钥,再点击按钮。

3
提交并重新构建
git add docs.json
git commit -m "Turn on JWT authentication"
git push

构建发布后,网站会保护每个页面。没有有效会话的请求会被重定向到您的 loginUrl。

集成登录流程

登录用户点击进入文档时,您的后端会签发 JWT,并将浏览器重定向到文档网站的回调 URL,同时将令牌放在 URL 片段中(# 后面)。片段不会到达服务器日志或任何反向代理,因为浏览器不会在请求中发送片段。

令牌必须使用 EdDSA 签名(Ed25519,与您在仪表板中生成的密钥匹配),并且其 exp 声明应不超过未来约 10 秒。这是握手窗口,不是会话时长。实际会话时长由负载中的 expiresAt 字段单独控制(请参阅下方的负载参考)。

import { SignJWT, importPKCS8 } from "jose";

// Store this in your secret manager. It's the private key Jamdesk showed
// you once when you generated it in Project Settings.
const privateKey = await importPKCS8(process.env.JAMDESK_JWT_PRIVATE_KEY!, "EdDSA");

async function signDocsToken(user: { groups: string[]; apiToken: string }) {
  return new SignJWT({
    host: "acme.jamdesk.app", // or your custom domain, e.g. "docs.example.com"
    expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7-day session
    groups: user.groups,
    apiPlaygroundInputs: {
      header: { Authorization: `Bearer ${user.apiToken}` },
    },
  })
    .setProtectedHeader({ alg: "EdDSA" })
    .setExpirationTime("10s") // handshake window, not session length
    .sign(privateKey);
}

// In your "open docs" route/button handler:
app.get("/docs-login", requireAuth, async (req, res) => {
  const token = await signDocsToken(req.user);
  const redirect = req.query.redirect ?? "/";
  res.redirect(
    `https://acme.jamdesk.app/_jd/auth/callback?redirect=${encodeURIComponent(
      String(redirect)
    )}#${token}`
  );
});

只能在服务器端签发令牌。私钥绝不能传递到浏览器或公开仓库中。持有私钥的任何人都可以为您的文档网站创建会话。

重定向流程

  1. 访客在没有有效会话的情况下请求受保护页面(例如 /quickstart)。Jamdesk 会将其重定向到 {loginUrl}?redirect=%2Fquickstart。
  2. 您的登录流程对访客进行身份验证(按照您的常规方式),签发 JWT,并将其重定向到 https://<your-docs-host>/_jd/auth/callback?redirect=%2Fquickstart#<jwt>。
  3. 回调页面在客户端从片段中读取令牌,并将其发送到 Jamdesk 的令牌交换端点。Jamdesk 会验证签名和声明,验证成功后设置已签名的会话 Cookie。
  4. 浏览器被重定向到原始目标 /quickstart,此时已有有效会话。redirect 值会在整个流程中保留,因此访客最终会准确回到最初的位置。

如果您的后端无法确定 redirect 值(例如有人直接将登录页面加入书签),请省略该值,Jamdesk 会回退到 /。

公共页面

某些页面应在无需登录的情况下保持可访问,例如状态页面或公开的更新日志。有三种方式可以将页面标记为公共页面,它们最终都会合并到同一个允许列表中:

Frontmatter,一次设置一个页面:

---
title: Changelog
public: true
---

导航组,设置整个部分:

docs.json
{
  "navigation": {
    "groups": [
      { "group": "Changelog", "public": true, "pages": ["changelog"] }
    ]
  }
}

显式 glob,位于 auth.jwt.public[] 下:

docs.json
{
  "auth": {
    "jwt": {
      "enabled": true,
      "loginUrl": "https://app.example.com/docs-login",
      "public": ["/changelog/*", "/status"]
    }
  }
}

将页面标记为公开,开放的只是页面本身。页面上的图片和视频由项目的资源路径提供,而这些路径仍在访问控制之后,因此未登录的访客会看到一个没有配图的公开页面。当公开页面需要这些资源时,请把资源路径加入 auth.jwt.public[]:

docs.json
{
  "auth": {
    "jwt": {
      "public": ["/changelog/*", "/status", "/_jd/images/changelog/**"]
    }
  }
}

请把通配符限制在公开页面实际用到的目录上。资源路径不会做分组校验,因此像 /_jd/images/** 这样宽泛的通配符会把站点上的所有图片开放给任何人,包括你用 groups 限制的页面里的截图。把公开页面的图片放在单独的目录里,只开放该目录。

基于组的访问控制

某些页面只应对特定的已认证用户可见,例如管理员运行手册或仅限企业用户的参考文档。请在页面的 frontmatter 中添加 groups:

---
title: Admin API Keys
groups: ["admin"]
---

访客的会话会携带后端放入 JWT 负载中的 groups 数组。如果页面声明了 groups,而访客的会话与该列表没有交集,则访客会收到 404,而不是 401 或解锁页面。这是有意设计的:组限制页面不会向组外用户透露自身存在。

以下细节会影响 groups 的使用方式:

  • 组页面会从站点地图、搜索、AI 聊天和 MCP 中排除,即使用户属于该组也是如此。从这些发现入口中排除页面是构建时决定的,而不是按访客决定的。admin 组成员仍可直接打开 /admin/api-keys(通过 URL 或内部链接),但该页面不会出现在搜索结果、聊天答案或 llms.txt 中。如果您希望受限页面能被其目标受众发现,请从该受众已经可以访问的其他页面链接到它。
  • 空的 groups: [] 表示完全不限制,而不是“任何人都不能查看”。要移除页面的组限制,请完全删除 groups 字段,而不是将其设置为空数组。
  • 要限制任何人访问页面,请取消发布该页面。没有任何 groups 值表示“任何人都不能访问”:组成员关系是累加的,只要存在交集就会授予访问权限。
  • 本地化副本会自动继承基础页面的 groups,除非翻译页面在其 frontmatter 中声明了自己的 groups。翻译受限页面不会意外地变成公共页面。
  • Jamdesk 会根据 navigation.languages,以及任何包含页面且名称为语言代码的顶层文件夹(如 fr、it、cs 等),确定哪些顶层文件夹是翻译目录。一个仅仅与语言代码同名的文件夹(例如存放 IT 运行手册的 it 文件夹)也会被视为翻译目录,其中的页面会继承根页面在相同路径上的 groups。这只会增加限制,不会减少限制。如果该行为造成问题,请重命名文件夹。
  • groups 限制的是页面,而不是页面中嵌入的图片、视频和其他文件。只有受限页面才链接的资源,仍会提供给任何直接请求其 URL 的已登录访客,无论其会话携带哪些组。资源 URL 与仓库中的文件路径一致,因此像 images/admin/sso-config.png 这样的名称很容易被猜到。不希望每一位登录读者都看到的内容,请不要放进文档仓库。
  • 侧边栏、标签页、面包屑以及上一页/下一页链接会按访客进行过滤。访客所属组无法访问的页面会被排除;最终为空的组或标签页也会一并排除,因此受限部分的名称不会显示给组外用户。此过滤发生在请求时,与上文适用于所有人的构建时排除机制相互独立。
  • 请保持组名称简短。组会存储在会话 Cookie 中:每个会话最多 32 个组,每个组最多 64 个字符。超过任一限制时,列表不会被截断;Jamdesk 会以 401 拒绝整个令牌,并且不会授予会话。

API playground 预填充

如果您的文档包含 API playground,可以为已登录访客预填充内容,使其无需粘贴自己的 API 密钥。请在 JWT 负载中加入 apiPlaygroundInputs:

{
  "host": "acme.jamdesk.app",
  "apiPlaygroundInputs": {
    "header": { "Authorization": "Bearer sk_live_user_specific_token" },
    "query": { "org_id": "acme-corp" },
    "path": { "workspace_id": "ws_123" }
  }
}
  • header.Authorization 会预填充 playground 的身份验证字段。如果存在 Bearer 前缀,系统会自动移除。
  • query 和 path 会预填充当前端点中名称匹配的参数。
  • 不支持 server 和 cookie 部分。只会应用 header、query 和 path。
  • 预填充不会覆盖访客已经在 playground 中输入的值。

负载参考

字段必填描述
host是必须与请求主机完全匹配(不区分大小写):您的 *.jamdesk.app 子域名或自定义域名。为一个主机签发的令牌,在其他主机上会被拒绝。
expiresAt否生成的会话应在何时到期的 Unix 时间戳(秒)。最长 30 天;省略时默认为 7 天。它与令牌自身的短期 exp 声明相互独立。
groups否会话应携带的组名称数组,最多 32 个条目,每个条目最多 64 个字符。超过任一限制时,整个令牌会被拒绝(401,不创建会话),而不是截断列表。
apiPlaygroundInputs否API playground 的预填充值。序列化大小上限为 2KB。如果超出限制,该字段会被静默丢弃,会话仍会授予。

轮换签名密钥

仪表板卡片上的 Rotate signing key 会生成新的密钥对,并像首次生成时一样只显示一次新的私钥。不存在重叠期。大约 15 秒内,旧密钥将停止接受,所有现有会话也会结束。在您的后端使用新密钥签名之前,所有登录都会被拒绝,访客会在登录页面和文档之间来回跳转。

因此操作顺序很重要:

  1. 准备好一个部署版本,使其从密钥管理器读取签名密钥,而不是使用硬编码值。
  2. 点击 Rotate signing key,并复制新的私钥。
  3. 更新密钥并部署。后端开始使用新密钥后,登录即可恢复。

如果可以,请选择访问量较低的时间进行轮换,并在点击按钮前通知负责后端部署的人员。

如果您只是想结束所有人的会话,例如笔记本电脑丢失后,请改用 Revoke sessions。它会保留密钥,因此后端无需更改;每位访客只需重新登录。

Clear signing key 会从 Jamdesk 移除公钥。auth.jwt 在 docs.json 中仍处于启用状态,因此网站仍会受到保护,但在生成新密钥之前无法验证任何令牌。只有在将网站迁移到其他访问模式或准备停用网站时,才应清除密钥。

注销

已登录访客会在文档页眉中看到 Log out 链接。该链接会将其发送到 /_jd/auth/logout,清除会话 Cookie,并重定向到您的 loginUrl。如果您希望在自己的应用其他位置提供“退出文档”链接,也可以直接链接到此地址。这是一个不需要请求正文或请求头的普通 GET 请求。

退出文档不会让访客退出您的产品。如果您的登录流程会为任何已有应用会话的用户签发令牌,那么访客点击 Log out 后再打开文档链接时,会立即重新登录。这通常正是您想要的行为。如果您需要真正退出,请让 loginUrl 处理程序检查用户是否明确登录,而不是自动创建令牌;或者将您自己的注销地址同时指向文档注销 URL。

身份验证下的功能行为

功能行为
llms.txt / llms-full.txt / sitemap与网站其余部分一样受到保护:没有有效会话时无法访问。
组限制页面从上述所有产物以及搜索和 AI 聊天中排除,与请求会话所属的组无关(请参阅基于组的访问控制)。
robots.txt始终公开。搜索引擎可以看到文档网站存在且受到保护,但看不到其内容。

故障排除

轮换和撤销会在大约 15 秒内生效,而不是立即生效,因为边缘网关会短暂缓存身份验证配置,以确保每次页面请求都很快。仪表板中的 Rotate 确实会使所有现有会话失效;请等待最多 15 秒,再将仍然有效的旧会话视为问题。

仪表板与运行时缓存中的签名密钥不一致,通常是因为临时写入失败中断了生成、轮换或清除操作。横幅会说明具体情况:最新密钥可能尚未到达缓存(使用该密钥签名的令牌可能会被拒绝),或者您清除的密钥仍在缓存中(使用该密钥签名的令牌仍会被接受)。每次打开设置页面时,Jamdesk 都会再次检查。如果横幅仍然存在,请点击 Retry sync。如果仍然失败,请轮换密钥;如果处于已清除密钥的情况,也可以再次生成并清除。如果横幅显示 Jamdesk 完全无法验证状态,说明检查本身失败;请在运行时恢复可访问后重试。

请检查 host 声明是否与实际请求的主机完全一致。如果您的文档既可通过自定义域名(docs.example.com)访问,也可通过底层的 *.jamdesk.app 子域名访问,则为其中一个主机签发的令牌会在另一个主机上被拒绝:host 绑定要求完全匹配且不区分大小写,但不识别别名。请为实际链接到的主机签发令牌;如果两个主机都会被链接,请签发两个变体。

Jamdesk 的回调路由不会重定向回自身:指向 /_jd/auth/callback(或其下方解锁样式页面)的 redirect 值会被改写为 /,而不会按原值处理。如果仍然出现循环,请检查您的登录流程是否自身循环重定向到文档的 loginUrl(例如登录页面找不到文档会话时,立即跳回 /docs-login)。文档侧的循环已有防护;循环几乎总是出在登录流程中。

这是一个 config_error,会阻止构建。请选择其中一种;如果要切换模式,请参阅从密码保护迁移了解安全的操作顺序。

安全提示

apiPlaygroundInputs(包括您放入其中的任何 Authorization 值)可以由文档网站上运行的 JavaScript 读取,读取入口是为 playground 预填充功能提供支持的会话信息端点。预填充很方便,但不适合存放高权限密钥。

请发送范围限制为访客允许执行的操作、且遵循最小权限原则的用户凭据,绝不要发送组织级管理员密钥。请将放入 apiPlaygroundInputs 的任何内容都视为对浏览文档的用户可见,因为事实确实如此。

从密码保护迁移

从共享密码切换到 JWT 身份验证无需停机,网站会始终保持受保护状态。请按以下顺序操作:

1
生成 JWT 签名密钥

首先执行此步骤,此时密码保护仍处于启用状态。生成密钥不会改变受保护的内容;在整个过程中密码仍然有效。

2
修改 docs.json 并重新构建
docs.json
{
  "auth": {
    "password": { "enabled": false },
    "jwt": { "enabled": true, "loginUrl": "https://app.example.com/docs-login" }
  }
}

提交并推送。此构建发布后,保护机制会从密码原子切换为 JWT,不会出现网站未受保护的窗口。现有通过密码解锁的会话会在切换时结束;此后访客将通过您的登录流程进行身份验证。

3
清除密码

确认 JWT 流程端到端运行正常后,返回 Project Settings 并清除存储的密码。此时密码已经不起作用(docs.json 中的密码模式已关闭),但清除操作会完全移除存储的哈希值。

下一步是什么?

访问控制概览

对比 JWT 身份验证与密码保护、SSO 以及多项目模式。

密码保护

共享密码短语替代方案:设置更简单,无需后端集成。

SSO(企业版)

由身份提供商驱动的企业客户登录方式。

自定义域名

在配置登录流程之前,先将文档放到您自己的域名上。