Kubernetes をワークロード ID プロバイダーとして使用するには、Pod に投影された Kubernetes サービスアカウントトークンを、有効期間の短い OpenAI アクセストークンと交換します。
このガイドでは、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 の各クレームを確認することで見つけられます。
OpenAI で Kubernetes の発行者に対応するワークロード ID プロバイダーを作成し、投影されたトークンの属性に一致するサービスアカウントマッピングを追加します。
先にワークロード 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 ディスカバリを実行しません。このモードでも、OpenAI は設定された発行者とトークンの 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 ワークロードが使用できる OpenAI サービスアカウント(例:kubernetes-prod-openai-wif)を指定します。既存のサービスアカウントを再利用せず、このマッピング用に新規作成する場合は、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 クレームと一致している必要があります。クラスターのアップグレードやメンテナンスの際にも、同じ URL を維持してください。
- 署名鍵は厳重に保護してください。クラスターのサービスアカウント署名鍵にアクセスできる人は誰でも、OpenAI が受け入れる可能性のあるトークンを発行できます。
- OpenAI との連携には専用のサービスアカウントを使用してください。無関係なインフラやアプリケーションへのアクセスにも使われているサービスアカウントは再利用しないでください。
- アップロードした JWKS を最新の状態に保ってください。ローカル JWKS モードでは、OpenAI は設定済みの JWKS を使ってワークロード ID トークンを検証します。新しい署名鍵に切り替える前に、ワークロード ID プロバイダーの JWKS を更新してください。
- カスタムクレームはできるだけ単純にしてください。照合には、
sub や aud などの標準クレームか、それらのクレームから直接導出した変換後の属性を優先して使用してください。
- 名前空間を誰が管理するかをセキュリティモデルに組み込んでください。名前空間の管理者がサービスアカウントを作成できる場合は、意図しない権限昇格を防ぐため、マッピングの適用範囲を適切に制限してください。
- 発行者と署名鍵の変更を監視してください。ワークロード ID プロバイダーの JWKS を更新せずに署名鍵をローテーションすると、トークンの交換に失敗することがあります。