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

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

다음 시나리오 중 하나에서 AWS를 Workload Identity Provider로 사용하세요:

  • AWS 아웃바운드 아이덴티티 페더레이션: GetWebIdentityToken에서 발행한 AWS STS 서명 OIDC JWT를 단기 OpenAI 액세스 토큰으로 교환해요.
  • Amazon EKS: projected Amazon EKS service account 토큰을 단기 OpenAI 액세스 토큰으로 교환해요.

출처: 문서

본문

OpenAI는 아웃바운드 아이덴티티 페더레이션의 AWS 발행 OIDC JWT와 Amazon EKS가 발행한 Kubernetes projected service account 토큰을 지원해요. OpenAI는 워크로드 아이덴티티 페더레이션 subject 토큰으로 SigV4 서명 요청이나 AWS STS 임시 액세스 키 자격 증명을 지원하지 않아요.

AWS 아웃바운드 아이덴티티 페더레이션

AWS 아웃바운드 아이덴티티 페더레이션은 AWS 주체가 AWS STS에서 서명된 OIDC JWT를 요청하고 그 토큰을 외부 서비스에 제시할 수 있게 해줘요. OpenAI 워크로드 아이덴티티 페더레이션에서 AWS 발행 JWT는 OpenAI가 OpenAI 액세스 토큰을 발행하기 전에 검증하는 subject 토큰이에요.

AWS 아웃바운드 아이덴티티 페더레이션 설정

토큰을 발행할 AWS 계정에 대해 아웃바운드 아이덴티티 페더레이션을 활성화하세요. 설정 세부 사항은 AWS의 아웃바운드 아이덴티티 페더레이션 시작하기 가이드를 참고하세요.

aws iam enable-outbound-web-identity-federation

AWS가 반환한 계정별 발행자 URL을 기록하세요. 이 값을 OpenAI Workload Identity Provider 발행자로 구성하며, AWS 발행 토큰의 iss 클레임과 일치해야 해요.

AWS STS GetWebIdentityToken API는 STS 전역 엔드포인트에서 사용할 수 없어요. AWS CLI 또는 SDK가 지역 STS 엔드포인트를 사용하도록 구성하세요.

워크로드에 sts:GetWebIdentityToken 호출 권한을 부여하세요. AWS 주체가 OpenAI 전용 토큰만 만들 수 있도록 IAM에서 audience와 최대 토큰 수명을 제한하세요. 이 예시는 audience https://api.openai.com/v1에 대해 최대 수명 300초의 토큰을 허용해요:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:GetWebIdentityToken",
      "Resource": "*",
      "Condition": {
        "ForAllValues:StringEquals": {
          "sts:IdentityTokenAudience": "https://api.openai.com/v1"
        },
        "NumericLessThanEquals": {
          "sts:DurationSeconds": 300
        }
      }
    }
  ]
}

OpenAI Workload Identity Provider에 구성할 것과 같은 audience로 AWS 발행 OIDC 토큰을 요청하세요. 환경이 RS256 호환성을 요구하지 않으면 ES384를 사용하세요.

TOKEN=$(aws sts get-web-identity-token \
  --audience "https://api.openai.com/v1" \
  --signing-algorithm ES384 \
  --duration-seconds 300 \
  --tags Key=environment,Value=production \
         Key=workload,Value=batch-ingest \
  --query "WebIdentityToken" \
  --output text)
export TOKEN

AWS 발행 토큰 검증

워크로드 아이덴티티 페더레이션을 구성하기 전에 AWS 발행 토큰을 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자 도구에 붙여넣지 마세요.

디코딩된 AWS 발행 OIDC 토큰은 비슷하게 보여요:

{
  "iss": "https://abc123-def456-ghi789-jkl012.tokens.sts.global.api.aws",
  "aud": "https://api.openai.com/v1",
  "sub": "arn:aws:iam::123456789012:role/OpenAIWifRole",
  "iat": 1716235422,
  "exp": 1716235722,
  "jti": "jwt-id-example",
  "https://sts.amazonaws.com/": {
    "aws_account": "123456789012",
    "source_region": "us-west-2",
    "org_id": "o-exampleorgid",
    "principal_tags": {
      "environment": "production"
    },
    "request_tags": {
      "environment": "production",
      "workload": "batch-ingest"
    }
  }
}

모든 AWS 발행 토큰이 모든 AWS 특정 클레임을 포함하는 것은 아니에요. https://sts.amazonaws.com/ 아래의 클레임은 호출 주체, 세션 컨텍스트, 요청 태그에 따라 달라져요.

OpenAI에 구성할 계획인 클레임을 검증하세요:

  • iss: OpenAI Workload Identity Provider에 구성된 AWS 계정별 발행자 URL과 일치해야 해요.
  • aud: GetWebIdentityToken audience와 OpenAI Workload Identity Provider audience와 일치해야 해요.
  • sub: 토큰을 요청한 IAM 주체 ARN을 식별해요. 정확한 역할 ARN 일치를 선호하세요.
  • AWS 특정 클레임: 계정, 조직, 주체 태그 또는 요청 태그 값을 일치시키기 전에 디코딩된 토큰을 진실의 원천으로 사용하세요.

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

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

AWS 계정 발행자에 대해 OpenAI에 Workload Identity Provider를 만든 다음 AWS 발행 토큰의 안정적인 클레임과 일치하는 서비스 계정 매핑을 추가하세요.

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

Workload Identity Provider 설정

  1. Workload Identity Provider 생성. Name을 aws-outbound-prod 같은 고유 값으로 설정하세요. Description(예: Production AWS outbound identity federation workloads)으로 관리자가 제공자를 식별하도록 도와주세요.

  2. 발행자와 audience 설정. OIDC Issuer URL을 아웃바운드 아이덴티티 페더레이션 활성화 시 반환된 AWS 계정별 발행자 URL로 설정하세요. 이 값은 토큰의 iss 클레임과 일치해야 해요. Audience를 GetWebIdentityToken에 전달한 것과 같은 audience로 설정하세요. 이 예시에서 그 값은 https://api.openai.com/v1이에요.

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

  4. 파생 매핑 속성이 필요할 때만 속성 변환 추가. 원시 토큰 일치는 sub, aud, iss 같은 최상위 스칼라 클레임을 지원해요. AWS 특정 네임스페이스 클레임은 https://sts.amazonaws.com/ 아래에 중첩되므로 매핑에서 사용하기 전에 CEL 대괄호 표기법으로 파생 속성을 만드세요. 예를 들어 위 디코딩된 토큰 예시에서 openai.aws_environment를 만들려면 aws_environment와 assertion["https://sts.amazonaws.com/"]["principal_tags"]["environment"] 표현식을 입력하세요. 사용하기 전에 샘플 토큰에서 중첩 클레임 경로를 검증하세요. 변환을 평가할 수 없으면 매핑 해석이 실패해요. 이미 openai.로 시작하는 원시 토큰 클레임은 일치하는 변환이 구성되지 않으면 openai. 매핑 키에 대해 무시돼요.

서비스 계정 매핑 설정

  1. 서비스 계정 매핑 생성. Workload Identity Provider 내에서 고유한 Name(예: aws-role-openai-wif)을 설정하세요. Description(예: Production AWS role for OpenAI API workload)으로 어떤 워크로드가 매핑을 사용할 수 있는지 설명하세요.

  2. AWS 주체 일치. Key를 sub로, Value를 디코딩된 토큰의 IAM 주체 ARN(예: arn:aws:iam::123456789012:role/OpenAIWifRole)으로 설정하세요. 정확한 sub 클레임 일치는 AWS 아웃바운드 아이덴티티 페더레이션에 대해 가장 강한 격리를 제공해요.

  3. 필요하면 추가 클레임 일치 추가. 사용 가능한 스칼라 클레임이나 변환 속성에서 일치시킬 수 있어요. 예를 들어 추가 신뢰 경계가 필요하면 AWS 계정, 조직, 주체 태그 또는 요청 태그 클레임에서 파생된 변환 속성을 사용하세요.

  4. OpenAI 대상 선택. Project를 대상 서비스 계정을 소유한 OpenAI 프로젝트로 설정하세요. Service account를 AWS 워크로드가 사용할 수 있는 OpenAI 서비스 계정(예: aws-outbound-prod-openai-wif)으로 설정하세요.

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

코드에서 토큰 사용

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

OPENAI_WIF_AUDIENCE를 OpenAI Workload Identity Provider에 구성된 것과 같은 audience로 설정하세요. subject 토큰 제공자는 그 audience로 AWS STS GetWebIdentityToken을 호출하고 AWS 발행 JWT를 subject 토큰으로 반환하며, OpenAI SDK는 그것을 OpenAI가 발행한 액세스 토큰으로 교환해요.

AWS 발행 OIDC 토큰으로 인증

import { GetWebIdentityTokenCommand, STSClient } from "@aws-sdk/client-sts";
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 awsRegion = process.env.AWS_REGION;

if (!identityProviderId || !serviceAccountId || !audience || !awsRegion) {
  throw new Error(
    "Set OPENAI_IDENTITY_PROVIDER_ID, OPENAI_SERVICE_ACCOUNT_ID, OPENAI_WIF_AUDIENCE, and AWS_REGION"
  );
}
const wifAudience = audience;

const sts = new STSClient({ region: awsRegion });

function awsOutboundWebIdentityTokenProvider() {
  return {
    tokenType: "jwt",
    getToken: async () => {
      const response = await sts.send(
        new GetWebIdentityTokenCommand({
          Audience: [wifAudience],
          SigningAlgorithm: "ES384",
          DurationSeconds: 300,
        })
      );

      if (!response.WebIdentityToken) {
        throw new Error("AWS STS did not return a web identity token.");
      }

      return response.WebIdentityToken;
    },
  };
}

const client = new OpenAI({
  workloadIdentity: {
    identityProviderId,
    serviceAccountId,
    provider: awsOutboundWebIdentityTokenProvider(),
  },
});

const response = await client.responses.create({
  model: "gpt-5.6-terra",
  input: "Say hello from AWS outbound workload identity federation.",
});

console.log(response.output_text);
import os

import boto3
from openai import OpenAI
from openai.auth import SubjectTokenProvider


def aws_outbound_web_identity_token_provider(audience: str) -> SubjectTokenProvider:
    sts = boto3.client("sts", region_name=os.environ["AWS_REGION"])

    def get_token() -> str:
        response = sts.get_web_identity_token(
            Audience=[audience],
            SigningAlgorithm="ES384",
            DurationSeconds=300,
        )
        token = response.get("WebIdentityToken", "")
        if not token:
            raise RuntimeError("AWS STS did not return a web identity token.")
        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": aws_outbound_web_identity_token_provider(
            os.environ["OPENAI_WIF_AUDIENCE"]
        ),
    },
)

response = client.responses.create(
    model="gpt-5.6-terra",
    input="Say hello from AWS outbound workload identity federation.",
)

print(response.output_text)
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	awssdk "github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/sts"
	"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 awsOutboundWebIdentityTokenProvider struct {
	client   *sts.Client
	audience string
}

func (p awsOutboundWebIdentityTokenProvider) TokenType() auth.SubjectTokenType {
	return auth.SubjectTokenTypeJWT
}

func (p awsOutboundWebIdentityTokenProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
	output, err := p.client.GetWebIdentityToken(ctx, &sts.GetWebIdentityTokenInput{
		Audience:         []string{p.audience},
		DurationSeconds:  awssdk.Int32(300),
		SigningAlgorithm: awssdk.String("ES384"),
	})
	if err != nil {
		return "", &auth.SubjectTokenProviderError{
			Provider: "aws-outbound",
			Message:  "failed to request AWS web identity token",
			Cause:    err,
		}
	}

	token := awssdk.ToString(output.WebIdentityToken)
	if token == "" {
		return "", &auth.SubjectTokenProviderError{
			Provider: "aws-outbound",
			Message:  "AWS STS did not return a web identity token",
		}
	}

	return token, nil
}

func main() {
	ctx := context.Background()
	audience := os.Getenv("OPENAI_WIF_AUDIENCE")
	if audience == "" {
		log.Fatal("Set OPENAI_WIF_AUDIENCE")
	}

	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		log.Fatal(err)
	}

	client := openai.NewClient(
		option.WithWorkloadIdentity(auth.WorkloadIdentity{
			IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
			ServiceAccountID:   os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
			Provider: awsOutboundWebIdentityTokenProvider{
				client:   sts.NewFromConfig(cfg),
				audience: audience,
			},
		}),
	)

	response, err := client.Responses.New(ctx, responses.ResponseNewParams{
		Model: openai.ChatModelGPT4_1Mini,
		Input: responses.ResponseNewParamsInputUnion{
			OfString: openai.String("Say hello from AWS outbound workload identity federation."),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(response.OutputText())
}
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.core.http.HttpClient;
import com.openai.errors.SubjectTokenProviderException;
import com.openai.models.responses.ResponseCreateParams;
import java.util.concurrent.CompletableFuture;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.model.GetWebIdentityTokenRequest;

public final class AwsOutboundWorkloadIdentityExample {
  private AwsOutboundWorkloadIdentityExample() {}

  static final class AwsOutboundWebIdentityTokenProvider implements SubjectTokenProvider {
    private final StsClient stsClient;
    private final String audience;

    AwsOutboundWebIdentityTokenProvider(StsClient stsClient, String audience) {
      this.stsClient = stsClient;
      this.audience = audience;
    }

    @Override
    public SubjectTokenType tokenType() {
      return SubjectTokenType.JWT;
    }

    @Override
    public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
      try {
        String token =
            stsClient
                .getWebIdentityToken(
                    GetWebIdentityTokenRequest.builder()
                        .audience(audience)
                        .durationSeconds(300)
                        .signingAlgorithm("ES384")
                        .build())
                .webIdentityToken();

        if (token == null || token.isEmpty()) {
          throw new SubjectTokenProviderException(
              "aws-outbound", "AWS STS did not return a web identity token", null);
        }

        return token;
      } catch (SubjectTokenProviderException e) {
        throw e;
      } catch (Exception e) {
        throw new SubjectTokenProviderException(
            "aws-outbound", "failed to request AWS web identity token", e);
      }
    }

    @Override
    public CompletableFuture<String> getTokenAsync(HttpClient httpClient, JsonMapper jsonMapper) {
      return CompletableFuture.supplyAsync(() -> getToken(httpClient, jsonMapper));
    }
  }

  public static void main(String[] args) {
    String audience = System.getenv("OPENAI_WIF_AUDIENCE");
    StsClient stsClient =
        StsClient.builder().region(Region.of(System.getenv("AWS_REGION"))).build();

    WorkloadIdentity workloadIdentity =
        WorkloadIdentity.builder()
            .identityProviderId(System.getenv("OPENAI_IDENTITY_PROVIDER_ID"))
            .serviceAccountId(System.getenv("OPENAI_SERVICE_ACCOUNT_ID"))
            .provider(new AwsOutboundWebIdentityTokenProvider(stsClient, audience))
            .build();

    OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();

    ResponseCreateParams params =
        ResponseCreateParams.builder()
            .model("gpt-5.6-terra")
            .input("Say hello from AWS outbound 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 "aws-sdk-sts"
require "openai"

class AwsOutboundWebIdentityTokenProvider
  include OpenAI::Auth::SubjectTokenProvider

  def initialize(audience:, sts_client:)
    @audience = audience
    @sts_client = sts_client
  end

  def token_type
    OpenAI::Auth::TokenType::JWT
  end

  def get_token
    response = @sts_client.get_web_identity_token(
      audience: [@audience],
      signing_algorithm: "ES384",
      duration_seconds: 300
    )
    token = response.web_identity_token.to_s
    if token.empty?
      raise OpenAI::Errors::SubjectTokenProviderError.new(
        message: "AWS STS did not return a web identity token",
        provider: "aws-outbound"
      )
    end
    token
  rescue Aws::STS::Errors::ServiceError => e
    raise OpenAI::Errors::SubjectTokenProviderError.new(
      message: "Failed to request AWS web identity token: #{e.message}",
      provider: "aws-outbound",
      cause: e
    )
  end
end

provider = AwsOutboundWebIdentityTokenProvider.new(
  audience: ENV.fetch("OPENAI_WIF_AUDIENCE"),
  sts_client: Aws::STS::Client.new(region: ENV.fetch("AWS_REGION"))
)

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 AWS outbound workload identity federation."
)

puts(response.output_text)

Amazon EKS projected service account 토큰

EKS가 발행한 projected service account 토큰을 단기 OpenAI 액세스 토큰으로 교환해 Amazon EKS를 Workload Identity Provider로 사용하세요.

EKS 설정

OpenAI API를 호출해야 하는 EKS 워크로드에 Kubernetes ServiceAccount를 사용하세요. 아직 없으면 만드세요:

kubectl create serviceaccount openai-wif --namespace default

EKS projected service account 토큰은 system:serviceaccount:<namespace>:<service-account-name> 형식의 sub 클레임을 사용해요. 위 service account의 경우 sub 클레임은 system:serviceaccount:default:openai-wif이에요.

EKS 클러스터와 연결된 OIDC 발행자 URL을 검색하세요:

aws eks describe-cluster \
  --name <cluster-name> \
  --region <region> \
  --query "cluster.identity.oidc.issuer" \
  --output text

예시 출력:

https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3

OpenAI Workload Identity Provider에 구성하는 발행자는 이 발행자 URL과 projected EKS service account 토큰의 iss 클레임과 일치해야 해요.

projected service account 토큰을 OpenAI가 기대하는 audience와 워크로드에 적합한 만료로 구성하세요. OpenAI는 토큰의 발행자, 서명, audience, 만료를 검증해요. 이 예시에서 토큰 파일은 /var/run/secrets/tokens/token에 마운트되고 audience https://api.openai.com/v1을 사용하며 3600초 후 만료돼요. projected 토큰 audience와 OpenAI Workload Identity Provider audience가 일치하면 다른 audience를 사용할 수 있어요:

apiVersion: v1
kind: Pod
metadata:
  name: openai-wif-app
  namespace: default
spec:
  serviceAccountName: openai-wif
  containers:
    - name: app
      image: my-image
      volumeMounts:
        - name: eks-sa-token
          mountPath: /var/run/secrets/tokens
          readOnly: true
  volumes:
    - name: eks-sa-token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              audience: "https://api.openai.com/v1"
              expirationSeconds: 3600

EKS 토큰 검증

워크로드 아이덴티티 페더레이션을 구성하기 전에 샘플 projected service account 토큰을 로컬에서 디코딩하고 그 클레임을 검사하세요. projected 토큰이 마운트된 실행 중인 파드에서 토큰을 가져와 TOKEN으로 내보내세요:

TOKEN=$(kubectl exec -n default openai-wif-app -- cat /var/run/secrets/tokens/token)
export 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자 도구에 붙여넣지 마세요.

디코딩된 EKS projected service account 토큰은 비슷하게 보여요:

{
  "iss": "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3",
  "aud": ["https://api.openai.com/v1"],
  "sub": "system:serviceaccount:default:openai-wif",
  "iat": 1716235422,
  "exp": 1716239022,
  "kubernetes.io": {
    "namespace": "default",
    "serviceaccount": {
      "name": "openai-wif",
      "uid": "11111111-2222-3333-4444-555555555555"
    }
  }
}

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

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

EKS 발행자에 대해 OpenAI에 Workload Identity Provider를 만든 다음 projected 토큰의 속성과 일치하는 서비스 계정 매핑을 추가하세요.

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

Workload Identity Provider 설정

  1. Workload Identity Provider 생성. Name을 aws-eks-prod 같은 고유 값으로 설정하세요. Description(예: Production EKS cluster)으로 관리자가 클러스터를 식별하도록 도와주세요.

  2. 발행자와 audience 설정. OIDC Issuer URL을 aws eks describe-cluster --query "cluster.identity.oidc.issuer"가 반환한 발행자로 설정하세요. 이 값은 projected EKS service account 토큰의 iss 클레임과 일치해야 해요. Audience를 projected service account 토큰 볼륨에 구성된 것과 같은 audience로 설정하세요. 이 예시에서 그 값은 https://api.openai.com/v1이에요.

  3. EKS OIDC discovery 사용. Use uploaded JWKS for token verification은 비활성화로 두세요. OpenAI는 EKS 발행자의 OIDC discovery 메타데이터와 JWKS를 사용해 projected service account 토큰을 검증해요.

  4. 파생 매핑 속성이 필요할 때만 속성 변환 추가. sub, aud, iss 같은 원시 토큰 클레임은 매핑 assertion에 직접 사용할 수 있어요. 예를 들어 assertion.sub 표현식으로 subject라는 변환 속성을 만드세요. 대시보드에서 속성 이름으로 subject를 입력하면 OpenAI가 openai.subject로 저장하며, 매핑에서 참조할 수 있어요.

    참고: 이미 openai.로 시작하는 원시 토큰 클레임은 일치하는 변환이 구성되지 않으면 openai. 매핑 키에 대해 무시돼요.

서비스 계정 매핑 설정

  1. 서비스 계정 매핑 생성. Workload Identity Provider 내에서 고유한 Name(예: openai-mapping-eks)을 설정하세요. Description(예: Workload Identity Provider Mapping for EKS Workloads)으로 어떤 워크로드가 매핑을 사용할 수 있는지 설명하세요.

  2. EKS service account subject 일치. Key를 sub로, Value를 system:serviceaccount:default:openai-wif로 설정하세요. 사용 가능한 클레임이나 변환 속성에서 일치시킬 수 있어요. sub 일치는 Kubernetes service account를 고유하게 식별하므로 가장 제한적인 옵션이에요.

  3. OpenAI 대상 선택. Project를 대상 서비스 계정을 소유한 OpenAI 프로젝트로 설정하세요. Service account를 EKS 워크로드가 사용할 수 있는 OpenAI 서비스 계정(예: aws-eks-prod-openai-wif)으로 설정하세요. 이 매핑을 위해 기존 계정을 재사용하기보다 새 서비스 계정을 만들고 싶으면 Create a new service account in this project를 선택하세요.

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

코드에서 토큰 사용

OpenAI SDK 클라이언트를 구성해 projected EKS service account 토큰을 읽고 OpenAI가 발행한 액세스 토큰으로 교환하세요.

SDK 워크로드 아이덴티티 페더레이션 제공자의 subject 토큰 소스로 /var/run/secrets/tokens/token 같은 마운트된 토큰 경로를 사용하세요. SDK는 그 EKS 토큰을 OpenAI가 발행한 액세스 토큰으로 교환하고 OpenAI 토큰을 사용해 API 요청을 인증해요.

다음 예시는 커스텀 subject 토큰 제공자로 OpenAI 클라이언트를 초기화해요. 제공자는 마운트된 파일 경로에서 projected EKS service account 토큰을 읽고 워크로드 아이덴티티 페더레이션의 subject 토큰으로 사용해요.

EKS projected service account 토큰으로 인증

import { readFile } from "node:fs/promises";
import OpenAI from "openai";

const tokenPath = "/var/run/secrets/tokens/token";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;

if (!identityProviderId || !serviceAccountId) {
  throw new Error(
    "Set OPENAI_IDENTITY_PROVIDER_ID and OPENAI_SERVICE_ACCOUNT_ID"
  );
}

function mountedEksServiceAccountTokenProvider(path) {
  return {
    tokenType: "jwt",
    getToken: async () => {
      const token = (await readFile(path, "utf8")).trim();
      if (!token) {
        throw new Error("The mounted EKS service account token file is empty.");
      }
      return token;
    },
  };
}

const client = new OpenAI({
  workloadIdentity: {
    identityProviderId,
    serviceAccountId,
    provider: mountedEksServiceAccountTokenProvider(tokenPath),
  },
});

const response = await client.responses.create({
  model: "gpt-5.6-terra",
  input: "Say hello from AWS workload identity federation.",
});

console.log(response.output_text);
import os
from pathlib import Path

from openai import OpenAI
from openai.auth import SubjectTokenProvider

TOKEN_PATH = "/var/run/secrets/tokens/token"


def mounted_eks_service_account_token_provider(token_path: str) -> SubjectTokenProvider:
    def get_token() -> str:
        token = Path(token_path).read_text().strip()
        if not token:
            raise RuntimeError("The mounted EKS service account token file is empty.")
        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": mounted_eks_service_account_token_provider(TOKEN_PATH),
    },
)

response = client.responses.create(
    model="gpt-5.6-terra",
    input="Say hello from AWS workload identity federation.",
)

print(response.output_text)
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	"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"
)

const tokenPath = "/var/run/secrets/tokens/token"

type mountedEksServiceAccountTokenProvider struct {
	path string
}

func (p mountedEksServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
	return auth.SubjectTokenTypeJWT
}

func (p mountedEksServiceAccountTokenProvider) GetToken(_ context.Context, _ auth.HTTPDoer) (string, error) {
	data, err := os.ReadFile(p.path)
	if err != nil {
		return "", &auth.SubjectTokenProviderError{
			Provider: "aws-eks",
			Message:  "failed to read mounted EKS service account token",
			Cause:    err,
		}
	}

	token := strings.TrimSpace(string(data))
	if token == "" {
		return "", &auth.SubjectTokenProviderError{
			Provider: "aws-eks",
			Message:  "mounted EKS service account token is empty",
		}
	}

	return token, 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: mountedEksServiceAccountTokenProvider{
				path: tokenPath,
			},
		}),
	)

	response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
		Model: openai.ChatModelGPT4_1Mini,
		Input: responses.ResponseNewParamsInputUnion{
			OfString: openai.String("Say hello from AWS workload identity federation."),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(response.OutputText())
}
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.core.http.HttpClient;
import com.openai.errors.SubjectTokenProviderException;
import com.openai.models.responses.ResponseCreateParams;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;

public final class AwsEksWorkloadIdentityExample {
  private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";

  private AwsEksWorkloadIdentityExample() {}

  static final class MountedEksServiceAccountTokenProvider implements SubjectTokenProvider {
    private final Path tokenPath;

    MountedEksServiceAccountTokenProvider(String tokenPath) {
      this.tokenPath = Path.of(tokenPath);
    }

    @Override
    public SubjectTokenType tokenType() {
      return SubjectTokenType.JWT;
    }

    @Override
    public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
      String token;
      try {
        token = Files.readString(tokenPath).trim();
      } catch (Exception e) {
        throw new SubjectTokenProviderException(
            "aws-eks", "failed to read mounted EKS service account token", e);
      }

      if (token.isEmpty()) {
        throw new SubjectTokenProviderException(
            "aws-eks", "mounted EKS service account token is empty", null);
      }

      return token;
    }

    @Override
    public CompletableFuture<String> getTokenAsync(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 MountedEksServiceAccountTokenProvider(TOKEN_PATH))
            .build();

    OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();

    ResponseCreateParams params =
        ResponseCreateParams.builder()
            .model("gpt-5.6-terra")
            .input("Say hello from AWS 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 "openai"

TOKEN_PATH = "/var/run/secrets/tokens/token"

class MountedEksServiceAccountTokenProvider
  include OpenAI::Auth::SubjectTokenProvider

  def initialize(token_path:)
    @token_path = token_path
  end

  def token_type
    OpenAI::Auth::TokenType::JWT
  end

  def get_token
    token = File.read(@token_path).strip
    if token.empty?
      raise OpenAI::Errors::SubjectTokenProviderError.new(
        message: "Mounted EKS service account token is empty",
        provider: "aws-eks"
      )
    end
    token
  rescue SystemCallError => e
    raise OpenAI::Errors::SubjectTokenProviderError.new(
      message: "Failed to read mounted EKS service account token: #{e.message}",
      provider: "aws-eks",
      cause: e
    )
  end
end

provider = MountedEksServiceAccountTokenProvider.new(token_path: TOKEN_PATH)

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 AWS workload identity federation."
)

puts(response.output_text)

AWS 모범 사례

  • 워크로드당 전용 AWS 아이덴티티를 사용하세요. AWS 아웃바운드 아이덴티티 페더레이션에는 별도의 IAM 역할을, EKS 워크로드에는 별도의 Kubernetes service account를 사용하세요.
  • OpenAI 액세스 전용 audience를 구성하세요. AWS 발행 또는 EKS projected 토큰과 OpenAI Workload Identity Provider 구성에서 같은 audience 값을 사용하세요.
  • 토큰 수명을 합리적으로 짧게 유지하세요. AWS 아웃바운드 아이덴티티 페더레이션에는 sts:DurationSeconds 같은 IAM 조건을, EKS에는 적절한 projected 토큰 만료를 설정하세요.
  • 정확한 subject 일치를 선호하세요. AWS 아웃바운드 토큰에는 전체 IAM 주체 ARN, EKS 토큰에는 전체 Kubernetes service account subject에서 일치시키세요.
  • 매핑을 안정적인 경계로 범위를 지정하세요. 계정, 조직, 네임스페이스 또는 변환 속성이 넓은 신뢰 규칙을 만들지 않고 접근을 줄일 때 사용하세요.
  • 교환할 때 토큰을 다시 로드하세요. 필요할 때 AWS 아웃바운드 토큰을 요청하고 EKS projected 토큰을 마운트된 파일 경로에서 읽어 회전된 토큰이 자동으로 선택되게 하세요.
  • 워크로드가 요구하는 권한만 부여하세요. 매핑 수준 권한으로 대상 OpenAI 서비스 계정이 부여한 접근을 더 좁히세요.

더 알아보기 (Learn more)