射影された Kubernetes サービスアカウントトークンを有効期間の短い OpenAI アクセストークンと交換することで、Kubernetes をワークロード ID プロバイダーとして使用できます。
Codex を使用する場合は、このページの手順で射影されたトークンを取得し、内容を確認します。その後、Codex のワークロード ID を構成し、マウントされたトークンファイルを Codex の参照先に指定します。このページのサービスアカウントマッピングと SDK の例は、OpenAI API 向けのものです。
このガイドでは、Kubernetes のサービスアカウントトークンの射影が有効になっていることを前提としています。この機能は、最近の Kubernetes リリースではデフォルトで利用できます。OpenAI のワークロード ID フェデレーションには、OIDC に準拠した射影サービスアカウントトークンが必要です。Secret に保存される従来の Kubernetes サービスアカウントトークンはサポートされていません。
OpenAI API を呼び出す必要があるワークロードには、Kubernetes の ServiceAccount を使用します。まだない場合は、次のように作成します。
kubectl create serviceaccount openai-wif --namespace default
Kubernetes クラスターの OIDC 発行者を取得します。
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
JWKS をアップロードし、OpenAI が OIDC 発行者に対して JWKS ディスカバリーを行わない場合でも、この発行者はワークロード ID プロバイダーに設定された発行者と一致する必要があります。
クラスターの JWKS を取得し、返された鍵セットを保存します。ワークロード ID プロバイダーの構成時に必要になります。
kubectl get --raw /openid/v1/jwks
射影されるサービスアカウントトークンに、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: ksa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: ksa-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 ペイロードをデコードします。本番環境のトークンにはローカルのデコーダーを使用し、サードパーティーのツールに貼り付けないでください。
射影された Kubernetes サービスアカウントトークンをデコードすると、次のような内容になります。
1234567891011121314{
"iss": "https://kubernetes.example.com",
"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 クレームで確認できます。
Kubernetes の発行者に対応するワークロード ID プロバイダーを OpenAI で作成し、射影されたトークンの属性に一致するサービスアカウントマッピングを追加します。
まずワークロード ID プロバイダーを構成し、その後でサービスアカウントマッピングを作成します。
-
ワークロード ID プロバイダーを作成します。 名前 には、kubernetes-prod などの一意の値を設定します。管理者がクラスターを識別しやすいように、 説明には Production Kubernetes cluster などを入力します。
-
発行者とオーディエンスを設定します。 OIDC 発行者 URL には、kubectl get --raw /.well-known/openid-configuration | jq -r .issuer で返された発行者を設定します。この値は、射影されたトークンの iss クレームと一致する必要があります。 オーディエンス には、射影サービスアカウントトークンのボリュームに設定したものと同じ、不透明なオーディエンス文字列を設定します。この例では、その値は https://api.openai.com/v1 です。
-
Kubernetes の JWKS をアップロードします。 アップロードした JWKS をトークンの検証に使用を有効にし、 JWKS JSON に kubectl get --raw /openid/v1/jwks の出力を設定します。OpenAI はこの公開鍵セットを使用して、射影された Kubernetes サービスアカウントトークンを検証します。鍵セットを格納する keys も含めて、鍵セット全体をアップロードしてください。
注: セルフホスト型の Kubernetes クラスターでは、OpenAI はローカル JWKS モードのみをサポートしています。クラスターが返す JWKS をアップロードしてください。OpenAI は、設定された発行者に対して OIDC ディスカバリーを行いません。ただし、設定された発行者とトークンの iss フィールドの比較は行います。
クラスターでサービスアカウントの署名鍵をローテーションする場合は、ワークロード ID プロバイダーの構成で、アップロード済みの JWKS を更新してください。設定された JWKS に存在しない鍵で署名されたトークンは拒否されます。JWKS に有効な公開鍵が複数含まれている場合は、keys 配列全体を含めてください。
-
マッピング用の派生属性が必要な場合にのみ、属性変換を追加します。 sub、aud、iss など、トークンの元のクレームは、マッピングのアサーションでそのまま使用できます。元のクレームではなく変換後の属性を照合に使う場合、ダッシュボードが openai. プレフィックスを自動的に付加します。たとえば、属性名に workload_subject、式に assertion.sub を入力すると、openai.workload_subject が作成されます。もともと openai. で始まるトークンのクレームは、対応する変換が設定されていない限り、openai. で始まるマッピングキーでは無視されます。
-
サービスアカウントマッピングを作成します。 名前 には、openai-mapping-kubernetes など、ワークロード ID プロバイダー内で一意の値を設定します。 説明には Workload Identity Provider Mapping for Kubernetes Workloads などを入力し、どのワークロードがこのマッピングを使用できるかを記述します。
-
Kubernetes サービスアカウントのサブジェクトを照合します。 キー を sub、 値 を system:serviceaccount:default:openai-wif に設定します。Kubernetes サービスアカウントのサブジェクトの形式は system:serviceaccount:<namespace>:<service-account-name> です。
-
OpenAI 側の対象を選択します。 プロジェクト には、対象のサービスアカウントが属する OpenAI プロジェクトを設定します。 サービスアカウント には、kubernetes-prod-openai-wif など、Kubernetes ワークロードが使用できる OpenAI サービスアカウントを設定します。既存のサービスアカウントを再利用せず、このマッピング用に新しく作成する場合は、Create a new service account in this project にチェックを入れます。
-
必要に応じて API 権限を絞り込みます。 api.model.request や api.vector_store.read などの適切な 権限 を選択し、このマッピングから発行されるアクセストークンの権限をさらに制限します。WIF 固有のスコープ制限を追加しない場合は、権限を空欄にします。その場合も、トークンはマッピング先のサービスアカウントとして認可されます。
射影された Kubernetes トークンを読み取り、OpenAI が発行するアクセストークンと交換するように、OpenAI SDK クライアントを構成します。
SDK のワークロード ID フェデレーションプロバイダーでは、/var/run/secrets/tokens/token などのマウントされたトークンのパスを、サブジェクトトークンの取得元として使用します。SDK はその Kubernetes トークンを OpenAI が発行するアクセストークンと交換し、OpenAI のトークンで API リクエストを認証します。
次の例では、カスタムのサブジェクトトークンプロバイダーを使用して OpenAI クライアントを初期化します。このプロバイダーは、マウントされたファイルのパスから射影された Kubernetes サービスアカウントトークンを読み取り、ワークロード 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 mountedServiceAccountTokenProvider(path) {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The mounted service account token file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: mountedServiceAccountTokenProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from Kubernetes 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_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 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_service_account_token_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from Kubernetes 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 mountedServiceAccountTokenProvider struct {
path string
}
func (p mountedServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p mountedServiceAccountTokenProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "kubernetes",
Message: "failed to read mounted service account token",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "kubernetes",
Message: "mounted 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: mountedServiceAccountTokenProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from Kubernetes 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 KubernetesWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";
private KubernetesWorkloadIdentityExample() {}
static final class MountedServiceAccountTokenProvider implements SubjectTokenProvider {
private final Path tokenPath;
MountedServiceAccountTokenProvider(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(
"kubernetes", "failed to read mounted service account token", e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"kubernetes", "mounted 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 MountedServiceAccountTokenProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from Kubernetes 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 MountedServiceAccountTokenProvider
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 service account token is empty",
provider: "kubernetes"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read mounted service account token: #{e.message}",
provider: "kubernetes",
cause: e
)
end
end
provider = MountedServiceAccountTokenProvider.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 Kubernetes workload identity federation."
)
puts(response.output_text)
- 安定した OIDC 発行者を使用してください。発行者 URL は、射影されたサービスアカウントトークンの
iss クレームと一致する必要があります。また、クラスターのアップグレードやメンテナンスの際にも変更されないことが望まれます。
- 署名鍵を厳重に保護してください。クラスターのサービスアカウント署名鍵にアクセスできる人は誰でも、OpenAI が受け入れる可能性のあるトークンを発行できます。
- OpenAI との連携には専用のサービスアカウントを使用してください。無関係なインフラストラクチャーやアプリケーションへのアクセスにも使われているサービスアカウントの再利用は避けてください。
- アップロード済みの JWKS を最新の状態に保ってください。ローカル JWKS モードでは、OpenAI は設定された JWKS を使用してワークロード ID トークンを検証します。そのため、新しい署名鍵にローテーションする前に、ワークロード ID プロバイダーを更新してください。
- カスタムクレームはできるだけシンプルにしてください。照合には、
sub や aud などの標準クレーム、またはそれらのクレームから直接変換した属性を優先して使用してください。
- 名前空間の所有権もセキュリティモデルの一部として扱ってください。名前空間の管理者がサービスアカウントを作成できる場合は、意図しない権限昇格を防ぐため、マッピングのスコープが適切に設定されていることを確認してください。
- 発行者と署名鍵の変更を監視してください。ワークロード ID プロバイダーの JWKS を更新せずに署名鍵をローテーションすると、トークンの交換に失敗する可能性があります。