GitHub Actions용 워크로드 아이덴티티 페더레이션 구성

GitHub Actions용 워크로드 아이덴티티 페더레이션 구성

GitHub이 발행한 OIDC 토큰을 단기 OpenAI 액세스 토큰으로 교환해 GitHub Actions를 Workload Identity Provider로 사용하세요. 이렇게 하면 긴 수명의 API 키를 GitHub secrets에 저장하지 않고도 워크플로가 OpenAI API에 인증할 수 있어요.

출처: 문서

본문

GitHub은 id-token: write 권한이 있고 아이덴티티 토큰을 요청하는 워크플로 작업에 대해 서명된 OIDC JWT를 만들 수 있어요. OpenAI는 OpenAI 액세스 토큰을 발행하기 전에 토큰 발행자, audience, 서명, 매핑 속성을 검증해요.

GitHub Actions 설정

워크플로 또는 작업에 GitHub OIDC 토큰을 요청할 권한을 부여하세요:

permissions:
  id-token: write
  contents: read

id-token: write 권한은 작업이 OIDC JWT를 요청하게 해줘요. 저장소 콘텐츠에 대한 쓰기 접근을 부여하지 않아요. actions/checkout에는 contents: read 권한이 필요해요.

OpenAI Workload Identity Provider에 구성된 정확한 audience로 토큰을 요청하세요. 커스텀 JavaScript 작업은 core.getIDToken("your-wif-audience")를 호출할 수 있고, 셸 단계는 GitHub의 OIDC 요청 URL을 직접 호출할 수 있어요. https://api.openai.com/v1 같은 예약 URL 문자를 포함하는 audience 값은 요청 URL에 추가되기 전에 URL 인코딩해야 해요:

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

중요한 GitHub OIDC 클레임:

  • iss: 토큰 발행자. GitHub Actions의 경우 https://token.actions.githubusercontent.com이에요.
  • aud: 워크플로가 요청한 audience 값. 요청한 정확한 값(your-wif-audience 또는 https://api.openai.com/v1 같은)을 요구하도록 OpenAI를 구성하세요.
  • sub: 주요 subject 문자열. GitHub은 저장소, 브랜치, 태그, 풀 리퀘스트 또는 환경 같은 워크플로 메타데이터에서 이를 만드세요.
  • repository: 워크플로를 실행하는 저장소(예: my-org/my-repo).
  • repository_owner: 저장소를 소유한 조직 또는 사용자(예: my-org).
  • ref: 워크플로를 트리거한 Git ref(예: refs/heads/main 또는 refs/tags/v1.0.0).
  • workflow: 워크플로 클레임. 작업에서 워크플로 클레임이 deploy라면 GitHub이 방출한 실제 클레임 값을 사용하세요.
  • workflow_ref: 워크플로 파일 경로와 ref(예: my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main).
  • environment: 작업이 환경을 사용할 때 GitHub 환경 이름(예: production).
  • run_id, run_number, run_attempt, job_workflow_ref: 감사 또는 더 고급 신뢰 규칙에 도움이 될 수 있는 실행 및 작업 식별자.

전체 클레임 목록과 subject 형식은 GitHub의 OpenID Connect 레퍼런스를 참고하세요.

토큰 검증

워크로드 아이덴티티 페더레이션을 구성하기 전에 GitHub OIDC 토큰을 TOKEN으로 내보낸 다음 워크플로 러너에서 이 스크립트를 실행해 그 클레임을 검사하세요:

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);
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)
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))
}
// 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]));
  }
}
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]));
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)

이 명령은 토큰 서명을 검증하지 않고 JWT 페이로드를 디코딩해요. 프로덕션 토큰에는 로컬 디코더를 사용하고 프로덕션 토큰을 제3자 도구에 붙여넣지 마세요. 원시 GitHub OIDC 토큰이나 교환된 OpenAI 액세스 토큰을 로그하지 마세요.

디코딩된 GitHub Actions OIDC 토큰은 비슷하게 보여요:

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

디코딩된 페이로드를 사용해 받은 토큰을 OpenAI에 구성된 발행자, audience, 매핑 값과 비교하세요. 대부분의 구성 문제는 토큰을 교환하기 전에 iss, aud, repository, ref, workflow_ref 클레임에서 보여요.

워크로드 아이덴티티 페더레이션 설정

GitHub Actions에 대해 OpenAI에 Workload Identity Provider를 만든 다음 신뢰하는 GitHub 워크플로 클레임과 일치하는 서비스 계정 매핑을 추가하세요.

먼저 Workload Identity Provider를 구성한 다음 서비스 계정 매핑을 만드세요.

Workload Identity Provider 설정

  1. Workload Identity Provider 생성. Name을 github-actions-prod 같은 고유 값으로 설정하세요. Description(예: Production GitHub Actions workflows)으로 관리자가 제공자를 식별하도록 도와주세요.

  2. 발행자와 audience 설정. OIDC Issuer URL을 https://token.actions.githubusercontent.com으로 설정하세요. Audience를 워크플로가 요청하는 정확한 audience(your-wif-audience 또는 https://api.openai.com/v1 같은)로 설정하세요.

  3. GitHub OIDC discovery 사용. Use uploaded JWKS for token verification은 비활성화로 두세요. OpenAI는 GitHub의 OIDC discovery 메타데이터와 JWKS를 사용해 GitHub 서명 토큰을 검증해요.

  4. 파생 매핑 속성이 필요할 때만 속성 변환 추가. repository, ref, workflow 같은 원시 GitHub 클레임은 매핑 assertion에 직접 사용할 수 있어요. 파생 속성을 만들면 대시보드가 openai. 접두사를 자동으로 적용해요. 예를 들어 openai.github_repository를 만들려면 github_repository와 assertion.repository 표현식을 입력하세요. 이미 openai.로 시작하는 원시 토큰 클레임은 일치하는 변환이 구성되지 않으면 openai. 매핑 키에 대해 무시돼요.

서비스 계정 매핑 설정

  1. 서비스 계정 매핑 생성. Workload Identity Provider 내에서 Name을 github-actions-main-deploy 같은 고유 값으로 설정하세요. Description(예: Production deploy workflow on main)으로 어떤 워크플로가 매핑을 사용할 수 있는지 설명하세요.

  2. 정확한 클레임 assertion 추가. 일치해야 하는 각 GitHub 클레임에 대해 Key와 Value 행을 하나씩 추가하세요. OpenAI는 액세스 토큰을 발행하기 전에 구성된 모든 행이 일치하도록 요구해요. 프로덕션 배포 워크플로의 경우 다음과 같은 assertion을 사용하세요:

   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보다 workflow_ref를 선호하세요. 관리자는 일반적으로 특정 워크플로 파일 경로와 ref를 신뢰하려 하기 때문이에요. 워크플로 이름은 바뀔 수 있고 여러 워크플로 파일이 같은 이름을 공유할 수 있어요.

매핑 UI에서 이를 키/값 행으로 입력하세요. 예를 들어 Key repository에 Value my-org/my-repo, Key ref에 Value refs/heads/main, Key workflow_ref에 Value my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main. 작업이 GitHub 환경을 사용하면 Key environment에 Value production도 추가하세요.

주의: repository_owner == "my-org"만 신뢰하는 같은 과도하게 넓은 매핑을 피하세요. 그 소유자 네임스페이스의 모든 저장소가 OpenAI 액세스 토큰을 만들 수 있어야 하는 경우가 아니면요.

  1. OpenAI 대상 선택. Project를 대상 서비스 계정을 소유한 OpenAI 프로젝트로 설정하세요. Service account를 GitHub 워크플로가 사용할 수 있는 OpenAI 서비스 계정(예: github-actions-prod-deploy)으로 설정하세요.

  2. 필요하면 API 권한 좁히기. 이 매핑에서 만들어진 액세스 토큰을 더 좁히려면 api.model.request, api.vector_store.read 같은 적절한 Permissions를 선택하세요. WIF 특정 스코프 제한을 추가하지 않으려면 권한을 비워 두세요. 토큰은 여전히 매핑된 서비스 계정으로 승인돼요.

워크플로에서 토큰 사용

OpenAI SDK 클라이언트를 구성해 GitHub OIDC 토큰을 요청하고 OpenAI가 발행한 액세스 토큰으로 교환하세요.

워크플로는 id-token: write 권한을 부여하고 워크로드 아이덴티티 페더레이션 설정을 SDK 코드에 전달해야 해요. SDK는 GitHub이 작업에 노출하는 ACTIONS_ID_TOKEN_REQUEST_URL과 ACTIONS_ID_TOKEN_REQUEST_TOKEN 환경 변수에서 GitHub OIDC 토큰을 요청한 다음 교환된 OpenAI 액세스 토큰을 사용해 API 요청을 인증해요.

예를 들어 다음과 같은 워크플로에서 애플리케이션 코드를 실행하세요:

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를 GitHub Actions 변수로 저장하세요. 이들은 제공자와 서비스 계정을 식별하지만 bearer 자격 증명은 아니에요.

다음 예시는 커스텀 subject 토큰 제공자로 OpenAI 클라이언트를 초기화해요. 제공자는 구성된 audience에 대한 GitHub OIDC 토큰을 요청하고 워크로드 아이덴티티 페더레이션의 subject 토큰으로 사용해요.

GitHub Actions OIDC 토큰으로 인증

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);
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)
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())
}
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()));
  }
}
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 모범 사례

  • 프로덕션 배포에는 환경 보호를 사용하세요. 워크플로가 프로덕션 OpenAI 리소스에 접근하기 전에 승인 또는 브랜치 제한을 요구하세요.
  • 매핑을 저장소로 제한하세요. 조직 내 모든 저장소의 접근을 허용하는 대신 가능하면 저장소 특정 클레임에서 일치시키세요.
  • 매핑을 브랜치 또는 워크플로로 제한하세요. repository, ref, environment, workflow_ref 같은 클레임에서 일치시켜 토큰 발행을 제한하는 것을 고려하세요.
  • CI/CD와 프로덕션 워크로드에 별도의 OpenAI 서비스 계정을 사용하세요. 빌드 파이프라인은 종종 배포된 애플리케이션과 다른 권한이 필요해요.
  • 신뢰할 수 없는 포크의 풀 리퀘스트에 접근을 부여하지 마세요. 포크된 풀 리퀘스트는 공격자가 제어하는 코드를 실행할 수 있으므로 프로덕션 자격 증명을 받지 않아야 해요.
  • 단기 교환을 사용하세요. GitHub OIDC 토큰은 임시 인증을 위한 것이며 필요할 때만 교환해야 해요.
  • 저장소 소유권 변경을 감사하세요. 저장소 이전, 이름 변경, 권한 변경은 기존 매핑 뒤의 보안 가정에 영향을 줄 수 있어요.
  • 정확한 클레임 일치를 선호하세요. 조직 전체 신뢰 관계에 의존하는 대신 repository, ref, environment 같은 클레임에서 일치시키세요.

더 알아보기 (Learn more)