Azure 관리 ID를 사용하면 Azure에서 실행되는 워크로드가 장기간 유효한 비밀 값을 저장하지 않고도 Microsoft Entra 토큰을 요청할 수 있습니다. OpenAI 워크로드 ID 페더레이션에서 관리 ID 토큰은 주체 토큰으로 사용됩니다. OpenAI는 이 토큰을 검증한 후 OpenAI 액세스 토큰을 발급합니다.
OpenAI가 신뢰할 토큰 대상을 나타내는 애플리케이션을 Microsoft Entra에 등록하거나 기존 애플리케이션 등록을 사용하세요. 등록한 애플리케이션의 애플리케이션 ID URI를 설정하세요. 이 URI는 워크로드가 Azure Instance Metadata Service(IMDS)에 토큰을 요청할 때 지정하는 resource 값이며, 발급된 토큰의 aud 클레임에 표시됩니다. Microsoft Entra 설정 절차는 새 Entra ID 애플리케이션 및 서비스 주체 만들기를 참고하세요.
Microsoft Entra ID에 설정한 애플리케이션 ID URI, IMDS의 resource 매개변수, 발급된 토큰의 aud 클레임, OpenAI 워크로드 ID 공급자에 설정한 대상은 모두 일치해야 합니다.
관리 ID를 만든 다음, 애플리케이션을 실행하는 가상 머신 등의 Azure 리소스에 해당 관리 ID를 할당하세요. 해당 리소스는 런타임에 IMDS를 호출할 수 있어야 합니다. Azure 설정에 관한 자세한 내용은 Microsoft의 관리 ID 개요와 해당 Azure 리소스 문서의 ID 할당 절차를 참고하세요.
관리 ID가 할당된 Azure 리소스에서 애플리케이션 ID URI를 resource 매개변수로 지정해 IMDS에 토큰을 요청하세요. OpenAI는 이 토큰을 주체 토큰으로 받아 OpenAI 액세스 토큰을 발급합니다.
12345678APPLICATION_ID_URI="api://<application-client-id>"
TOKEN=$(curl -sS -G -H "Metadata: true" \
"http://169.254.169.254/metadata/identity/oauth2/token" \
--data-urlencode "api-version=2018-02-01" \
--data-urlencode "resource=${APPLICATION_ID_URI}" \
| jq -r .access_token)
export TOKEN
리소스에 사용자 할당 관리 ID가 여러 개 있으면 client_id, object_id, msi_res_id 중 하나를 쿼리 매개변수로 추가해 사용할 관리 ID를 지정하세요. IMDS 토큰 요청 매개변수는 Microsoft 문서 가상 머신에서 관리 ID를 사용하여 액세스 토큰 가져오기에서 확인할 수 있습니다.
워크로드 ID 페더레이션을 구성하기 전에 Microsoft Entra 토큰을 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 페이로드를 디코딩합니다. 프로덕션 토큰은 로컬 디코더로 확인하고 타사 도구에 붙여 넣지 마세요.
Microsoft Entra ID 관리 ID 토큰을 디코딩하면 다음과 비슷한 결과가 나옵니다.
1234567891011{
"iss": "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0",
"aud": "api://00000000-1111-2222-3333-444444444444",
"tid": "11111111-2222-3333-4444-555555555555",
"appid": "22222222-3333-4444-5555-666666666666",
"oid": "33333333-4444-5555-6666-777777777777",
"sub": "33333333-4444-5555-6666-777777777777",
"xms_mirid": "/subscriptions/<subscription-id>/resourcegroups/my-resource-group/providers/Microsoft.Compute/virtualMachines/openai-wif-vm",
"iat": 1716235422,
"exp": 1716239022
}
OpenAI에 설정할 클레임을 확인하세요.
iss: 토큰에 표시된 발급자 값을 그대로 사용하세요. 발급자 값이 https://login.microsoftonline.com/<tenant-id>/v2.0일 수 있지만 항상 같은 접미사로 끝난다고 가정하지 마세요.
aud: 애플리케이션 ID URI, IMDS의 resource 매개변수, OpenAI 워크로드 ID 공급자에 설정한 대상과 일치해야 합니다.
tid: Microsoft Entra 테넌트 ID입니다.
appid: 이 클레임이 있으면 그 값은 관리 ID의 애플리케이션 ID(클라이언트 ID)입니다.
iat 및 exp: exp - iat로 토큰의 전체 수명을 초 단위로 확인하세요.
Codex에서는 공급자의 max_assertion_lifetime_seconds를 해당 발급자의 예상 토큰 수명 범위를 포괄하는 승인된 한도로 설정하세요. 토큰의 남은 유효 시간을 기준으로 삼거나 모든 Entra 토큰의 수명이 1시간이라고 가정하지 마세요. Microsoft 문서에 따르면 액세스 토큰의 수명은 달라질 수 있으며, Microsoft는 관리 ID 토큰의 수명 설정을 지원하지 않습니다.
관리 ID 토큰에는 azp, oid, sub, xms_mirid 등의 클레임도 포함될 수 있습니다. 디코딩한 토큰을 기준으로, 신뢰할 관리 ID와 리소스 범위를 정확히 식별하는 클레임을 선택하세요.
디코딩한 페이로드를 사용해 받은 토큰의 값과 OpenAI에 설정한 발급자, 대상, 매핑 값을 비교하세요. 대부분의 구성 문제는 토큰을 교환하기 전에 iss, aud, tid 및 관리 ID 관련 클레임을 확인하면 찾을 수 있습니다.
OpenAI에서 Microsoft Entra ID 발급자용 워크로드 ID 공급자를 만든 다음, 관리 ID 토큰의 안정적인 클레임과 일치하는 서비스 계정 매핑을 추가하세요.
먼저 워크로드 ID 공급자를 구성한 다음 서비스 계정 매핑을 만드세요.
-
워크로드 ID 공급자를 만드세요. 이름에 azure-managed-identity-prod와 같은 고유한 값을 입력하세요. 관리자가 공급자를 식별할 수 있도록 설명에 Production Azure managed identity workloads와 같은 내용을 입력하세요.
-
발급자와 대상을 설정하세요. 먼저 샘플 관리 ID 토큰을 가져와 클레임을 확인하세요. OIDC 발급자 URL에 토큰의 iss 클레임 값을 그대로 입력하세요. 예를 들어 발급자 값은 https://login.microsoftonline.com/<tenant-id>/v2.0일 수 있습니다. 대상에는 api://<application-client-id>와 같이 Microsoft Entra에 설정한 애플리케이션 ID URI를 입력하세요. 이 값은 토큰의 aud 클레임과 일치해야 합니다.
-
Microsoft Entra 토큰 검증을 사용하세요. 토큰 검증에 업로드한 JWKS 사용 옵션은 비활성화 상태로 두세요. OpenAI는 Microsoft Entra 발급자의 메타데이터와 JWKS를 사용해 관리 ID 토큰을 검증합니다.
-
매핑에 파생 속성이 필요하면 속성 변환을 추가하세요. 예를 들어 속성 이름으로 managed_identity_client_id를 입력하고 표현식으로 assertion.appid를 지정하면 관리 ID의 애플리케이션 ID(클라이언트 ID) 클레임에서 openai.managed_identity_client_id를 생성할 수 있습니다. 대시보드가 openai. 접두사를 자동으로 붙입니다. 원본 토큰에서 이름이 이미 openai.로 시작하는 클레임은 해당 변환을 구성한 경우에만 openai.로 시작하는 매핑 키에 사용됩니다.
-
서비스 계정 매핑을 만드세요. 이름에 vm-openai-wif와 같이 해당 워크로드 ID 공급자 내에서 고유한 값을 입력하세요. 어떤 워크로드가 이 매핑을 사용할 수 있는지 알 수 있도록 설명에 Production VM Azure managed identity workload와 같은 내용을 입력하세요.
-
값이 일정한 관리 ID 클레임을 매핑 조건으로 지정하세요. 일치해야 하는 클레임마다 행을 하나씩 추가하고 키와 값을 입력하세요. 토큰에 appid가 있으면 키에 appid를, 값에 관리 ID의 클라이언트 ID를 입력하세요. appid 클레임은 관리 ID의 애플리케이션 ID(클라이언트 ID)를 나타내며, 일반적으로 매핑을 특정 관리 ID에 연결할 때 가장 안정적인 클레임입니다. 토큰에 appid가 없으면 디코딩한 토큰에서 azp, oid, sub, xms_mirid 등 다른 안정적인 클레임을 사용하세요. 매핑을 단일 테넌트로 제한하려면 키에 tid를, 값에 Microsoft Entra 테넌트 ID를 입력한 행도 추가하세요. IMDS에서 받은 샘플 토큰을 디코딩해 신뢰할 관리 ID와 리소스에서 값이 안정적으로 유지되는 클레임을 사용하세요.
-
OpenAI 대상을 선택하세요. 프로젝트에서 대상 서비스 계정이 속한 OpenAI 프로젝트를 선택하세요. 서비스 계정에서 azure-managed-identity-prod-openai-wif와 같이 Azure 워크로드가 사용할 수 있는 OpenAI 서비스 계정을 선택하세요.
-
필요하면 API 권한을 제한하세요. 권한에서 api.model.request, api.vector_store.read 등 적절한 권한을 선택해 이 매핑으로 발급되는 액세스 토큰의 권한 범위를 더 좁히세요. WIF에만 적용되는 범위 제한을 추가하지 않으려면 권한을 비워 두세요. 이 경우에도 토큰에는 매핑된 서비스 계정의 권한이 적용됩니다.
IMDS에서 Azure 관리 ID 토큰을 요청하고 이를 OpenAI가 발급하는 액세스 토큰으로 교환하도록 OpenAI SDK 클라이언트를 구성하세요.
OPENAI_WIF_AUDIENCE를 워크로드 ID 공급자의 대상으로 설정한 Microsoft Entra 애플리케이션 ID URI로 지정하세요. SDK는 해당 대상의 관리 ID 토큰을 요청해 OpenAI가 발급하는 액세스 토큰으로 교환한 다음, OpenAI 토큰으로 API 요청을 인증합니다.
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
61import OpenAI from "openai";
const imdsEndpoint = "http://169.254.169.254/metadata/identity/oauth2/token";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
const audience = process.env.OPENAI_WIF_AUDIENCE;
if (!identityProviderId || !serviceAccountId || !audience) {
throw new Error(
"Set OPENAI_IDENTITY_PROVIDER_ID, OPENAI_SERVICE_ACCOUNT_ID, and OPENAI_WIF_AUDIENCE"
);
}
function azureManagedIdentityTokenProvider(resource) {
return {
tokenType: "jwt",
getToken: async () => {
const url = new URL(imdsEndpoint);
url.searchParams.set("api-version", "2018-02-01");
url.searchParams.set("resource", resource);
const clientId = process.env.AZURE_CLIENT_ID;
if (clientId) {
url.searchParams.set("client_id", clientId);
}
const response = await fetch(url, {
headers: { Metadata: "true" },
});
if (!response.ok) {
throw new Error(
`Azure IMDS token request failed with status ${response.status}.`
);
}
const body = await response.json();
if (!body.access_token) {
throw new Error("Azure IMDS did not return an access token.");
}
return body.access_token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: azureManagedIdentityTokenProvider(audience),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from Azure managed identity 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from openai import OpenAI
from openai.auth import SubjectTokenProvider
IMDS_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token"
def azure_managed_identity_token_provider(resource: str) -> SubjectTokenProvider:
def get_token() -> str:
params = {
"api-version": "2018-02-01",
"resource": resource,
}
client_id = os.environ.get("AZURE_CLIENT_ID")
if client_id:
params["client_id"] = client_id
request = Request(
f"{IMDS_ENDPOINT}?{urlencode(params)}",
headers={"Metadata": "true"},
)
with urlopen(request, timeout=10) as response:
body = json.loads(response.read().decode("utf-8"))
token = body.get("access_token", "")
if not token:
raise RuntimeError("Azure IMDS did not return an access 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": azure_managed_identity_token_provider(
os.environ["OPENAI_WIF_AUDIENCE"]
),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from Azure managed identity 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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"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 azureIMDSEndpoint = "http://169.254.169.254/metadata/identity/oauth2/token"
type azureManagedIdentityTokenProvider struct {
resource string
}
func (p azureManagedIdentityTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p azureManagedIdentityTokenProvider) GetToken(ctx context.Context, httpClient auth.HTTPDoer) (string, error) {
values := url.Values{}
values.Set("api-version", "2018-02-01")
values.Set("resource", p.resource)
if clientID := os.Getenv("AZURE_CLIENT_ID"); clientID != "" {
values.Set("client_id", clientID)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, azureIMDSEndpoint+"?"+values.Encode(), nil)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-managed-identity",
Message: "failed to build Azure IMDS token request",
Cause: err,
}
}
req.Header.Set("Metadata", "true")
resp, err := httpClient.Do(req)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-managed-identity",
Message: "failed to request Azure managed identity token",
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-managed-identity",
Message: fmt.Sprintf("Azure IMDS token request failed with status %d", resp.StatusCode),
}
}
var body struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-managed-identity",
Message: "failed to decode Azure IMDS token response",
Cause: err,
}
}
if body.AccessToken == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-managed-identity",
Message: "Azure IMDS did not return an access token",
}
}
return body.AccessToken, nil
}
func main() {
audience := os.Getenv("OPENAI_WIF_AUDIENCE")
if audience == "" {
log.Fatal("Set OPENAI_WIF_AUDIENCE")
}
client := openai.NewClient(
option.WithWorkloadIdentity(auth.WorkloadIdentity{
IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),
ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),
Provider: azureManagedIdentityTokenProvider{
resource: audience,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from Azure managed identity 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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108import com.fasterxml.jackson.databind.JsonNode;
import 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.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
public final class AzureManagedIdentityWorkloadIdentityExample {
private static final String IMDS_ENDPOINT =
"http://169.254.169.254/metadata/identity/oauth2/token";
private AzureManagedIdentityWorkloadIdentityExample() {}
static final class AzureManagedIdentityTokenProvider implements SubjectTokenProvider {
private final String resource;
AzureManagedIdentityTokenProvider(String resource) {
this.resource = resource;
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {
try {
String query =
"api-version=2018-02-01&resource="
+ URLEncoder.encode(resource, StandardCharsets.UTF_8);
String clientId = System.getenv("AZURE_CLIENT_ID");
if (clientId != null && !clientId.isEmpty()) {
query += "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8);
}
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(IMDS_ENDPOINT + "?" + query))
.header("Metadata", "true")
.GET()
.build();
HttpResponse<String> response =
java.net.http.HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new SubjectTokenProviderException(
"azure-managed-identity",
"Azure IMDS token request failed with status " + response.statusCode(),
null);
}
JsonNode body = jsonMapper.readTree(response.body());
String token = body.path("access_token").asText();
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"azure-managed-identity", "Azure IMDS did not return an access token", null);
}
return token;
} catch (SubjectTokenProviderException e) {
throw e;
} catch (Exception e) {
throw new SubjectTokenProviderException(
"azure-managed-identity", "failed to request Azure managed identity token", e);
}
}
@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 AzureManagedIdentityTokenProvider(System.getenv("OPENAI_WIF_AUDIENCE")))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from Azure managed identity 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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76require "json"
require "net/http"
require "openai"
require "uri"
class AzureManagedIdentityTokenProvider
include OpenAI::Auth::SubjectTokenProvider
IMDS_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token"
def initialize(resource:)
@resource = resource
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
uri = URI(IMDS_ENDPOINT)
params = {
"api-version" => "2018-02-01",
"resource" => @resource
}
params["client_id"] = ENV["AZURE_CLIENT_ID"] if ENV["AZURE_CLIENT_ID"]
uri.query = URI.encode_www_form(params)
request = Net::HTTP::Get.new(uri)
request["Metadata"] = "true"
response = Net::HTTP.start(uri.hostname, uri.port, read_timeout: 10) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Azure IMDS token request failed with status #{response.code}",
provider: "azure-managed-identity"
)
end
token = JSON.parse(response.body).fetch("access_token", "")
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Azure IMDS did not return an access token",
provider: "azure-managed-identity"
)
end
token
rescue JSON::ParserError, SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to request Azure managed identity token: #{e.message}",
provider: "azure-managed-identity",
cause: e
)
end
end
provider = AzureManagedIdentityTokenProvider.new(
resource: ENV.fetch("OPENAI_WIF_AUDIENCE")
)
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 Azure managed identity workload identity federation."
)
puts(response.output_text)
AKS가 발급한 프로젝션 서비스 계정 토큰을 수명이 짧은 OpenAI 액세스 토큰으로 교환하는 방식으로 AKS를 워크로드 ID 공급자로 사용할 수 있습니다.
AKS 워크로드는 Azure Workload Identity를 사용해 워크로드에 연결된 관리 ID용 Microsoft Entra ID 액세스 토큰을 가져올 수도 있습니다. 이 구성에서 OpenAI는 프로젝션된 Kubernetes 서비스 계정 토큰 대신 Microsoft Entra 토큰을 검증합니다. Azure 관리 ID의 절차에 따라 OpenAI 워크로드 ID 페더레이션을 구성하고, Microsoft 문서에 따라 Azure Workload Identity를 구성하세요.
AKS 클러스터의 OIDC 발급자 URL을 가져오세요.
12345az aks show \
--name <cluster-name> \
--resource-group <resource-group> \
--query "oidcIssuerProfile.issuerUrl" \
--output tsv
발급자 URL이 비어 있으면 다음 명령으로 클러스터의 AKS OIDC 발급자를 활성화하세요.
1234az aks update \
--resource-group <resource-group> \
--name <cluster-name> \
--enable-oidc-issuer
OpenAI 워크로드 ID 공급자에 설정하는 발급자는 이 발급자 URL 및 프로젝션된 AKS 서비스 계정 토큰의 iss 클레임과 일치해야 합니다.
OpenAI API를 호출해야 하는 AKS 워크로드에는 Kubernetes ServiceAccount를 사용하세요. 아직 서비스 계정이 없으면 다음 명령으로 만드세요.
kubectl create serviceaccount openai-wif --namespace default
프로젝션 서비스 계정 토큰의 대상은 OpenAI에서 요구하는 값으로, 만료 시간은 워크로드에 적합한 값으로 설정하세요. OpenAI는 토큰의 발급자, 서명, 대상, 만료 시간을 검증합니다. 이 예시에서 토큰 파일은 /var/run/secrets/tokens/token에 마운트됩니다. 토큰의 대상은 https://api.openai.com/v1이며 토큰은 3600초 후에 만료됩니다. 프로젝션된 토큰의 대상과 OpenAI 워크로드 ID 공급자에 설정한 대상이 일치하면 다른 대상 값을 사용해도 됩니다.
12345678910111213141516171819202122apiVersion: v1
kind: Pod
metadata:
name: openai-wif-app
namespace: default
spec:
serviceAccountName: openai-wif
containers:
- name: app
image: my-image
volumeMounts:
- name: aks-sa-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: aks-sa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: "https://api.openai.com/v1"
expirationSeconds: 3600
워크로드 ID 페더레이션을 구성하기 전에 프로젝션된 서비스 계정 토큰 샘플을 로컬에서 디코딩해 클레임을 확인하세요. 프로젝션된 토큰을 마운트한 파드가 실행 중일 때 해당 파드에서 토큰을 가져와 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 페이로드를 디코딩합니다. 프로덕션 토큰은 로컬 디코더로 확인하고 타사 도구에 붙여 넣지 마세요.
프로젝션된 AKS 서비스 계정 토큰을 디코딩하면 다음과 비슷한 결과가 나옵니다.
1234567891011121314{
"iss": "https://eastus.oic.prod-aks.azure.com/11111111-2222-3333-4444-555555555555/22222222-3333-4444-5555-666666666666/",
"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: OpenAI 워크로드 ID 공급자에 설정한 AKS 발급자 URL과 일치해야 합니다.
aud: 프로젝션된 서비스 계정 토큰의 대상 및 OpenAI 워크로드 ID 공급자에 설정한 대상과 일치해야 합니다.
sub: 서비스 계정 매핑에 설정한 Kubernetes 서비스 계정 주체와 일치해야 합니다.
디코딩한 페이로드를 사용해 받은 토큰의 값과 OpenAI에 설정한 발급자, 대상, 매핑 값을 비교하세요. 대부분의 구성 문제는 토큰을 교환하기 전에 iss, aud, sub 클레임을 확인하면 찾을 수 있습니다.
OpenAI에서 AKS 발급자용 워크로드 ID 공급자를 만든 다음, 프로젝션된 토큰의 속성과 일치하는 서비스 계정 매핑을 추가하세요.
먼저 워크로드 ID 공급자를 구성한 다음 서비스 계정 매핑을 만드세요.
-
워크로드 ID 공급자를 만드세요. 이름에 azure-aks-prod와 같은 고유한 값을 입력하세요. 관리자가 클러스터를 식별할 수 있도록 설명에 Production AKS cluster와 같은 내용을 입력하세요.
-
발급자와 대상을 설정하세요. OIDC 발급자 URL에 az aks show --query "oidcIssuerProfile.issuerUrl" 명령이 반환한 발급자 값을 입력하세요. 이 값은 프로젝션된 AKS 서비스 계정 토큰의 iss 클레임과 일치해야 합니다. 대상에는 프로젝션 서비스 계정 토큰 볼륨에 설정한 대상과 같은 값을 입력하세요. 이 예시에서는 https://api.openai.com/v1입니다.
-
AKS OIDC 검색을 사용하세요. 토큰 검증에 업로드한 JWKS 사용 옵션은 비활성화 상태로 두세요. OpenAI는 AKS 발급자의 OIDC 검색 메타데이터와 JWKS를 사용해 프로젝션된 서비스 계정 토큰을 검증합니다.
-
매핑에 파생 속성이 필요하면 속성 변환을 추가하세요. 예를 들어 속성 이름으로 aks_subject를 입력하고 표현식으로 assertion.sub를 지정하면 openai.aks_subject가 생성됩니다. 대시보드가 openai. 접두사를 자동으로 붙입니다. 원본 토큰에서 이름이 이미 openai.로 시작하는 클레임은 해당 변환을 구성한 경우에만 openai.로 시작하는 매핑 키에 사용됩니다.
-
서비스 계정 매핑을 만드세요. 이름에 default-openai-wif와 같이 해당 워크로드 ID 공급자 내에서 고유한 값을 입력하세요. 어떤 워크로드가 이 매핑을 사용할 수 있는지 알 수 있도록 설명에 Default namespace AKS OpenAI API workload와 같은 내용을 입력하세요.
-
AKS 서비스 계정 주체를 매핑 조건으로 지정하세요. 키에 sub를, 값에 system:serviceaccount:default:openai-wif를 입력하세요. AKS 서비스 계정의 주체 형식은 system:serviceaccount:<namespace>:<service-account-name>입니다.
워크로드 ID 공급자는 설정된 AKS 발급자가 발급한 토큰만 허용합니다. 서비스 계정 매핑은 지정된 Kubernetes 서비스 계정 주체만 액세스할 수 있도록 범위를 더 좁힙니다.
-
OpenAI 대상을 선택하세요. 프로젝트에서 대상 서비스 계정이 속한 OpenAI 프로젝트를 선택하세요. 서비스 계정에서 azure-aks-prod-openai-wif와 같이 AKS 워크로드가 사용할 수 있는 OpenAI 서비스 계정을 선택하세요.
-
필요하면 API 권한을 제한하세요. 권한에서 api.model.request, api.vector_store.read 등 적절한 권한을 선택해 이 매핑으로 발급되는 액세스 토큰의 권한 범위를 더 좁히세요. WIF에만 적용되는 범위 제한을 추가하지 않으려면 권한을 비워 두세요. 이 경우에도 토큰에는 매핑된 서비스 계정의 권한이 적용됩니다.
프로젝션된 AKS 서비스 계정 토큰을 읽고 이를 OpenAI가 발급하는 액세스 토큰으로 교환하도록 OpenAI SDK 클라이언트를 구성하세요.
SDK의 워크로드 ID 페더레이션 공급자에서 /var/run/secrets/tokens/token과 같이 토큰이 마운트된 경로를 주체 토큰 소스로 지정하세요. SDK는 AKS 토큰을 OpenAI가 발급하는 액세스 토큰으로 교환한 다음, OpenAI 토큰으로 API 요청을 인증합니다.
다음 예시에서는 사용자 지정 주체 토큰 공급자로 OpenAI 클라이언트를 초기화합니다. 공급자는 마운트된 파일 경로에서 프로젝션된 AKS 서비스 계정 토큰을 읽어 워크로드 ID 페더레이션의 주체 토큰으로 사용합니다.
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 mountedAksServiceAccountTokenProvider(path) {
return {
tokenType: "jwt",
getToken: async () => {
const token = (await readFile(path, "utf8")).trim();
if (!token) {
throw new Error("The mounted AKS service account token file is empty.");
}
return token;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: mountedAksServiceAccountTokenProvider(tokenPath),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from AKS 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_aks_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 AKS 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_aks_service_account_token_provider(TOKEN_PATH),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from AKS 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 mountedAksServiceAccountTokenProvider struct {
path string
}
func (p mountedAksServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p mountedAksServiceAccountTokenProvider) GetToken(_ context.Context, _ auth.HTTPDoer) (string, error) {
data, err := os.ReadFile(p.path)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-aks",
Message: "failed to read mounted AKS service account token",
Cause: err,
}
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "azure-aks",
Message: "mounted AKS 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: mountedAksServiceAccountTokenProvider{
path: tokenPath,
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from AKS 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 AzureAksWorkloadIdentityExample {
private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";
private AzureAksWorkloadIdentityExample() {}
static final class MountedAksServiceAccountTokenProvider implements SubjectTokenProvider {
private final Path tokenPath;
MountedAksServiceAccountTokenProvider(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(
"azure-aks", "failed to read mounted AKS service account token", e);
}
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"azure-aks", "mounted AKS 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 MountedAksServiceAccountTokenProvider(TOKEN_PATH))
.build();
OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-5.6-terra")
.input("Say hello from AKS 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 MountedAksServiceAccountTokenProvider
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 AKS service account token is empty",
provider: "azure-aks"
)
end
token
rescue SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to read mounted AKS service account token: #{e.message}",
provider: "azure-aks",
cause: e
)
end
end
provider = MountedAksServiceAccountTokenProvider.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 AKS workload identity federation."
)
puts(response.output_text)