# 认证与凭证

本文介绍开发者平台的凭证体系、运行环境、访问令牌获取方式以及密钥的生命周期管理。

## 理解三种凭证

平台将开发者可见的凭证归拢为三种类型：

| 凭证 | 使用场景 | 表现形式与规则 |
|---|---|---|
| **应用密钥** | 应用的后端服务调用开放接口 | 由客户端 ID 与密钥组成。按环境使用 `kwo_test_` 或 `kwo_live_` 前缀。每个应用每个环境最多保留 5 把有效密钥，支持 IP 白名单与带重叠期的平滑轮换。 |
| **页面会话** | 嵌入工作台的应用前端页面 | 用户访问应用页面时由平台自动注入。前端通过 SDK 获取，后端通过 SDK 验签。开发者无需手动换取。 |
| **API 凭证**（CI 凭证） | 开发者持续集成与发布流水线、命令行 `k3` | 用于调用开发者平台自身接口（创建版本、上传包、发布）。供命令行工具 `k3 login` 登录使用。 |

## 区分测试与正式环境

平台提供完全隔离的两种运行环境：

| 环境 | 密钥前缀 | 访问范围 |
|---|---|---|
| `test`（测试环境） | `kwo_test_` | 仅允许访问本开发者组织名下的测试企业（SandboxISV），不可访问真实企业。 |
| `live`（正式环境） | `kwo_live_` | 仅允许访问安装了该应用的真实租户，不可访问测试企业。 |

历史存量密钥 `kwo_<随机字符>` 均归入正式环境。

如果使用测试密钥请求真实租户，或者使用正式密钥请求测试企业，接口返回 HTTP 403 与错误码 `wrong_environment`。所有已认证接口在响应头中均会返回当前环境标识 `Raise-Environment: test|live`。

## 换取租户访问令牌

应用后端通过调用令牌端点换取访问令牌（Tenant Token）：

```http
POST https://api.${DOMAIN}/open/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
```

客户端认证支持通过 HTTP Basic 头传入 `client_id:client_secret`，也支持在请求正文中传入 `client_id` 与 `client_secret`。

### 自建应用与商店应用的参数区别

在发起 `grant_type=client_credentials` 请求时，`tenant` 参数的传递规则如下：

- **自建应用**：凭证与单一租户绑定。请求正文中必须省略 `tenant` 参数，或者传入与该凭证所属租户一致的标识。
- **商店应用**：凭证属于开发者组织，可在多个租户间通用。换取令牌时**必须**在请求正文中显式提供目标租户标识 `tenant=${TENANT}`。

通过 curl 换取租户令牌示例：

```bash
curl -sS -X POST "https://api.${DOMAIN}/open/v1/oauth/token" \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials&tenant=${TENANT}"
```

成功返回 HTTP 200：

```json
{
  "access_token": "at_live_example_token_value",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "platform:ping tables.base:read",
  "tenant": "${TENANT}",
  "environment": "live"
}
```

租户令牌的有效期固定为 300 秒（5 分钟）。SDK 会在令牌过期前 60 秒自动进行单飞（single-flight）刷新。

## 换取客户令牌

客户令牌（Customer Token）用于终端客户（例如微信小程序用户）在不注册平台账号的前提下，访问自身业务数据。

你的后端在换取客户令牌前，必须自行验证客户手机号真实性。该操作需要应用具备 `customer:assert` 权限范围。

**谁可以使用 `customer:assert`。** 平台不核对手机号，所以这个范围是受限的，只有下面的应用可以持有：

- 组织自己的集成（组织管理员在管理后台「集成」里创建，只服务这一个组织）。管理员把 `customer:assert` 写进它的范围列表即可。
- 平台运营明确信任的上架应用。外部开发者的应用默认不能持有：版本里申请了 `customer:assert` 时，提交审核的自动检查 `scope.customer:assert.holder` 不通过；组织安装或升级到这样的版本会被拒绝（`403`）；换客户令牌返回 `403` `scope_required`，`message` 以 `this app may not hold customer:assert` 开头。需要它时，先联系平台运营说明你的后台怎样验证手机号，获得信任后再提交。

在测试企业里可以不经信任走通客户令牌流程（那里没有真实客户）；正式组织不行。每次匹配成功的断言和每次因这条规则被拒绝的断言都会写进组织的审计，带应用 id，不带手机号。

调用端点并指定授权类型 `urn:raise:grant-type:customer`：

```bash
curl -sS -X POST "https://api.${DOMAIN}/open/v1/oauth/token" \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:raise:grant-type:customer&tenant=${TENANT}&phone=13800000000"
```

匹配成功返回 HTTP 200：

```json
{
  "access_token": "at_cus_example_token_value",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "membership.me:read membership.booking:write",
  "tenant": "${TENANT}",
  "environment": "live",
  "customer": "cus_example_123"
}
```

未找到对应会员时返回 HTTP 200 与 `{"matched": false}`。

若同一个手机号在租户中存在多个匹配客户，接口返回 HTTP 409 与错误码 `ambiguous_customer`，并在 `details.candidates` 中给出候选客户 ID 列表。此时可在请求正文中追加 `customer_id` 参数重新换取。

客户令牌有效期为 900 秒（15 分钟）。写操作要求断言时间在 10 分钟内完成，否则返回 HTTP 401 与错误码 `assertion_stale`。

## 调用开发者平台接口

开发者平台的接口 `https://dev.${DOMAIN}/api/dev/…`（应用、版本、凭证、测试企业、日志）用开发者组织成员的令牌，本文的示例写作 `${DEV_SESSION_TOKEN}`。它不是应用密钥换来的 `/open/v1` 令牌。令牌有两种来源：

- **人**：在开发者门户登录（见 [快速开始](/developers/guide/quickstart) 的「注册开发者账号」）。门户替你持有令牌，门户里的操作都用它。
- **程序与命令行**：用开发者组织的 **API 凭证**换令牌。API 凭证在门户的「API 凭证」页创建，只有组织管理员能创建、轮换、删除，每个组织最多 10 个，带开发者角色，密钥只在创建时显示一次。第一把怎样建见下文「建第一把 API 凭证」。

最省事的是交给 `k3`：`k3 login` 保存 API 凭证，之后 `k3 api` 自己换令牌、调用任意 `/api/dev` 接口：

```bash
k3 login --url "https://dev.${DOMAIN}" --client-id "${K3_CLIENT_ID}" --secret-stdin
k3 api GET /api/dev/apps
```

不用 `k3` 时自己换。`GET https://dev.${DOMAIN}/api/platform` 不需要登录，给出令牌地址 `tokenUrl` 和要申请的 `scope`；再用 API 凭证的客户端 ID 与密钥（HTTP Basic）走 `client_credentials`：

```bash
curl -sS "https://dev.${DOMAIN}/api/platform"
curl -sS -X POST "${TOKEN_URL}"   -u "${K3_CLIENT_ID}:${K3_CLIENT_SECRET}"   -H "Content-Type: application/x-www-form-urlencoded"   -d "grant_type=client_credentials"   --data-urlencode "scope=${SCOPE}"
```

回答里的 `access_token` 就是 `DEV_SESSION_TOKEN`，`expires_in` 是它还有几秒有效；快到期时再换一个。客户端 ID 或密钥里有特殊字符时，先做 URL 编码再放进 Basic 认证。开发者平台接口的错误与角色不够时的 403 见 [错误码与请求 ID](/developers/guide/errors)。

### 建第一把 API 凭证

API 凭证要组织管理员登录门户后才能建，所以第一把凭证要先登录一次：在门户的「API 凭证」页点「创建凭证」，或者用门户登录拿到的令牌调接口：

```bash
curl -sS -X POST "https://dev.${DOMAIN}/api/dev/credentials" \
  -H "Authorization: Bearer ${DEV_SESSION_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"name": "ci-release"}'
```

- `name`：2 到 63 个小写字母、数字、`-` 与 `_`，以字母开头。
- 成功返回 HTTP 201，`credential.clientId` 与 `credential.clientSecret`（只显示这一次），还有 `tokenUrl`、`scope` 与 `grantType`。
- 组织已经有 10 个，或者同名的凭证或成员已经存在时，返回 HTTP 409。不是组织管理员时返回 403。
- `GET /api/dev/credentials` 列出组织的凭证；轮换密钥是 `POST /api/dev/credentials/{id}/rotate-secret`，删除是 `DELETE /api/dev/credentials/{id}`，两者都要组织管理员。

之后用它 `k3 login`（见上文），或者自己换令牌。

### 不用浏览器登录门户

门户的登录是标准的 OpenID Connect 授权码流程，带 PKCE。能不能不用浏览器走完，要看部署让门户用哪个登录页。先做第 1、2 步，看授权地址跳到哪里：

1. `GET https://dev.${DOMAIN}/api/portal-config` 给出身份服务的 `issuer`、门户的 `clientId` 和要申请的 `scope`。`{issuer}/.well-known/openid-configuration` 给出 `authorization_endpoint` 与 `token_endpoint`。
2. 生成 PKCE 的 `code_verifier` 和它的 `code_challenge`（S256），请求 `authorization_endpoint`，参数是 `response_type=code`、`client_id`、`redirect_uri=https://dev.${DOMAIN}/auth/callback`、`scope`、`state`、`code_challenge` 与 `code_challenge_method=S256`。它跳转到登录页，跳转地址有两种：
   - `https://id.${DOMAIN}/ui/v2/login/login?authRequest=V2_…`：平台自己的登录页。`authRequest` 的值就是下面的 `<ID>`，接着做第 3、4 步。
   - `…/ui/login/login?authRequestID=…`：身份服务自己的登录页。部署没有把门户切到平台的登录页时就是这样。这样的部署没有不用浏览器的登录办法：平台的登录接口不认这个 ID，`POST /api/login/password` 答 HTTP 409 `wrong_login_page`（较早的版本答 410 `expired`）。请部署的运维把门户切到平台的登录页（`deploy/scripts/login-page.sh on`）；或者在任何一台有浏览器的机器上登录一次门户，建好 API 凭证（见上文「建第一把 API 凭证」），之后只用凭证。
3. 在登录页所在的主机上 `POST /api/login/password`，正文 `{"authRequest": "<ID>", "loginName": "<邮箱>", "password": "<密码>"}`，`Content-Type: application/json`。第一次登录、还没有密码时改用 `POST /api/login/activate`，正文 `{"authRequest": "<ID>", "loginName": "<邮箱>", "code": "<激活码>", "password": "<新密码>"}`。回答的 `step` 说下一步做什么：
   - `done`：打开回答里的 `redirect`，它最后跳到 `https://dev.${DOMAIN}/auth/callback?code=…&state=…`。
   - `passkey_setup`：登录页请你设置通行密钥，可以不设。`POST /api/login/passkey/skip`，正文 `{"authRequest": "<ID>", "session": <回答里的 session>}`，回答是 `done`。再加 `"remember": true`，以后这个账号登录时不再问。回答是 `passkey_setup` 时，跳过总是允许的，不看组织的设置。
   - `mfa`、`mfa_setup` 或 `unsupported_factor`：账号有第二因素，或者组织要求设置一个。不用浏览器的办法本文不写；请用浏览器登录一次。
4. 向 `token_endpoint` 发 `grant_type=authorization_code`、`code`、`redirect_uri`、`client_id` 与 `code_verifier`（表单），回答里的 `access_token` 就是令牌，`expires_in` 是它还有几秒有效。

第 3 步的接口是平台登录页自己的接口，不是开发者平台的稳定接口；其中 `POST /api/login/passkey/skip` 写在平台的接口描述 `GET https://dev.${DOMAIN}/api/openapi.json` 里。

登录接口答 HTTP 410 `expired` 时，这个 `authRequest` 已经过期或用过了：从第 2 步重新开始。答 409 `wrong_login_page` 时，照第 2 步的第二种情况做。

### 门户各页用的接口

门户的每一页都调开发者平台接口，同样的事都可以用 `k3 api` 或 curl 做：

| 门户的页 | 接口 |
|---|---|
| 首页、组织 | `GET /api/dev/overview`，`GET /api/dev/isv` |
| 应用 | `GET /api/dev/apps`，`POST /api/dev/apps`，`GET /api/dev/apps/{id}`，`PUT /api/dev/apps/{id}/listing` |
| 版本与发布 | `POST /api/dev/apps/{id}/versions`，`GET`、`PUT /api/dev/apps/{id}/versions/{version}`，`POST …/versions/{version}/submit`、`/publish`、`/canary`、`/rollout`，`PUT …/versions/{version}/bundle` |
| 部署 | `GET /api/dev/apps/{id}/deployments`，`GET …/deployments/{did}`，`POST …/deployments/{did}/rollback`、`/abort`、`/advance` |
| 应用的凭证 | `GET`、`POST /api/dev/apps/{id}/credentials`，`POST …/credentials/{cid}/rotate`，`DELETE …/credentials/{cid}` |
| 测试企业 | `GET`、`POST /api/dev/sandboxes`，`POST /api/dev/sandboxes/{sid}/extend`、`/install`，`GET`、`POST /api/dev/sandboxes/{sid}/users`，`POST …/users/{uid}/signin`，`DELETE /api/dev/sandboxes/{sid}` |
| 日志与错误 | `GET /api/dev/apps/{id}/logs`，`GET …/installs`，`GET …/errors`，`GET …/errors/{fingerprint}`，`GET …/requests/{requestId}` |
| 事件订阅 | `GET`、`POST /api/dev/apps/{id}/open/subscriptions`，`PUT`、`DELETE …/open/subscriptions/{sid}` |
| API 凭证 | `GET`、`POST /api/dev/credentials`，`POST /api/dev/credentials/{id}/rotate-secret`，`DELETE /api/dev/credentials/{id}` |
| 团队 | `GET`、`POST /api/dev/members`，`PUT /api/dev/members/{id}/roles` |
| 企业认证 | `GET`、`PUT /api/dev/certification` |

每个接口的参数与回答见平台自己的接口描述 `GET https://dev.${DOMAIN}/api/openapi.json`（`/api/openapi.yaml` 是同一份），不需要登录。

## 轮换应用密钥

为了保障业务不中断，轮换密钥时新旧密钥会同时生效一段时间。

调用轮换接口，并指定新旧密钥共存的重叠天数 `overlapDays`（默认 7 天，最大 30 天）：

```bash
curl -sS -X POST "https://dev.${DOMAIN}/api/dev/apps/${APP_ID}/credentials/${CREDENTIAL_ID}/rotate" \
  -H "Authorization: Bearer ${DEV_SESSION_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"overlapDays": 7}'
```

接口返回新生成的凭证密钥与缩短后的旧密钥到期时间：

```json
{
  "credential": {
    "id": "crd_new_id",
    "app": "${APP_ID}",
    "env": "live",
    "name": "Production Key",
    "prefix": "kwo_live_",
    "expiresAt": "2027-10-28T00:00:00Z"
  },
  "clientId": "cid_new_example",
  "secret": "kwo_live_new_secret_string",
  "shownOnce": true,
  "tokenUrl": "https://api.${DOMAIN}/open/v1/oauth/token",
  "previous": {
    "id": "${CREDENTIAL_ID}",
    "expiresAt": "2026-11-04T00:00:00Z"
  }
}
```

每个应用在单个环境下最多保留 5 把存活密钥（包括未吊销、未过期及处于重叠期的旧密钥）。若已达上限，必须先吊销无用密钥。

## 吊销应用密钥

如果密钥泄露或旧服务废弃，你可以立即吊销指定密钥：

```bash
curl -sS -X DELETE "https://dev.${DOMAIN}/api/dev/apps/${APP_ID}/credentials/${CREDENTIAL_ID}" \
  -H "Authorization: Bearer ${DEV_SESSION_TOKEN}"
```

成功返回 HTTP 204 无正文。

密钥吊销后立即失效。使用已吊销密钥请求令牌端点会返回 HTTP 401 与错误码 `credential_ended`。
