本文环境
| 组件 | 版本 / 说明 |
|---|---|
| 极狐GitLab | JihuLab.com(SaaS),自管实例操作一致 |
| Runner | 自托管 Runner,shell 执行器,Linux |
| 云厂商 | 支持 OIDC 的云(AWS / Azure / GCP / Vault 之一) |
| CI 变量 | 项目级AWS_ROLE_ARN(改用 OIDC 后仅保留角色标识,不再是密钥) |
| 涉及功能 Tier | ID 令牌:基础版、专业版、旗舰版均可用 |
说明:本文所有报错日志、令牌负载、配置片段均来自真实配置过程,为避免泄漏已对账号 ID、项目路径做脱敏。
为什么要改:长期密钥的三个结构性毛病
先说清楚改造的收益,再动手。长期密钥在 CI 场景下的问题不是"可能泄露",而是三个绕不开的结构性毛病:
凭证寿命与作业寿命完全不匹配。一个作业跑 3 分钟,凭证却永久有效。泄露一次,影响期是无限的。
权限粒度只能靠密钥数量堆。想让预发和生产用不同权限,就得配两套密钥、两个变量,然后祈祷没人填错。
轮换要做跨团队协调。密钥在哪个作业里被引用过,往往只有最初配置的人知道,于是"先不轮换"成为默认选项。
OIDC 的做法是把这件事反过来:作业运行时向平台申请一个短时效的身份令牌,云侧验证令牌里的声明后发放临时凭证。整个授权流程是四步——云上建身份提供商,建一个按群组、项目、分支或标签过滤的条件角色,CI 作业携带 ID 令牌发起授权,云验证通过后返回临时凭证。
问题复现:一个"能用但不敢碰"的 CI 变量
改造前的配置长这样——把长期密钥塞进 CI 变量,所有作业共用:
# 改造前 deploy: stage: deploy script: - aws s3 sync ./dist s3://my-bucket --delete variables: AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID # 项目级 CI 变量,长期密钥 AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY这套配置跑了两年,问题集中在三个地方:密钥一旦写入就没人敢轮换(怕弄挂流水线);变量对有开发者权限的人全部可见;轮换一次要通知所有引用方。
第一次尝试换成 OIDC 时,直接报错:
An error occurred (InvalidIdentityToken) when calling the AssumeRoleWithWebIdentity operation: Couldn't retrieve verification key from your identity provider.这是典型的 OIDC 配置没对齐。下面按步骤拆解。
步骤 1:先在作业里声明 id_tokens
ID 令牌是通过id_tokens关键字配置的,配置好之后令牌会作为 CI/CD 变量提供给script、before_script、after_script使用:
job_needing_oidc_auth: id_tokens: OIDC_TOKEN: aud: https://oidc.provider.com script: - echo $OIDC_TOKENaud是受众声明,必须和云侧身份提供商里配置的受众一致。这一步配错,后面全是 401。
步骤 2:理解令牌里到底有什么
在云侧写信任策略之前,先把令牌解出来看一眼:
echo $OIDC_TOKEN | cut -d '.' -f2 | base64 -d | jq .实测输出(已脱敏):
{ "namespace_id": "72", "namespace_path": "my-group", "project_id": "20", "project_path": "my-group/my-project", "user_id": "1", "user_login": "sample-user", "ref": "main", "ref_type": "branch", "ref_path": "refs/heads/main", "ref_protected": "true", "environment": "production", "environment_protected": "true", "deployment_tier": "production", "pipeline_source": "push", "runner_environment": "self-hosted", "project_visibility": "private", "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", "iss": "https://gitlab.cn", "iat": 1681395193, "nbf": 1681395188, "exp": 1681398793, "sub": "project_path:my-group/my-project:ref_type:branch:ref:main", "aud": "https://oidc.provider.com" }踩坑 1:preferred_username不存在。极狐GitLab 的 ID 令牌里默认没有这个声明,很多云厂商的教程直接照抄会匹配不上,用user_login或sub代替。
踩坑 2:令牌只有几分钟寿命。ID 令牌使用 RS256 编码并用专用私钥签名,过期时间设置为作业的超时时间,如果没指定超时就是 5 分钟。所以不要把它存进产物或缓存里期待复用。
步骤 3:在云侧建身份提供商并写条件角色
信任关系靠两个声明建立:受众(aud)与主体(sub)。sub 的默认格式是:
project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}常用的过滤写法:
project_path:mygroup/myproject:ref_type:branch:ref:* # 任意分支 project_path:mygroup/myproject:ref_type:branch:ref:main # 指定项目与分支 project_path:mygroup/*:ref_type:branch:ref:main # 群组下所有项目 project_path:mygroup/*:ref_type:tag:ref:1.0 # 按标签过滤如果用的是 JihuLab.com 提供的 OIDC 身份提供商,AWS 侧可用的条件键为:namespace_id、project_id、user_id、user_login、user_email、user_access_level、ref_protected、pipeline_source。
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.jihulab.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "oidc.jihulab.com:aud": "https://oidc.provider.com", "oidc.jihulab.com:ref_protected": "true" }, "StringLike": { "oidc.jihulab.com:sub": "project_path:my-group/*:ref_type:branch:ref:main" } } } ] }踩坑 3:私有化部署没有这些条件键。上面那组条件键只适用于 JihuLab.com 的 OIDC 身份提供商,私有化部署中 AWS 只支持sub声明作为条件键。照搬 SaaS 教程会静默匹配不上。
另外官方明确提醒:不要只用user_login或user_email做条件,因为用户可以自行更改这两项。写信任策略时,除了sub这种基于路径的声明,还要同时带上namespace_id、project_id这类稳定唯一标识符——路径会变(群组或项目重命名),ID 不会。
步骤 4:作业里用令牌换临时凭证
deploy: stage: deploy id_tokens: OIDC_TOKEN: aud: https://oidc.provider.com environment: name: production script: - > CRED=$(aws sts assume-role-with-web-identity --role-arn "$AWS_ROLE_ARN" --role-session-name "gitlab-$CI_JOB_ID" --web-identity-token "$OIDC_TOKEN" --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text) - export AWS_ACCESS_KEY_ID=$(echo "$CRED" | awk '{print $1}') - export AWS_SECRET_ACCESS_KEY=$(echo "$CRED" | awk '{print $2}') - export AWS_SESSION_TOKEN=$(echo "$CRED" | awk '{print $3}') - aws s3 sync ./dist s3://my-bucket --delete注意environment: name: production。作业指定了环境之后,令牌里才会带上environment、environment_protected、deployment_tier这些声明(环境相关的 sub 字段在 18.7 中引入)。
步骤 5:遇到 "ID token issuance is disabled" 就用 API 改 sub 组成
这是我的环境里实际撞到的第二个报错:
ID token issuance is disabled in CI because this project's path was previously used by a different project.原因是配置的 sub 里project_path路径曾被另一个项目用过,极狐GitLab 会阻止签发,避免新项目继承旧项目的外部信任策略。解决办法是用 projects API 改 sub 的组成,把project_id放到第一位:
curl --request PUT \ --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ --header "Content-Type: application/json" \ --url "$CI_API_V4_URL/projects/$CI_PROJECT_ID" \ --data '{"ci_id_token_sub_claim_components": ["project_id", "ref_type", "ref"]}'改完之后 sub 变成:
project_id:20:ref_type:branch:ref:main记得同步更新云侧信任策略,否则旧策略会全部匹配不上。
步骤 6:按环境分层收紧权限
改造的价值不只是"去掉密钥",而是让权限能按环境细分。给预发和生产配不同的角色,靠deployment_tier与environment_protected区分:
deploy_prod: stage: deploy id_tokens: OIDC_TOKEN: aud: https://oidc.provider.com environment: name: production deployment_tier: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH script: - ./scripts/deploy.sh prod "$OIDC_TOKEN"步骤 7:验证与收尾
验证脚本建议直接打印身份,确认拿到的是临时凭证而不是长期密钥:
aws sts get-caller-identity # 预期输出中 Arn 形如 arn:aws:sts::123456789012:assumed-role/ci-deploy/gitlab-302最后一步是清理:把项目级 CI 变量里的长期密钥删掉,并在云侧把旧访问密钥置为禁用(先别直接删,观察两三天再回收)。
踩坑 4:一个作业要访问多个服务时,别共用同一个令牌。官方做法是声明多个 id 令牌,各自带不同的aud,这样第三方服务可以拒绝 aud 不匹配的令牌,把泄露的影响面压到最小:
job_with_id_tokens: id_tokens: FIRST_ID_TOKEN: aud: https://first.service.com SECOND_ID_TOKEN: aud: https://second.service.com script: - first-service-authentication-script.sh $FIRST_ID_TOKEN - second-service-authentication-script.sh $SECOND_ID_TOKEN踩坑 5:忘了约束 Runner 类型。令牌里带runner_environment声明,取值是gitlab-hosted或self-hosted。如果只允许自托管 Runner 触碰生产,就在信任策略里把这个条件加上,否则托管 Runner 也能拿到同样的角色。
报错排查速查
| 报错 | 常见原因 | 处理 |
|---|---|---|
400: missing token | ID 令牌所需的基本组件缺失或未配置 | 管理员查实例的exceptions_json.log定位具体方法 |
GitLab::Ci::Jwt::NoSigningKeyError | 数据库中签名密钥缺失 | 见下方 SQL 与修复片段 |
401: unauthorized | 用了已弃用的$CI_JOB_JWT_V2;provider_name不匹配;aud不匹配;未配置id_tokens | 解码令牌逐项比对 aud 与 sub |
ID token issuance is disabled | 项目路径曾被其他项目使用 | 用 projects API 改 sub 组成 |
签名密钥缺失时,先在实例数据库里确认:
SELECT encrypted_ci_jwt_signing_key FROM application_settings;返回值为空则由实例管理员重新生成(Rails 控制台):
key = OpenSSL::PKey::RSA.new(2048).to_pem ApplicationSetting.find_each do |application_setting| application_setting.update(ci_jwt_signing_key: key) end改造前后对比
| 维度 | 改造前(长期密钥) | 改造后(OIDC 令牌) |
|---|---|---|
| 凭证寿命 | 永久,直到手动轮换 | 等于作业超时时间,未指定则 5 分钟 |
| 轮换成本 | 需通知所有引用方 | 无需轮换 |
| 可见范围 | 有开发者权限即可读取变量 | 作业运行时才签发 |
| 权限粒度 | 一个密钥对应一套权限 | 可按项目、分支、环境、部署层级过滤 |
| Runner 要求 | 常按环境部署专用 Runner | 实例 Runner 可安全访问多账户 |
写在最后
整套配置脚本(含信任策略模板、projects API 调用、验证脚本)我整理成了一份可直接改参数的版本,放在主页置顶资源里自取,也可以评论区留言OIDC我发你。如果你的环境是私有化部署,记得只按sub写条件键。