Idira Secure Workload Access(SWA)を使用すると、OpenAI API キーを保存せずに OpenAI API を呼び出せます。SWA はワークロードに SPIFFE ID を割り当て、その ID を表す署名付きトークンである JWT-SVID を発行します。OpenAI SDK は、このトークンを有効期間の短い OpenAI アクセストークンと交換します。このアクセストークンには、マッピング先のサービスアカウントの権限が適用されます。
このガイドでは OpenAI API の設定手順を説明します。Codex を使用する場合は、このガイドの手順に従ってワークロードにトークンを渡してから、Codex でのワークロード ID の使用に進んでください。
以下が必要です。
- トラストドメインと、ワークロードに所定の SPIFFE ID を割り当てるアテステーションポリシーを設定した SWA 環境
- ワークロードから SWA Agent の Workload API ソケットにアクセスできること
- OpenAI 組織でワークロード ID プロバイダー、サービスアカウントのマッピング、プロジェクト、サービスアカウントを管理する権限
- ワークロード ID の認証情報に対応した OpenAI SDK
Idira の OpenAI 連携ガイドに記載されている前提条件に従って、SWA インフラのセットアップを完了してください。
トラストドメインの jwt.discovery_endpoints.oidc_discovery_url と jwt.discovery_endpoints.jwks_uri を取得します。これらの値の確認方法は、Idira の OIDC 設定手順を参照してください。
OpenAI が認証情報を使わずに、HTTPS 経由でディスカバリードキュメントと JWKS にアクセスできる必要があります。発行者 URL は、トークンの iss クレームに含まれる HTTPS URL です。sub に含まれる SPIFFE ID はワークロードを識別します。
OpenAI へのアクセス専用のオーディエンスを選びます。たとえば https://api.openai.com/v1 を使用できます。JWT-SVID をリクエストするときと OpenAI プロバイダーを設定するときには、同じ値を使用します。オーディエンスはトークンの受信者を示す値です。トークン交換のエンドポイントを指定するものではありません。
SWA はデフォルトで RS512 と 4096 ビットの RSA 鍵を使用して署名します。OpenAI の JWT 検証機能はこの組み合わせに対応しています。SWA で使用できるその他の署名オプションについては、Idira の JWT 要件を参照してください。
ワークロード環境に spiffe-helper をインストールし、SWA Agent から JWT-SVID を取得して更新できるようにします。以下の設定を helper.conf として保存します。ヘルパーはトークンを /var/run/spiffe/openai.jwt に書き込みます。このパスは SPIFFE 向けの共通 SDK サンプルでも使用します。
12345678agent_address = "/tmp/swa-agent/public/api.sock"
cert_dir = "/var/run/spiffe"
daemon_mode = true
jwt_svids = [{
jwt_audience = "https://api.openai.com/v1"
jwt_svid_file_name = "openai.jwt"
}]
Kubernetes では、ワークロードの Pod 内でヘルパーをサイドカーとして実行します。メモリを使用する emptyDir ボリュームを /var/run/spiffe で共有し、SWA ソケットの親ディレクトリ /tmp/swa-agent/public をヘルパーにマウントします。VM では /run/swa-agent/api.sock を agent_address に指定し、トークンディレクトリをメモリ上のストレージに配置します。ソケットへのアクセスとアテステーションの設定は、Idira のデプロイ手順に従ってください。
ヘルパーを起動する前にトークンディレクトリを作成し、ヘルパーに書き込み権限を付与します。サイドカーまたは VM のサービスで、以下のコマンドを実行してこの設定でヘルパーを起動します。
spiffe-helper -config helper.conf
トークンファイルとエージェントソケットへのアクセスは、それぞれを必要とするプロセスに限定してください。トークンの有効期限が切れる前にヘルパーが新しいトークンに置き換えられるよう、ヘルパーは稼働させたままにします。
使用する言語に SPIFFE Workload API クライアントがある場合は、ヘルパーを使う代わりに、サブジェクトトークンプロバイダー内で最新の JWT-SVID を取得できます。Idira の連携ガイドで、Workload API を直接使用する方法を参照してください。
ワークロード内で、ローカルのデコーダーが使用する環境変数にトークンを読み込みます。
export TOKEN="$(cat /var/run/spiffe/openai.jwt)"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25const parts = process.env.TOKEN?.split(".") ?? [];
if (parts.length !== 3) {
throw new Error("Expected a compact JWT with three segments");
}
const decode = (segment) => {
if (!/^[A-Za-z0-9_-]+$/.test(segment) || segment.length % 4 === 1) {
throw new Error("JWT segment is not valid Base64URL");
}
const bytes = Buffer.from(segment, "base64url");
if (bytes.toString("base64url") !== segment) {
throw new Error("JWT segment is not valid Base64URL");
}
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const value = JSON.parse(decoded);
if (value === null || Array.isArray(value) || typeof value !== "object") {
throw new Error("JWT segment is not a JSON object");
}
return decoded;
};
console.log("Header:");
console.log(decode(parts[0]));
console.log("\nPayload:");
console.log(decode(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
33import base64
import json
import os
import re
def reject_non_json_constant(value):
raise ValueError(f"JWT segment contains non-JSON constant: {value}")
parts = os.environ.get("TOKEN", "").split(".")
if len(parts) != 3:
raise ValueError("Expected a compact JWT with three segments")
def decode(segment):
if re.fullmatch(r"[A-Za-z0-9_-]+", segment) is None or len(segment) % 4 == 1:
raise ValueError("JWT segment is not valid Base64URL")
padded_segment = segment + "=" * (-len(segment) % 4)
decoded = base64.b64decode(padded_segment, altchars=b"-_", validate=True)
if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != segment:
raise ValueError("JWT segment is not valid Base64URL")
decoded_text = decoded.decode("utf-8")
value = json.loads(decoded_text, parse_constant=reject_non_json_constant)
if not isinstance(value, dict):
raise ValueError("JWT segment is not a JSON object")
return decoded_text
print("Header:")
print(decode(parts[0]))
print("\nPayload:")
print(decode(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
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
84package 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 printJSON(label string, value json.RawMessage) error {
formatted, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
fmt.Printf("%s:\n%s\n", label, formatted)
return nil
}
func main() {
parts := strings.Split(os.Getenv("TOKEN"), ".")
if len(parts) != 3 {
panic("Expected a compact JWT with three segments")
}
header, err := decodeSegment(parts[0])
if err != nil {
panic(err)
}
payload, err := decodeSegment(parts[1])
if err != nil {
panic(err)
}
if err := printJSON("Header", header); err != nil {
panic(err)
}
fmt.Println()
if err := printJSON("Payload", payload); err != nil {
panic(err)
}
}
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// 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 DecodeJwtExample {
private static final ObjectMapper JSON =
new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private DecodeJwtExample() {}
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("Header:");
System.out.println(decodeSegment(parts[0]));
System.out.println("\nPayload:");
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
59
60
61
62using 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("Header:");
Console.WriteLine(DecodeSegment(parts[0]));
Console.WriteLine("\nPayload:");
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
26
27
28
29
30
31
32
33
34require "base64"
require "json"
parts = ENV.fetch("TOKEN", "").split(".", -1)
raise "Expected a compact JWT with three segments" unless parts.length == 3
decode = lambda do |segment|
unless segment.match?(/\A[A-Za-z0-9_-]+\z/) && segment.length % 4 != 1
raise "JWT segment is not valid Base64URL"
end
padded = segment.ljust((segment.length + 3) & ~3, "=")
begin
decoded = Base64.urlsafe_decode64(padded)
rescue ArgumentError
raise "JWT segment is not valid Base64URL"
end
unless Base64.urlsafe_encode64(decoded, padding: false) == segment
raise "JWT segment is not valid Base64URL"
end
decoded.force_encoding(Encoding::UTF_8)
raise "JWT segment is not valid UTF-8" unless decoded.valid_encoding?
value = JSON.parse(decoded)
raise "JWT segment is not a JSON object" unless value.is_a?(Hash)
decoded
end
puts("Header:")
puts(decode.call(parts[0]))
puts("\nPayload:")
puts(decode.call(parts[1]))
OpenAI プロバイダーを作成する前に、以下のフィールドを確認します。
| フィールド |
期待する値 |
ヘッダーの alg |
設定した署名アルゴリズム(例:RS512)。 |
ヘッダーの kid |
トラストドメインの公開 JWKS に含まれる署名鍵の ID。 |
iss |
トラストドメインの HTTPS 発行者 URL。 |
sub |
このワークロードに割り当てられた SPIFFE ID。 |
aud |
OpenAI へのアクセス用に選んだオーディエンスが含まれていること。 |
iat と exp |
トークンの発行時刻と有効期限。有効期限内のトークンを使用してください。 |
デコーダーは署名を検証せずにクレームを読み取ります。トークンをログに記録したり、ウェブ上のデコーダーに貼り付けたりしないでください。確認後は TOKEN の設定を解除してください。
組織設定 > セキュリティ > ワークロード ID プロバイダーに移動し、以下の設定でプロバイダーを作成します。
| 設定項目 |
値 |
| 名前 |
一意の名前(例:idira-prod)。 |
| 説明 |
このプロバイダーを使用するアプリケーションと環境を記載します。 |
| OIDC 発行者 URL |
JWT-SVID の iss の値。 |
| オーディエンス |
ヘルパーがリクエストしたオーディエンス(例:https://api.openai.com/v1)。 |
| アップロードした JWKS をトークンの検証に使用 |
OIDC ディスカバリーを使用するため、オフのままにします。 |
| 属性変換 |
sub から直接マッピングする場合は空欄にします。 |
OpenAI API のトークン交換では、発行者 URL を比較する際に末尾のスラッシュを無視します。たとえば、https://issuer.example.com と https://issuer.example.com/ は一致します。スキームやパスを含め、それ以外の文字はすべて一致する必要があります。オーディエンスの値は完全に一致する必要があります。設定に一貫性を持たせるため、発行されたトークンの発行者 URL をそのままコピーしてください。
OpenAI からディスカバリードキュメントまたは JWKS にアクセスできない場合は、プロバイダーの共通設定手順に従って公開 JWKS をアップロードしてください。SWA が署名鍵をローテーションしたら、アップロードした鍵も更新してください。
- プロバイダーでサービスアカウントのマッピングを作成し、
idira-production-workload などの名前を付けます。
- キーに
sub を指定し、値にワークロードのトークンに含まれる SPIFFE ID をそのまま指定します(例:spiffe://prod.example.com/k8s-nodegroup/ns/my-namespace/sa/my-service-account)。
- ワークロードが使用できるプロジェクトとサービスアカウントを選択します。
- ワークロードに必要な権限を選択します。たとえば、モデルへのリクエストには
api.model.request を選択します。ここでサービスアカウントのアクセス範囲を狭めることはできますが、元の範囲を超える権限は付与できません。権限を空欄にすると、WIF 固有の制限は追加されません。
アプリケーションと環境の組み合わせごとに、別のサービスアカウントを使用してください。SPIFFE ID は完全一致で指定することを推奨します。同じプレフィックスを持つすべての ID に同じアクセス権を付与する場合に限り、末尾にワイルドカードを使用してください。マッピングの条件が重複しないようにしてください。詳しくは、SPIFFE のサービスアカウントマッピングを参照してください。
OpenAI プロバイダーの ID と選択したサービスアカウントの ID を、ワークロードの環境変数に設定します。
export OPENAI_IDENTITY_PROVIDER_ID="<workload identity provider ID>"
export OPENAI_SERVICE_ACCOUNT_ID="<service account ID>"
以下の SPIFFE 共通サンプルでは /var/run/spiffe/openai.jwt を読み取ります。SDK がトークン交換で OpenAI アクセストークンを取得または更新するたびに、サブジェクトトークンプロバイダーはその時点のトークンを返します。外部の JWT-SVID は SWA とヘルパーが更新し、OpenAI アクセストークンは OpenAI SDK が管理します。
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/spiffe/openai.jwt";
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 spiffeJwtSvidProvider(path) {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The SPIFFE JWT-SVID file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: spiffeJwtSvidProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from SPIFFE 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/spiffe/openai.jwt"
def spiffe_jwt_svid_provider(token_path: str) -> SubjectTokenProvider:
def get_token() -> str:
token = Path(token_path).read_text().strip()
if not token:
raise RuntimeError("The SPIFFE JWT-SVID 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": spiffe_jwt_svid_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from SPIFFE 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/spiffe/openai.jwt"
type spiffeJWTSVIDProvider struct {
path string
}
func (p spiffeJWTSVIDProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p spiffeJWTSVIDProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "spiffe",
Message: "failed to read SPIFFE JWT-SVID",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "spiffe",
Message: "SPIFFE JWT-SVID file 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: spiffeJWTSVIDProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from SPIFFE 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
75import 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 SpiffeWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/spiffe/openai.jwt";
private SpiffeWorkloadIdentityExample() {}
static final class SpiffeJwtSvidProvider implements SubjectTokenProvider {
private final Path tokenPath;
SpiffeJwtSvidProvider(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("spiffe", "failed to read SPIFFE JWT-SVID", e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException("spiffe", "SPIFFE JWT-SVID file 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 SpiffeJwtSvidProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from SPIFFE 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/spiffe/openai.jwt"
class SpiffeJWTSVIDProvider
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: "SPIFFE JWT-SVID file is empty",
provider: "spiffe"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read SPIFFE JWT-SVID: #{e.message}",
provider: "spiffe",
cause: e
)
end
end
provider = SpiffeJWTSVIDProvider.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 SPIFFE workload identity federation."
)
puts(response.output_text)
アテステーション済みのワークロード内で、OPENAI_API_KEY を設定していない状態でサンプルを実行します。リクエストが成功すると、モデルの応答が出力されます。ヘルパーが JWT-SVID を置き換え、SDK が OpenAI アクセストークンを更新した後も、リクエストが引き続き成功することを確認してください。
| 症状 |
確認事項 |
| ヘルパーがワークロード ID を取得できない |
ワークロードが SWA のアテステーションポリシーに適合し、エージェントソケットにアクセスできることを確認します。確認はワークロードの実行コンテキストで行ってください。 |
| トークンファイルが存在しない、空である、またはトークンの有効期限が切れている |
ヘルパーが稼働していること、ヘルパーとワークロードで共有ボリュームとファイルパスが一致していること、ワークロードがファイルを読み取れることを確認します。各ホストの時計は同期させてください。 |
| トークンの検証に失敗する |
iss、aud、kid、トークンの有効期限を確認し、ディスカバリードキュメントと JWKS にアクセスできるか確認します。発行者 URL は前述のルールに従って比較してください。 |
| 一致するサービスアカウントのマッピングがない |
プロバイダー ID とサービスアカウント ID が正しいこと、マッピングが有効であること、sub の値が完全に一致することを確認します。 |
| 複数のマッピングが一致する |
一致する有効なマッピングがちょうど 1 つになるように、重複するマッピングの条件を絞り込むか、該当するマッピングを無効にします。 |
| トークン交換は成功するが、API がリクエストを拒否する |
マッピング先のサービスアカウントのプロジェクトと権限、および適用される API アクセスポリシーを確認します。 |
SWA のデプロイ環境で問題を診断する方法は、Idira の連携に関するトラブルシューティングを参照してください。