---
url: /zh/build/oidc.md
description: >
  介绍如何在云原生构建流水线中通过 OIDC 换取 id_token， 与腾讯云等支持 OIDC 联合身份验证的云服务集成，实现免长期密钥访问外部资源，
  以及自有服务如何校验 CNB 签发的 id_token。
---
云原生构建支持在流水线中签发 OIDC `id_token`，向支持 OIDC 联合身份验证的服务
（云厂商、自建服务）证明「本次构建的身份」，进而换取短期凭证，
避免在仓库中存放长期密钥。

## 为什么使用 OIDC

传统做法是把云厂商的 `SecretId` / `SecretKey` 配置为仓库 [密钥](../repo/secret.md)，
存在密钥集中存放、泄露面大、难以轮换等问题。使用 OIDC 后：

* **无长期密钥**：仓库中不再存放云凭证，流水线运行时按需换取短期凭证。
* **认证与授权分离**：谁能换取什么权限，由目标服务侧的信任策略与权限策略控制，
  可精确到仓库、分支与事件。
* **凭证自动轮换**：`id_token` 有效期 5 分钟，换取的临时凭证到期自动失效，无需人工轮换。

## 工作原理

```text
① 一次性配置：在目标服务侧把 CNB 注册为可信任的 OIDC 身份提供商，并配置信任策略
② 流水线运行时：用 $CNB_TOKEN 调 CNB OpenAPI /{repo}/-/id_token 换取 id_token（JWT）
③ 插件或脚本把 id_token 交给云厂商（如腾讯云 STS AssumeRoleWithWebIdentity）或自有服务
④ 目标服务用 CNB 的 JWKS 公钥验签，并校验 aud / sub / exp 等声明
⑤ 校验通过后下发短期凭证，或返回业务数据
```

CNB 侧提供的标准端点：

| 端点 | 说明 |
|------|------|
| `/.well-known/openid-configuration` | OIDC 发现文档，含 `issuer`、`jwks_uri` 等 |
| `/.well-known/jwks.json` | 签名公钥集合（RS256），供目标服务验签 |

站点的签发者（`issuer`）即实例地址 `https://cnb.cool`，对应端点为：

* 发现文档：`https://cnb.cool/.well-known/openid-configuration`
* 签名公钥集合（JWKS）：`https://cnb.cool/.well-known/jwks.json`

## 前提条件

* 所在实例已启用 OIDC 能力。
* 流水线由允许签发的事件触发（见下表）。PR、Issue、定时任务等事件的 ref 不可信或无换证场景，
  平台会直接拒绝签发。
* 触发者具备仓库 [开发者及以上](../guide/role-permissions.md)权限。
* 请求中的 `aud` 已在该实例注册。audience 需由管理员在管理端
  「OIDC 配置 - 授权目标（Audience）」中创建，腾讯云场景填写 `sts.tencentcloudapi.com`。

### 允许签发的事件

| 事件 | 触发来源 | 权限门槛 | `sub` 示例 |
|------|---------|---------|-----------|
| `push` | Git 推送（平台签发） | — | `my-group/my-repo:main:push` |
| `tag_push` | 推送 Tag（平台签发） | — | `my-group/my-repo:v1.0:tag_push` |
| `tag_deploy.{env}` | 页面 / API 部署 | 推送代码 + 推送 Tag 权限 | `my-group/my-repo:v1.0:tag_deploy.prod` |
| `api_trigger[_{name}]` | OpenAPI | 仓库写权限 | `my-group/my-repo:main:api_trigger` |
| `web_trigger[_{name}]` | 页面按钮 | 仓库写权限 | `my-group/my-repo:main:web_trigger` |

## id\_token 声明

`id_token` 是一个 RS256 签名的 JWT，示例：

```json
{
  "iss": "https://cnb.cool",
  "aud": "sts.tencentcloudapi.com",
  "sub": "my-group/my-repo:main:push",
  "iat": 1692000000,
  "exp": 1692000300,
  "jti": "b1f0e2a4-8c3d-4f6a-9e7b-2c5d8a1f3e09",
  "repository": "my-group/my-repo",
  "ref": "main",
  "event_name": "push",
  "actor": "robin",
  "run_id": "5368227742",
  "sha": "abc123..."
}
```

| 声明 | 说明 |
|------|------|
| `iss` | 签发者，实例的对外公网 HTTPS 地址 |
| `aud` | 受众，即换取 `id_token` 时传入的 `aud` |
| `sub` | 主体，三段式 `{slug}:{ref}:{event}`，由服务端生成 |
| `iat` / `exp` | 签发时间 / 过期时间，`id_token` 有效期 5 分钟 |
| `jti` | 令牌唯一 ID，可用于防重放 |
| `repository` | 仓库完整路径，同 `CNB_REPO_SLUG` |
| `ref` | 分支名或 Tag 名（短名，无 `refs/` 前缀） |
| `event_name` | 触发事件名 |
| `actor` | 触发者用户名 |
| `run_id` | 流水线 ID |
| `sha` | 当前提交的 SHA |

### sub 三段式

| 段 | 含义 | 示例 |
|----|------|------|
| `{slug}` | 仓库完整路径，同 `CNB_REPO_SLUG` | `my-group/my-repo` |
| `{ref}` | 分支名或 Tag 名（短名，无 `refs/` 前缀） | `main` / `v1.0` |
| `{event}` | 触发事件名 | `push` / `tag_push` / `tag_deploy.prod` |

`sub` 已包含仓库、引用、事件三个维度，目标服务只需匹配 `sub` 即可实现分支级、事件级的精确授权。
各段均由服务端依据流水线上下文生成，调用方无法覆盖。

:::: warning
`aud` 只标识「凭证给谁用」，同一 audience 下所有仓库签发的 `id_token` 的 `aud` 完全相同，
**无法**区分是哪个仓库、哪个分支发起的请求。目标服务的信任策略**必须**同时校验 `sub`，
否则等于把权限开放给该实例上的任意仓库。
::::

## 在流水线中获取 id\_token

### 接口

```text
POST {CNB_API_ENDPOINT}/{repo}/-/id_token
Authorization: Bearer $CNB_TOKEN
Content-Type: application/json

{"aud":"sts.tencentcloudapi.com"}
```

请求体只允许传 `aud`，`sub` 及其余声明全部由服务端依据流水线上下文生成，调用方不可覆盖。

响应：

```json
{
  "id_token": "eyJhbGciOiJSUzI1NiIs..."
}
```

:::: tip
响应体**不返回** `expires_in`。`id_token` 的实际有效期以 JWT 内的 `exp` 声明为准，
调用方应解析 `exp` 判断剩余有效期。
::::

### 示例

```yaml title=".cnb.yml"
main:
  push:
    - stages:
        - name: 换取 id_token
          image: alpine
          script: |
            apk add --no-cache curl jq
            ID_TOKEN=$(curl -sS -X POST "$CNB_API_ENDPOINT/$CNB_REPO_SLUG/-/id_token" \
              -H "Authorization: Bearer $CNB_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"aud":"sts.tencentcloudapi.com"}' | jq -r '.id_token')
            # 不要把 ID_TOKEN 打印到构建日志
```

单条流水线 1 分钟内最多签发 10 次 `id_token`，超出将返回 429。

## 与腾讯云集成

使用 [tencentcloud-oidc-auth](https://cnb.cool/cnb/plugins/tencentcom/tencentcloud-oidc-auth) 插件，
可在流水线中免密获取腾讯云 CAM 临时密钥，无需在仓库配置 `SecretId` / `SecretKey`。

### 步骤一：腾讯云侧配置（一次性）

#### 1. 创建 OIDC 身份提供商

前往[新建身份提供商](https://console.cloud.tencent.com/cam/idp/create)（**CAM 控制台 → 身份提供商 → 角色 SSO → 新建身份提供商**），
按控制台表单填写：

| 配置项 | 取值 |
|--------|------|
| 提供商类型 | 选择 `OIDC` |
| 身份提供商名称 | 建议使用域名（如 `cnb.example.com`），便于区分同一 CAM 账号下的多个 CNB 实例；该值即提供商标识，需与下文信任策略的 `oidc-provider/<名称>` 及插件 `provider_id` 参数保持一致 |
| 身份提供商 URL | CNB 的 issuer 地址，必须与 `id_token` 的 `iss` 完全一致，即 `https://cnb.cool` |
| 客户端 ID | `sts.tencentcloudapi.com` |
| 身份提供商公钥 | 访问本站 JWKS 端点 `https://cnb.cool/.well-known/jwks.json`，复制公钥内容粘贴至此 |

#### 2. 创建 CAM 角色并配置信任策略

创建「身份提供商」类型的角色，选择上一步创建的提供商，信任策略如下：

```json
{
  "version": "2.0",
  "statement": [
    {
      "effect": "allow",
      "action": ["sts:AssumeRoleWithWebIdentity"],
      "principal": {
        "federated": ["qcs::cam::uin/<主账号ID>:oidc-provider/cnb.example.com"]
      },
      "condition": {
        "string_equal": {
          "oidc:aud": "sts.tencentcloudapi.com",
          "oidc:sub": "my-group/my-repo:main:push"
        }
      }
    }
  ]
}
```

按粒度从粗到细：

* 仓库级（最低要求）：`"oidc:sub": "my-group/my-repo:*"`，使用 `string_like`
* 分支级：`"oidc:sub": "my-group/my-repo:main:*"`，使用 `string_like`
* 精确锁定：`"oidc:sub": "my-group/my-repo:main:push"`，使用 `string_equal`

需要授权多个分支或事件时，可在 `statement` 数组中配置多条，
或对分支段使用 `string_like` 通配（如 `my-group/my-repo:release-*:push`）。

:::: danger
信任策略**必须**配置 `oidc:sub` 条件，且**至少精确到仓库级别**（`{slug}`）。

`oidc:aud` 只能校验受众是否为 `sts.tencentcloudapi.com`，所有 CNB 流水线的 `id_token` 都相同，
无法区分签发主体。缺少 `oidc:sub` 限制时，任何 CNB 用户都能用自己任意仓库签发的 `id_token`
扮演你的角色，获取该角色名下的全部云资源权限。禁止使用裸 `%` 通配。
::::

#### 3. 为角色绑定权限策略

按最小权限原则为角色绑定策略，如 `QcloudCOSDataWriteOnly`、`QcloudCDNPushOnly` 或自定义策略。

### 步骤二：流水线配置

```yaml title=".cnb.yml"
main:
  push:
    - stages:
        - name: 腾讯云 OIDC 免密
          image: tencentcom/tencentcloud-oidc-auth
          settings:
            role_arn: qcs::cam::uin/123456789:roleName/cnb-deploy
            provider_id: cnb.example.com
            region: ap-guangzhou

        - name: 准备 Python 环境
          image: python:3-slim
          script: |
            python -m venv "$CNB_BUILD_WORKSPACE/.venv"
            "$CNB_BUILD_WORKSPACE/.venv/bin/pip" install -q --disable-pip-version-check tccli

        - name: 验证凭证
          image: python:3-slim
          script: |
            . "$CNB_BUILD_WORKSPACE/.tencentcloud-credentials"
            "$CNB_BUILD_WORKSPACE/.venv/bin/tccli" sts GetCallerIdentity

        - name: 部署
          image: python:3-slim
          script: |
            . "$CNB_BUILD_WORKSPACE/.tencentcloud-credentials"
            "$CNB_BUILD_WORKSPACE/.venv/bin/tccli" cos PutBucket --Bucket my-bucket --Region "$TENCENTCLOUD_REGION"

        - name: 示例 coscli
          image: tencentcom/coscli
          script: |
            . "$CNB_BUILD_WORKSPACE/.tencentcloud-credentials"
            coscli ls cos://my-bucket --init-skip \
              -e "cos.${TENCENTCLOUD_REGION}.myqcloud.com" \
              -i "$TENCENTCLOUD_SECRET_ID" \
              -k "$TENCENTCLOUD_SECRET_KEY" \
              --token "$TENCENTCLOUD_TOKEN" \
              --limit 10
```

::::: tip
`coscli` 与 tccli / SDK 不同，**不读取 `TENCENTCLOUD_*` 环境变量**，需用 `-i` / `-k` / `--token`
显式传入凭证，并用 `--init-skip` 跳过交互式初始化；`cos://` 后须为完整桶名（含 APPID）。
`ls` 需要 bucket 级权限（`cos:HeadBucket` / `cos:GetBucket`），
仅授予对象级权限（如 `cos:GetObject`）会导致 `ls` 返回 403。
:::::

### 插件参数

| 参数 | 必填 | 默认值 | 说明 |
|------|:--:|--------|------|
| `role_arn` | 是 | — | 要扮演的 CAM 角色 ARN，须为 `qcs::cam::uin/<主账号ID>:roleName/<角色名>` 格式，格式不合法插件启动即报错 |
| `provider_id` | 是 | — | 腾讯云侧 OIDC 提供商的标识，即控制台「身份提供商名称」，须与信任策略 `oidc-provider/<名称>` 一致（本文示例为 `cnb.example.com`） |
| `region` | 是 | — | 腾讯云 API 3.0 必填公共参数（STS 请求地域），同时写入 `TENCENTCLOUD_REGION` |
| `role_session_name` | 否 | `cnb-${CNB_BUILD_USER}` | STS 会话名，用于云审计；无触发者时为 `cnb`，超 64 字符自动截断 |
| `duration_seconds` | 否 | `3600` | 临时凭证有效期（秒），超出区间自动收紧到 `[900, 7200]` |
| `audience` | 否 | `sts.tencentcloudapi.com` | `id_token` 受众，须与腾讯云提供商的 Client ID 一致 |
| `cnb_api` | 否 | `$CNB_API_ENDPOINT` | CNB API 地址（覆盖用），必须为 `https://` 开头，插件拒绝 `http://` 明文传输 |

### 凭证文件

插件把临时凭证写入工作空间的 `.tencentcloud-credentials`（权限 `0600`），
文件内容为 shell-safe 的 `export` 语句（值用单引号包裹），后续任务通过 `source` 载入：

| 变量名 | 说明 |
|--------|------|
| `TENCENTCLOUD_SECRET_ID` | 临时 SecretId |
| `TENCENTCLOUD_SECRET_KEY` | 临时 SecretKey |
| `TENCENTCLOUD_TOKEN` | 临时 Token |
| `TENCENTCLOUD_SECURITY_TOKEN` | 同 Token，供 Pulumi 等工具读取 |
| `TENCENTCLOUD_REGION` | 地域 |

文件内容示例：

```bash
export TENCENTCLOUD_SECRET_ID='AKID...'
export TENCENTCLOUD_SECRET_KEY='...'
export TENCENTCLOUD_TOKEN='...'
export TENCENTCLOUD_SECURITY_TOKEN='...'
export TENCENTCLOUD_REGION='ap-guangzhou'
```

每个插件任务只换取一组凭证，多账号场景可配置多个插件任务（使用不同的 `role_arn`）。

### 错误排查

| 错误提示 | 原因与处理 |
|----------|-----------|
| `CNB_TOKEN is empty` | 未在流水线环境中运行（本地调试无 token） |
| `403: event not allowed for oidc id_token` | 当前事件不在白名单，改用 push / tag 等事件触发 |
| `403: pipeline token missing ref context` | 流水线 token 缺少 ref 元数据，检查构建服务版本 |
| `403: audience not allowed` | `aud` 未在该实例注册，联系管理员在管理端创建该 Audience |
| STS `UnauthorizedOperation` | 角色信任策略的 `oidc:sub` 与当前流水线不匹配，对照插件日志中的实际 `sub` 修改 |
| STS `InvalidParameter.WebIdentityTokenError` | 腾讯云提供商的 Provider URL / Client ID 与 CNB 的 issuer / audience 不一致 |
| STS `InvalidParameter.ValueTooLarge` | `duration_seconds` 超过 7200，插件已自动收紧到 7200，检查是否有其他调用方 |
| `PLUGIN_ROLE_ARN 格式错误` | `role_arn` 不符合 `qcs::cam::uin/<主账号ID>:roleName/<角色名>` 格式，插件启动即拒绝 |
| `CNB API 地址必须使用 https://` | `cnb_api` 使用了 `http://`，插件拒绝明文传输 `CNB_TOKEN` |

## 与自有服务集成

如果目标是自建的发布系统、配置中心或内部 API，可以让流水线把 `id_token` 作为 Bearer 令牌传入，
服务端按标准 OIDC 流程校验后即可信任本次构建的身份，无需为流水线单独发放长期 API Key。

### 步骤一：注册 audience

由管理员在管理端「OIDC 配置 - 授权目标（Audience）」中创建 audience，
取值须为域名格式（如 `deploy.example.com`），长度不超过 128 字符。
该值将作为 `id_token` 的 `aud` 声明，也是服务端校验的依据。

### 步骤二：流水线换取并传递 id\_token

```yaml title=".cnb.yml"
main:
  push:
    - stages:
        - name: 调用自有发布服务
          image: curlimages/curl
          script: |
            ID_TOKEN=$(curl -sS -X POST "$CNB_API_ENDPOINT/$CNB_REPO_SLUG/-/id_token" \
              -H "Authorization: Bearer $CNB_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"aud":"deploy.example.com"}' | jq -r '.id_token')
            curl -sS -X POST https://deploy.example.com/api/deploy \
              -H "Authorization: Bearer $ID_TOKEN" \
              -d "version=$CNB_BRANCH"
```

### 步骤三：服务端校验 id\_token

服务端需完成以下校验，缺一不可：

1. **获取公钥**：从 `{iss}/.well-known/openid-configuration` 读取 `jwks_uri`，
   再拉取 JWKS 公钥集合。公钥会轮换，建议按 `kid` 匹配并缓存，定期刷新。
2. **验签**：用 `kid` 对应的公钥校验 RS256 签名，算法白名单只允许 `RS256`。
3. **校验标准声明**：
   * `iss` 等于预期的 CNB 实例地址
   * `aud` 等于本服务注册的 audience
   * `exp` 未过期，必要时校验 `nbf`
4. **校验 `sub`**：与允许调用的仓库 / 分支 / 事件白名单比对，**至少精确到仓库级别**。
5. **防重放（可选）**：记录 `jti`，拒绝重复使用。

:::: warning
不要跳过 `aud` 与 `sub` 校验，也不要在校验通过前根据 `repository`、`ref` 等声明做授权决策——
这些声明只有在验签通过后才是可信的。
::::

### 示例：Node.js（使用 jose）

```js
import express from 'express'
import { createRemoteJWKSet, jwtVerify } from 'jose'

const ISSUER = 'https://cnb.cool'
const AUDIENCE = 'deploy.example.com'
// 仅允许 my-group/my-repo 的 main 分支 push 事件调用
const ALLOWED_SUBS = [/^my-group\/my-repo:main:push$/]

const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))

async function verifyRequest(req) {
  const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
  if (!token) throw new Error('missing bearer token')

  // 校验签名 + iss + aud + exp，算法限定 RS256
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['RS256'],
  })

  // 校验 sub，至少精确到仓库级别
  if (!ALLOWED_SUBS.some((re) => re.test(payload.sub))) {
    throw new Error(`sub not allowed: ${payload.sub}`)
  }
  return payload
}

const app = express()
app.post('/api/deploy', async (req, res) => {
  try {
    const claims = await verifyRequest(req)
    res.json({ ok: true, repository: claims.repository, ref: claims.ref })
  } catch (err) {
    res.status(401).json({ ok: false, message: err.message })
  }
})
app.listen(3000)
```

其他语言可使用对应的 OIDC 库，例如 Go 的 `github.com/coreos/go-oidc`、
Python 的 `pyjwt` + `jwcrypto`、Java 的 `spring-security-oauth2-jose`。

## 安全建议

* **必须校验 `sub`**：目标服务的信任策略至少要限定到仓库级别，建议进一步精确到分支与事件。
* **最小权限**：为每个环境（dev / staging / prod）创建独立角色，按需绑定权限策略。
* **避免凭证泄露**：不要把 `id_token` 或临时凭证打印到构建日志，
  也不要通过 `set-output` 传递；需要跨任务传递时使用带脱敏的 `set-secret` 或文件。
* **优先使用专用插件**：云厂商场景尽量使用官方/社区插件，避免自行处理凭证。
* **插件凭据链路不落日志**：`tencentcloud-oidc-auth` 的 stdout 只输出凭证 sha256 前 8 位与过期时间，
  凭证经工作空间文件（`0600`）传递，不经过 `set-output`。
* **CNB API 强制 HTTPS**：插件参数 `cnb_api` 必须为 `https://`，插件会拒绝 `http://`，
  避免 `CNB_TOKEN` 与 `id_token` 明文传输；也不要把 `cnb_api` 指向不可信地址。
* **换证后前置校验**：插件拿到 `id_token` 后会立即校验 `aud` 与期望一致、`exp` 未过期，
  把错误 token 挡在 STS 之前；自行实现换证逻辑时同样要做这两项校验。
* **关注事件白名单**：仅 push、tag\_push、tag\_deploy、api\_trigger、web\_trigger 事件可签发 `id_token`，
  自定义事件名中不要包含 `:`，否则会被拒绝签发。

## 限制

* 仅支持已启用 OIDC 能力的实例。
* `id_token` 有效期固定为 5 分钟，需在有效期内完成换证。
* 单条流水线 1 分钟内最多签发 10 次 `id_token`。
* `aud` 需由平台管理员预先注册，取值须为域名格式。
* PR、Issue、定时任务等事件触发的流水线无法换取 `id_token`。
