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 호환 공급자도 JWT-SVID를 발급하고 OpenAI가 검증에 사용할 수 있는 발급자 정보와 서명 키의 JWKS를 제공한다면 동일한 OpenAI 설정을 적용할 수 있습니다.
SPIFFE 환경에는 다음이 필요합니다.
spiffe://example.org/ns/production/sa/openai-wif처럼 워크로드를 일관되게 식별하는 SPIFFE ID.
- OpenAI 액세스 전용으로 지정한 단일 JWT-SVID 대상 값.
https://api.openai.com/v1 또는 별도로 정한 불투명 식별자를 사용할 수 있습니다.
- OpenAI가 검증할 수 있도록 JWT-SVID의
iss 클레임에 명시한 JWT 발급자 URL.
- OIDC 검색 또는 JWKS 업로드를 통해 제공하는 JWT-SVID 서명 키의 공개 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 엔드포인트를 제공합니다. 업로드한 JWKS로 토큰 검증 옵션이 꺼져 있으면 OpenAI가 이를 사용할 수 있습니다.
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 서명 키 설정에 따라 달라집니다.
OpenAI에서 SPIFFE JWT-SVID 발급자에 대한 워크로드 ID 공급자를 만든 다음, 신뢰하는 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에서 직접 매핑할 때는 속성 변환이 필요하지 않습니다. 하나 이상의 토큰 클레임에서 매핑 값을 도출해야 할 때만 사용하세요. 변환 방식은 워크로드 ID 페더레이션 기본 가이드를 참고하세요.
-
서비스 계정 매핑을 만드세요. 이름 필드에 production-openai-wif처럼 해당 워크로드 ID 공급자 내에서 고유한 값을 입력하세요. 설명 필드에는 Production SPIFFE workload for OpenAI API access처럼 어떤 워크로드에서 이 매핑을 사용할 수 있는지 적으세요.
-
SPIFFE ID를 매핑 조건으로 지정하세요. 키 필드에는 sub를, 값 필드에는 spiffe://example.org/ns/production/sa/openai-wif처럼 해당 워크로드의 SPIFFE ID를 입력하세요.
높은 권한이 있는 워크로드에는 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 액세스 전용으로 단일 대상 값을 사용하세요. 신뢰 도메인 전체나 환경 이름처럼 범위가 넓은 대상 값은 피하세요.
- 가능하면 SPIFFE ID를 정확히 일치시키세요. 의도적으로 신뢰 경계를 공유하는 경우에만 와일드카드 매핑을 사용하세요.
- 전달자 토큰의 재전송 공격 위험을 줄이려면 JWT-SVID의 유효 기간을 짧게 설정하세요. OpenAI 액세스 토큰의 만료 시각은 교환에 사용한 외부 주체 토큰의 만료 시각을 넘지 않습니다.
- 서명 키를 교체할 때는 주의하세요. 교체 기간에는 OIDC 검색을 통해 기존 공개 키와 새 공개 키를 모두 제공하세요. JWKS 업로드 방식을 사용한다면 새
kid로 JWT-SVID를 발급하기 전에 업로드한 공개 JWKS를 업데이트하세요.
- SPIRE Server와 워크로드의 시계를 동기화하세요. 시각 차이가 크면 유효한 JWT-SVID도 유효 기간이 아직 시작되지 않았거나, 발급된 지 너무 오래되었거나, 이미 만료된 것으로 판단되어 거부될 수 있습니다.
- SPIFFE Workload API 소켓을 보호하세요. 워크로드의 JWT-SVID를 가져올 수 있는 프로세스는 해당 토큰을 OpenAI 액세스 토큰으로 교환하려고 시도할 수 있습니다.
- OpenAI 서비스 계정의 권한 경계를 애플리케이션과 환경의 권한 경계에 맞추세요. 서로 관련 없는 SPIFFE 워크로드끼리 높은 권한을 가진 서비스 계정을 공유하지 마세요.
- 토큰 교환 실패를 모니터링해 발급자, 대상, 서명 키 또는 매핑이 일치하지 않는지 확인하세요.