> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# 令牌与 JWKS：验证 Didit 身份认证 JWT

> Didit 身份认证令牌：PKCE 授权码交换、ES256 访问令牌与 ID 令牌声明、按应用的发现与 JWKS、密钥轮换、刷新令牌复用检测。

> [!WARNING]
>
> 身份验证功能是新功能。本页的每个调用都只能使用**终端用户**凭证，或者完全不使用凭证。切勿将您的验证 API 密钥或控制台 token 发送到这些 endpoint。

每个应用都是独立的 OpenID Connect 颁发者（issuer）。颁发者即应用的登录源：

* 生产应用使用 `https://<name>.auth.didit.me`，
* 沙盒应用使用 `https://<name>.sandbox.auth.didit.me`，
* 或者您的自定义域名（域名生效后）。

生产环境与沙盒是两个独立的颁发者，各自拥有独立的密钥。将 `DIDIT_AUTH_ISSUER` 设置为您的颁发者，末尾不要带斜杠；将 `DIDIT_APPLICATION_ID` 设置为您的应用 ID，token 会以 `aud` 字段携带该值。您的可发布密钥（publishable key）中编码了颁发者：在 `pk_live_` 或 `pk_test_` 之后的部分，就是经过无填充 base64url 编码的颁发者。

> [!NOTE]
>
> 自定义域名生效后，它会取代默认主机成为颁发者。新 token 会在 `iss` 中写入该域名，发现文档会将 `jwks_uri` 指向该域名，您的可发布密钥也会随之变化。在刷新之前，使用默认主机签发的访问 token 调用 `GET /session/` 会失败。激活域名时请更新 `DIDIT_AUTH_ISSUER`；或者在旧 token 于 5 分钟内过期之前，同时接受两个颁发者。

本页涉及的服务器端 endpoint（发现、密钥、token 和会话）记录在[身份验证 API 参考](https://docs.didit.me/zh/authentication/api-reference/get-openid-configuration.md)中，该参考由 [openapi-authentication.json](https://docs.didit.me/openapi-authentication.json) 生成。托管登录页面不在其中。

## 发现与密钥

| 请求 | 结果 |
| - | - |
| `GET {issuer}/.well-known/openid-configuration` | `issuer`、`authorization_endpoint`、`token_endpoint`、`jwks_uri` 以及颁发者支持的内容。 |
| `GET {issuer}/.well-known/jwks.json` | `{"keys": [...]}`，即公开的签名密钥。 |

颁发者支持以下内容：

* 响应类型 `code`，响应模式 `query`。
* 授权类型 `authorization_code` 和 `refresh_token`。
* PKCE 方法仅支持 `S256`。客户端为公开客户端：没有客户端密钥，`client_id` 即您的应用 ID。
* 签名算法 `ES256`（P-256 密钥）。
* 作用域 `openid`、`email`、`phone`、`profile` 和 `offline_access`。

不提供 userinfo、introspection 或 end-session endpoint，也不支持 `prompt=none`。请使用 [`GET /session/`](#检查会话) 读取并检查会话。

### 缓存与轮换

两份文档都返回 `Cache-Control: public, max-age=300`，因此您和共享缓存可以将其保存 5 分钟。每个 IP 地址每分钟允许 1,200 次请求，每小时允许 12,000 次。这一限额远高于登录 endpoint 的速率限制，因为许多后端实例通常共用同一个地址。

* 每个密钥都有 `kid`；新密钥 ID 以 `k_` 开头。每个 token 都会在 `kid` 头部中注明其密钥。
* Didit 每 30 天轮换一次密钥。Didit 会在新密钥开始签名前一天发布该密钥，并在旧密钥停止签名后继续发布它一天。因此，缓存 5 分钟的 JWKS 在轮换期间仍可继续验证。
* 按 `kid` 缓存密钥。当 token 包含您没有的 `kid` 时，重新获取 JWKS，但每分钟最多一次；如果仍找不到该密钥，则拒绝该 token。
* 当 Didit 吊销已泄露的密钥时，会立即将其从 JWKS 中移除，`GET /session/` 也会拒绝该密钥签发的所有 token。替换密钥会立即开始签名，无需提前一天发布。最多缓存 5 分钟的 JWKS 仍会接受已吊销的密钥，且尚不知道替换密钥，因此在缓存刷新之前，新 token 可能无法通过离线验证。

## 开始登录

打开托管页面，即 `authorization_endpoint`：

```text theme={null}
{issuer}/sign-in?redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256
```

| 参数 | 规则 |
| - | - |
| `redirect_uri` | 必须与您[已注册的重定向 URL](https://docs.didit.me/zh/authentication/overview.md#重定向-url) 之一完全一致。 |
| `state` | 最多 512 个字符。在回调时进行校验。 |
| `code_challenge` | PKCE 验证器的 SHA-256 经 base64url 编码的结果：恰好 43 个字符。 |
| `code_challenge_method` | `S256`。 |
| `client_id` | 可选。必须与您的应用 ID 相同。 |
| `nonce` | 可选。会复制到 ID token 中，便于您将其与本次登录绑定。 |
| `scope`、`login_hint`、`ui_locales`、`max_age`、`prompt` | 可选。`login_hint` 仅用于预填邮箱字段。 |

[快速入门](https://docs.didit.me/zh/authentication/quickstarts/javascript.md)会为您构建此请求。

## 交换授权码

回调会接收 `code` 和 `state`。请校验 `state`，然后在 60 秒内交换授权码。每个授权码只能使用一次。请求体可以是 JSON，也可以是表单编码。

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "CODE_FROM_CALLBACK",
    "code_verifier": "YOUR_PKCE_VERIFIER",
    "redirect_uri": "https://app.example.com/auth/callback"
  }'
```

```json theme={null}
{
  "access_token": "eyJhbGciOiJFUzI1NiIs...",
  "id_token": "eyJhbGciOiJFUzI1NiIs...",
  "refresh_token": "N4yZTl6y24YKYq77X_SO...",
  "token_type": "Bearer",
  "expires_in": 299,
  "session_id": "c9abf6cc-2d64-4bf2-b31e-a4d09a070ab1"
}
```

`expires_in` 表示访问 token 剩余的有效秒数。关闭多会话功能后，此次交换会结束该用户的其他会话。

| 响应 | 含义 |
| - | - |
| `400 {"error": "invalid_grant"}` | 授权码未知、已使用或已过期；验证器或 `redirect_uri` 不匹配，或者 `redirect_uri` 已不再注册；用户已被封禁，或其邮箱已不再通过验证；或者您关闭了用户所用的登录方式。 |
| `400 {"error": "invalid_client"}` | `client_id` 不是您的应用 ID。 |
| `400 {"error": "mfa_required"}` | 本次登录未满足您的 MFA 策略。 |
| `400 {"<field>": [...]}` | 某个字段缺失或无效，例如授权请求缺少其必需字段时返回 `{"non_field_errors": ["invalid_request"]}`。 |
| `400 {"<field>": "unknown_field"}` | 请求体包含该 endpoint 不接受的字段，例如 `client_secret`。公开客户端不发送任何此类字段。 |

## 访问令牌声明

访问令牌是一个 JWT，头部为 `alg: ES256` 和 `typ: at+jwt`。其有效期最多为 5 分钟，且绝不会超过所属会话的有效期。

| 声明 | 含义 |
| - | - |
| `iss` | 您的签发者。 |
| `aud` | 您的应用 ID。 |
| `client_id` | 您的应用 ID。 |
| `sub` | 该用户在此应用中的身份 ID。 |
| `sid` | 会话 ID。对于 AI 智能体的令牌，为用户授予该智能体的访问权限的 ID。 |
| `jti`, `iat`, `exp` | 令牌 ID、签发时间和过期时间。 |
| `vendor_data` | 用户的稳定引用，与身份验证和交易监控共享。 |
| `amr` | 登录方式，例如 `["passkey"]`。 |
| `org` | 用户在您的应用中最早加入的组织成员身份，格式为 `{"id", "role"}`；如果用户没有任何组织成员身份，或未启用组织功能，则为 `null`。`GET /session/` 会列出所有成员身份。 |
| `verified` | 验证级别以及您选择的声明，或 `{}`。会话期间批准的检查会在下一次刷新时写入令牌。 |
| `act` | 用户本人时为 `null`。对于代表用户行事的 AI 智能体，为 `{"sub", "agent", "scopes"}`。参见 [第 5 步](#在服务器上验证令牌)。 |

ID 令牌的头部为 `typ: JWT`。它包含与访问令牌相同的声明，但不含 `client_id`，另外还包含 `auth_time` 和 `email_verified`，以及在有值时的 `email`、`name` 和 `phone_number`。只有授权码交换得到的 ID 令牌才会包含 `nonce`（前提是登录时带有该值）。请在该令牌中校验它，因为刷新得到的 ID 令牌从不包含它。ID 令牌永远不会被当作访问令牌接受。

## 在服务器上验证令牌

在信任任何声明之前，请检查以下所有项目：

1. `alg` 为 `ES256`，`typ` 为 `at+jwt`。拒绝所有其他算法，包括 `none`。
2. `kid` 存在于您应用的 JWKS 中，且签名验证通过。
3. `iss` 等于您配置的签发者，`aud` 等于您的应用 ID。
4. `exp` 和 `iat` 有效，允许最多 60 秒的时钟误差，且 `sub`、`sid` 和 `jti` 均存在。
5. `act` 为 `null`。AI 智能体的访问令牌使用同一密钥签名，并带有相同的 `typ`、`iss` 和 `aud`；只有 `act` 能将其区分出来。除非该端点接受智能体，否则请拒绝它；若接受，则只允许 `act.scopes` 授予的操作。`GET /session/` 始终拒绝智能体的令牌。

签发者应从您的配置中获取，切勿从令牌中获取。

```javascript Node.js theme={null}
// npm install jose
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = process.env.DIDIT_AUTH_ISSUER; // https://<name>.auth.didit.me
const AUDIENCE = process.env.DIDIT_APPLICATION_ID;
// Cache keys for 5 minutes; refetch an unknown kid at most once a minute.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,
  cooldownDuration: 60 * 1000,
});

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["ES256"],
    typ: "at+jwt",
    clockTolerance: 60,
    maxTokenAge: "5m", // also rejects an iat in the future
    requiredClaims: ["sub", "sid", "jti", "exp", "iat"],
  });
  if (payload.act !== null) throw new Error("agent tokens are not accepted");
  return payload; // payload.vendor_data is the person across Didit products
}
```

```python Python theme={null}
# pip install "pyjwt[crypto]"
import os
import threading
import time

import jwt

ISSUER = os.environ["DIDIT_AUTH_ISSUER"]  # https://<name>.auth.didit.me
AUDIENCE = os.environ["DIDIT_APPLICATION_ID"]
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json", lifespan=300)
refresh_lock = threading.Lock()
last_refresh = 0.0


def signing_key(kid: str | None) -> jwt.PyJWK:
    # PyJWKClient refetches for every unknown kid: limit it to once a minute.
    global last_refresh
    keys = {key.key_id: key for key in jwks.get_signing_keys()}
    if kid not in keys:
        with refresh_lock:
            if time.monotonic() - last_refresh >= 60:
                last_refresh = time.monotonic()
                fresh = jwks.get_signing_keys(refresh=True)
                keys = {key.key_id: key for key in fresh}
    if kid not in keys:
        raise jwt.InvalidTokenError("unknown signing key")
    return keys[kid]


def verify_access_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header.get("typ") != "at+jwt":
        raise jwt.InvalidTokenError("not an access token")
    claims = jwt.decode(
        token,
        signing_key(header.get("kid")).key,
        algorithms=["ES256"],
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=60,
        options={"require": ["iss", "aud", "sub", "sid", "jti", "exp", "iat"]},
    )
    if "act" not in claims or claims["act"] is not None:
        raise jwt.InvalidTokenError("agent tokens are not accepted")
    return claims
```

离线验证无法识别刚刚结束的会话。在这点很重要的情况下，例如付款之前，请调用 `GET /session/`。

## 检查会话

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/session/" \
  -H "Authorization: Bearer $DIDIT_ACCESS_TOKEN"
```

响应包含 `sub`、`sid`、`application_id`、`vendor_data`、`amr`、`mfa_verified`、`email`、`email_verified`、`phone`、`verified_identity`、`verified`、`expires_at`、`post_logout_redirect_uri` 和 `organizations`。每个组织条目包含 `organization_id`、`organization__name`、`role` 和 `custom_role`。

此检查比离线验证更严格。以下情况也会导致检查失败：会话被撤销、空闲超时或超过有效期；用户被封禁，或其邮箱不再通过验证；会话不再符合您的 MFA 策略（例如用户添加了身份验证器应用，或成为组织管理员之后）；或令牌已被刷新替换。失败时返回 `401 {"detail": "invalid_token"}` 以及 `WWW-Authenticate: Bearer`。

`GET /session/` 每个 IP 地址每分钟允许 30 次请求，每小时允许 300 次，该限制与 `DELETE /session/` 共享。请在每次请求时离线验证令牌，并将此调用保留给确实需要的时刻。每次成功调用都会计为活动，因此会重新开始会话的 `idle_timeout`。

已验证的电子邮件并不等于身份验证。请为此读取 `verified`：没有时其值为 `null`，而令牌中则为 `{}`。

## 刷新令牌

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{"grant_type": "refresh_token", "refresh_token": "YOUR_REFRESH_TOKEN"}'
```

刷新会返回新的访问令牌、ID 令牌和刷新令牌，您所发送的刷新令牌随即失效。

* 刷新令牌是一个随机字符串，有效期至会话结束。
* **重复使用会结束会话。** 当已使用过的刷新令牌再次出现时，Didit 会将其视为盗用的迹象：撤销该会话，并返回 `400 {"error": "invalid_grant"}`。该会话的令牌也会同时失效，包括之前那次刷新得到的令牌：使用其刷新令牌会失败，使用其访问令牌调用 `GET /session/` 会立即失败，不过在过期之前（最多 5 分钟内）仍可离线验证。
* 对于同一会话，请在标签页、设备和服务器实例之间一次只执行一次刷新，并将三个令牌一起替换。
* 如果刷新以 `400` 失败或没有响应，请重新开始登录。不要盲目重试：如果刷新实际已成功，重试就构成重复使用。
* `503 {"error": "temporarily_unavailable"}` 表示刷新未发生，您的刷新令牌仍然有效。请使用退避策略重试。
* Didit 会在刷新运行之前返回 `429`，因此请在 `Retry-After` 之后重试。令牌端点每个 IP 地址每分钟允许 30 次请求，每小时允许 300 次。此外，您应用的每个 `POST` 和 `DELETE` 请求共享机器人防护桶：这些端点合计每个 IP 地址每分钟 60 次请求，每台设备每分钟 30 次。在整个应用层面，刷新的桶容量为每分钟 6,000 次，其他所有 `POST` 和 `DELETE` 请求（包括浏览器和登录步骤）共用一个每分钟 3,000 次的桶，因此刷新不会耗尽您的登录额度。
* 若访问令牌的有效期为 5 分钟，每小时 300 次刷新大约可满足 25 名保持活跃的用户。如果您的服务器要为来自同一地址的大量用户刷新，请仅在请求需要新令牌时才刷新，并尽可能在浏览器或应用中刷新。

## 退出登录

使用用户当前的访问 token 调用 `DELETE /session/` 即可结束会话，并返回 `204`。该会话的刷新 token 随即失效，其访问 token 会立即无法通过 `GET /session/` 验证；您离线验证的 token 则会保持有效，直到过期。之后，如果您设置了 `post_logout_redirect_uri`，请将用户跳转到该地址。

退出登录需要一个 `GET /session/` 会接受的访问 token，即未过期、且未被刷新替换的 token。返回 `401` 表示会话并未结束。请先刷新，再使用新的访问 token 退出登录；如果刷新返回 `400`，则该会话已无法继续使用。

以下情况下，会话也会结束：

* 超过 `session_lifetime`（默认 30 天）或 `idle_timeout`（默认 1 天），
* 多会话功能关闭时，用户再次登录，
* 开启 `revoke_on_password_change` 时，用户重置密码，
* 您的团队在控制台中撤销该会话，或封禁该用户，
* 已使用过的刷新 token 被再次发送。

## 有效期

| 项目 | 有效期 |
| - | - |
| 登录事务（`POST /authorize/`） | 10 分钟 |
| 授权码 | 60 秒，仅可使用一次 |
| 访问 token | 最长 5 分钟 |
| ID token | 与签发它的访问 token 相同的 `exp` |
| 刷新 token | 直至会话结束，仅可使用一次 |
| 会话 | `session_lifetime` 和 `idle_timeout` |
| 升级验证 | 30 分钟 |
| 发现与 JWKS 缓存 | 5 分钟 |

## 按用途存储凭据

* 验证 API 密钥和控制台 token 请保存在您的服务器上。
* 浏览器端的访问 token 应尽量保存在内存中。对于服务端渲染的应用，请使用安全的 HttpOnly Cookie，并防范依赖 Cookie 认证的请求遭受 CSRF 攻击。
* 移动端的刷新 token 请存储在平台提供保护的存储中。退出登录后，请清除本地状态。
* 切勿将 token 放入 URL 或日志中。

继续阅读 [JavaScript 快速入门](https://docs.didit.me/zh/authentication/quickstarts/javascript.md)、[Next.js 快速入门](https://docs.didit.me/zh/authentication/quickstarts/nextjs.md) 或 [身份验证 API 参考](https://docs.didit.me/zh/authentication/api-reference/get-openid-configuration.md)。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.