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 リソースに割り当てます。このリソースは、実行時に 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 がある場合は、使用するマネージド ID を示す client_id、object_id、または msi_res_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 ですが、末尾が常に /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 に関するクレームを確認することで特定できます。
Microsoft Entra ID の発行者に対応するワークロード ID プロバイダーを OpenAI で作成し、マネージド ID トークンの安定したクレームに一致するサービスアカウントマッピングを追加します。
まずワークロード ID プロバイダーを設定し、次にサービスアカウントマッピングを作成します。
-
ワークロード ID プロバイダーを作成します。 名前には azure-managed-identity-prod などの一意の値を設定します。説明には Production Azure managed identity workloads のように、管理者がプロバイダーを識別できる情報を入力します。
-
発行者とオーディエンスを設定します。 OIDC 発行者 URL には、トークンの iss クレームの値をそのまま設定します。あらかじめサンプルのマネージド ID トークンを取得し、クレームを確認してください。発行者の例は https://login.microsoftonline.com/<tenant-id>/v2.0 です。オーディエンスには、Microsoft Entra で設定したアプリケーション ID URI(例:api://<application-client-id>)を指定します。この値はトークンの 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 の安定したクレームを照合します。 一致を必須とするクレームごとに、キーと値の組を 1 行ずつ追加します。トークンに appid が含まれている場合は、キーを appid に、値をマネージド ID のクライアント ID に設定します。appid クレームはマネージド ID のアプリケーション ID(クライアント ID)を示し、通常、マッピングを特定のマネージド ID に関連付けるうえで最も安定したクレームです。トークンに appid が含まれていない場合は、デコードしたトークンから azp、oid、sub、xms_mirid など、別の安定したクレームを選びます。マッピングを 1 つのテナントに限定するには、さらにキーを 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 が発行し、Pod に投影されたサービスアカウントトークンを、有効期間の短い OpenAI アクセストークンと交換することで、AKS をワークロード ID プロバイダーとして使用できます。
AKS ワークロードでは、Azure Workload Identity を使って、ワークロードに関連付けられたマネージド ID の Microsoft Entra ID アクセストークンを取得することもできます。この構成で OpenAI が検証するのは、投影された Kubernetes サービスアカウントトークンではなく、Microsoft Entra トークンです。OpenAI のワークロード ID フェデレーションは「Azure マネージド ID」の手順に従って設定し、Azure Workload Identity は Microsoft のドキュメントに従って設定してください。
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 フェデレーションを構成する前に、投影されたサービスアカウントトークンのサンプルをローカルでデコードし、クレームを確認します。投影されたトークンがマウントされている実行中の Pod からトークンを取得し、環境変数 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 の各クレームを確認することで特定できます。
AKS の発行者に対応するワークロード ID プロバイダーを OpenAI で作成し、投影されたトークンの属性に一致するサービスアカウントマッピングを追加します。
まずワークロード 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)