Google Cloud용 워크로드 아이덴티티 페더레이션 구성
Google Cloud용 워크로드 아이덴티티 페더레이션 구성
다음 시나리오 중 하나에서 Google Cloud를 Workload Identity Provider로 사용하세요:
- Google 워크로드 아이덴티티: 연결된 Google 서비스 계정에 발행된 Google 서명 OIDC 토큰을 단기 OpenAI 액세스 토큰으로 교환해요.
- Google Kubernetes Engine: projected GKE service account 토큰을 단기 OpenAI 액세스 토큰으로 교환해요.
출처: 문서
본문
Google 워크로드 아이덴티티
Google Cloud 워크로드는 오래 지속되는 서비스 계정 키를 저장하지 않고 Google 메타데이터 서버에서 서명된 OIDC 아이덴티티 토큰을 요청할 수 있어요. OpenAI 워크로드 아이덴티티 페더레이션에서 Google 아이덴티티 토큰은 OpenAI가 OpenAI 액세스 토큰을 발행하기 전에 검증하는 subject 토큰이에요. 이 흐름은 Compute Engine, Cloud Run, 연결된 Google 서비스 계정을 사용하는 GKE 워크로드 및 메타데이터 서버 아이덴티티 엔드포인트를 노출하는 다른 Google 관리형 런타임에서 작동해요.
Google 워크로드 아이덴티티 설정
OpenAI API를 호출해야 하는 워크로드에 Google 서비스 계정을 만드세요. 전체 설정 흐름은 Google의 서비스 계정 만들기 가이드를 참고하세요.
예를 들어 Google Cloud CLI로 서비스 계정을 만드세요:
gcloud iam service-accounts create openai-wif \
--description="Service account for OpenAI workload identity federation" \
--display-name="OpenAI workload identity federation"
서비스 계정이 연결된 Compute Engine VM을 만들거나, 애플리케이션을 실행하는 Google Cloud 리소스에 서비스 계정을 연결하세요. 리소스는 런타임에 Google 메타데이터 서버를 호출할 수 있어야 해요. VM 설정 세부 사항은 Google의 사용자 관리 서비스 계정을 사용하는 VM 만들기 가이드를 참고하세요.
이 흐름에는 서비스 계정 키를 만들거나 다운로드하지 마세요. 워크로드는 연결된 서비스 계정과 메타데이터 서버를 사용해 단기 OIDC 토큰을 요청해요.
Google 아이덴티티 토큰 얻기
서비스 계정이 연결된 Google Cloud 리소스에서 구성된 audience로 메타데이터 서버에 OIDC 아이덴티티 토큰을 요청하세요. 이 토큰은 OpenAI가 OpenAI가 발행한 액세스 토큰으로 교환하는 subject 토큰이에요.
AUDIENCE="https://api.openai.com/v1"
TOKEN=$(curl -sS -G -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity" \
--data-urlencode "audience=${AUDIENCE}")
export TOKEN
메타데이터 서버는 Google 서명 JWT를 반환해요. 메타데이터 서버 아이덴티티 엔드포인트에 대한 자세한 내용은 Google의 VM 아이덴티티 검증 가이드를 참고하세요.
토큰 검증
워크로드 아이덴티티 페더레이션을 구성하기 전에 Google 아이덴티티 토큰을 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자 도구에 붙여넣지 마세요.
디코딩된 Google 메타데이터 서버 아이덴티티 토큰은 비슷하게 보여요:
{
"iss": "https://accounts.google.com",
"aud": "https://api.openai.com/v1",
"azp": "110123456789012345678",
"sub": "110123456789012345678",
"email": "[email protected]",
"email_verified": true,
"iat": 1716235422,
"exp": 1716239022
}
디코딩된 페이로드를 사용해 받은 토큰을 OpenAI에 구성된 발행자, audience, 매핑 값과 비교하세요. 대부분의 구성 문제는 토큰을 교환하기 전에 iss, aud, email, sub 클레임에서 보여요.
워크로드 아이덴티티 페더레이션 설정
Google이 발행한 아이덴티티 토큰에 대해 OpenAI에 Workload Identity Provider를 만든 다음 토큰의 안정적인 클레임과 일치하는 서비스 계정 매핑을 추가하세요.
먼저 Workload Identity Provider를 구성한 다음 서비스 계정 매핑을 만드세요.
Workload Identity Provider 설정
-
Workload Identity Provider 생성. Name을
google-workload-identity-prod같은 고유 값으로 설정하세요. Description(예:Production Google Cloud workloads)으로 관리자가 제공자를 식별하도록 도와주세요. -
발행자와 audience 설정. OIDC Issuer URL을
https://accounts.google.com으로 설정하세요. Audience를 워크로드가 Google 메타데이터 서버에서 요청하는 커스텀 audience(예:https://api.openai.com/v1)로 설정하세요. 이 값은 토큰의aud클레임과 일치해야 해요. -
Google OIDC discovery 사용. Use uploaded JWKS for token verification은 비활성화로 두세요. OpenAI는 Google의 OIDC discovery 메타데이터와 JWKS를 사용해 Google 서명 아이덴티티 토큰을 검증해요.
-
파생 매핑 속성이 필요하면 속성 변환 추가. 예를 들어 subject 클레임에서
openai.subject를 만들려면subject와assertion.sub표현식을 입력하세요. 대시보드가openai.접두사를 자동으로 적용해요. 이미openai.로 시작하는 원시 토큰 클레임은 일치하는 변환이 구성되지 않으면openai.매핑 키에 대해 무시돼요.
서비스 계정 매핑 설정
-
서비스 계정 매핑 생성. Workload Identity Provider 내에서 고유한 Name(예:
compute-openai-wif)을 설정하세요. Description(예:Production Compute Engine OpenAI API workload)으로 어떤 워크로드가 매핑을 사용할 수 있는지 설명하세요. -
안정적인 Google 서비스 계정 클레임 일치. 일치해야 하는 각 클레임에 대해 Key와 Value 행을 하나씩 추가하세요.
sub는 안정적이고 고유하므로 기본 아이덴티티 바인딩으로 사용하세요. 가독성을 위해email을 추가로 일치시킬 수도 있어요. -
OpenAI 대상 선택. Project를 대상 서비스 계정을 소유한 OpenAI 프로젝트로 설정하세요. Service account를 Google Cloud 워크로드가 사용할 수 있는 OpenAI 서비스 계정(예:
google-workload-identity-prod-openai-wif)으로 설정하세요. -
필요하면 API 권한 좁히기. 이 매핑에서 만들어진 액세스 토큰을 더 좁히려면
api.model.request,api.vector_store.read같은 적절한 Permissions를 선택하세요. WIF 특정 스코프 제한을 추가하지 않으려면 권한을 비워 두세요. 토큰은 여전히 매핑된 서비스 계정으로 승인돼요.
코드에서 토큰 사용
OpenAI SDK 클라이언트를 구성해 메타데이터 서버에서 Google 아이덴티티 토큰을 요청하고 OpenAI가 발행한 액세스 토큰으로 교환하세요.
OPENAI_WIF_AUDIENCE를 Workload Identity Provider audience로 구성된 커스텀 audience로 설정하세요. SDK는 그 audience에 대한 Google 아이덴티티 토큰을 요청하고, 그것을 OpenAI가 발행한 액세스 토큰으로 교환하며, OpenAI 토큰을 사용해 API 요청을 인증해요.
Google 메타데이터 서버 아이덴티티 토큰으로 인증
import OpenAI from "openai";
const metadataEndpoint =
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
const audience = process.env.OPENAI_WIF_AUDIENCE;
if (!identityProviderId || !serviceAccountId || !audience) {
throw new Error(
"Set OPENAI_IDENTITY_PROVIDER_ID, OPENAI_SERVICE_ACCOUNT_ID, and OPENAI_WIF_AUDIENCE"
);
}
function googleMetadataIdentityTokenProvider(audience) {
return {
tokenType: "jwt",
getToken: async () => {
const url = new URL(metadataEndpoint);
url.searchParams.set("audience", audience);
url.searchParams.set("format", "full");
const response = await fetch(url, {
headers: { "Metadata-Flavor": "Google" },
});
if (!response.ok) {
throw new Error(
`Google metadata token request failed with status ${response.status}.`
);
}
const token = (await response.text()).trim();
if (!token) {
throw new Error(
"Google metadata server did not return an identity token."
);
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: googleMetadataIdentityTokenProvider(audience),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from Google Cloud workload identity federation.",
});
console.log(response.output_text);
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from openai import OpenAI
from openai.auth import SubjectTokenProvider
METADATA_ENDPOINT = (
"http://metadata.google.internal/computeMetadata/v1/instance/"
"service-accounts/default/identity"
)
def google_metadata_identity_token_provider(audience: str) -> SubjectTokenProvider:
def get_token() -> str:
request = Request(
f"{METADATA_ENDPOINT}?{urlencode({'audience': audience, 'format': 'full'})}",
headers={"Metadata-Flavor": "Google"},
)
with urlopen(request, timeout=10) as response:
token = response.read().decode("utf-8").strip()
if not token:
raise RuntimeError(
"Google metadata server did not return an 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": google_metadata_identity_token_provider(
audience=os.environ["OPENAI_WIF_AUDIENCE"]
),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from Google Cloud workload identity federation.",
)
print(response.output_text)
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"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 googleMetadataEndpoint = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
type googleMetadataIdentityTokenProvider struct {
audience string
}
func (p googleMetadataIdentityTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p googleMetadataIdentityTokenProvider) GetToken(ctx context.Context, httpClient auth.HTTPDoer) (string, error) {
values := url.Values{}
values.Set("audience", p.audience)
values.Set("format", "full")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleMetadataEndpoint+"?"+values.Encode(), nil)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "google-metadata",
Message: "failed to build Google metadata token request",
Cause: err,
}
}
req.Header.Set("Metadata-Flavor", "Google")
resp, err := httpClient.Do(req)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "google-metadata",
Message: "failed to request Google identity token",
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &auth.SubjectTokenProviderError{
Provider: "google-metadata",
Message: fmt.Sprintf("Google metadata token request failed with status %d", resp.StatusCode),
}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "google-metadata",
Message: "failed to read Google metadata token response",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "google-metadata",
Message: "Google metadata server did not return an identity token",
}
}
return token, nil
}
func main() {
audience := os.Getenv("OPENAI_WIF_AUDIENCE")
if audience == "" {
log.Fatal("Set OPENAI_WIF_AUDIENCE")
}
client := openai.NewClient(
option.WithWorkloadIdentity(auth.WorkloadIdentity{
IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
Provider: googleMetadataIdentityTokenProvider{
audience: audience,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from Google Cloud 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.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 GoogleWorkloadIdentityExample {
private static final String METADATA_ENDPOINT =
"http://metadata.google.internal/computeMetadata/v1/instance/"
+ "service-accounts/default/identity";
private GoogleWorkloadIdentityExample() {}
static final class GoogleMetadataIdentityTokenProvider implements SubjectTokenProvider {
private final String audience;
GoogleMetadataIdentityTokenProvider(String audience) {
this.audience = audience;
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
try {
String query =
"audience=" + URLEncoder.encode(audience, StandardCharsets.UTF_8) + "&format=full";
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(METADATA_ENDPOINT + "?" + query))
.header("Metadata-Flavor", "Google")
.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(
"google-metadata",
"Google metadata token request failed with status " + response.statusCode(),
null);
}
String token = response.body().trim();
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"google-metadata", "Google metadata server did not return an identity token", null);
}
return token;
} catch (SubjectTokenProviderException e) {
throw e;
} catch (Exception e) {
throw new SubjectTokenProviderException(
"google-metadata", "failed to request Google identity token", e);
}
}
@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 GoogleMetadataIdentityTokenProvider(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 Google Cloud 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 "net/http"
require "openai"
require "uri"
class GoogleMetadataIdentityTokenProvider
include OpenAI::Auth::SubjectTokenProvider
METADATA_ENDPOINT =
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
def initialize(audience:)
@audience = audience
end
def token_type
OpenAI::Auth::TokenType::ID
end
def get_token
uri = URI(METADATA_ENDPOINT)
uri.query = URI.encode_www_form(
audience: @audience,
format: "full"
)
request = Net::HTTP::Get.new(uri)
request["Metadata-Flavor"] = "Google"
response = Net::HTTP.start(uri.hostname, uri.port, read_timeout: 10) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Google metadata token request failed with status #{response.code}",
provider: "google-metadata"
)
end
token = response.body.strip
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Google metadata server did not return an identity token",
provider: "google-metadata"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to request Google identity token: #{e.message}",
provider: "google-metadata",
cause: e
)
end
end
provider = GoogleMetadataIdentityTokenProvider.new(
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 Google Cloud workload identity federation."
)
puts(response.output_text)
Google Kubernetes Engine
GKE가 발행한 projected service account 토큰을 단기 OpenAI 액세스 토큰으로 교환해 Google Kubernetes Engine을 Workload Identity Provider로 사용하세요.
GKE 워크로드는 다음 중 하나로 인증할 수 있어요:
- 클러스터 OIDC 발행자가 발행한 projected Kubernetes service account 토큰.
- Kubernetes service account가 Google service account에 바인딩된 GKE Workload Identity를 통해 얻은 Google service account 아이덴티티 토큰.
OpenAI가 클러스터의 OIDC 발행자를 직접 신뢰하게 하려면 projected Kubernetes service account 토큰을 사용하세요. 워크로드가 이미 Google service account 아이덴티티에 의존하고 OpenAI가 Google 발행 아이덴티티 토큰을 신뢰하게 하려면 GKE Workload Identity를 사용하세요.
GKE 워크로드가 GKE Workload Identity로 구성되고 메타데이터 서버에서 Google 아이덴티티 토큰을 요청할 수 있으면 GKE projected 토큰 흐름 대신 위의 Google 워크로드 아이덴티티 지침을 따르세요.
GKE 설정
이 지침은 관리형 GKE 클러스터를 가정해요. 자체 관리 Kubernetes 클러스터에는 Kubernetes 가이드를 사용하세요.
OpenAI API를 호출해야 하는 GKE 워크로드에 Kubernetes ServiceAccount를 사용하세요. 아직 없으면 만드세요:
kubectl create serviceaccount openai-wif --namespace default
GKE 클러스터와 연결된 발행자 URL을 검색하세요:
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
예시 출력:
https://container.googleapis.com/v1/projects/my-project/locations/us-central1/clusters/openai-wif
OpenAI Workload Identity Provider에 구성하는 발행자는 이 발행자 URL과 projected GKE 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: gke-sa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: gke-sa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: "https://api.openai.com/v1"
expirationSeconds: 3600
토큰 검증
워크로드 아이덴티티 페더레이션을 구성하기 전에 샘플 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자 도구에 붙여넣지 마세요.
디코딩된 GKE projected service account 토큰은 비슷하게 보여요:
{
"iss": "https://container.googleapis.com/v1/projects/my-project/locations/us-central1/clusters/openai-wif",
"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 클레임에서 보여요.
워크로드 아이덴티티 페더레이션 설정
GKE 발행자에 대해 OpenAI에 Workload Identity Provider를 만든 다음 projected 토큰의 속성과 일치하는 서비스 계정 매핑을 추가하세요.
먼저 Workload Identity Provider를 구성한 다음 서비스 계정 매핑을 만드세요.
Workload Identity Provider 설정
-
Workload Identity Provider 생성. Name을
google-gke-prod같은 고유 값으로 설정하세요. Description(예:Production GKE cluster)으로 관리자가 클러스터를 식별하도록 도와주세요. -
발행자와 audience 설정. OIDC Issuer URL을
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer가 반환한 발행자로 설정하세요. 이 값은 projected GKE service account 토큰의iss클레임과 일치해야 해요. Audience를 projected service account 토큰 볼륨에 구성된 것과 같은 audience로 설정하세요. 이 예시에서 그 값은https://api.openai.com/v1이에요. -
GKE OIDC discovery 사용. Use uploaded JWKS for token verification은 비활성화로 두세요. OpenAI는 GKE 발행자의 OIDC discovery 메타데이터와 JWKS를 사용해 projected service account 토큰을 검증해요.
-
파생 매핑 속성이 필요하면 속성 변환 추가. 예를 들어
openai.gke_subject를 만들려면gke_subject와assertion.sub표현식을 입력하세요. 대시보드가openai.접두사를 자동으로 적용해요. 이미openai.로 시작하는 원시 토큰 클레임은 일치하는 변환이 구성되지 않으면openai.매핑 키에 대해 무시돼요.
서비스 계정 매핑 설정
-
서비스 계정 매핑 생성. Workload Identity Provider 내에서 고유한 Name(예:
default-openai-wif)을 설정하세요. Description(예:Default namespace GKE OpenAI API workload)으로 어떤 워크로드가 매핑을 사용할 수 있는지 설명하세요. -
GKE service account subject 일치. Key를
sub로, Value를system:serviceaccount:default:openai-wif로 설정하세요. GKE service account의 subject 형식은system:serviceaccount:<namespace>:<service-account-name>이에요. -
OpenAI 대상 선택. Project를 대상 서비스 계정을 소유한 OpenAI 프로젝트로 설정하세요. Service account를 GKE 워크로드가 사용할 수 있는 OpenAI 서비스 계정(예:
google-gke-prod-openai-wif)으로 설정하세요. -
필요하면 API 권한 좁히기. 이 매핑에서 만들어진 액세스 토큰을 더 좁히려면
api.model.request,api.vector_store.read같은 적절한 Permissions를 선택하세요. WIF 특정 스코프 제한을 추가하지 않으려면 권한을 비워 두세요. 토큰은 여전히 매핑된 서비스 계정으로 승인돼요.
코드에서 토큰 사용
OpenAI SDK 클라이언트를 구성해 projected GKE service account 토큰을 읽고 OpenAI가 발행한 액세스 토큰으로 교환하세요.
SDK 워크로드 아이덴티티 페더레이션 제공자의 subject 토큰 소스로 /var/run/secrets/tokens/token 같은 마운트된 토큰 경로를 사용하세요. SDK는 그 GKE 토큰을 OpenAI가 발행한 액세스 토큰으로 교환하고 OpenAI 토큰을 사용해 API 요청을 인증해요.
다음 예시는 커스텀 subject 토큰 제공자로 OpenAI 클라이언트를 초기화해요. 제공자는 마운트된 파일 경로에서 projected GKE service account 토큰을 읽고 워크로드 아이덴티티 페더레이션의 subject 토큰으로 사용해요.
GKE 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 mountedGkeServiceAccountTokenProvider(path) {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The mounted GKE service account token file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: mountedGkeServiceAccountTokenProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from Google GKE 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_gke_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 GKE 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_gke_service_account_token_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from Google GKE 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 mountedGkeServiceAccountTokenProvider struct {
path string
}
func (p mountedGkeServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p mountedGkeServiceAccountTokenProvider) GetToken(_ context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "google-gke",
Message: "failed to read mounted GKE service account token",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "google-gke",
Message: "mounted GKE 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: mountedGkeServiceAccountTokenProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from Google GKE 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 GoogleGkeWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";
private GoogleGkeWorkloadIdentityExample() {}
static final class MountedGkeServiceAccountTokenProvider implements SubjectTokenProvider {
private final Path tokenPath;
MountedGkeServiceAccountTokenProvider(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(
"google-gke", "failed to read mounted GKE service account token", e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"google-gke", "mounted GKE 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 MountedGkeServiceAccountTokenProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from Google GKE 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 MountedGkeServiceAccountTokenProvider
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 GKE service account token is empty",
provider: "google-gke"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read mounted GKE service account token: #{e.message}",
provider: "google-gke",
cause: e
)
end
end
provider = MountedGkeServiceAccountTokenProvider.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 Google GKE workload identity federation."
)
puts(response.output_text)
Google Cloud 모범 사례
- 각 워크로드에 전용 Google 서비스 계정을 사용하세요. 관련 없는 서비스나 환경에서 서비스 계정을 공유하지 마세요.
- 오래 지속되는 서비스 계정 키 대신 워크로드 아이덴티티 흐름을 사용하세요. 메타데이터 서버 아이덴티티 토큰이나 GKE Workload Identity를 사용할 수 있는 워크로드에 JSON 키 파일을 배포하고 회전하는 것을 피하세요.
- 아이덴티티를 가장 작은 실용적인 워크로드 경계로 범위를 지정하세요. 개별 애플리케이션에 대한 별도의 서비스 계정은 더 명확한 감사와 최소 권한 접근을 제공해요.
- 속성 기반 매핑을 조심스럽게 사용하세요. 가능하면 변경 가능한 메타데이터보다 서비스 계정 subject 클레임 같은 안정적인 식별자를 선호하세요.
- 프로덕션과 비프로덕션 프로젝트를 분리하세요. 별도의 프로젝트는 우발적 권한 공유 위험을 줄이고 감사를 단순화해요.
- 필수 IAM 권한만 부여하세요. Google 아이덴티티를 워크로드에 필요한 권한만으로 제한하세요.
- 서비스 계정 사용을 모니터링하세요. 예기치 않은 토큰 교환은 구성 드리프트나 손상된 워크로드를 나타낼 수 있어요.