AWS 아웃바운드 아이덴티티 페더레이션을 사용하면 AWS 보안 주체가 AWS STS에 서명된 OIDC JWT를 요청해 외부 서비스에 제시할 수 있습니다. OpenAI 워크로드 아이덴티티 페더레이션에서는 AWS가 발급한 JWT가 주체 토큰 역할을 합니다. OpenAI는 이 토큰을 검증한 뒤 OpenAI 액세스 토큰을 발급합니다.
토큰을 발급할 AWS 계정에서 아웃바운드 아이덴티티 페더레이션을 활성화하세요. 자세한 설정 방법은 AWS의 아웃바운드 아이덴티티 페더레이션 시작하기를 참고하세요.
aws iam enable-outbound-web-identity-federation
AWS가 반환한 계정별 발급자 URL을 기록해 두세요. 이 URL은 OpenAI 워크로드 아이덴티티 공급자의 발급자로 설정할 값이며, AWS가 발급한 토큰의 iss 클레임과 일치해야 합니다.
STS 글로벌 엔드포인트에서는 AWS STS GetWebIdentityToken API를 사용할 수 없습니다. AWS CLI 또는 SDK에서 리전별 STS 엔드포인트를 사용하도록 설정하세요.
워크로드에 sts:GetWebIdentityToken 호출 권한을 부여하세요. AWS 보안 주체가 OpenAI용 토큰만 발급받도록 IAM에서 대상과 토큰의 최대 유효 기간을 제한하세요. 다음 예시에서는 대상이 https://api.openai.com/v1이고 유효 기간이 최대 300초인 토큰을 허용합니다.
123456789101112131415161718{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:GetWebIdentityToken",
"Resource": "*",
"Condition": {
"ForAllValues:StringEquals": {
"sts:IdentityTokenAudience": "https://api.openai.com/v1"
},
"NumericLessThanEquals": {
"sts:DurationSeconds": 300
}
}
}
]
}
OpenAI 워크로드 아이덴티티 공급자에 설정할 대상과 동일한 값을 지정해 AWS에 OIDC 토큰을 요청하세요. 서명 알고리즘은 ES384를 사용하세요. 환경에서 RS256 호환성이 필요한 경우는 예외입니다.
123456789TOKEN=$(aws sts get-web-identity-token \
--audience "https://api.openai.com/v1" \
--signing-algorithm ES384 \
--duration-seconds 300 \
--tags Key=environment,Value=production \
Key=workload,Value=batch-ingest \
--query "WebIdentityToken" \
--output text)
export TOKEN
워크로드 아이덴티티 페더레이션을 구성하기 전에 AWS가 발급한 토큰을 환경 변수 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 페이로드를 디코딩합니다. 프로덕션 토큰은 로컬 디코더로 디코딩하고 타사 도구에 붙여넣지 마세요.
AWS가 발급한 OIDC 토큰을 디코딩하면 다음과 비슷한 결과를 볼 수 있습니다.
1234567891011121314151617181920{
"iss": "https://abc123-def456-ghi789-jkl012.tokens.sts.global.api.aws",
"aud": "https://api.openai.com/v1",
"sub": "arn:aws:iam::123456789012:role/OpenAIWifRole",
"iat": 1716235422,
"exp": 1716235722,
"jti": "jwt-id-example",
"https://sts.amazonaws.com/": {
"aws_account": "123456789012",
"source_region": "us-west-2",
"org_id": "o-exampleorgid",
"principal_tags": {
"environment": "production"
},
"request_tags": {
"environment": "production",
"workload": "batch-ingest"
}
}
}
AWS가 발급한 토큰에 포함되는 AWS 전용 클레임은 토큰마다 다를 수 있습니다. https://sts.amazonaws.com/ 아래의 클레임은 호출한 보안 주체, 세션 컨텍스트, 요청 태그에 따라 달라집니다.
OpenAI에 설정할 클레임을 확인하세요.
iss: OpenAI 워크로드 아이덴티티 공급자에 설정한 AWS 계정별 발급자 URL과 일치해야 합니다.
aud: GetWebIdentityToken 호출 시 지정한 대상 및 OpenAI 워크로드 아이덴티티 공급자에 설정한 대상과 일치해야 합니다.
sub: 토큰을 요청한 IAM 보안 주체의 ARN을 나타냅니다. 가능하면 역할 ARN 전체가 정확히 일치하도록 설정하세요.
- AWS 전용 클레임: 계정, 조직, 보안 주체 태그 또는 요청 태그를 일치 조건으로 지정하기 전에 디코딩한 토큰에서 실제 값을 확인하세요.
디코딩한 페이로드의 값을 OpenAI에 설정한 발급자, 대상, 매핑 값과 비교하세요. 대부분의 구성 문제는 토큰을 교환하기 전에 iss, aud, sub 클레임에서 확인할 수 있습니다.
OpenAI에서 AWS 계정의 발급자에 해당하는 워크로드 아이덴티티 공급자를 만든 뒤, AWS가 발급한 토큰에서 값이 일정하게 유지되는 클레임을 기준으로 서비스 계정 매핑을 추가하세요.
먼저 워크로드 아이덴티티 공급자를 구성한 다음 서비스 계정 매핑을 만드세요.
-
워크로드 아이덴티티 공급자를 만드세요. 이름 필드에 aws-outbound-prod와 같이 고유한 값을 입력하세요. 설명 필드에는 Production AWS outbound identity federation workloads와 같이 관리자가 공급자를 식별하는 데 도움이 되는 내용을 입력하세요.
-
발급자와 대상을 설정하세요. OIDC 발급자 URL 필드에 아웃바운드 아이덴티티 페더레이션을 활성화했을 때 반환된 AWS 계정별 발급자 URL을 입력하세요. 이 값은 토큰의 iss 클레임과 일치해야 합니다. 대상 필드에는 GetWebIdentityToken 호출 시 전달한 대상과 동일한 값을 입력하세요. 이 예시의 값은 https://api.openai.com/v1입니다.
-
AWS OIDC 디스커버리를 사용하세요. 토큰 검증에 업로드한 JWKS 사용 옵션은 비활성화 상태로 두세요. OpenAI는 AWS 발급자의 OIDC 디스커버리 메타데이터와 JWKS를 사용해 AWS가 발급한 토큰을 검증합니다.
-
매핑에 파생 속성이 필요한 경우에만 속성 변환을 추가하세요. 원본 토큰에서 직접 값을 비교할 때는 sub, aud, iss와 같은 최상위 스칼라 클레임을 사용할 수 있습니다. AWS 전용 네임스페이스의 클레임은 https://sts.amazonaws.com/ 아래에 중첩되어 있으므로, 매핑에 사용하려면 먼저 CEL 대괄호 표기법으로 파생 속성을 만드세요. 예를 들어 속성 이름으로 aws_environment를 입력하고 표현식으로 assertion["https://sts.amazonaws.com/"]["principal_tags"]["environment"]를 지정하면 위에서 디코딩한 예시 토큰의 값으로 openai.aws_environment를 만들 수 있습니다. 중첩된 클레임 경로는 사용하기 전에 샘플 토큰에서 확인하세요. 변환식을 평가할 수 없으면 매핑을 결정할 수 없습니다. 원본 토큰의 클레임 이름이 이미 openai.로 시작하더라도 해당 변환을 설정하지 않으면 openai. 매핑 키에는 그 클레임이 사용되지 않습니다.
-
서비스 계정 매핑을 만드세요. 이름 필드에 aws-role-openai-wif와 같이 워크로드 아이덴티티 공급자 내에서 고유한 값을 입력하세요. 설명 필드에는 Production AWS role for OpenAI API workload와 같이 이 매핑을 사용할 수 있는 워크로드를 명시하세요.
-
AWS 보안 주체를 일치 조건으로 지정하세요. 키 필드에 sub를 입력하고, 값 필드에는 arn:aws:iam::123456789012:role/OpenAIWifRole과 같이 디코딩한 토큰에서 확인한 IAM 보안 주체의 ARN을 입력하세요. AWS 아웃바운드 아이덴티티 페더레이션에서는 sub 클레임 전체가 정확히 일치하도록 설정할 때 보안 주체를 가장 엄격하게 격리할 수 있습니다.
-
필요하면 클레임 일치 조건을 추가하세요. 사용 가능한 스칼라 클레임이나 변환된 속성을 일치 조건으로 지정할 수 있습니다. 예를 들어 신뢰 경계를 추가로 설정해야 한다면 AWS 계정, 조직, 보안 주체 태그 또는 요청 태그 클레임에서 파생한 속성을 사용하세요.
-
OpenAI에서 사용할 대상을 선택하세요. 프로젝트 항목에서 대상 서비스 계정이 속한 OpenAI 프로젝트를 선택하세요. 서비스 계정 항목에서 aws-outbound-prod-openai-wif와 같이 AWS 워크로드가 사용할 수 있는 OpenAI 서비스 계정을 선택하세요.
-
필요하면 API 권한을 제한하세요. 권한 항목에서 api.model.request, api.vector_store.read 등 필요한 권한을 선택해 이 매핑으로 발급되는 액세스 토큰의 권한 범위를 더 좁히세요. WIF 전용 범위 제한을 추가하지 않으려면 권한을 비워 두세요. 이 경우에도 토큰에는 매핑된 서비스 계정의 권한이 적용됩니다.
OpenAI SDK 클라이언트가 AWS STS에 OIDC 토큰을 요청하고, AWS가 발급한 토큰을 OpenAI 액세스 토큰으로 교환하도록 구성하세요.
OPENAI_WIF_AUDIENCE를 OpenAI 워크로드 아이덴티티 공급자에 설정한 대상과 동일한 값으로 설정하세요. 주체 토큰 공급자는 이 대상을 지정해 AWS STS GetWebIdentityToken을 호출하고 AWS가 발급한 JWT를 주체 토큰으로 반환합니다. OpenAI SDK는 이 토큰을 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
40
41
42
43
44
45
46
47
48
49
50
51
52import { GetWebIdentityTokenCommand, STSClient } from "@aws-sdk/client-sts";
import OpenAI from "openai";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
const audience = process.env.OPENAI_WIF_AUDIENCE;
const awsRegion = process.env.AWS_REGION;
if (!identityProviderId || !serviceAccountId || !audience || !awsRegion) {
throw new Error(
"Set OPENAI_IDENTITY_PROVIDER_ID, OPENAI_SERVICE_ACCOUNT_ID, OPENAI_WIF_AUDIENCE, and AWS_REGION"
);
}
const wifAudience = audience;
const sts = new STSClient({ region: awsRegion });
function awsOutboundWebIdentityTokenProvider() {
return {
tokenType: "jwt",
getToken: async () => {
const response = await sts.send(
new GetWebIdentityTokenCommand({
Audience: [wifAudience],
SigningAlgorithm: "ES384",
DurationSeconds: 300,
})
);
if (!response.WebIdentityToken) {
throw new Error("AWS STS did not return a web identity token.");
}
return response.WebIdentityToken;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: awsOutboundWebIdentityTokenProvider(),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from AWS outbound 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
33
34
35
36
37
38
39
40import os
import boto3
from openai import OpenAI
from openai.auth import SubjectTokenProvider
def aws_outbound_web_identity_token_provider(audience: str) -> SubjectTokenProvider:
sts = boto3.client("sts", region_name=os.environ["AWS_REGION"])
def get_token() -> str:
response = sts.get_web_identity_token(
Audience=[audience],
SigningAlgorithm="ES384",
DurationSeconds=300,
)
token = response.get("WebIdentityToken", "")
if not token:
raise RuntimeError("AWS STS did not return a web identity token.")
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": aws_outbound_web_identity_token_provider(
os.environ["OPENAI_WIF_AUDIENCE"]
),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from AWS outbound 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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86package main
import (
"context"
"fmt"
"log"
"os"
awssdk "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sts"
"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"
)
type awsOutboundWebIdentityTokenProvider struct {
client *sts.Client
audience string
}
func (p awsOutboundWebIdentityTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p awsOutboundWebIdentityTokenProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {
output, err := p.client.GetWebIdentityToken(ctx, &sts.GetWebIdentityTokenInput{
Audience: []string{p.audience},
DurationSeconds: awssdk.Int32(300),
SigningAlgorithm: awssdk.String("ES384"),
})
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "aws-outbound",
Message: "failed to request AWS web identity token",
Cause: err,
}
}
token := awssdk.ToString(output.WebIdentityToken)
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "aws-outbound",
Message: "AWS STS did not return a web identity token",
}
}
return token, nil
}
func main() {
ctx := context.Background()
audience := os.Getenv("OPENAI_WIF_AUDIENCE")
if audience == "" {
log.Fatal("Set OPENAI_WIF_AUDIENCE")
}
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatal(err)
}
client := openai.NewClient(
option.WithWorkloadIdentity(auth.WorkloadIdentity{
IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
Provider: awsOutboundWebIdentityTokenProvider{
client: sts.NewFromConfig(cfg),
audience: audience,
},
}),
)
response, err := client.Responses.New(ctx, responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from AWS outbound 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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91import 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.util.concurrent.CompletableFuture;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.model.GetWebIdentityTokenRequest;
public final class AwsOutboundWorkloadIdentityExample {
private AwsOutboundWorkloadIdentityExample() {}
static final class AwsOutboundWebIdentityTokenProvider implements SubjectTokenProvider {
private final StsClient stsClient;
private final String audience;
AwsOutboundWebIdentityTokenProvider(StsClient stsClient, String audience) {
this.stsClient = stsClient;
this.audience = audience;
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
try {
String token =
stsClient
.getWebIdentityToken(
GetWebIdentityTokenRequest.builder()
.audience(audience)
.durationSeconds(300)
.signingAlgorithm("ES384")
.build())
.webIdentityToken();
if (token == null || token.isEmpty()) {
throw new SubjectTokenProviderException(
"aws-outbound", "AWS STS did not return a web identity token", null);
}
return token;
} catch (SubjectTokenProviderException e) {
throw e;
} catch (Exception e) {
throw new SubjectTokenProviderException(
"aws-outbound", "failed to request AWS web identity token", e);
}
}
@Override
public CompletableFuture<String> getTokenAsync(HttpClient httpClient, JsonMapper jsonMapper) {
return CompletableFuture.supplyAsync(() -> getToken(httpClient, jsonMapper));
}
}
public static void main(String[] args) {
String audience = System.getenv("OPENAI_WIF_AUDIENCE");
StsClient stsClient =
StsClient.builder().region(Region.of(System.getenv("AWS_REGION"))).build();
WorkloadIdentity workloadIdentity =
WorkloadIdentity.builder()
.identityProviderId(System.getenv("OPENAI_IDENTITY_PROVIDER_ID"))
.serviceAccountId(System.getenv("OPENAI_SERVICE_ACCOUNT_ID"))
.provider(new AwsOutboundWebIdentityTokenProvider(stsClient, audience))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from AWS outbound 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
49
50
51
52
53
54
55
56
57require "aws-sdk-sts"
require "openai"
class AwsOutboundWebIdentityTokenProvider
include OpenAI::Auth::SubjectTokenProvider
def initialize(audience:, sts_client:)
@audience = audience
@sts_client = sts_client
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
response = @sts_client.get_web_identity_token(
audience: [@audience],
signing_algorithm: "ES384",
duration_seconds: 300
)
token = response.web_identity_token.to_s
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "AWS STS did not return a web identity token",
provider: "aws-outbound"
)
end
token
rescue Aws::STS::Errors::ServiceError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to request AWS web identity token: #{e.message}",
provider: "aws-outbound",
cause: e
)
end
end
provider = AwsOutboundWebIdentityTokenProvider.new(
audience: ENV.fetch("OPENAI_WIF_AUDIENCE"),
sts_client: Aws::STS::Client.new(region: ENV.fetch("AWS_REGION"))
)
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 AWS outbound workload identity federation."
)
puts(response.output_text)
EKS가 발급한 프로젝션 서비스 계정 토큰을 유효 기간이 짧은 OpenAI 액세스 토큰으로 교환하면 Amazon EKS를 워크로드 아이덴티티 공급자로 사용할 수 있습니다.
OpenAI API를 호출해야 하는 EKS 워크로드에는 Kubernetes ServiceAccount를 사용하세요. 서비스 계정이 아직 없다면 다음 명령어로 만드세요.
kubectl create serviceaccount openai-wif --namespace default
EKS 프로젝션 서비스 계정 토큰의 sub 클레임은 system:serviceaccount:<namespace>:<service-account-name> 형식입니다. 위 서비스 계정의 sub 클레임 값은 system:serviceaccount:default:openai-wif입니다.
다음 명령어로 EKS 클러스터의 OIDC 발급자 URL을 조회하세요.
12345aws eks describe-cluster \
--name <cluster-name> \
--region <region> \
--query "cluster.identity.oidc.issuer" \
--output text
출력 예시:
https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3
OpenAI 워크로드 아이덴티티 공급자에 설정하는 발급자는 이 발급자 URL 및 EKS 프로젝션 서비스 계정 토큰의 iss 클레임과 일치해야 합니다.
프로젝션 서비스 계정 토큰의 대상을 OpenAI가 검증할 대상 값으로 설정하고, 워크로드에 적합한 유효 기간을 지정하세요. OpenAI는 토큰의 발급자, 서명, 대상, 만료 여부를 검증합니다. 다음 예시에서는 토큰 파일이 /var/run/secrets/tokens/token에 마운트됩니다. 토큰의 대상은 https://api.openai.com/v1이며 3600초 후에 만료됩니다. 프로젝션 토큰과 OpenAI 워크로드 아이덴티티 공급자에 설정한 대상이 서로 일치하면 다른 대상 값을 사용해도 됩니다.
12345678910111213141516171819202122apiVersion: v1
kind: Pod
metadata:
name: openai-wif-app
namespace: default
spec:
serviceAccountName: openai-wif
containers:
- name: app
image: my-image
volumeMounts:
- name: eks-sa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: eks-sa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: "https://api.openai.com/v1"
expirationSeconds: 3600
워크로드 아이덴티티 페더레이션을 구성하기 전에 샘플 프로젝션 서비스 계정 토큰을 로컬에서 디코딩해 클레임을 확인하세요. 프로젝션 토큰이 마운트된 실행 중인 파드에서 토큰을 가져와 환경 변수 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 페이로드를 디코딩합니다. 프로덕션 토큰은 로컬 디코더로 디코딩하고 타사 도구에 붙여넣지 마세요.
EKS 프로젝션 서비스 계정 토큰을 디코딩하면 다음과 비슷한 결과를 볼 수 있습니다.
1234567891011121314{
"iss": "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3",
"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에서 EKS 발급자에 해당하는 워크로드 아이덴티티 공급자를 만든 뒤, 프로젝션 토큰의 속성을 기준으로 서비스 계정 매핑을 추가하세요.
먼저 워크로드 아이덴티티 공급자를 구성한 다음 서비스 계정 매핑을 만드세요.
-
워크로드 아이덴티티 공급자를 만드세요. 이름 필드에 aws-eks-prod와 같이 고유한 값을 입력하세요. 설명 필드에는 Production EKS cluster와 같이 관리자가 클러스터를 식별하는 데 도움이 되는 내용을 입력하세요.
-
발급자와 대상을 설정하세요. OIDC 발급자 URL 필드에 aws eks describe-cluster --query "cluster.identity.oidc.issuer" 명령어가 반환한 발급자 URL을 입력하세요. 이 값은 EKS 프로젝션 서비스 계정 토큰의 iss 클레임과 일치해야 합니다. 대상 필드에는 서비스 계정 토큰의 프로젝션 볼륨에 설정한 대상과 동일한 값을 입력하세요. 이 예시의 값은 https://api.openai.com/v1입니다.
-
EKS OIDC 디스커버리를 사용하세요. 토큰 검증에 업로드한 JWKS 사용 옵션은 비활성화 상태로 두세요. OpenAI는 EKS 발급자의 OIDC 디스커버리 메타데이터와 JWKS를 사용해 프로젝션 서비스 계정 토큰을 검증합니다.
-
매핑에 파생 속성이 필요한 경우에만 속성 변환을 추가하세요. sub, aud, iss와 같은 원본 토큰의 클레임은 매핑 조건에 직접 사용할 수 있습니다. 예를 들어 이름이 subject이고 표현식이 assertion.sub인 변환 속성을 만드세요. 대시보드에 속성 이름으로 subject를 입력하면 OpenAI는 이를 openai.subject로 저장합니다. 매핑에서 이 이름으로 속성을 참조할 수 있습니다.
참고: 원본 토큰의 클레임 이름이 이미 openai.로 시작하더라도 해당 변환을 설정하지 않으면 openai. 매핑 키에는 그 클레임이 사용되지 않습니다.
-
서비스 계정 매핑을 만드세요. 이름 필드에 openai-mapping-eks와 같이 워크로드 아이덴티티 공급자 내에서 고유한 값을 입력하세요. 설명 필드에는 Workload Identity Provider Mapping for EKS Workloads와 같이 이 매핑을 사용할 수 있는 워크로드를 명시하세요.
-
EKS 서비스 계정 주체를 일치 조건으로 지정하세요. 키 필드에 sub를, 값 필드에 system:serviceaccount:default:openai-wif를 입력하세요. 사용 가능한 클레임이나 변환된 속성을 일치 조건으로 지정할 수 있습니다. sub는 Kubernetes 서비스 계정을 고유하게 식별하므로 가장 엄격한 일치 조건입니다.
-
OpenAI에서 사용할 대상을 선택하세요. 프로젝트 항목에서 대상 서비스 계정이 속한 OpenAI 프로젝트를 선택하세요. 서비스 계정 항목에서 aws-eks-prod-openai-wif와 같이 EKS 워크로드가 사용할 수 있는 OpenAI 서비스 계정을 선택하세요. 기존 서비스 계정을 재사용하는 대신 이 매핑에 사용할 서비스 계정을 새로 만들려면 Create a new service account in this project를 선택하세요.
-
필요하면 API 권한을 제한하세요. 권한 항목에서 api.model.request, api.vector_store.read 등 필요한 권한을 선택해 이 매핑으로 발급되는 액세스 토큰의 권한 범위를 더 좁히세요. WIF 전용 범위 제한을 추가하지 않으려면 권한을 비워 두세요. 이 경우에도 토큰에는 매핑된 서비스 계정의 권한이 적용됩니다.
OpenAI SDK 클라이언트가 EKS 프로젝션 서비스 계정 토큰을 읽고 OpenAI가 발급하는 액세스 토큰으로 교환하도록 구성하세요.
SDK의 워크로드 아이덴티티 페더레이션 공급자가 /var/run/secrets/tokens/token과 같이 토큰이 마운트된 경로에서 주체 토큰을 읽도록 설정하세요. SDK는 이 EKS 토큰을 OpenAI가 발급하는 액세스 토큰으로 교환하고, OpenAI 토큰으로 API 요청을 인증합니다.
다음 예시에서는 사용자 지정 주체 토큰 공급자로 OpenAI 클라이언트를 초기화합니다. 이 공급자는 마운트된 파일 경로에서 EKS 프로젝션 서비스 계정 토큰을 읽어 워크로드 아이덴티티 페더레이션의 주체 토큰으로 사용합니다.
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 mountedEksServiceAccountTokenProvider(path) {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The mounted EKS service account token file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: mountedEksServiceAccountTokenProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from AWS 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_eks_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 EKS 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_eks_service_account_token_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from AWS 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 mountedEksServiceAccountTokenProvider struct {
path string
}
func (p mountedEksServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p mountedEksServiceAccountTokenProvider) GetToken(_ context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "aws-eks",
Message: "failed to read mounted EKS service account token",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "aws-eks",
Message: "mounted EKS 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: mountedEksServiceAccountTokenProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from AWS 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 AwsEksWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";
private AwsEksWorkloadIdentityExample() {}
static final class MountedEksServiceAccountTokenProvider implements SubjectTokenProvider {
private final Path tokenPath;
MountedEksServiceAccountTokenProvider(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(
"aws-eks", "failed to read mounted EKS service account token", e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"aws-eks", "mounted EKS 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 MountedEksServiceAccountTokenProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from AWS 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 MountedEksServiceAccountTokenProvider
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 EKS service account token is empty",
provider: "aws-eks"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read mounted EKS service account token: #{e.message}",
provider: "aws-eks",
cause: e
)
end
end
provider = MountedEksServiceAccountTokenProvider.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 AWS workload identity federation."
)
puts(response.output_text)