將投射的 Kubernetes 服務帳戶 Token 交換為短效的 OpenAI 存取權杖,即可使用 Kubernetes 作為工作負載身分提供者。
若使用 Codex,請依照本頁取得並檢查投射的 Token,然後設定 Codex 工作負載身分,讓 Codex 指向已掛載的 Token 檔案。本頁的服務帳戶對應與 SDK 範例適用於 OpenAI API。
本指南假設已啟用 Kubernetes 服務帳戶 Token 投射功能;新版 Kubernetes 預設提供此功能。OpenAI 工作負載身分聯合需要相容於 OIDC 的投射式服務帳戶 Token,不支援儲存在 Secrets 中的舊式 Kubernetes 服務帳戶 Token。
為需要呼叫 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 探索,此簽發者仍必須與工作負載身分提供者中設定的簽發者一致。
取得叢集的 JWKS,並儲存傳回的金鑰集。設定工作負載身分提供者時會用到:
kubectl get --raw /openid/v1/jwks
為投射式服務帳戶 Token 設定 OpenAI 預期的對象,以及適合工作負載的有效期限。OpenAI 會驗證 Token 的簽發者、簽章、對象和有效期限。在此範例中,Token 檔案掛載於 /var/run/secrets/tokens/token,使用的對象為 https://api.openai.com/v1,並在 3600 秒後到期。您也可以使用其他對象,只要投射式 Token 的對象與 OpenAI 工作負載身分提供者的對象一致即可:
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
設定工作負載身分聯合之前,請先在本機解碼一個投射式服務帳戶 Token 範例,並檢查其宣告。從已掛載投射式 Token 且正在執行的 Pod 中取得 Token,並將其匯出為 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 承載資料,但不會驗證 Token 簽章。請使用本機解碼器處理正式環境的 Token,並避免將正式環境的 Token 貼入第三方工具。
解碼後的 Kubernetes 投射式服務帳戶 Token 會類似以下內容:
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"
}
}
}
使用解碼後的承載資料,將收到的 Token 與 OpenAI 中設定的簽發者、對象和對應值進行比對。在交換 Token 之前,就能從 iss、aud 和 sub 宣告中看出大多數組態問題。
在 OpenAI 中為 Kubernetes 簽發者建立工作負載身分提供者,然後新增服務帳戶對應,讓對應條件符合投射式 Token 中的屬性。
請先設定工作負載身分提供者,再建立服務帳戶對應。
-
建立工作負載身分提供者。 將 名稱 設為唯一值,例如 kubernetes-prod。使用 說明協助管理員識別叢集,例如 Production Kubernetes cluster。
-
設定簽發者和對象。 將 OIDC 簽發者 URL 設為 kubectl get --raw /.well-known/openid-configuration | jq -r .issuer 傳回的簽發者。此值必須與投射式 Token 中的 iss 宣告一致。將 對象 設為投射式服務帳戶 Token 磁碟區中設定的相同不透明對象字串。在此範例中,該值為 https://api.openai.com/v1。
-
上傳 Kubernetes JWKS。 啟用 使用已上傳的 JWKS 驗證 Token,然後將 JWKS JSON 設為 kubectl get --raw /openid/v1/jwks 的輸出。OpenAI 會使用此公開金鑰集驗證投射的 Kubernetes 服務帳戶 Token。請上傳完整的金鑰集,包括外層的 keys。
注意: 對於自行託管的 Kubernetes 叢集,OpenAI 僅支援本機 JWKS 模式。請上傳叢集傳回的 JWKS;OpenAI 不會向設定的簽發者執行 OIDC 探索。OpenAI 仍會將設定的簽發者與 Token 中的 iss 欄位進行比對。
如果叢集輪替了服務帳戶簽署金鑰,請更新工作負載身分提供者組態中已上傳的 JWKS。若簽署 Token 的金鑰未列於設定的 JWKS 中,該 Token 就會遭到拒絕。如果 JWKS 包含多個使用中的公開金鑰,請包含完整的 keys 陣列。
-
僅在需要衍生的對應屬性時,才新增屬性轉換。 原始 Token 宣告(例如 sub、aud 和 iss)可直接用於對應判斷條件。如果您打算使用轉換後的屬性,而非原始 Token 宣告來比對,儀表板會自動加上 openai. 前綴;例如,輸入 workload_subject 並使用運算式 assertion.sub,即可建立 openai.workload_subject。對於 openai. 對應鍵,除非已設定相符的轉換,否則會忽略原本就以 openai. 開頭的原始 Token 宣告。
-
建立服務帳戶對應。 將 名稱 設為在該工作負載身分提供者內唯一的值,例如 openai-mapping-kubernetes。使用 說明指出哪些工作負載可以使用此對應,例如 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 專屬的範圍限制,請將權限留空;Token 仍會以對應的服務帳戶身分取得授權。
設定 OpenAI SDK 用戶端,讓它讀取投射的 Kubernetes Token,並將其交換為 OpenAI 簽發的存取權杖。
使用掛載的 Token 路徑(例如 /var/run/secrets/tokens/token),作為 SDK 工作負載身分聯合提供者的主體 Token 來源。SDK 會將該 Kubernetes Token 交換為 OpenAI 簽發的存取權杖,再使用 OpenAI Token 驗證 API 請求的身分。
以下範例使用自訂的主體 Token 提供者來初始化 OpenAI 用戶端。此提供者會從掛載的檔案路徑讀取投射的 Kubernetes 服務帳戶 Token,並將其用作工作負載身分聯合的主體 Token。
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 必須與投射式服務帳戶 Token 的
iss 宣告一致,且應在叢集升級和維護作業期間保持不變。
- 妥善保護簽署金鑰。任何能存取叢集服務帳戶簽署金鑰的人,都能簽發可能被 OpenAI 接受的 Token。
- 為 OpenAI 整合使用專用的服務帳戶。避免重複使用同時用於存取其他無關基礎架構或應用程式的服務帳戶。
- 確保已上傳的 JWKS 維持最新狀態。在本機 JWKS 模式下,OpenAI 會使用設定的 JWKS 驗證工作負載身分 Token,因此請在輪替至新的簽署金鑰之前,先更新工作負載身分提供者。
- 盡量降低自訂宣告的複雜度。優先使用標準宣告(例如
sub 和 aud)進行比對,或使用直接由這些宣告轉換而來的屬性。
- 將命名空間的擁有權納入安全性模型。如果命名空間管理員可以建立服務帳戶,請確保對應的範圍設定得當,以防止非預期的權限提升。
- 監控簽發者與簽署金鑰的變更。如果輪替簽署金鑰卻未更新工作負載身分提供者的 JWKS,可能會導致 Token 交換失敗。