OpenAI GitHub Actions 身份联合配置

2026-11-09 15:42 更新

通过 GitHub Actions 签发的 OIDC 令牌换取短期 OpenAI 访问令牌,使工作流无须在 GitHub secrets 保存长期 API 密钥。具有 id-token: write 且主动请求身份令牌的 job 可获得 GitHub 签名 JWT;OpenAI 验证 issuer、audience、签名和映射属性后签发访问令牌。

设置 GitHub Actions

为工作流或 job 授予令牌请求权限:

来源示例 1(yaml):

## [中文] 按本节配置受众、工作负载身份和令牌挂载;受众必须与OpenAI提供商一致,生产环境须限定账户及权限。
permissions:
  id-token: write
  contents: read

id-token: write 不赋予仓库内容写权限;contents: read 用于 actions/checkout。请求 audience 必须与 OpenAI 提供商完全一致。自定义 JS action 可调用 core.getIDToken("your-wif-audience");Shell 可直接调用 GitHub OIDC 请求 URL。https://api.openai.com/v1 等含保留字符的 audience,追加到 URL 前须编码。

来源示例 2(bash):

## [中文] 按本小节获取或重读当前外部身份令牌,交由SDK交换为短期OpenAI令牌;提供商与服务账户ID是配置,令牌本身不得记录或提交版本库。
AUDIENCE="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

主要声明:iss 是 https://token.actions.githubusercontent.com;aud 是请求的精确受众;sub 由仓库、分支、标签、PR 或环境等元数据生成;repository 如 my-org/my-repo,repository_owner 如 my-org;ref 如 refs/heads/main 或 refs/tags/v1.0.0;workflow 使用真实签发值(例如 deploy);workflow_ref 如 my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main;使用环境时有 environment(如 production)。run_id、run_number、run_attempt、job_workflow_ref 可辅助审计或高级规则。完整字段与主体格式见 GitHub OIDC 参考。

检查令牌

配置前导出 TOKEN,在 runner 本地检查:

来源示例 3(javascript):

// [中文] 在工作负载本地解码JWT查看头部/声明,仅解码并不验证签名;不要打印原始令牌或提交第三方解码网站。
const 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);

来源示例 4(python):

## [中文] 在工作负载本地解码JWT查看头部/声明,仅解码并不验证签名;不要打印原始令牌或提交第三方解码网站。
import 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)

来源示例 5(go):

// [中文] 在工作负载本地解码JWT查看头部/声明,仅解码并不验证签名;不要打印原始令牌或提交第三方解码网站。
package 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))
}

来源示例 6(java):

// [中文] 在工作负载本地解码JWT查看头部/声明,仅解码并不验证签名;不要打印原始令牌或提交第三方解码网站。
// 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]));
  }
}

来源示例 7(csharp):

// [中文] 在工作负载本地解码JWT查看头部/声明,仅解码并不验证签名;不要打印原始令牌或提交第三方解码网站。
using 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]));

来源示例 8(ruby):

## [中文] 在工作负载本地解码JWT查看头部/声明,仅解码并不验证签名;不要打印原始令牌或提交第三方解码网站。
require "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)

示例仅解码、不验签。生产令牌不粘贴到第三方工具,原始 GitHub JWT 和交换后的 OpenAI 令牌都不得写日志。

来源示例 9(json):

中文说明:来源的JSON数据或配置/输出片段,保留字段和值;令牌声明中的iss/aud/sub等须对照真实工作负载,解码本身不等于验签。

{
  "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"
}

重点比较 iss、aud、repository、ref、workflow_ref 与配置。

设置身份联合

先创建 GitHub 提供商,再添加可信工作流的服务账户映射。

设置工作负载身份提供商

  1. Name 唯一,例如 github-actions-prod,Description 标明生产工作流。
  2. OIDC Issuer URL 为 https://token.actions.githubusercontent.com;Audience 与工作流请求完全相同,例如 your-wif-audience 或 https://api.openai.com/v1。
  3. 关闭上传 JWKS,使用 GitHub OIDC 发现与公钥。
  4. repository、ref、workflow 等可直接匹配,仅按需转换。名称 github_repository 配 assertion.repository,自动存为 openai.github_repository;未经匹配转换的原始 openai.* 声明不会用于该前缀映射。

设置服务账户映射

  1. Name 在提供商内唯一,例如 github-actions-main-deploy,Description 说明 main 的生产部署。
  2. 每个必须匹配的声明添加 Key/Value 行,所有行都须通过。例如:

来源示例 10(text):

中文说明:来源的JSON数据或配置/输出片段,保留字段和值;令牌声明中的iss/aud/sub等须对照真实工作负载,解码本身不等于验签。

   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,因为它锁定文件路径和 ref;工作流名称可改且多个文件可同名。界面分别输入 repository = my-org/my-repo、ref = refs/heads/main、workflow_ref = my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main;使用环境时再加 environment = production。除非整个 owner 命名空间的仓库都应获得访问,否则不要只匹配 repository_owner == "my-org"。

  1. 选择目标 Project 与 Service account,例如 github-actions-prod-deploy。
  2. Permissions 可用 api.model.request、api.vector_store.read 收窄;留空不增加 WIF scope 限制,但仍受服务账户权限约束。

在工作流中使用令牌

SDK 请求 GitHub OIDC,再交换为 OpenAI 令牌。工作流授予 id-token: write 并传递 WIF 设置;SDK 使用 job 暴露的 ACTIONS_ID_TOKEN_REQUEST_URL 与 ACTIONS_ID_TOKEN_REQUEST_TOKEN。示例工作流:

来源示例 11(yaml):

## [中文] 按本节配置受众、工作负载身份和令牌挂载;受众必须与OpenAI提供商一致,生产环境须限定账户及权限。
name: 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 存为 Actions variables;它们是配置 ID,不是 bearer 凭证。下列自定义主体令牌提供函数请求所配受众的 GitHub JWT,并交由 SDK 交换:

来源示例 12(javascript):

// [中文] 按本小节获取或重读当前外部身份令牌,交由SDK交换为短期OpenAI令牌;提供商与服务账户ID是配置,令牌本身不得记录或提交版本库。
import 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);

来源示例 13(python):

## [中文] 按本小节获取或重读当前外部身份令牌,交由SDK交换为短期OpenAI令牌;提供商与服务账户ID是配置,令牌本身不得记录或提交版本库。
import 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)

来源示例 14(go):

// [中文] 按本小节获取或重读当前外部身份令牌,交由SDK交换为短期OpenAI令牌;提供商与服务账户ID是配置,令牌本身不得记录或提交版本库。
package 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())
}

来源示例 15(java):

// [中文] 按本小节获取或重读当前外部身份令牌,交由SDK交换为短期OpenAI令牌;提供商与服务账户ID是配置,令牌本身不得记录或提交版本库。
import 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()));
  }
}

来源示例 16(ruby):

## [中文] 按本小节获取或重读当前外部身份令牌,交由SDK交换为短期OpenAI令牌;提供商与服务账户ID是配置,令牌本身不得记录或提交版本库。
require "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)

GitHub Actions 最佳实践

生产使用环境保护、审批或分支限制;按仓库与 repository、ref、environment、workflow_ref 约束映射,避免组织级广泛信任;CI/CD 与生产用不同 OpenAI 服务账户;不要给不可信 fork PR 生产凭证,因其可能运行攻击者代码;短期令牌仅按需交换;审计仓库转移、改名和权限变化,定期核对信任假设;优先精确声明匹配。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号