将 GitHub 签发的 OIDC Token 换取为短期 OpenAI 访问令牌,即可将 GitHub Actions 用作工作负载身份提供方。这样,工作流无需在 GitHub 机密中存储长期有效的 API 密钥,就能向 OpenAI API 进行身份验证。
如果您使用 Codex,请按照本页说明获取并检查 GitHub Token。然后配置 Codex 工作负载身份,将该 Token 写入文件,并让 Codex 使用该文件。本页的服务账户映射和 SDK 示例适用于 OpenAI API。
对于拥有 id-token: write 权限并请求身份 Token 的工作流程作业,GitHub 可以为其签发带签名的 OIDC JWT。OpenAI 会先验证 Token 的签发方、受众、签名和映射属性,再签发 OpenAI 访问令牌。
授予工作流程或作业请求 GitHub OIDC Token 的权限:
123permissions:
id-token: write
contents: read
id-token: write 权限允许作业请求 OIDC JWT,但不会授予对代码仓库内容的写入权限。actions/checkout 需要 contents: read 权限。
请求 Token 时,使用与您在 OpenAI 工作负载身份提供方中配置的受众完全一致的值。自定义 JavaScript 操作可以调用 core.getIDToken("your-wif-audience");Shell 步骤可以直接调用 GitHub 的 OIDC 请求 URL。如果受众值包含 URL 保留字符,例如 https://api.openai.com/v1,应先进行 URL 编码,再将其附加到请求 URL:
123456AUDIENCE="https://api.openai.com/v1"
ENCODED_AUDIENCE=$(jq -rn --arg audience "$AUDIENCE" '$audience | @uri')
TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ENCODED_AUDIENCE}" | jq -r .value)
export TOKEN
重要的 GitHub OIDC 声明包括:
iss:Token 的签发方。对于 GitHub Actions,此值为 https://token.actions.githubusercontent.com。
aud:工作流程请求的受众值。配置 OpenAI,使其要求的值与您请求的值完全一致,例如 your-wif-audience 或 https://api.openai.com/v1。
sub:主要的主体字符串。GitHub 根据代码仓库、分支、标签、Pull Request 或环境等工作流程元数据构建此字符串。
repository:运行工作流程的代码仓库,例如 my-org/my-repo。
repository_owner:拥有该代码仓库的组织或用户,例如 my-org。
ref:触发工作流程的 Git 引用,例如 refs/heads/main 或 refs/tags/v1.0.0。
workflow:工作流程声明。使用 GitHub 实际生成的声明值;例如,如果您作业中的工作流程声明为 deploy,就使用该值。
workflow_ref:工作流程文件的路径和引用,例如 my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main。
environment:作业使用环境时的 GitHub 环境名称,例如 production。
run_id、run_number、run_attempt 和 job_workflow_ref:运行和作业的标识符,可用于审计或更高级的信任规则。
有关完整的声明列表和主体格式,请参阅 GitHub 的 OpenID Connect 参考资料。
配置工作负载身份联合之前,请将 GitHub OIDC Token 导出为 TOKEN,然后在工作流程运行器中运行此脚本,检查其中的声明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const parts = process.env.TOKEN?.split(".") ?? [];
if (parts.length !== 3) {
throw new Error("Expected a compact JWT with three segments");
}
if (!/^[A-Za-z0-9_-]+$/.test(parts[1]) || parts[1].length % 4 === 1) {
throw new Error("JWT payload is not valid Base64URL");
}
const bytes = Buffer.from(parts[1], "base64url");
if (bytes.toString("base64url") !== parts[1]) {
throw new Error("JWT payload is not valid Base64URL");
}
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const claims = JSON.parse(decoded);
if (claims === null || Array.isArray(claims) || typeof claims !== "object") {
throw new Error("JWT payload is not a JSON object");
}
console.log(decoded);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import base64
import json
import os
import re
def reject_non_json_constant(value):
raise ValueError(f"JWT payload contains non-JSON constant: {value}")
parts = os.environ.get("TOKEN", "").split(".")
if len(parts) != 3:
raise ValueError("Expected a compact JWT with three segments")
payload = parts[1]
if re.fullmatch(r"[A-Za-z0-9_-]+", payload) is None or len(payload) % 4 == 1:
raise ValueError("JWT payload is not valid Base64URL")
padded_payload = payload + "=" * (-len(payload) % 4)
decoded = base64.b64decode(padded_payload, altchars=b"-_", validate=True)
if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != payload:
raise ValueError("JWT payload is not valid Base64URL")
decoded_text = decoded.decode("utf-8")
claims = json.loads(decoded_text, parse_constant=reject_non_json_constant)
if not isinstance(claims, dict):
raise ValueError("JWT payload is not a JSON object")
print(decoded_text)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"unicode/utf8"
)
func decodeSegment(segment string) (json.RawMessage, error) {
if !isBase64URLSegment(segment) {
return nil, fmt.Errorf("JWT segment is not valid Base64URL")
}
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return nil, err
}
if base64.RawURLEncoding.EncodeToString(decoded) != segment {
return nil, fmt.Errorf("JWT segment is not valid Base64URL")
}
if !utf8.Valid(decoded) {
return nil, fmt.Errorf("JWT segment is not valid UTF-8")
}
var value json.RawMessage
if err := json.Unmarshal(decoded, &value); err != nil {
return nil, err
}
if trimmed := bytes.TrimSpace(value); len(trimmed) == 0 || trimmed[0] != '{' {
return nil, fmt.Errorf("JWT segment is not a JSON object")
}
return value, nil
}
func isBase64URLSegment(segment string) bool {
if segment == "" || len(segment)%4 == 1 {
return false
}
for _, character := range segment {
if !('A' <= character && character <= 'Z') &&
!('a' <= character && character <= 'z') &&
!('0' <= character && character <= '9') &&
character != '-' &&
character != '_' {
return false
}
}
return true
}
func main() {
parts := strings.Split(os.Getenv("TOKEN"), ".")
if len(parts) != 3 {
panic("Expected a compact JWT with three segments")
}
payload, err := decodeSegment(parts[1])
if err != nil {
panic(err)
}
formatted, err := json.MarshalIndent(payload, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(formatted))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77// Add Jackson (com.fasterxml.jackson.core:jackson-databind) to your project.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public final class DecodeJwtPayloadExample {
private static final ObjectMapper JSON =
new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private DecodeJwtPayloadExample() {}
static String decodeUtf8(byte[] bytes) throws IOException {
try {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException exception) {
throw new IOException("JWT segment is not valid UTF-8", exception);
}
}
static String decodeSegment(String segment) throws IOException {
if (!isBase64UrlSegment(segment)) {
throw new IllegalArgumentException("JWT segment is not valid Base64URL");
}
byte[] bytes = Base64.getUrlDecoder().decode(segment);
if (!Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).equals(segment)) {
throw new IllegalArgumentException("JWT segment is not valid Base64URL");
}
String decoded = decodeUtf8(bytes);
JsonNode value = JSON.readTree(decoded);
if (value == null || value.isMissingNode() || !value.isObject()) {
throw new IOException("JWT segment is not a JSON object");
}
return decoded;
}
static boolean isBase64UrlSegment(String segment) {
if (segment.isEmpty() || segment.length() % 4 == 1) {
return false;
}
return segment
.chars()
.allMatch(
character ->
character >= 'A' && character <= 'Z'
|| character >= 'a' && character <= 'z'
|| character >= '0' && character <= '9'
|| character == '-'
|| character == '_');
}
static String[] requireCompactJwt(String token) {
if (token == null) {
throw new IllegalArgumentException("Expected a compact JWT with three segments");
}
String[] parts = token.split("\\.", -1);
if (parts.length != 3) {
throw new IllegalArgumentException("Expected a compact JWT with three segments");
}
return parts;
}
public static void main(String[] args) throws IOException {
String[] parts = requireCompactJwt(System.getenv("TOKEN"));
System.out.println(decodeSegment(parts[1]));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59using System.Text;
using System.Text.Json;
static string DecodeSegment(string segment)
{
if (
segment.Length % 4 == 1 ||
segment.Any(
character =>
!(
character is >= 'A' and <= 'Z' ||
character is >= 'a' and <= 'z' ||
character is >= '0' and <= '9' ||
character is '-' or '_'
)
)
)
{
throw new FormatException("JWT segment is not valid Base64URL");
}
byte[] decoded = Convert.FromBase64String(
segment.Replace('-', '+').Replace('_', '/') +
new string('=', (4 - segment.Length % 4) % 4)
);
string canonicalSegment = Convert
.ToBase64String(decoded)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
if (canonicalSegment != segment)
{
throw new FormatException("JWT segment is not valid Base64URL");
}
string decodedJson = new UTF8Encoding(false, true).GetString(decoded);
using JsonDocument document = JsonDocument.Parse(decodedJson);
if (document.RootElement.ValueKind is not JsonValueKind.Object)
{
throw new FormatException("JWT segment is not a JSON object");
}
return decodedJson;
}
string? token = Environment.GetEnvironmentVariable("TOKEN");
if (token is null)
{
throw new InvalidOperationException(
"Expected a compact JWT with three segments"
);
}
string[] parts = token.Split('.');
if (parts.Length != 3)
{
throw new InvalidOperationException(
"Expected a compact JWT with three segments"
);
}
Console.WriteLine(DecodeSegment(parts[1]));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26require "base64"
require "json"
parts = ENV.fetch("TOKEN", "").split(".", -1)
raise "Expected a compact JWT with three segments" unless parts.length == 3
unless parts[1].match?(/\A[A-Za-z0-9_-]+\z/) && parts[1].length % 4 != 1
raise "JWT payload is not valid Base64URL"
end
begin
payload = Base64.urlsafe_decode64(parts[1].ljust((parts[1].length + 3) & ~3, "="))
rescue ArgumentError
raise "JWT payload is not valid Base64URL"
end
unless Base64.urlsafe_encode64(payload, padding: false) == parts[1]
raise "JWT payload is not valid Base64URL"
end
payload.force_encoding(Encoding::UTF_8)
raise "JWT payload is not valid UTF-8" unless payload.valid_encoding?
claims = JSON.parse(payload)
raise "JWT payload is not a JSON object" unless claims.is_a?(Hash)
puts(payload)
此命令会解码 JWT 载荷,但不会验证 Token 签名。对于生产环境 Token,请使用本地解码器,避免将其粘贴到第三方工具中。切勿将原始 GitHub OIDC Token 或换取的 OpenAI 访问令牌记录到日志中。
解码后的 GitHub Actions OIDC Token 类似如下:
12345678910111213{
"iss": "https://token.actions.githubusercontent.com",
"aud": "https://api.openai.com/v1",
"sub": "repo:my-org/my-repo:environment:production",
"repository": "my-org/my-repo",
"repository_owner": "my-org",
"ref": "refs/heads/main",
"workflow": "deploy",
"workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main",
"environment": "production",
"run_id": "1234567890",
"run_attempt": "1"
}
使用解码后的载荷,将您收到的 Token 与 OpenAI 中配置的签发方、受众和映射值进行比较。在交换 Token 之前,检查 iss、aud、repository、ref 和 workflow_ref 声明,就能发现大多数配置问题。
在 OpenAI 中为 GitHub Actions 创建工作负载身份提供方,然后添加服务账户映射,使其匹配您信任的 GitHub 工作流程声明。
先配置工作负载身份提供方,再创建服务账户映射。
-
创建工作负载身份提供方。 将 名称 设为唯一值,例如 github-actions-prod。填写 描述(例如 Production GitHub Actions workflows),帮助管理员识别该提供方。
-
设置签发方和受众。 将 OIDC 签发方 URL 设为 https://token.actions.githubusercontent.com。将 受众 设为与您的工作流程请求的受众完全一致的值,例如 your-wif-audience 或 https://api.openai.com/v1。
-
使用 GitHub OIDC 发现机制。 保持 使用上传的 JWKS 验证 Token 处于禁用状态。OpenAI 使用 GitHub 的 OIDC 发现元数据和 JWKS 来验证由 GitHub 签名的 Token。
-
仅在需要派生映射属性时添加属性转换。 原始 GitHub 声明(例如 repository、ref 和 workflow)可以直接用于映射断言。如果您创建派生属性,控制台会自动添加 openai. 前缀;例如,输入 github_repository 并将表达式设为 assertion.repository,即可创建 openai.github_repository。对于 openai. 映射键,系统会忽略本身已以 openai. 开头的原始 Token 声明,除非配置了匹配的转换。
-
创建服务账户映射。 将 名称 设为在该工作负载身份提供方内唯一的值,例如 github-actions-main-deploy。填写 描述(例如 Production deploy workflow on main),说明哪个工作流程可以使用该映射。
-
添加精确匹配的声明断言。 为每个必须匹配的 GitHub 声明添加一行 键 和 值 。只有所有配置行都匹配,OpenAI 才会签发访问令牌。对于生产环境部署工作流程,可使用如下断言:
iss == "https://token.actions.githubusercontent.com"
aud == "https://api.openai.com/v1"
repository == "my-org/my-repo"
ref == "refs/heads/main"
workflow_ref == "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main"
对于具有特权的映射,优先使用 workflow_ref 而非 workflow,因为管理员通常希望信任的是特定的工作流程文件路径和引用。工作流程名称可以更改,多个工作流程文件也可以使用相同的名称。
在映射界面中,将这些内容逐行输入为键值对,例如: 键 为 repository, 值 为 my-org/my-repo; 键 为 ref, 值 为 refs/heads/main; 键 为 workflow_ref, 值 为 my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main。如果作业使用 GitHub 环境,还应添加一行, 键 为 environment, 值 为 production。
注意: 避免使用范围过宽的映射,例如仅信任 repository_owner == "my-org",除非该所有者命名空间下的每个代码仓库都应能够获取 OpenAI 访问令牌。
-
选择 OpenAI 目标。 将 项目 设为目标服务账户所属的 OpenAI 项目。将 服务账户 设为 GitHub 工作流程可以使用的 OpenAI 服务账户,例如 github-actions-prod-deploy。
-
根据需要收窄 API 权限。 选择适当的 权限 ,例如 api.model.request 和 api.vector_store.read,以进一步限制通过此映射签发的访问令牌的权限。将权限留空,则不会添加 WIF 特有的作用域限制;该令牌仍以映射的服务账户身份获得授权。
配置您的 OpenAI SDK 客户端,使其请求 GitHub OIDC Token,并将其换取为 OpenAI 签发的访问令牌。
工作流程必须授予 id-token: write 权限,并将工作负载身份联合设置传递给 SDK 代码。SDK 使用 GitHub 向作业提供的 ACTIONS_ID_TOKEN_REQUEST_URL 和 ACTIONS_ID_TOKEN_REQUEST_TOKEN 环境变量请求 GitHub OIDC Token,然后使用换取的 OpenAI 访问令牌对 API 请求进行身份验证。
例如,通过如下工作流程运行您的应用程序代码:
12345678910111213141516171819202122232425name: deploy
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Run OpenAI SDK code
env:
OPENAI_WIF_AUDIENCE: ${{ vars.OPENAI_WIF_AUDIENCE }}
OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.OPENAI_IDENTITY_PROVIDER_ID }}
OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.OPENAI_SERVICE_ACCOUNT_ID }}
run: node ./scripts/call-openai.js
将 OPENAI_WIF_AUDIENCE、OPENAI_IDENTITY_PROVIDER_ID 和 OPENAI_SERVICE_ACCOUNT_ID 存储为 GitHub Actions 变量。它们用于标识提供方和服务账户,但不是持有者凭据。
以下示例使用自定义主体 Token 提供方初始化 OpenAI 客户端。该提供方为配置的受众请求 GitHub OIDC Token,并将其用作工作负载身份联合的主体 Token。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65import OpenAI from "openai";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
const audience = process.env.OPENAI_WIF_AUDIENCE;
const requestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (
!identityProviderId ||
!serviceAccountId ||
!audience ||
!requestURL ||
!requestToken
) {
throw new Error(
"Set OPENAI_IDENTITY_PROVIDER_ID, OPENAI_SERVICE_ACCOUNT_ID, OPENAI_WIF_AUDIENCE, and run inside GitHub Actions with id-token: write"
);
}
function githubActionsOIDCTokenProvider(requestURL, requestToken, audience) {
return {
tokenType: "jwt",
getToken: async () => {
const url = new URL(requestURL);
url.searchParams.set("audience", audience);
const response = await fetch(url, {
headers: { Authorization: `bearer ${requestToken}` },
});
if (!response.ok) {
throw new Error(
`Failed to request GitHub OIDC token: ${response.status} ${response.statusText}`
);
}
const body = await response.json();
if (!body.value) {
throw new Error("GitHub OIDC token response did not include a value.");
}
return body.value;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: githubActionsOIDCTokenProvider(
requestURL,
requestToken,
audience
),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from GitHub Actions workload identity federation.",
});
console.log(response.output_text);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52import json
import os
import urllib.parse
import urllib.request
from openai import OpenAI
from openai.auth import SubjectTokenProvider
def github_actions_oidc_token_provider(audience: str) -> SubjectTokenProvider:
request_url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]
request_token = os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
def get_token() -> str:
parsed_url = urllib.parse.urlparse(request_url)
query = dict(urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True))
query["audience"] = audience
url = urllib.parse.urlunparse(
parsed_url._replace(query=urllib.parse.urlencode(query))
)
request = urllib.request.Request(
url,
headers={"Authorization": f"bearer {request_token}"},
)
with urllib.request.urlopen(request) as response:
payload = json.loads(response.read().decode("utf-8"))
token = payload.get("value")
if not token:
raise RuntimeError("GitHub OIDC token response did not include a value.")
return token
return {"token_type": "jwt", "get_token": get_token}
client = OpenAI(
workload_identity={
"identity_provider_id": os.environ["OPENAI_IDENTITY_PROVIDER_ID"],
"service_account_id": os.environ["OPENAI_SERVICE_ACCOUNT_ID"],
"provider": github_actions_oidc_token_provider(
os.environ["OPENAI_WIF_AUDIENCE"]
),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from GitHub Actions workload identity federation.",
)
print(response.output_text)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/auth"
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
)
type githubActionsOIDCTokenProvider struct {
requestURL string
requestToken string
audience string
}
func (p githubActionsOIDCTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p githubActionsOIDCTokenProvider) GetToken(ctx context.Context, httpClient auth.HTTPDoer) (string, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}
oidcURL, err := url.Parse(p.requestURL)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to parse GitHub OIDC request URL",
Cause: err,
}
}
query := oidcURL.Query()
query.Set("audience", p.audience)
oidcURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, oidcURL.String(), nil)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to create GitHub OIDC token request",
Cause: err,
}
}
req.Header.Set("Authorization", "bearer "+p.requestToken)
resp, err := httpClient.Do(req)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to request GitHub OIDC token",
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: fmt.Sprintf("GitHub OIDC token request failed with status %s", resp.Status),
}
}
var body struct {
Value string `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to decode GitHub OIDC token response",
Cause: err,
}
}
if body.Value == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "GitHub OIDC token response did not include a value",
}
}
return body.Value, nil
}
func main() {
client := openai.NewClient(
option.WithWorkloadIdentity(auth.WorkloadIdentity{
IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
Provider: githubActionsOIDCTokenProvider{
requestURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
requestToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
audience: os.Getenv("OPENAI_WIF_AUDIENCE"),
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from GitHub Actions workload identity federation."),
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(response.OutputText())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.openai.auth.SubjectTokenProvider;
import com.openai.auth.SubjectTokenType;
import com.openai.auth.WorkloadIdentity;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.errors.SubjectTokenProviderException;
import com.openai.models.responses.ResponseCreateParams;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
public final class GitHubActionsWorkloadIdentityExample {
private GitHubActionsWorkloadIdentityExample() {}
static final class GitHubActionsOidcTokenProvider implements SubjectTokenProvider {
private final String requestUrl;
private final String requestToken;
private final String audience;
GitHubActionsOidcTokenProvider(String requestUrl, String requestToken, String audience) {
this.requestUrl = requestUrl;
this.requestToken = requestToken;
this.audience = audience;
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(com.openai.core.http.HttpClient httpClient, JsonMapper jsonMapper) {
try {
String separator = requestUrl.contains("?") ? "&" : "?";
URI uri =
URI.create(
requestUrl
+ separator
+ "audience="
+ URLEncoder.encode(audience, StandardCharsets.UTF_8));
HttpRequest request =
HttpRequest.newBuilder(uri)
.header("Authorization", "bearer " + requestToken)
.GET()
.build();
HttpResponse<String> response =
java.net.http.HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new SubjectTokenProviderException(
"github-actions",
"GitHub OIDC token request failed with status " + response.statusCode(),
null);
}
JsonNode payload = jsonMapper.readTree(response.body());
String token = payload.path("value").asText("");
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"github-actions", "GitHub OIDC token response did not include a value", null);
}
return token;
} catch (SubjectTokenProviderException e) {
throw e;
} catch (Exception e) {
throw new SubjectTokenProviderException(
"github-actions", "failed to request GitHub OIDC token", e);
}
}
@Override
public CompletableFuture<String> getTokenAsync(
com.openai.core.http.HttpClient httpClient, JsonMapper jsonMapper) {
return CompletableFuture.supplyAsync(() -> getToken(httpClient, jsonMapper));
}
}
public static void main(String[] args) {
WorkloadIdentity workloadIdentity =
WorkloadIdentity.builder()
.identityProviderId(System.getenv("OPENAI_IDENTITY_PROVIDER_ID"))
.serviceAccountId(System.getenv("OPENAI_SERVICE_ACCOUNT_ID"))
.provider(
new GitHubActionsOidcTokenProvider(
System.getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
System.getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
System.getenv("OPENAI_WIF_AUDIENCE")))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from GitHub Actions workload identity federation.")
.build();
client.responses().create(params).output().stream()
.flatMap(item -> item.message().stream())
.flatMap(message -> message.content().stream())
.flatMap(content -> content.outputText().stream())
.forEach(outputText -> System.out.println(outputText.text()));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77require "json"
require "net/http"
require "openai"
require "uri"
class GitHubActionsOIDCTokenProvider
include OpenAI::Auth::SubjectTokenProvider
def initialize(request_url:, request_token:, audience:)
@request_url = request_url
@request_token = request_token
@audience = audience
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
uri = URI(@request_url)
params = URI.decode_www_form(uri.query || "")
params.reject! { |key, _| key == "audience" }
params << ["audience", @audience]
uri.query = URI.encode_www_form(params)
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "bearer #{@request_token}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "GitHub OIDC token request failed with status #{response.code}",
provider: "github-actions"
)
end
token = JSON.parse(response.body).fetch("value", "").to_s
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "GitHub OIDC token response did not include a value",
provider: "github-actions"
)
end
token
rescue JSON::ParserError, SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to request GitHub OIDC token: #{e.message}",
provider: "github-actions",
cause: e
)
end
end
provider = GitHubActionsOIDCTokenProvider.new(
request_url: ENV.fetch("ACTIONS_ID_TOKEN_REQUEST_URL"),
request_token: ENV.fetch("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
audience: ENV.fetch("OPENAI_WIF_AUDIENCE")
)
workload_identity = OpenAI::Auth::WorkloadIdentity.new(
identity_provider_id: ENV.fetch("OPENAI_IDENTITY_PROVIDER_ID"),
service_account_id: ENV.fetch("OPENAI_SERVICE_ACCOUNT_ID"),
provider: provider
)
client = OpenAI::Client.new(workload_identity: workload_identity)
response = client.responses.create(
model: "gpt-5.6-terra",
input: "Say hello from GitHub Actions workload identity federation."
)
puts(response.output_text)
- 为生产环境部署使用环境保护措施。要求工作流经过审批或满足分支限制后,才能访问生产环境中的 OpenAI 资源。
- 按代码仓库限制映射。尽可能匹配特定于代码仓库的声明,而不是允许组织内的所有代码仓库访问。
- 按分支或工作流程限制映射。考虑匹配
repository、ref、environment 或 workflow_ref 等声明,以限制 Token 的签发。
- 为 CI/CD 和生产环境工作负载使用不同的 OpenAI 服务账户。构建流水线所需的权限通常不同于已部署的应用程序。
- 避免向来自不受信任的派生代码仓库的 Pull Request 授予访问权限。来自派生代码仓库的 Pull Request 可能执行攻击者控制的代码,不应获得生产环境凭据。
- 采用短期 Token 交换。GitHub OIDC Token 用于临时身份验证,应仅在需要时进行交换。
- 审计代码仓库所有权变更。代码仓库的转移、重命名和权限变更可能影响现有映射所依据的安全假设。
- 优先采用精确的声明匹配。匹配
repository、ref 和 environment 等声明,而不是依赖覆盖整个组织的信任关系。