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

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

SPIFFE JWT-SVID를 단기 OpenAI 액세스 토큰으로 교환해 SPIFFE를 Workload Identity Provider로 사용하세요. 이렇게 하면 SPIRE 또는 다른 SPIFFE 호환 아이덴티티 제공자로 인증된 워크로드가 오래 지속되는 API 키를 저장하지 않고 OpenAI API를 호출할 수 있어요.

출처: 문서

본문

OpenAI는 발행자, audience, 만료, 발행 시각, JWKS 기반 서명이 있는 JWT subject 토큰으로 검증될 수 있는 SPIFFE JWT-SVID를 지원해요. OpenAI는 워크로드 아이덴티티 페더레이션 subject 토큰으로 SPIFFE X.509-SVID를 지원하지 않아요.

JWT-SVID 사양은 sub, aud, exp 클레임을 요구해요. JWT-SVID를 OpenAI와 함께 사용하려면 토큰에 iss와 iat 클레임, kid 헤더도 포함해야 OpenAI가 Workload Identity Provider 구성에 대해 토큰을 검증할 수 있어요.

JWT-SVID는 OpenID Connect ID 토큰이 아니에요. SPIRE OIDC Discovery Provider는 OpenAI가 JWT-SVID를 검증할 수 있도록 discovery 메타데이터와 JWKS 키를 제공해요. 이는 토큰의 SPIFFE 의미론을 바꾸거나 OIDC 로그인 흐름을 요구하지 않아요.

SPIFFE 용어와 토큰 요구사항은 SPIFFE JWT-SVID 사양과 Workload API 사양을 참고하세요.

SPIFFE 설정

OpenAI API를 호출해야 하는 워크로드에 JWT-SVID를 발행하도록 SPIFFE 제공자를 구성하세요. 이 지침은 SPIRE 용어를 사용하지만, OpenAI가 검증할 수 있는 발행자와 JWKS 서명 자료가 있는 JWT-SVID를 방출하는 모든 SPIFFE 호환 제공자에 같은 OpenAI 구성이 적용돼요.

SPIFFE 설정은 다음을 제공해야 해요.

  • 워크로드의 안정적인 SPIFFE ID(예: spiffe://example.org/ns/production/sa/openai-wif).
  • OpenAI 액세스 전용 단일 JWT-SVID audience(예: https://api.openai.com/v1 또는 선택한 다른 불투명 값).
  • OpenAI 검증을 위해 JWT-SVID iss 클레임에 나타나는 JWT 발행자 URL.
  • OIDC discovery 또는 업로드된 JWKS를 통한 JWT-SVID 서명 키의 공개 JWKS.
  • SPIFFE Workload API에서 새 JWT-SVID를 가져오는 워크로드 측 방법.

audience는 JWT-SVID를 받는 엔드포인트가 아니라 정확히 일치하는 식별자예요. SPIFFE Workload API 요청과 OpenAI 제공자 구성이 일치하는 한 https://api.openai.com/v1 또는 다른 서비스 특정 값을 사용할 수 있어요.

가능하면 SPIRE OIDC Discovery Provider를 통해 SPIFFE 발행자를 노출하세요. SPIRE Server jwt_issuer와 OIDC Discovery Provider jwt_issuer를 OpenAI에서 구성할 것과 같은 HTTPS 발행자 URL로 구성하세요.

SPIRE Server 구성에서:

server {
  trust_domain = "example.org"
  jwt_issuer   = "https://spire-oidc.example.org"
}

별도의 SPIRE OIDC Discovery Provider 구성에서:

# Relevant issuer fields only
domains    = ["spire-oidc.example.org"]
jwt_issuer = "https://spire-oidc.example.org"

OIDC Discovery Provider 구성에는 또한 server_api, workload_api, file 같은 키 자료 소스와 ACME, TLS 인증서 또는 Unix 소켓 같은 제공 메커니즘이 필요해요. 완전한 구성 옵션은 SPIRE OIDC Discovery Provider 문서를 참고하세요.

SPIFFE trust domain과 JWT 발행자는 다른 개념이에요. 이 예시에서 JWT-SVID subject는 example.org trust domain의 SPIFFE ID이고 발행자는 HTTPS 발행자 URL이에요:

{
  "sub": "spiffe://example.org/ns/production/sa/openai-wif",
  "iss": "https://spire-oidc.example.org"
}

SPIRE OIDC Discovery Provider는 Use uploaded JWKS for token verification이 비활성화되어 있을 때 OpenAI가 사용할 수 있는 OIDC discovery 문서와 JWKS 엔드포인트를 제공해요.

OpenAI가 발행자 discovery 엔드포인트에 도달할 수 없으면 대신 업로드된 JWKS 모드를 사용하세요. 그 모드에서 OpenAI는 여전히 Workload Identity Provider 발행자를 JWT-SVID iss 클레임과 비교하지만, Workload Identity Provider에 저장한 JWKS JSON에 대해 서명을 검증해요.

참고: SPIFFE JWT-SVID 사양은 JWT 헤더 kid를 선택적으로 만들지만, OpenAI는 구성된 JWKS에서 서명 키를 선택할 수 있도록 JWT subject 토큰이 kid 헤더를 포함하도록 요구해요. SPIFFE 제공자가 kid를 생략할 수 있다면 OpenAI 워크로드 아이덴티티 페더레이션용으로 포함하도록 구성하세요.

SPIFFE Workload API를 호출할 수 있는 워크로드에서 JWT-SVID를 검사하려면 OpenAI에서 구성할 것과 같은 audience로 하나를 요청하세요. Workload API 권한 부여는 호출 프로세스의 아이덴티티에 의존하므로 애플리케이션과 같은 워크로드 컨텍스트에서 이 명령을 실행하세요.

TOKEN=$(spire-agent api fetch jwt \
  -socketPath /run/spire/sockets/agent.sock \
  -audience "https://api.openai.com/v1" | sed -n '2p')
export TOKEN

워크로드에 SPIFFE ID가 둘 이상이면 특정 아이덴티티를 요청하세요:

TOKEN=$(spire-agent api fetch jwt \
  -socketPath /run/spire/sockets/agent.sock \
  -spiffeID "spiffe://example.org/ns/production/sa/openai-wif" \
  -audience "https://api.openai.com/v1" | sed -n '2p')
export TOKEN

토큰 검증

워크로드 아이덴티티 페더레이션을 구성하기 전에 JWT-SVID를 TOKEN으로 내보낸 다음 다음 예시 중 하나를 로컬에서 실행해 헤더와 클레임을 검사하세요:

const parts = process.env.TOKEN?.split(".") ?? [];
if (parts.length !== 3) {
  throw new Error("Expected a compact JWT with three segments");
}

const decode = (segment) => {
  if (!/^[A-Za-z0-9_-]+$/.test(segment) || segment.length % 4 === 1) {
    throw new Error("JWT segment is not valid Base64URL");
  }
  const bytes = Buffer.from(segment, "base64url");
  if (bytes.toString("base64url") !== segment) {
    throw new Error("JWT segment is not valid Base64URL");
  }
  const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  const value = JSON.parse(decoded);
  if (value === null || Array.isArray(value) || typeof value !== "object") {
    throw new Error("JWT segment is not a JSON object");
  }
  return decoded;
};

console.log("Header:");
console.log(decode(parts[0]));
console.log("\nPayload:");
console.log(decode(parts[1]));
import base64
import json
import os
import re


def reject_non_json_constant(value):
    raise ValueError(f"JWT segment contains non-JSON constant: {value}")


parts = os.environ.get("TOKEN", "").split(".")
if len(parts) != 3:
    raise ValueError("Expected a compact JWT with three segments")


def decode(segment):
    if re.fullmatch(r"[A-Za-z0-9_-]+", segment) is None or len(segment) % 4 == 1:
        raise ValueError("JWT segment is not valid Base64URL")
    padded_segment = segment + "=" * (-len(segment) % 4)
    decoded = base64.b64decode(padded_segment, altchars=b"-_", validate=True)
    if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != segment:
        raise ValueError("JWT segment is not valid Base64URL")
    decoded_text = decoded.decode("utf-8")
    value = json.loads(decoded_text, parse_constant=reject_non_json_constant)
    if not isinstance(value, dict):
        raise ValueError("JWT segment is not a JSON object")
    return decoded_text


print("Header:")
print(decode(parts[0]))
print("\nPayload:")
print(decode(parts[1]))
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 printJSON(label string, value json.RawMessage) error {
	formatted, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		return err
	}
	fmt.Printf("%s:\n%s\n", label, formatted)
	return nil
}

func main() {
	parts := strings.Split(os.Getenv("TOKEN"), ".")
	if len(parts) != 3 {
		panic("Expected a compact JWT with three segments")
	}

	header, err := decodeSegment(parts[0])
	if err != nil {
		panic(err)
	}
	payload, err := decodeSegment(parts[1])
	if err != nil {
		panic(err)
	}
	if err := printJSON("Header", header); err != nil {
		panic(err)
	}
	fmt.Println()
	if err := printJSON("Payload", payload); err != nil {
		panic(err)
	}
}
// 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 DecodeJwtExample {
  private static final ObjectMapper JSON =
      new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);

  private DecodeJwtExample() {}

  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("Header:");
    System.out.println(decodeSegment(parts[0]));
    System.out.println("\nPayload:");
    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("Header:");
Console.WriteLine(DecodeSegment(parts[0]));
Console.WriteLine("\nPayload:");
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

decode = lambda do |segment|
  unless segment.match?(/\A[A-Za-z0-9_-]+\z/) && segment.length % 4 != 1
    raise "JWT segment is not valid Base64URL"
  end

  padded = segment.ljust((segment.length + 3) & ~3, "=")
  begin
    decoded = Base64.urlsafe_decode64(padded)
  rescue ArgumentError
    raise "JWT segment is not valid Base64URL"
  end
  unless Base64.urlsafe_encode64(decoded, padding: false) == segment
    raise "JWT segment is not valid Base64URL"
  end

  decoded.force_encoding(Encoding::UTF_8)
  raise "JWT segment is not valid UTF-8" unless decoded.valid_encoding?

  value = JSON.parse(decoded)
  raise "JWT segment is not a JSON object" unless value.is_a?(Hash)

  decoded
end

puts("Header:")
puts(decode.call(parts[0]))
puts("\nPayload:")
puts(decode.call(parts[1]))

각 예시는 토큰 서명을 검증하지 않고 JWT를 디코딩해요. 프로덕션 토큰에는 로컬 디코더를 사용하고 프로덕션 토큰을 제3자 도구에 붙여넣지 마세요.

디코딩된 SPIFFE JWT-SVID는 비슷하게 보여요:

{
  "alg": "ES256",
  "kid": "jwt-svid-key-1"
}
{
  "iss": "https://spire-oidc.example.org",
  "aud": ["https://api.openai.com/v1"],
  "sub": "spiffe://example.org/ns/production/sa/openai-wif",
  "iat": 1716235422,
  "exp": 1716235722
}

디코딩된 토큰을 사용해 받은 토큰을 교환하기 전에 OpenAI 구성과 비교하세요. 헤더에서 alg와 kid를, 페이로드에서 iss, aud, sub, iat, exp를 확인하세요. 정확한 alg 값은 SPIRE Server JWT 서명 키 구성에 따라 달라져요.

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

SPIFFE JWT-SVID 발행자에 대해 OpenAI에 Workload Identity Provider를 만든 다음 신뢰하는 SPIFFE ID와 일치하는 서비스 계정 매핑을 추가하세요.

Workload Identity Provider 설정

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

  2. 발행자와 audience 설정. OIDC Issuer URL을 JWT-SVID iss 클레임의 정확한 값(예: https://spire-oidc.example.org)으로 설정하세요. Audience를 SPIFFE Workload API에서 요청한 audience 값으로 설정하세요. 이 예시에서 그 값은 https://api.openai.com/v1이에요.

  3. JWKS 소스 선택. OpenAI가 SPIRE OIDC Discovery Provider에 도달할 수 있으면 Use uploaded JWKS for token verification은 비활성화로 두세요. OpenAI는 OIDC discovery와 발견된 JWKS를 사용해 JWT-SVID 서명을 검증해요.

    발행자가 OpenAI에서 도달할 수 없으면 Use uploaded JWKS for token verification을 활성화한 다음 JWKS JSON을 JWT-SVID 서명 키의 공개 키 집합으로 설정하세요. 주변 keys 배열을 포함한 완전한 공개 JWKS 객체를 업로드하세요. 비밀 키 자료를 포함하지 마세요.

  4. 파생 매핑 속성이 필요할 때만 속성 변환 추가. sub에서 직접 매핑할 때 속성 변환은 필요하지 않아요. 하나 이상의 토큰 클레임에서 매핑 값을 파생해야 할 때만 사용하세요. 변환 동작은 주요 워크로드 아이덴티티 페더레이션 가이드를 참고하세요.

서비스 계정 매핑 설정

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

  2. SPIFFE ID 일치. Key를 sub로, Value를 워크로드의 SPIFFE ID(예: spiffe://example.org/ns/production/sa/openai-wif)로 설정하세요.

    특권 워크로드에는 정확한 SPIFFE ID 일치를 선호하세요. 해당 접두사 아래의 모든 SPIFFE ID가 OpenAI 액세스 토큰을 만들 수 있어야 할 때만 뒤따르는 와일드카드를 사용하세요. 예를 들어 spiffe://example.org/ns/production/sa/*는 일치하는 모든 프로덕션 서비스 계정 경로를 허용해요.

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

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

코드에서 토큰 사용

OpenAI SDK 클라이언트를 구성해 새 SPIFFE JWT-SVID를 OpenAI가 발행한 액세스 토큰으로 교환하세요.

아래 SDK 샘플은 SPIFFE 통합이 JWT-SVID를 새로고침해 /var/run/spiffe/openai.jwt에 쓰는 것으로 가정해요. 파일을 워크로드만 읽을 수 있게 유지하세요. JWT-SVID는 수명이 짧으므로 토큰이 만료되기 전에 파일을 새로고침하세요. 대안으로, 가능하면 subject 토큰 제공자에서 SPIFFE Workload API에서 JWT-SVID를 직접 가져오는 언어별 SPIFFE 라이브러리를 사용해 오래된 토큰 파일을 피하세요.

워크로드 환경에 OPENAI_IDENTITY_PROVIDER_ID와 OPENAI_SERVICE_ACCOUNT_ID를 설정하세요. 토큰 파일은 외부 subject 토큰을 포함해요. OPENAI_IDENTITY_PROVIDER_ID는 OpenAI Workload Identity Provider를 식별하고 OPENAI_SERVICE_ACCOUNT_ID는 대상 OpenAI 서비스 계정을 식별해요. OpenAI는 그런 다음 토큰 클레임을 기반으로 그 제공자와 서비스 계정에 대한 일치하는 매핑을 찾아요.

SPIFFE JWT-SVID로 인증

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

const tokenPath = "/var/run/spiffe/openai.jwt";
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 spiffeJwtSvidProvider(path) {
  return {
    tokenType: "jwt",
    getToken: async () => {
      const token = (await readFile(path, "utf8")).trim();
      if (!token) {
        throw new Error("The SPIFFE JWT-SVID file is empty.");
      }
      return token;
    },
  };
}

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

const response = await client.responses.create({
  model: "gpt-5.6-terra",
  input: "Say hello from SPIFFE 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/spiffe/openai.jwt"


def spiffe_jwt_svid_provider(token_path: str) -> SubjectTokenProvider:
    def get_token() -> str:
        token = Path(token_path).read_text().strip()
        if not token:
            raise RuntimeError("The SPIFFE JWT-SVID 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": spiffe_jwt_svid_provider(TOKEN_PATH),
    },
)

response = client.responses.create(
    model="gpt-5.6-terra",
    input="Say hello from SPIFFE 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/spiffe/openai.jwt"

type spiffeJWTSVIDProvider struct {
	path string
}

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

func (p spiffeJWTSVIDProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
	data, err := os.ReadFile(p.path)
	if err != nil {
		return "", &auth.SubjectTokenProviderError{
			Provider: "spiffe",
			Message:  "failed to read SPIFFE JWT-SVID",
			Cause:    err,
		}
	}

	token := strings.TrimSpace(string(data))
	if token == "" {
		return "", &auth.SubjectTokenProviderError{
			Provider: "spiffe",
			Message:  "SPIFFE JWT-SVID file 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: spiffeJWTSVIDProvider{
				path: tokenPath,
			},
		}),
	)

	response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
		Model: openai.ChatModelGPT4_1Mini,
		Input: responses.ResponseNewParamsInputUnion{
			OfString: openai.String("Say hello from SPIFFE 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 SpiffeWorkloadIdentityExample {
  private static final String TOKEN_PATH = "/var/run/spiffe/openai.jwt";

  private SpiffeWorkloadIdentityExample() {}

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

    SpiffeJwtSvidProvider(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("spiffe", "failed to read SPIFFE JWT-SVID", e);
      }

      if (token.isEmpty()) {
        throw new SubjectTokenProviderException("spiffe", "SPIFFE JWT-SVID file 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 SpiffeJwtSvidProvider(TOKEN_PATH))
            .build();

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

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

class SpiffeJWTSVIDProvider
  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: "SPIFFE JWT-SVID file is empty",
        provider: "spiffe"
      )
    end
    token
  rescue SystemCallError => e
    raise OpenAI::Errors::SubjectTokenProviderError.new(
      message: "Failed to read SPIFFE JWT-SVID: #{e.message}",
      provider: "spiffe",
      cause: e
    )
  end
end

provider = SpiffeJWTSVIDProvider.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 SPIFFE workload identity federation."
)

puts(response.output_text)

SPIFFE 모범 사례

  • OpenAI 워크로드 아이덴티티 페더레이션에는 JWT-SVID를 사용하세요. X.509-SVID는 상호 TLS에 유용하지만 OpenAI 토큰 교환 엔드포인트에서 수락되지 않아요.
  • OpenAI 액세스에는 단일 전용 audience를 사용하세요. 전체 trust domain이나 환경 이름 같은 넓은 audience를 피하세요.
  • 가능하면 정확한 SPIFFE ID를 일치시키세요. 의도적으로 공유된 신뢰 경계에서만 와일드카드 매핑을 사용하세요.
  • bearer 토큰 재생 위험을 줄이려면 JWT-SVID 수명을 짧게 유지하세요. OpenAI 액세스 토큰은 교환에 사용된 외부 subject 토큰보다 오래 가지 않아요.
  • 서명 키를 조심스럽게 회전하세요. 회전 창 동안 OIDC discovery를 통해 이전·새 공개 키를 모두 게시하거나, 새 kid로 JWT-SVID를 발행하기 전에 업로드된 공개 JWKS를 업데이트하세요.
  • SPIRE Server와 워크로드 시계를 동기화하세요. 큰 시계 드리프트는 유효한 JWT-SVID를 아직 유효하지 않음, 너무 오래됨 또는 만료됨으로 거부될 수 있게 해요.
  • SPIFFE Workload API 소켓을 보호하세요. 워크로드의 JWT-SVID를 가져올 수 있는 프로세스는 그것을 OpenAI 액세스로 교환하려 시도할 수 있어요.
  • OpenAI 서비스 계정 경계를 애플리케이션과 환경 권한 경계와 정렬하세요. 고특권 서비스 계정을 관련 없는 SPIFFE 워크로드와 공유하지 마세요.
  • 발행자, audience, 서명 키, 매핑 불일치에 대해 토큰 교환 실패를 모니터링하세요.

더 알아보기 (Learn more)