Configuration de la fédération d’identités de charge de travail pour Microsoft Azure
Vous pouvez utiliser Microsoft Azure comme fournisseur d’identités de charge de travail dans deux cas :
Identité managée Azure : échangez un jeton d’accès Microsoft Entra ID émis pour une identité managée contre un jeton d’accès OpenAI de courte durée.
AKS : échangez un jeton projeté de compte de service Azure Kubernetes Service (AKS) contre un jeton d’accès OpenAI de courte durée.
Identité managée Azure
Les identités managées Azure permettent aux charges de travail hébergées sur Azure de demander des jetons Microsoft Entra sans stocker de secrets de longue durée. Dans le cadre de la fédération d’identités de charge de travail OpenAI, le jeton de l’identité managée sert de jeton du sujet. OpenAI le valide avant d’émettre un jeton d’accès OpenAI.
Configuration de l’identité managée Azure
Dans Microsoft Entra, créez ou utilisez une inscription d’application qui représente l’audience de jetons à laquelle OpenAI doit faire confiance. Configurez son URI d’ID d’application. Cet URI correspond à la valeur resource que votre charge de travail transmet au service de métadonnées d’instance Azure (IMDS). Il figure dans la revendication aud du jeton émis. Pour effectuer cette configuration côté Microsoft, consultez le guide Microsoft Entra pour créer une application Entra ID et un principal de service.
L’URI d’ID d’application configuré dans Microsoft Entra ID, le paramètre resource d’IMDS,
la revendication aud du jeton obtenu et l’audience du fournisseur d’identités de charge
de travail configuré dans OpenAI doivent tous avoir la même valeur.
Créez une identité managée, puis attribuez-la à la ressource Azure qui exécute votre application, par exemple une machine virtuelle. La ressource doit pouvoir appeler IMDS pendant l’exécution. Pour plus de détails sur la configuration d’Azure, consultez la présentation des identités managées de Microsoft et les instructions d’attribution d’une identité dans la documentation de la ressource Azure concernée.
Obtention d’un jeton d’identité managée Azure
Depuis la ressource Azure à laquelle vous avez attribué l’identité managée, demandez un jeton à IMDS en indiquant l’URI d’ID d’application comme valeur du paramètre resource. OpenAI utilise ce jeton comme jeton du sujet et l’échange contre un jeton d’accès qu’il émet.
Avant de configurer la fédération d’identités de charge de travail, exportez le jeton Microsoft Entra dans la variable TOKEN, puis exécutez ce script localement pour examiner ses revendications :
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18constparts= process.env.TOKEN?.split(".") ?? [];if (parts.length!==3) {thrownewError("Expected a compact JWT with three segments");}if (!/^[A-Za-z0-9_-]+$/.test(parts[1]) || parts[1].length%4===1) {thrownewError("JWT payload is not valid Base64URL");}constbytes= Buffer.from(parts[1], "base64url");if (bytes.toString("base64url") !== parts[1]) {thrownewError("JWT payload is not valid Base64URL");}constdecoded=newTextDecoder("utf-8", { fatal: true }).decode(bytes);constclaims=JSON.parse(decoded);if (claims ===null|| Array.isArray(claims) ||typeof claims !=="object") {thrownewError("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 base64import jsonimport osimport redef 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 mainimport ( "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 == 3unless parts[1].match?(/\A[A-Za-z0-9_-]+\z/) && parts[1].length % 4 != 1 raise "JWT payload is not valid Base64URL"endbegin payload = Base64.urlsafe_decode64(parts[1].ljust((parts[1].length + 3) & ~3, "="))rescue ArgumentError raise "JWT payload is not valid Base64URL"endunless Base64.urlsafe_encode64(payload, padding: false) == parts[1] raise "JWT payload is not valid Base64URL"endpayload.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)
Cette commande décode la charge utile du JWT sans vérifier la signature du jeton. Pour les jetons de production, utilisez un décodeur local et évitez de les coller dans des outils tiers.
Voici un exemple de jeton d’identité managée Microsoft Entra ID décodé :
Vérifiez les revendications que vous comptez utiliser dans votre configuration OpenAI :
iss : utilisez la valeur exacte de l’émetteur figurant dans le jeton. Il peut s’agir de https://login.microsoftonline.com/<tenant-id>/v2.0, mais ne présumez pas que le suffixe est toujours le même.
aud : la valeur doit correspondre à l’URI d’ID d’application, au paramètre resource d’IMDS et à l’audience du fournisseur d’identités de charge de travail configuré dans OpenAI.
tid : ID du locataire Microsoft Entra.
appid : ID d’application (ID client) de l’identité managée, si cette revendication est présente.
iat et exp : vérifiez la durée de vie totale du jeton, calculée en secondes avec exp - iat.
Pour Codex, définissez le paramètre max_assertion_lifetime_seconds du fournisseur sur une limite approuvée qui couvre toute la plage des durées de vie prévues pour les jetons de l’émetteur. N’utilisez pas la durée de validité restante du jeton et ne présumez pas que tous les jetons Entra sont valides une heure. Microsoft décrit les durées de vie variables des jetons d’accès et ne prend pas en charge la configuration de la durée de vie des jetons d’identité managée.
Les jetons d’identité managée peuvent aussi contenir des revendications telles que azp, oid, sub ou xms_mirid. Prenez le jeton décodé comme référence et choisissez des revendications qui identifient précisément l’identité managée et le périmètre de ressources auxquels vous faites confiance.
À partir de la charge utile décodée, comparez les valeurs du jeton reçu à celles de l’émetteur, de l’audience et du mappage configurés dans OpenAI. Avant même d’échanger le jeton, vous pouvez repérer la plupart des problèmes de configuration en examinant les revendications iss, aud, tid et celles propres à l’identité managée.
Configuration de la fédération d’identités de charge de travail
Dans OpenAI, créez un fournisseur d’identités de charge de travail pour l’émetteur Microsoft Entra ID, puis ajoutez un mappage de compte de service fondé sur des revendications stables du jeton d’identité managée.
Configurez d’abord le fournisseur d’identités de charge de travail, puis créez le mappage de compte de service.
Configuration du fournisseur d’identités de charge de travail
Créez le fournisseur d’identités de charge de travail. Dans Nom, saisissez une valeur unique, par exemple azure-managed-identity-prod. Renseignez le champ Description, par exemple avec Production Azure managed identity workloads, pour aider les administrateurs à identifier le fournisseur.
Définissez l’émetteur et l’audience. Dans URL de l’émetteur OIDC, saisissez la valeur exacte de la revendication iss du jeton. Pour la connaître, obtenez d’abord un jeton d’identité managée et examinez ses revendications. L’émetteur peut être, par exemple, https://login.microsoftonline.com/<tenant-id>/v2.0. Dans Audience, saisissez l’URI d’ID d’application Microsoft Entra que vous avez configuré, par exemple api://<application-client-id>. Cette valeur doit correspondre à la revendication aud du jeton.
Utilisez la vérification des jetons Microsoft Entra. Laissez l’option Utiliser le JWKS importé pour vérifier les jetons désactivée. OpenAI utilise les métadonnées et le JWKS de l’émetteur Microsoft Entra pour vérifier le jeton d’identité managée.
Ajoutez des transformations d’attributs si votre mappage nécessite des attributs dérivés. Par exemple, saisissez managed_identity_client_id et l’expression assertion.appid pour créer openai.managed_identity_client_id à partir de la revendication contenant l’ID d’application (ID client) de l’identité managée. Le tableau de bord ajoute automatiquement le préfixe openai.. OpenAI ignore les revendications brutes du jeton dont le nom commence déjà par openai. pour les clés de mappage préfixées par openai., sauf si vous configurez la transformation correspondante.
Configuration du mappage de compte de service
Créez un mappage de compte de service. Dans Nom, saisissez une valeur unique pour ce fournisseur d’identités de charge de travail, par exemple vm-openai-wif. Renseignez le champ Description, par exemple avec Production VM Azure managed identity workload, pour indiquer quelle charge de travail peut utiliser ce mappage.
Faites correspondre les revendications stables de l’identité managée. Pour chaque revendication à vérifier, ajoutez une ligne avec une Clé et une Valeur. Si le jeton contient appid, saisissez dans Clé la valeur appid et dans Valeur l’ID client de l’identité managée. La revendication appid contient l’ID d’application (ID client) de l’identité managée. C’est généralement la revendication la plus stable pour lier un mappage à une identité managée précise. Si votre jeton ne contient pas appid, utilisez une autre revendication stable du jeton décodé, par exemple azp, oid, sub ou xms_mirid. Pour limiter le mappage à un seul locataire, ajoutez une ligne avec Clé définie sur tid et Valeur définie sur l’ID du locataire Microsoft Entra. Décodez un jeton obtenu auprès d’IMDS et utilisez des revendications stables pour l’identité managée et la ressource auxquelles vous faites confiance.
Choisissez la cible OpenAI. Dans Projet, sélectionnez le projet OpenAI auquel appartient le compte de service cible. Dans Compte de service, sélectionnez le compte de service OpenAI que la charge de travail Azure peut utiliser, par exemple azure-managed-identity-prod-openai-wif.
Limitez les autorisations d’API si nécessaire. Sélectionnez les Autorisations appropriées, par exemple api.model.request et api.vector_store.read, pour restreindre davantage les droits conférés par les jetons d’accès émis à partir de ce mappage. Si vous laissez le champ vide, aucune restriction de portée propre à la fédération (WIF) ne s’ajoute. Les requêtes effectuées avec le jeton restent soumises aux autorisations du compte de service associé.
Utilisation du jeton dans le code
Configurez le client du SDK OpenAI pour qu’il demande un jeton d’identité managée Azure à IMDS et l’échange contre un jeton d’accès émis par OpenAI.
Définissez OPENAI_WIF_AUDIENCE sur l’URI d’ID d’application Microsoft Entra configuré comme audience du fournisseur d’identités de charge de travail. Le SDK demande un jeton d’identité managée pour cette audience, l’échange contre un jeton d’accès émis par OpenAI, puis utilise ce dernier pour authentifier les requêtes API.
Authentification à partir d’un jeton d’identité managée Azure
Pour utiliser AKS comme fournisseur d’identités de charge de travail, échangez un jeton projeté de compte de service émis par AKS contre un jeton d’accès OpenAI de courte durée.
Les charges de travail AKS peuvent aussi utiliser Azure Workload Identity pour obtenir un jeton d’accès Microsoft Entra ID pour une identité managée associée à la charge de travail. Dans cette configuration, OpenAI valide le jeton Microsoft Entra à la place du jeton projeté de compte de service Kubernetes. Pour configurer la fédération d’identités de charge de travail dans OpenAI, suivez les étapes de la section Identité managée Azure. Pour configurer Azure Workload Identity, suivez la documentation de Microsoft.
Configuration d’AKS
Récupérez l’URL de l’émetteur OIDC associé au cluster AKS :
12345az aks show \ --name <cluster-name> \ --resource-group <resource-group> \ --query "oidcIssuerProfile.issuerUrl" \ --output tsv
Si l’URL de l’émetteur est vide, activez l’émetteur OIDC AKS pour le cluster avec la commande suivante :
L’émetteur configuré dans le fournisseur d’identités de charge de travail OpenAI doit correspondre à cette URL et à la revendication iss du jeton projeté de compte de service AKS.
Utilisez un ServiceAccount Kubernetes pour la charge de travail AKS qui doit appeler l’API OpenAI. Si vous n’en avez pas encore, créez-en un :
Configurez le jeton projeté de compte de service avec l’audience attendue par OpenAI et une durée de validité adaptée à votre charge de travail. OpenAI vérifie l’émetteur, la signature, l’audience et l’expiration du jeton. Dans cet exemple, le fichier du jeton est monté à l’emplacement /var/run/secrets/tokens/token. Le jeton a pour audience https://api.openai.com/v1 et expire au bout de 3 600 secondes. Vous pouvez choisir une autre audience si celle du jeton projeté correspond à celle du fournisseur d’identités de charge de travail configuré dans OpenAI.
Avant de configurer la fédération d’identités de charge de travail, décodez localement un jeton projeté de compte de service et examinez ses revendications. Récupérez le jeton depuis un pod en cours d’exécution dans lequel le jeton projeté est monté, puis exportez-le dans la variable TOKEN :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18constparts= process.env.TOKEN?.split(".") ?? [];if (parts.length!==3) {thrownewError("Expected a compact JWT with three segments");}if (!/^[A-Za-z0-9_-]+$/.test(parts[1]) || parts[1].length%4===1) {thrownewError("JWT payload is not valid Base64URL");}constbytes= Buffer.from(parts[1], "base64url");if (bytes.toString("base64url") !== parts[1]) {thrownewError("JWT payload is not valid Base64URL");}constdecoded=newTextDecoder("utf-8", { fatal: true }).decode(bytes);constclaims=JSON.parse(decoded);if (claims ===null|| Array.isArray(claims) ||typeof claims !=="object") {thrownewError("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 base64import jsonimport osimport redef 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 mainimport ( "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 == 3unless parts[1].match?(/\A[A-Za-z0-9_-]+\z/) && parts[1].length % 4 != 1 raise "JWT payload is not valid Base64URL"endbegin payload = Base64.urlsafe_decode64(parts[1].ljust((parts[1].length + 3) & ~3, "="))rescue ArgumentError raise "JWT payload is not valid Base64URL"endunless Base64.urlsafe_encode64(payload, padding: false) == parts[1] raise "JWT payload is not valid Base64URL"endpayload.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)
Cette commande décode la charge utile du JWT sans vérifier la signature du jeton. Pour les jetons de production, utilisez un décodeur local et évitez de les coller dans des outils tiers.
Voici un exemple de jeton projeté de compte de service AKS décodé :
Vérifiez les revendications que vous comptez utiliser dans votre configuration OpenAI :
iss : la valeur doit correspondre à l’URL de l’émetteur AKS configurée dans le fournisseur d’identités de charge de travail OpenAI.
aud : la valeur doit correspondre à l’audience du jeton projeté de compte de service et à celle du fournisseur d’identités de charge de travail configuré dans OpenAI.
sub : la valeur doit correspondre au sujet du compte de service Kubernetes que vous indiquez dans le mappage de compte de service.
À partir de la charge utile décodée, comparez les valeurs du jeton reçu à celles de l’émetteur, de l’audience et du mappage configurés dans OpenAI. Avant même d’échanger le jeton, vous pouvez repérer la plupart des problèmes de configuration en examinant les revendications iss, aud et sub.
Configuration de la fédération d’identités de charge de travail
Dans OpenAI, créez un fournisseur d’identités de charge de travail pour l’émetteur AKS, puis ajoutez un mappage de compte de service fondé sur les attributs du jeton projeté.
Configurez d’abord le fournisseur d’identités de charge de travail, puis créez le mappage de compte de service.
Configuration du fournisseur d’identités de charge de travail
Créez le fournisseur d’identités de charge de travail. Dans Nom, saisissez une valeur unique, par exemple azure-aks-prod. Renseignez le champ Description, par exemple avec Production AKS cluster, pour aider les administrateurs à identifier le cluster.
Définissez l’émetteur et l’audience. Dans URL de l’émetteur OIDC, saisissez l’URL renvoyée par az aks show --query "oidcIssuerProfile.issuerUrl". Cette valeur doit correspondre à la revendication iss du jeton projeté de compte de service AKS. Dans Audience, saisissez l’audience configurée sur le volume du jeton projeté de compte de service. Dans cet exemple, il s’agit de https://api.openai.com/v1.
Utilisez la découverte OIDC d’AKS. Laissez l’option Utiliser le JWKS importé pour vérifier les jetons désactivée. OpenAI utilise les métadonnées de découverte OIDC et le JWKS de l’émetteur AKS pour vérifier le jeton projeté de compte de service.
Ajoutez des transformations d’attributs si votre mappage nécessite des attributs dérivés. Par exemple, saisissez aks_subject et l’expression assertion.sub pour créer openai.aks_subject. Le tableau de bord ajoute automatiquement le préfixe openai.. OpenAI ignore les revendications brutes du jeton dont le nom commence déjà par openai. pour les clés de mappage préfixées par openai., sauf si vous configurez la transformation correspondante.
Configuration du mappage de compte de service
Créez un mappage de compte de service. Dans Nom, saisissez une valeur unique pour ce fournisseur d’identités de charge de travail, par exemple default-openai-wif. Renseignez le champ Description, par exemple avec Default namespace AKS OpenAI API workload, pour indiquer quelle charge de travail peut utiliser ce mappage.
Faites correspondre le sujet du compte de service AKS. Dans Clé, saisissez sub et dans Valeur, saisissez system:serviceaccount:default:openai-wif. Pour les comptes de service AKS, le sujet suit le format system:serviceaccount:<namespace>:<service-account-name>.
Le fournisseur d’identités de charge de travail accepte uniquement les jetons de l’émetteur AKS configuré. Le mappage de compte de service limite ensuite l’accès au sujet du compte de service Kubernetes indiqué.
Choisissez la cible OpenAI. Dans Projet, sélectionnez le projet OpenAI auquel appartient le compte de service cible. Dans Compte de service, sélectionnez le compte de service OpenAI que la charge de travail AKS peut utiliser, par exemple azure-aks-prod-openai-wif.
Limitez les autorisations d’API si nécessaire. Sélectionnez les Autorisations appropriées, par exemple api.model.request et api.vector_store.read, pour restreindre davantage les droits conférés par les jetons d’accès émis à partir de ce mappage. Si vous laissez le champ vide, aucune restriction de portée propre à la fédération (WIF) ne s’ajoute. Les requêtes effectuées avec le jeton restent soumises aux autorisations du compte de service associé.
Utilisation du jeton dans le code
Configurez le client du SDK OpenAI pour qu’il lise le jeton projeté de compte de service AKS et l’échange contre un jeton d’accès émis par OpenAI.
Indiquez le chemin du fichier de jeton monté, par exemple /var/run/secrets/tokens/token, comme source du jeton du sujet pour le fournisseur de fédération d’identités de charge de travail du SDK. Le SDK échange ce jeton AKS contre un jeton d’accès émis par OpenAI, puis utilise ce dernier pour authentifier les requêtes API.
Les exemples suivants initialisent un client OpenAI avec un fournisseur personnalisé de jetons du sujet. Ce fournisseur lit le jeton projeté de compte de service AKS dans le fichier monté au chemin indiqué et l’utilise comme jeton du sujet pour la fédération d’identités de charge de travail.
Authentification à partir d’un jeton projeté de compte de service 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
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 ) endendprovider = 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)
Bonnes pratiques pour Microsoft Azure
Utilisez des identités managées chaque fois que possible. Elles offrent un mode d’authentification plus simple et plus sûr que la distribution manuelle d’informations d’identification.
Utilisez des identités managées, des applications Microsoft Entra et des mappages OpenAI distincts pour chaque application et chaque environnement. Évitez de partager une identité entre les charges de travail de développement, de préproduction et de production.
Limitez les audiences acceptées. Configurez uniquement celles dont vous avez besoin pour la fédération d’identités de charge de travail OpenAI.
Utilisez une application Microsoft Entra ID dédiée à chaque périmètre de sécurité. Des applications distinctes permettent de mieux identifier les responsables et facilitent les audits et la gestion des accès.
Privilégiez les mappages propres à chaque charge de travail. Fondez les correspondances sur des revendications spécifiques à la charge de travail plutôt que sur des attributs communs à l’ensemble du locataire.
Vérifiez régulièrement la configuration des informations d’identification fédérées. Des informations d’identification obsolètes peuvent continuer à accorder un accès involontaire bien après la mise hors service des charges de travail.
Séparez les identités de production de celles des autres environnements. Les charges de travail de production doivent s’authentifier avec des identités fédérées et des comptes de service OpenAI distincts.