Google Cloud 워크로드는 장기 서비스 계정 키를 저장하지 않고도 Google 메타데이터 서버에 서명된 OIDC ID 토큰을 요청할 수 있습니다. OpenAI 워크로드 아이덴티티 제휴에서는 Google ID 토큰이 주체 토큰 역할을 합니다. OpenAI는 이 토큰을 검증한 후 OpenAI 액세스 토큰을 발급합니다. 이 플로우는 Compute Engine, Cloud Run, 연결된 Google 서비스 계정을 사용하는 GKE 워크로드, 그리고 메타데이터 서버의 ID 엔드포인트를 제공하는 다른 Google 관리형 런타임에서 사용할 수 있습니다.
OpenAI API를 호출할 워크로드용 Google 서비스 계정을 만드세요. 전체 설정 절차는 Google의 서비스 계정 만들기 가이드를 참고하세요.
예를 들어 Google Cloud CLI로 다음과 같이 서비스 계정을 만들 수 있습니다.
123gcloud 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 Cloud 리소스에서 미리 설정한 대상 값을 지정해 메타데이터 서버에 OIDC ID 토큰을 요청하세요. OpenAI는 이 토큰을 주체 토큰으로 받아 액세스 토큰을 발급합니다.
123456AUDIENCE="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를 반환합니다. 메타데이터 서버의 ID 엔드포인트에 대한 자세한 내용은 Google의 VM ID 확인 가이드를 참고하세요.
워크로드 아이덴티티 제휴를 구성하기 전에 Google ID 토큰을 환경 변수 TOKEN으로 내보낸 다음, 아래 스크립트를 로컬에서 실행해 토큰의 클레임을 확인하세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69package 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))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77// 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]));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59using 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]));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26require "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 페이로드를 디코딩합니다. 프로덕션 토큰은 로컬 디코더로 확인하고 타사 도구에 붙여 넣지 마세요.
Google 메타데이터 서버에서 받은 ID 토큰을 디코딩하면 다음과 비슷한 결과가 나옵니다.
12345678910{
"iss": "https://accounts.google.com",
"aud": "https://api.openai.com/v1",
"azp": "110123456789012345678",
"sub": "110123456789012345678",
"email": "openai-wif@my-project.iam.gserviceaccount.com",
"email_verified": true,
"iat": 1716235422,
"exp": 1716239022
}
디코딩한 페이로드의 값을 OpenAI에 설정한 발급자, 대상, 매핑 값과 비교하세요. 토큰을 교환하기 전에 iss, aud, email, sub 클레임을 확인하면 대부분의 구성 문제를 찾을 수 있습니다.
OpenAI에서 Google이 발급한 ID 토큰에 사용할 워크로드 아이덴티티 공급자를 만든 다음, 토큰에서 값이 변하지 않는 클레임을 기준으로 서비스 계정 매핑을 추가하세요.
먼저 워크로드 아이덴티티 공급자를 구성한 후 서비스 계정 매핑을 만드세요.
-
워크로드 아이덴티티 공급자를 만드세요. 이름 필드에 google-workload-identity-prod처럼 고유한 값을 입력하세요. 설명 필드에는 Production Google Cloud workloads처럼 관리자가 공급자를 식별하는 데 도움이 되는 내용을 입력하세요.
-
발급자와 대상을 설정하세요. OIDC 발급자 URL 필드에 https://accounts.google.com을 입력하세요. 대상 필드에는 워크로드가 Google 메타데이터 서버에 토큰을 요청할 때 지정하는 맞춤 대상 값(예: https://api.openai.com/v1)을 입력하세요. 이 값은 토큰의 aud 클레임과 일치해야 합니다.
-
Google OIDC 검색을 사용하세요. 토큰 검증에 업로드한 JWKS 사용 옵션은 꺼 두세요. OpenAI는 Google의 OIDC 검색 메타데이터와 JWKS를 사용해 Google이 서명한 ID 토큰을 검증합니다.
-
기존 클레임에서 매핑 속성을 도출해야 한다면 속성 변환을 추가하세요. 예를 들어 속성 이름에 subject, 표현식에 assertion.sub를 입력하면 주체 클레임에서 openai.subject를 만들 수 있습니다. 대시보드는 openai. 접두사를 자동으로 붙입니다. 원본 토큰 클레임의 이름이 이미 openai.로 시작하더라도 해당하는 변환을 설정하지 않으면 openai. 매핑 키에 그 클레임이 사용되지 않습니다.
-
서비스 계정 매핑을 만드세요. 이름 필드에 compute-openai-wif처럼 해당 워크로드 아이덴티티 공급자 내에서 고유한 값을 입력하세요. 설명 필드에는 Production Compute Engine OpenAI API workload처럼 어떤 워크로드가 이 매핑을 사용할 수 있는지 적으세요.
-
값이 변하지 않는 Google 서비스 계정 클레임을 매핑 조건으로 지정하세요. 일치해야 하는 클레임마다 행을 하나씩 추가하고 키 필드와 값 필드를 채우세요. sub는 값이 변하지 않고 고유하므로 서비스 계정을 식별하는 기본 기준으로 사용하세요. 계정을 쉽게 알아볼 수 있도록 email을 추가 조건으로 지정해도 됩니다.
-
OpenAI 대상을 선택하세요. 프로젝트 필드에서 대상 서비스 계정이 속한 OpenAI 프로젝트를 선택하세요. 서비스 계정 필드에서는 google-workload-identity-prod-openai-wif처럼 Google Cloud 워크로드가 사용할 수 있는 OpenAI 서비스 계정을 선택하세요.
-
필요하면 API 권한을 제한하세요. 권한 필드에서 api.model.request, api.vector_store.read 등 적절한 권한을 선택하면 이 매핑을 통해 발급되는 액세스 토큰의 권한 범위를 더 좁힐 수 있습니다. WIF에만 적용되는 범위 제한을 추가하지 않으려면 이 필드를 비워 두세요. 이 경우에도 토큰에는 매핑된 서비스 계정의 권한이 적용됩니다.
OpenAI SDK 클라이언트가 메타데이터 서버에 Google ID 토큰을 요청하고 이를 OpenAI가 발급하는 액세스 토큰으로 교환하도록 구성하세요.
OPENAI_WIF_AUDIENCE에 워크로드 아이덴티티 공급자의 대상으로 설정한 맞춤 대상 값을 지정하세요. SDK는 해당 대상에 대한 Google ID 토큰을 요청해 OpenAI가 발급하는 액세스 토큰으로 교환하고, 이 OpenAI 토큰으로 API 요청을 인증합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59import 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48import 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108package 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())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101import 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()));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74require "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)
GKE가 발급한 프로젝션된 서비스 계정 토큰을 단기 OpenAI 액세스 토큰으로 교환하면 Google Kubernetes Engine을 워크로드 아이덴티티 공급자로 사용할 수 있습니다.
GKE 워크로드는 다음 두 가지 토큰 중 하나로 인증할 수 있습니다.
- 클러스터의 OIDC 발급자가 발급한 프로젝션된 Kubernetes 서비스 계정 토큰
- GKE 워크로드 아이덴티티를 통해 가져온 Google 서비스 계정 ID 토큰. 이 방식에서는 Kubernetes 서비스 계정을 Google 서비스 계정에 바인딩합니다.
OpenAI가 클러스터의 OIDC 발급자를 직접 신뢰하도록 하려면 프로젝션된 Kubernetes 서비스 계정 토큰을 사용하세요. 워크로드가 이미 Google 서비스 계정 ID를 사용하고 있고 OpenAI가 Google에서 발급한 ID 토큰을 신뢰하도록 하려면 GKE 워크로드 아이덴티티를 사용하세요.
GKE 워크로드에 GKE 워크로드 아이덴티티가 구성되어 있고 메타데이터 서버에 Google ID 토큰을 요청할 수 있다면, GKE 프로젝션 토큰 플로우 대신 위의 Google 워크로드 아이덴티티 절차를 따르세요.
다음 절차는 관리형 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 워크로드 아이덴티티 공급자에 설정하는 발급자는 이 발급자 URL 및 프로젝션된 GKE 서비스 계정 토큰의 iss 클레임과 일치해야 합니다.
프로젝션된 서비스 계정 토큰에 OpenAI가 요구하는 대상 값과 워크로드에 적합한 유효 기간을 설정하세요. OpenAI는 토큰의 발급자, 서명, 대상, 만료 시간을 검증합니다. 다음 예시에서 토큰 파일은 /var/run/secrets/tokens/token에 마운트됩니다. 토큰의 대상은 https://api.openai.com/v1이고 유효 기간은 3600초입니다. 프로젝션된 토큰의 대상과 OpenAI 워크로드 아이덴티티 공급자의 대상이 일치하면 다른 대상 값도 사용할 수 있습니다.
12345678910111213141516171819202122apiVersion: 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
워크로드 아이덴티티 제휴를 구성하기 전에 프로젝션된 서비스 계정 토큰 하나를 로컬에서 디코딩해 클레임을 확인하세요. 프로젝션된 토큰이 마운트되어 있는 실행 중인 파드에서 토큰을 가져와 환경 변수 TOKEN으로 내보내세요.
TOKEN=$(kubectl exec -n default openai-wif-app -- cat /var/run/secrets/tokens/token)
export TOKEN
그런 다음 아래 스크립트를 실행하세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18const 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69package 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))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77// 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]));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59using 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]));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26require "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 페이로드를 디코딩합니다. 프로덕션 토큰은 로컬 디코더로 확인하고 타사 도구에 붙여 넣지 마세요.
프로젝션된 GKE 서비스 계정 토큰을 디코딩하면 다음과 비슷한 결과가 나옵니다.
1234567891011121314{
"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에 설정한 발급자, 대상, 매핑 값과 비교하세요. 토큰을 교환하기 전에 iss, aud, sub 클레임을 확인하면 대부분의 구성 문제를 찾을 수 있습니다.
OpenAI에서 GKE 발급자에 사용할 워크로드 아이덴티티 공급자를 만든 다음, 프로젝션된 토큰의 속성을 기준으로 서비스 계정 매핑을 추가하세요.
먼저 워크로드 아이덴티티 공급자를 구성한 후 서비스 계정 매핑을 만드세요.
-
워크로드 아이덴티티 공급자를 만드세요. 이름 필드에 google-gke-prod처럼 고유한 값을 입력하세요. 설명 필드에는 Production GKE cluster처럼 관리자가 클러스터를 식별하는 데 도움이 되는 내용을 입력하세요.
-
발급자와 대상을 설정하세요. OIDC 발급자 URL 필드에 kubectl get --raw /.well-known/openid-configuration | jq -r .issuer 명령어가 반환한 발급자 URL을 입력하세요. 이 값은 프로젝션된 GKE 서비스 계정 토큰의 iss 클레임과 일치해야 합니다. 대상 필드에는 프로젝션된 서비스 계정 토큰 볼륨에 설정한 것과 같은 대상 값을 입력하세요. 이 예시에서는 https://api.openai.com/v1을 사용합니다.
-
GKE OIDC 검색을 사용하세요. 토큰 검증에 업로드한 JWKS 사용 옵션은 꺼 두세요. OpenAI는 GKE 발급자의 OIDC 검색 메타데이터와 JWKS를 사용해 프로젝션된 서비스 계정 토큰을 검증합니다.
-
기존 클레임에서 매핑 속성을 도출해야 한다면 속성 변환을 추가하세요. 예를 들어 속성 이름에 gke_subject, 표현식에 assertion.sub를 입력하면 openai.gke_subject를 만들 수 있습니다. 대시보드는 openai. 접두사를 자동으로 붙입니다. 원본 토큰 클레임의 이름이 이미 openai.로 시작하더라도 해당하는 변환을 설정하지 않으면 openai. 매핑 키에 그 클레임이 사용되지 않습니다.
-
서비스 계정 매핑을 만드세요. 이름 필드에 default-openai-wif처럼 해당 워크로드 아이덴티티 공급자 내에서 고유한 값을 입력하세요. 설명 필드에는 Default namespace GKE OpenAI API workload처럼 어떤 워크로드가 이 매핑을 사용할 수 있는지 적으세요.
-
GKE 서비스 계정의 주체를 매핑 조건으로 지정하세요. 키 필드에 sub를, 값 필드에 system:serviceaccount:default:openai-wif를 입력하세요. GKE 서비스 계정의 주체 형식은 system:serviceaccount:<namespace>:<service-account-name>입니다.
-
OpenAI 대상을 선택하세요. 프로젝트 필드에서 대상 서비스 계정이 속한 OpenAI 프로젝트를 선택하세요. 서비스 계정 필드에서는 google-gke-prod-openai-wif처럼 GKE 워크로드가 사용할 수 있는 OpenAI 서비스 계정을 선택하세요.
-
필요하면 API 권한을 제한하세요. 권한 필드에서 api.model.request, api.vector_store.read 등 적절한 권한을 선택하면 이 매핑을 통해 발급되는 액세스 토큰의 권한 범위를 더 좁힐 수 있습니다. WIF에만 적용되는 범위 제한을 추가하지 않으려면 이 필드를 비워 두세요. 이 경우에도 토큰에는 매핑된 서비스 계정의 권한이 적용됩니다.
OpenAI SDK 클라이언트가 프로젝션된 GKE 서비스 계정 토큰을 읽고 이를 OpenAI가 발급하는 액세스 토큰으로 교환하도록 구성하세요.
SDK의 워크로드 아이덴티티 제휴 공급자가 주체 토큰을 가져올 위치로 /var/run/secrets/tokens/token처럼 토큰 파일이 마운트된 경로를 지정하세요. SDK는 해당 GKE 토큰을 OpenAI가 발급하는 액세스 토큰으로 교환하고, 이 OpenAI 토큰으로 API 요청을 인증합니다.
다음 예시에서는 맞춤 주체 토큰 공급자로 OpenAI 클라이언트를 초기화합니다. 공급자는 마운트된 파일 경로에서 프로젝션된 GKE 서비스 계정 토큰을 읽어 워크로드 아이덴티티 제휴의 주체 토큰으로 사용합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40import { 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33import 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69package 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())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77import 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()));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49require "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)