Azure 托管身份让 Azure 上托管的工作负载无需存储长期密钥,即可请求 Microsoft Entra Token。在 OpenAI 工作负载身份联合中,托管身份 Token 充当主体 Token,OpenAI 会先验证该 Token,再签发 OpenAI 访问令牌。
创建或使用一个 Microsoft Entra 应用注册,用于表示 OpenAI 应信任的 Token 受众。配置其 应用程序 ID URI;此 URI 是您的工作负载向 Azure 实例元数据服务(IMDS)请求 Token 时使用的 resource 值,也会作为已签发 Token 中的 aud 声明。有关 Microsoft 的设置步骤,请参阅 Microsoft Entra 的创建新的 Entra ID 应用程序和服务主体指南。
Microsoft Entra ID 中配置的应用程序 ID URI、IMDS 的 resource 参数、
生成的 Token 中的 aud 声明,以及 OpenAI 工作负载身份提供方的受众
必须全部一致。
创建一个托管身份,然后将其分配给运行您应用程序的 Azure 资源,例如虚拟机。该资源必须能够在运行时调用 IMDS。有关 Azure 设置的详细信息,请参阅 Microsoft 的托管身份概述,以及相关 Azure 资源文档中关于分配身份的说明。
从已分配托管身份的 Azure 资源向 IMDS 请求 Token,并将应用程序 ID URI 用作 resource 参数。此 Token 是主体 Token,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
如果资源具有多个用户分配的托管身份,请添加 client_id、object_id 或 msi_res_id 查询参数,以指定您要使用的托管身份。Microsoft 在使用虚拟机上的托管身份获取访问令牌中介绍了 IMDS Token 请求参数。
配置工作负载身份联合之前,请将 Microsoft Entra Token 导出为环境变量 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 载荷,但不会验证 Token 签名。对于生产环境的 Token,请使用本地解码器,避免将其粘贴到第三方工具中。
解码后的 Microsoft Entra ID 托管身份 Token 类似如下:
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:使用 Token 中的确切签发者值。签发者可能为 https://login.microsoftonline.com/<tenant-id>/v2.0,但不要假定其一定包含该后缀。
aud:必须与应用程序 ID URI、IMDS 的 resource 参数和 OpenAI 工作负载身份提供方的受众一致。
tid:Microsoft Entra 租户 ID。
appid:如果存在此声明,其值为托管身份的应用程序/客户端 ID。
iat 和 exp:检查 Token 的完整有效期,即 exp - iat,单位为秒。
如果您使用 Codex,请将提供方的 max_assertion_lifetime_seconds 设置为经批准的上限,
且该上限应涵盖签发者预期的 Token 有效期范围。不要使用
Token 的剩余有效时间,也不要假定每个 Entra Token 的有效期都是一小时。
Microsoft 文档说明了访问令牌有效期
的可变性,
并且不支持配置托管身份 Token
的有效期。
请参阅管理 API 提供方
示例。
托管身份 Token 还可能包含 azp、oid、sub 或 xms_mirid 等声明。请以解码后的 Token 为准,选择能够准确标识您信任的托管身份及资源边界的声明。
使用解码后的载荷,将您收到的 Token 与 OpenAI 中配置的签发者、受众和映射值进行比较。在交换 Token 之前,通过 iss、aud、tid 和托管身份声明就能发现大多数配置问题。
在 OpenAI 中为 Microsoft Entra ID 签发者创建工作负载身份提供方,然后添加服务账户映射,使其匹配托管身份 Token 中的稳定声明。
先配置工作负载身份提供方,再创建服务账户映射。
-
创建工作负载身份提供方。 将 名称 设置为唯一值,例如 azure-managed-identity-prod。填写 描述(例如 Production Azure managed identity workloads),帮助管理员识别该提供方。
-
设置签发者和受众。 将 OIDC 签发者 URL 设置为 Token 中 iss 声明的确切值。请先获取一个托管身份 Token 样本,并检查其声明。例如,签发者可能为 https://login.microsoftonline.com/<tenant-id>/v2.0。将 受众 设置为您配置的 Microsoft Entra 应用程序 ID URI,例如 api://<application-client-id>。此值必须与 Token 的 aud 声明一致。
-
使用 Microsoft Entra Token 验证。 保持 使用上传的 JWKS 验证 Token 处于禁用状态。OpenAI 使用 Microsoft Entra 签发者元数据和 JWKS 来验证托管身份 Token。
-
如果需要派生的映射属性,请添加属性转换。 例如,输入 managed_identity_client_id 并使用表达式 assertion.appid,从托管身份的应用程序/客户端 ID 声明创建 openai.managed_identity_client_id。控制台会自动添加 openai. 前缀。对于 openai. 映射键,除非配置了匹配的转换,否则已带有 openai. 前缀的原始 Token 声明会被忽略。
-
创建服务账户映射。 将 名称 设置为在该工作负载身份提供方内唯一的值,例如 vm-openai-wif。填写 描述(例如 Production VM Azure managed identity workload),说明哪些工作负载可以使用此映射。
-
匹配稳定的托管身份声明。 为每个必须匹配的声明添加一行 键 和 值 。如果 Token 包含 appid,请将 键 设置为 appid,将 值 设置为托管身份的客户端 ID。appid 声明标识托管身份的应用程序/客户端 ID,通常是将映射绑定到特定托管身份时最稳定的声明。如果您的 Token 不包含 appid,请使用解码后 Token 中的其他稳定声明,例如 azp、oid、sub 或 xms_mirid。要将映射绑定到单个租户,还需将 键 设置为 tid,将 值 设置为 Microsoft Entra 租户 ID。解码来自 IMDS 的 Token 样本,并使用对您信任的托管身份和资源保持稳定的声明。
-
选择 OpenAI 目标。 将 项目 设置为目标服务账户所属的 OpenAI 项目。将 服务账户 设置为 Azure 工作负载可以使用的 OpenAI 服务账户,例如 azure-managed-identity-prod-openai-wif。
-
根据需要缩小 API 权限范围。 选择适当的 权限 ,例如 api.model.request 和 api.vector_store.read,进一步限制通过此映射签发的访问令牌。将权限留空则不会添加 WIF 专属的作用域限制;该 Token 仍以映射到的服务账户身份获得授权。
配置您的 OpenAI SDK 客户端,使其从 IMDS 请求 Azure 托管身份 Token,并将其交换为由 OpenAI 签发的访问令牌。
将 OPENAI_WIF_AUDIENCE 设置为已配置为工作负载身份提供方受众的 Microsoft Entra 应用程序 ID URI。SDK 会为该受众请求托管身份 Token,将其交换为由 OpenAI 签发的访问令牌,并使用该 OpenAI Token 对 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 签发的投射服务账户 Token 交换为短期 OpenAI 访问令牌,即可将 AKS 用作工作负载身份提供方。
AKS 工作负载也可以使用 Azure 工作负载身份,为关联到该工作负载的托管身份
获取 Microsoft Entra ID 访问令牌。在此
配置下,OpenAI 验证的是 Microsoft Entra Token,而不是
投射的 Kubernetes 服务账户 Token。请按照
Azure 托管
身份中的步骤配置 OpenAI 工作负载身份联合,并根据 Microsoft 文档
配置 Azure 工作负载身份。
获取与 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 工作负载身份提供方中配置的签发者必须与此签发者 URL 以及投射的 AKS 服务账户 Token 中的 iss 声明一致。
为需要调用 OpenAI API 的 AKS 工作负载使用 Kubernetes ServiceAccount。如果您尚未创建,请先创建一个:
kubectl create serviceaccount openai-wif --namespace default
为投射服务账户 Token 配置 OpenAI 预期的受众,以及适合您工作负载的到期时间。OpenAI 会验证 Token 的签发者、签名、受众和到期时间。在此示例中,Token 文件挂载在 /var/run/secrets/tokens/token,使用的受众为 https://api.openai.com/v1,并在 3600 秒后到期。您可以使用其他受众,只要投射 Token 的受众与 OpenAI 工作负载身份提供方的受众一致即可。
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
配置工作负载身份联合之前,请在本地解码一个投射服务账户 Token 样本,并检查其声明。从已挂载投射 Token 且正在运行的 Pod 中获取该 Token,然后将其导出为环境变量 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 工作负载身份提供方中配置的 AKS 颁发者 URL 匹配。
aud:必须与投射服务账户令牌的受众以及 OpenAI 工作负载身份提供方的受众匹配。
sub:必须与您在服务账户映射中配置的 Kubernetes 服务账户主体匹配。
使用解码后的载荷,将收到的令牌与 OpenAI 中配置的颁发者、受众和映射值进行比较。在交换令牌之前,通过 iss、aud 和 sub 声明就能发现大多数配置问题。
在 OpenAI 中为 AKS 颁发者创建工作负载身份提供方,然后添加与投射令牌中的属性匹配的服务账户映射。
先配置工作负载身份提供方,再创建服务账户映射。
-
创建工作负载身份提供方。 将 名称 设为唯一值,例如 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。填写 描述(例如 Default namespace AKS OpenAI API workload),以说明哪些工作负载可以使用此映射。
-
匹配 AKS 服务账户主体。 将 键 设为 sub,将 值 设为 system:serviceaccount:default:openai-wif。AKS 服务账户的主体格式为 system:serviceaccount:<namespace>:<service-account-name>。
工作负载身份提供方仅接受由所配置的 AKS 颁发者颁发的令牌。服务账户映射会进一步将访问权限限制为指定的 Kubernetes 服务账户主体。
-
选择 OpenAI 目标。 将 项目 设为目标服务账户所属的 OpenAI 项目。将 服务账户 设为 AKS 工作负载可以使用的 OpenAI 服务账户,例如 azure-aks-prod-openai-wif。
-
根据需要缩小 API 权限范围。 选择适当的 权限 ,例如 api.model.request 和 api.vector_store.read,以进一步限制通过此映射签发的访问令牌的权限范围。将权限留空可避免添加 WIF 专属的作用域限制;令牌仍以所映射服务账户的身份获得授权。
配置您的 OpenAI SDK 客户端,使其读取 AKS 投射服务账户令牌,并将其交换为 OpenAI 颁发的访问令牌。
使用已挂载的令牌路径(例如 /var/run/secrets/tokens/token)作为 SDK 工作负载身份联合提供方的主体令牌来源。SDK 会将该 AKS 令牌交换为 OpenAI 颁发的访问令牌,并使用此 OpenAI 令牌对 API 请求进行身份验证。
以下示例使用自定义主体令牌提供方初始化 OpenAI 客户端。该提供方从已挂载的文件路径读取 AKS 投射服务账户令牌,并将其用作工作负载身份联合的主体令牌。
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)