SPIFFE JWT-SVID を有効期間の短い OpenAI アクセストークンに交換することで、SPIFFE をワークロード ID プロバイダーとして利用できます。これにより、SPIRE または他の SPIFFE 互換 ID プロバイダーで認証されたワークロードは、長期間有効な API キーを保存せずに OpenAI API を呼び出せます。
Idira Secure Workload Access を使用する場合は、Idira セットアップガイドに従ってエージェントとトークン配信を設定してください。
OpenAI は、発行者、オーディエンス、有効期限、発行日時が含まれ、JWKS の公開鍵で署名を検証できる SPIFFE JWT-SVID を JWT サブジェクトトークンとしてサポートしています。SPIFFE X.509-SVID は、OpenAI のワークロード ID フェデレーションのサブジェクトトークンとしては使用できません。
JWT-SVID 仕様では、sub、aud、exp の各クレームが必須です。OpenAI で JWT-SVID を使用するには、さらに iss と iat の各クレーム、および kid ヘッダーが必要です。OpenAI はこれらを使って、ワークロード ID プロバイダーの設定に照らしてトークンを検証します。
JWT-SVID は OpenID Connect の ID トークンではありません。SPIRE OIDC Discovery Provider は、OpenAI が JWT-SVID を検証するためのディスカバリメタデータと JWKS の公開鍵を提供します。トークンは引き続き SPIFFE 仕様に従って解釈され、OIDC のログインフローは不要です。
SPIFFE の用語とトークンの要件については、SPIFFE の JWT-SVID 仕様と Workload API 仕様を参照してください。
OpenAI API を呼び出すワークロードに JWT-SVID を発行するように、SPIFFE プロバイダーを設定します。ここでは SPIRE の用語を使用しますが、他の SPIFFE 互換プロバイダーでも OpenAI 側の設定は同じです。プロバイダーが発行する JWT-SVID には、OpenAI が検証できる発行者情報と、JWKS の公開鍵で検証できる署名が必要です。
SPIFFE 側では次のものを用意してください。
spiffe://example.org/ns/production/sa/openai-wif など、ワークロードに割り当てる固定の SPIFFE ID
https://api.openai.com/v1 など、OpenAI へのアクセス専用の単一の JWT-SVID オーディエンス(任意の不透明な識別子も使用可能)
- OpenAI での検証に使用する JWT 発行者 URL(JWT-SVID の
iss クレームに含める値)
- JWT-SVID の署名鍵に対応する公開 JWKS(OIDC ディスカバリまたは JWKS のアップロードで提供)
- ワークロード側で SPIFFE Workload API から新しい JWT-SVID を取得する仕組み
オーディエンスは完全一致で照合する識別子です。JWT-SVID を受け取るエンドポイントを指す必要はありません。SPIFFE Workload API へのリクエストと OpenAI のプロバイダー設定で値が一致していれば、https://api.openai.com/v1 やサービス固有の別の値を使用できます。
可能であれば、SPIRE OIDC Discovery Provider を通じて SPIFFE の発行者情報を公開してください。SPIRE Server の jwt_issuer と OIDC Discovery Provider の jwt_issuer の両方に、OpenAI で設定するものと同じ HTTPS 発行者 URL を指定します。
SPIRE Server の設定例です。
1234server {
trust_domain = "example.org"
jwt_issuer = "https://spire-oidc.example.org"
}
SPIRE OIDC Discovery Provider は別途、次のように設定します。
123# Relevant issuer fields only
domains = ["spire-oidc.example.org"]
jwt_issuer = "https://spire-oidc.example.org"
OIDC Discovery Provider では、鍵情報の取得元として server_api、workload_api、file などを設定する必要があります。また、ACME、TLS 証明書、Unix ソケットなどを使ってディスカバリ情報を配信する方法も設定してください。すべての設定項目については、SPIRE OIDC Discovery Provider のドキュメントを参照してください。
SPIFFE のトラストドメインと JWT の発行者は別の概念です。この例では、JWT-SVID のサブジェクトは example.org トラストドメイン内の SPIFFE ID で、発行者は HTTPS の発行者 URL です。
1234{
"sub": "spiffe://example.org/ns/production/sa/openai-wif",
"iss": "https://spire-oidc.example.org"
}
SPIRE OIDC Discovery Provider は、OIDC ディスカバリドキュメントと JWKS エンドポイントを公開します。OpenAI は、アップロードした JWKS をトークン検証に使用が無効の場合にこれらを使用できます。
OpenAI から発行者のディスカバリエンドポイントにアクセスできない場合は、JWKS をアップロードして使用してください。この場合も、OpenAI はワークロード ID プロバイダーに設定された発行者と JWT-SVID の iss クレームを照合します。署名の検証には、ワークロード ID プロバイダーに保存した JWKS JSON を使用します。
注: SPIFFE の JWT-SVID 仕様では、JWT ヘッダーの kid は任意です。一方、OpenAI は設定済みの JWKS から署名鍵を選択するため、JWT サブジェクトトークンに kid ヘッダーが必要です。SPIFFE プロバイダーが kid を省略することがある場合は、OpenAI のワークロード ID フェデレーション用のトークンには必ず含めるように設定してください。
SPIFFE Workload API を呼び出せるワークロードで JWT-SVID の内容を確認するには、OpenAI に設定するものと同じオーディエンスを指定してトークンをリクエストします。Workload API は呼び出し元プロセスの ID に基づいてアクセスを許可するため、次のコマンドはアプリケーションと同じワークロードコンテキストで実行してください。
1234TOKEN=$(spire-agent api fetch jwt \
-socketPath /run/spire/sockets/agent.sock \
-audience "https://api.openai.com/v1" | sed -n '2p')
export TOKEN
ワークロードに複数の SPIFFE ID がある場合は、使用する ID を指定してリクエストします。
12345TOKEN=$(spire-agent api fetch jwt \
-socketPath /run/spire/sockets/agent.sock \
-spiffeID "spiffe://example.org/ns/production/sa/openai-wif" \
-audience "https://api.openai.com/v1" | sed -n '2p')
export TOKEN
ワークロード ID フェデレーションを設定する前に、JWT-SVID を環境変数 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
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]))
どの例も JWT をデコードするだけで、トークンの署名は検証しません。本番環境のトークンはローカルのデコーダーで確認し、サードパーティのツールには貼り付けないでください。
SPIFFE JWT-SVID をデコードすると、次のような内容が表示されます。
1234{
"alg": "ES256",
"kid": "jwt-svid-key-1"
}
1234567{
"iss": "https://spire-oidc.example.org",
"aud": ["https://api.openai.com/v1"],
"sub": "spiffe://example.org/ns/production/sa/openai-wif",
"iat": 1716235422,
"exp": 1716235722
}
受け取ったトークンを交換する前に、デコードした内容を OpenAI の設定と照合してください。ヘッダーでは alg と kid を、ペイロードでは iss、aud、sub、iat、exp を確認します。alg の値は SPIRE Server の JWT 署名鍵の設定によって異なります。
SPIFFE JWT-SVID の発行者に対応するワークロード ID プロバイダーを OpenAI で作成し、信頼する SPIFFE ID に一致するサービスアカウントのマッピングを追加します。
-
ワークロード ID プロバイダーを作成します。 名前には spiffe-prod などの一意の値を設定します。説明には Production SPIFFE workloads など、管理者がプロバイダーを識別しやすい説明を入力します。
-
発行者とオーディエンスを設定します。 OIDC 発行者 URL には、JWT-SVID の iss クレームと完全に一致する値(例:https://spire-oidc.example.org)を設定します。オーディエンスには、SPIFFE Workload API へのリクエストで指定するオーディエンスの値を設定します。この例では https://api.openai.com/v1 です。
-
JWKS の取得元を選択します。 OpenAI から SPIRE OIDC Discovery Provider にアクセスできる場合は、アップロードした JWKS をトークン検証に使用を無効のままにします。OpenAI は OIDC ディスカバリで JWKS を取得し、その公開鍵で JWT-SVID の署名を検証します。
OpenAI から発行者にアクセスできない場合は、アップロードした JWKS をトークン検証に使用を有効にし、JWKS JSON に JWT-SVID の署名鍵に対応する公開鍵セットを設定します。各鍵を格納する keys 配列を含め、公開 JWKS オブジェクト全体をアップロードしてください。秘密鍵の情報は含めないでください。
-
クレームからマッピング用の属性を導出する場合のみ、属性変換を追加します。 sub を直接マッピングする場合、属性変換は不要です。1 つ以上のトークンクレームからマッピングに使用する値を導出する必要がある場合にのみ使用してください。変換の仕組みについては、ワークロード ID フェデレーションのメインガイドを参照してください。
-
サービスアカウントのマッピングを作成します。 名前には、production-openai-wif など、ワークロード ID プロバイダー内で一意の値を設定します。説明には Production SPIFFE workload for OpenAI API access など、このマッピングを使用できるワークロードがわかる説明を入力します。
-
SPIFFE ID を照合します。 キーには sub を、値にはワークロードの SPIFFE ID(例:spiffe://example.org/ns/production/sa/openai-wif)を設定します。
強い権限を持つワークロードには、SPIFFE ID の完全一致を推奨します。末尾にワイルドカードを使用するのは、そのプレフィックスで始まるすべての SPIFFE ID に OpenAI アクセストークンの発行を許可する場合に限ります。たとえば、spiffe://example.org/ns/production/sa/* を指定すると、このパターンに一致する本番環境のサービスアカウントのパスがすべて許可されます。
-
OpenAI 側のマッピング先を選択します。 プロジェクトには、対象のサービスアカウントが属する OpenAI プロジェクトを設定します。サービスアカウントには、spiffe-prod-openai-wif など、SPIFFE ワークロードが使用できる OpenAI サービスアカウントを設定します。既存のサービスアカウントを再利用せず、このマッピング用に新規作成する場合は、Create a new service account in this project にチェックを入れます。
-
必要に応じて API 権限を絞り込みます。 権限で api.model.request や api.vector_store.read などの適切な権限を選択すると、このマッピングで発行されるアクセストークンの権限をさらに制限できます。WIF 固有のスコープ制限を追加しない場合は、権限を空欄にしてください。その場合も、トークンにはマッピング先のサービスアカウントの権限が適用されます。
新しく取得した SPIFFE JWT-SVID を OpenAI が発行するアクセストークンに交換するように、OpenAI SDK クライアントを設定します。
以下の SDK サンプルでは、SPIFFE との連携処理で JWT-SVID を更新し、/var/run/spiffe/openai.jwt に書き込むことを前提としています。ファイルの読み取り権限はワークロードだけに付与してください。JWT-SVID の有効期間は短いため、トークンの有効期限が切れる前にファイルを更新してください。使用する言語の SPIFFE ライブラリが対応していれば、サブジェクトトークンプロバイダー内で SPIFFE Workload API から JWT-SVID を直接取得する方法もあります。これにより、期限切れのトークンファイルを使用せずに済みます。
ワークロードの環境変数 OPENAI_IDENTITY_PROVIDER_ID と OPENAI_SERVICE_ACCOUNT_ID を設定します。トークンファイルには外部サブジェクトトークンが格納されています。OPENAI_IDENTITY_PROVIDER_ID は OpenAI のワークロード ID プロバイダーを、OPENAI_SERVICE_ACCOUNT_ID は対象の OpenAI サービスアカウントを識別します。OpenAI はトークンのクレームに基づいて、そのプロバイダーとサービスアカウントに一致するマッピングを検索します。
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 のワークロード ID フェデレーションには JWT-SVID を使用してください。X.509-SVID は相互 TLS に利用できますが、OpenAI のトークン交換エンドポイントでは受け付けません。
- OpenAI へのアクセス専用のオーディエンスを 1 つ使用してください。トラストドメイン全体や環境名など、対象範囲の広いオーディエンスは避けてください。
- 可能な限り SPIFFE ID を完全一致で照合してください。ワイルドカードによるマッピングは、複数のワークロードで意図的に信頼境界を共有する場合にのみ使用してください。
- ベアラートークンを使ったリプレイ攻撃のリスクを抑えるため、JWT-SVID の有効期間は短く設定してください。OpenAI アクセストークンの有効期限が、交換に使用した外部サブジェクトトークンの有効期限を超えることはありません。
- 署名鍵は慎重にローテーションしてください。切り替え期間中は OIDC ディスカバリで新旧両方の公開鍵を公開してください。公開 JWKS をアップロードしている場合は、新しい
kid を持つ JWT-SVID を発行する前に JWKS を更新してください。
- SPIRE Server とワークロードの時計を同期してください。時刻に大きなずれがあると、本来は有効な JWT-SVID でも、有効期間の開始前、発行からの時間超過、または有効期限切れと判定され、拒否されることがあります。
- SPIFFE Workload API のソケットを保護してください。ワークロードの JWT-SVID を取得できるプロセスは、そのトークンを使って OpenAI アクセストークンへの交換を試みることができます。
- アプリケーションや環境ごとの権限の境界に合わせて、OpenAI サービスアカウントを分けてください。互いに無関係な SPIFFE ワークロード間で、強い権限を持つサービスアカウントを共有しないでください。
- トークン交換の失敗を監視し、発行者、オーディエンス、署名鍵、マッピングの不一致を検出してください。