Google Cloud のワークロードは、有効期間の長いサービスアカウントキーを保存することなく、Google メタデータサーバーから署名付き OIDC ID トークンをリクエストできます。OpenAI のワークロード ID フェデレーションでは、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 が 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 の検証ガイドを参照してください。
ワークロード 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 の各クレームで確認できます。
Google 発行の ID トークン用に OpenAI でワークロード ID プロバイダーを作成し、トークン内の変化しにくいクレームと照合するサービスアカウントマッピングを追加します。
まずワークロード ID プロバイダーを構成し、その後サービスアカウントマッピングを作成します。
-
ワークロード ID プロバイダーを作成します。 名前 には、google-workload-identity-prod などの一意の値を設定します。管理者がプロバイダーを識別しやすいように、 説明には Production Google Cloud workloads などを入力します。
-
発行者とオーディエンスを設定します。 OIDC 発行者 URL を https://accounts.google.com に設定します。 オーディエンス には、https://api.openai.com/v1 など、ワークロードが Google メタデータサーバーへのリクエストで指定するカスタムオーディエンスを設定します。この値は、トークンの aud クレームと一致する必要があります。
-
Google の OIDC ディスカバリを使用します。 アップロードした JWKS をトークンの検証に使用 は無効のままにします。OpenAI は、Google の OIDC ディスカバリメタデータと JWKS を使用して、Google 署名付き ID トークンを検証します。
-
マッピング用の派生属性が必要な場合は、属性変換を追加します。 たとえば、subject と式 assertion.sub を入力すると、サブジェクトクレームから openai.subject を作成できます。ダッシュボードは openai. プレフィックスを自動的に付加します。元のトークンのクレームがすでに openai. で始まる場合、対応する変換が構成されていない限り、openai. マッピングキーではそのクレームが無視されます。
-
サービスアカウントマッピングを作成します。 名前 には、compute-openai-wif など、ワークロード ID プロバイダー内で一意の値を設定します。 説明には Production Compute Engine OpenAI API workload などを入力し、このマッピングを使用できるワークロードを説明します。
-
Google サービスアカウントの変化しにくいクレームを照合します。 一致が必要なクレームごとに、 キー と 値 の行を 1 つ追加します。sub は変化しにくく一意であるため、ID の主要な紐付けに使用します。読みやすくするために、email も照合条件に追加できます。
-
OpenAI 側の対象を選択します。 プロジェクト には、対象のサービスアカウントが属する OpenAI プロジェクトを設定します。 サービスアカウント には、google-workload-identity-prod-openai-wif など、Google Cloud ワークロードが使用できる OpenAI サービスアカウントを設定します。
-
必要に応じて API 権限を絞り込みます。 api.model.request や api.vector_store.read などの適切な 権限 を選択し、このマッピングから発行されるアクセストークンの権限をさらに絞り込みます。WIF 固有のスコープ制限を追加しない場合は、権限を空欄のままにします。その場合も、トークンはマッピング先のサービスアカウントとして認可されます。
メタデータサーバーに Google ID トークンをリクエストし、OpenAI 発行のアクセストークンと交換するように、OpenAI SDK クライアントを構成します。
OPENAI_WIF_AUDIENCE には、ワークロード ID プロバイダーのオーディエンスとして構成したカスタムオーディエンスを設定します。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 をワークロード ID プロバイダーとして使用します。
GKE ワークロードは、次のいずれかを使用して認証できます。
- クラスターの OIDC 発行者が発行した、投影された Kubernetes サービスアカウントトークン
- Kubernetes サービスアカウントを Google サービスアカウントに紐付ける GKE ワークロード ID を通じて取得した、Google サービスアカウントの ID トークン
OpenAI がクラスターの OIDC 発行者を直接信頼するようにする場合は、投影された Kubernetes サービスアカウントトークンを使用します。ワークロードがすでに Google サービスアカウントの ID を使用しており、代わりに Google 発行の ID トークンを OpenAI が信頼するようにする場合は、GKE ワークロード ID を使用します。
GKE ワークロードが GKE ワークロード ID を使用するように構成されており、
メタデータサーバーに Google ID トークンをリクエストできる場合は、上記のGoogle
ワークロード IDの手順に従ってください。
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 のワークロード ID プロバイダーで構成する発行者は、この発行者 URL、および投影された GKE サービスアカウントトークンの iss クレームと一致する必要があります。
投影されたサービスアカウントトークンに、OpenAI が期待するオーディエンスと、ワークロードに適した有効期限を設定します。OpenAI は、トークンの発行者、署名、オーディエンス、有効期限を検証します。この例では、トークンファイルは /var/run/secrets/tokens/token にマウントされ、オーディエンスは https://api.openai.com/v1 で、3600 秒後に失効します。投影トークンのオーディエンスと OpenAI のワークロード ID プロバイダーのオーディエンスが一致していれば、別のオーディエンスを使用することもできます。
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
ワークロード ID フェデレーションを構成する前に、投影されたサービスアカウントトークンのサンプルをローカルでデコードし、クレームを確認します。投影トークンがマウントされている実行中の Pod からトークンを取得し、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 クレームで確認できます。
GKE の発行者用に OpenAI でワークロード ID プロバイダーを作成し、射影されたトークンの属性に一致するサービスアカウントマッピングを追加します。
まずワークロード ID プロバイダーを設定し、その後にサービスアカウントマッピングを作成します。
-
ワークロード ID プロバイダーを作成します。 名前 には、google-gke-prod などの一意の値を設定します。管理者がクラスターを識別しやすいよう、 説明には Production GKE cluster などを入力します。
-
発行者とオーディエンスを設定します。 OIDC 発行者 URL には、kubectl get --raw /.well-known/openid-configuration | jq -r .issuer が返す発行者を設定します。この値は、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 など、ワークロード ID プロバイダー内で一意の値を設定します。 説明には 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 固有のスコープ制限を追加しない場合は、権限を空欄にします。その場合も、トークンはマッピング先のサービスアカウントとして認可されます。
GKE の射影されたサービスアカウントトークンを読み取り、OpenAI が発行するアクセストークンと交換するように OpenAI SDK クライアントを設定します。
SDK のワークロード ID フェデレーションプロバイダーのサブジェクトトークンソースには、/var/run/secrets/tokens/token などのマウントされたトークンのパスを使用します。SDK はその GKE トークンを OpenAI が発行するアクセストークンと交換し、OpenAI トークンを使用して API リクエストを認証します。
次の例では、カスタムのサブジェクトトークンプロバイダーを使って OpenAI クライアントを初期化します。このプロバイダーは、マウントされたファイルパスから GKE の射影されたサービスアカウントトークンを読み取り、ワークロード ID フェデレーションのサブジェクトトークンとして使用します。
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)