Configuration de la fédération d’identités de charge de travail pour Kubernetes
Utilisez Kubernetes comme fournisseur d’identités de charge de travail en échangeant un jeton projeté de compte de service Kubernetes contre un jeton d’accès OpenAI de courte durée.
Configuration de Kubernetes
Ce guide suppose que la projection des jetons de compte de service Kubernetes est activée. Cette fonctionnalité est disponible par défaut dans les versions récentes de Kubernetes. La fédération d’identités de charge de travail d’OpenAI exige des jetons projetés de compte de service compatibles avec OIDC. Elle ne prend pas en charge les anciens jetons de compte de service Kubernetes stockés dans des Secrets.
Utilisez un ServiceAccount Kubernetes pour la charge de travail qui doit appeler l’API OpenAI. Si vous n’en avez pas encore, créez-en un :
Récupérez l’URL de l’émetteur OIDC de votre cluster Kubernetes :
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
Même si vous importez le JWKS et qu’OpenAI ne le recherche pas auprès de l’émetteur OIDC, l’URL de cet émetteur doit correspondre à celle configurée dans le fournisseur d’identités de charge de travail.
Récupérez le JWKS du cluster et enregistrez le jeu de clés renvoyé. Vous en aurez besoin pour configurer le fournisseur d’identités de charge de travail :
kubectl get --raw /openid/v1/jwks
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 la date d’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 à condition de configurer la même valeur pour le jeton projeté et pour le fournisseur d’identités de charge de travail dans OpenAI :
Avant de configurer la fédération d’identités de charge de travail, décodez localement un exemple de 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 fichier du 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. Utilisez un décodeur local pour les jetons de production et évitez de les coller dans des outils tiers.
Voici à quoi ressemble un jeton projeté de compte de service Kubernetes une fois décodé :
À partir de la charge utile décodée, comparez les valeurs du jeton reçu avec l’émetteur, l’audience et les valeurs de mappage configurés dans OpenAI. Avant d’échanger le jeton, vous pouvez repérer la plupart des erreurs 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 Kubernetes, puis ajoutez un mappage de compte de service dont les critères correspondent aux 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 le champ Nom, saisissez une valeur unique, par exemple kubernetes-prod. Dans le champ Description, saisissez un texte qui aidera les administrateurs à identifier le cluster, par exemple Production Kubernetes cluster.
Définissez l’émetteur et l’audience. Dans le champ URL de l’émetteur OIDC, saisissez l’URL renvoyée par kubectl get --raw /.well-known/openid-configuration | jq -r .issuer. Cette valeur doit correspondre à la revendication iss du jeton projeté. Dans le champ Audience, saisissez la même chaîne opaque que celle définie pour l’audience du jeton de compte de service dans le volume projeté. Dans cet exemple, la valeur est https://api.openai.com/v1.
Importez le JWKS de Kubernetes. Activez l’option Utiliser le JWKS importé pour vérifier les jetons, puis collez dans le champ JWKS au format JSON la sortie de kubectl get --raw /openid/v1/jwks. OpenAI utilise ce jeu de clés publiques pour vérifier les jetons projetés de compte de service Kubernetes. Importez le jeu de clés complet, y compris le champ keys qui les contient.
Remarque : Pour les clusters Kubernetes auto-hébergés, OpenAI prend uniquement en charge le mode JWKS local. Importez le JWKS renvoyé par votre cluster. OpenAI n’effectue pas de découverte OIDC auprès de l’émetteur configuré. OpenAI compare toutefois cet émetteur au champ iss du jeton.
Si votre cluster renouvelle les clés de signature des comptes de service, mettez à jour le JWKS importé dans la configuration du fournisseur d’identités de charge de travail. OpenAI rejette les jetons signés avec des clés absentes du JWKS configuré. Si le JWKS contient plusieurs clés publiques actives, incluez le tableau keys complet.
Ajoutez des transformations d’attributs uniquement si le mappage nécessite des attributs dérivés. Vous pouvez utiliser directement les revendications brutes du jeton, telles que sub, aud et iss, dans les assertions de mappage. Si vous utilisez des attributs transformés comme critères de correspondance à la place des revendications brutes, le tableau de bord ajoute automatiquement le préfixe openai.. Par exemple, saisissez workload_subject avec l’expression assertion.sub pour créer openai.workload_subject. Les revendications brutes du jeton qui commencent déjà par openai. sont ignorées pour les clés de mappage préfixées par openai., sauf si vous configurez une transformation correspondante.
Configuration du mappage de compte de service
Créez un mappage de compte de service. Dans le champ Nom, saisissez une valeur unique au sein du fournisseur d’identités de charge de travail, par exemple openai-mapping-kubernetes. Dans le champ Description, indiquez quelle charge de travail peut utiliser ce mappage, par exemple Workload Identity Provider Mapping for Kubernetes Workloads.
Utilisez le sujet du compte de service Kubernetes comme critère de correspondance. Dans le champ Clé, saisissez sub. Dans le champ Valeur, saisissez system:serviceaccount:default:openai-wif. Pour les comptes de service Kubernetes, le sujet est au format system:serviceaccount:<namespace>:<service-account-name>.
Choisissez le compte de service OpenAI cible. Dans le champ Projet, sélectionnez le projet OpenAI auquel appartient le compte de service cible. Dans le champ Compte de service, sélectionnez le compte de service OpenAI que la charge de travail Kubernetes pourra utiliser, par exemple kubernetes-prod-openai-wif. Cochez Create a new service account in this project si vous souhaitez créer un compte de service pour ce mappage plutôt que réutiliser un compte existant.
Limitez les autorisations d’accès à l’API si nécessaire. Dans Autorisations, sélectionnez les autorisations appropriées, par exemple api.model.request et api.vector_store.read, pour restreindre davantage les droits accordés par les jetons d’accès émis via ce mappage. Laissez ce champ vide si vous ne souhaitez pas ajouter de restriction de portée propre à la fédération d’identités de charge de travail. Les droits associés au jeton restent ceux du compte de service défini dans le mappage.
Utilisation du jeton dans le code
Configurez le client du SDK OpenAI pour qu’il lise le jeton projeté Kubernetes et l’échange contre un jeton d’accès émis par OpenAI.
Indiquez au fournisseur de fédération d’identités de charge de travail du SDK le chemin du fichier monté, par exemple /var/run/secrets/tokens/token, comme source du jeton du sujet. Le SDK échange ce jeton Kubernetes contre un jeton d’accès émis par OpenAI, puis utilise le jeton OpenAI pour authentifier les requêtes à l’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 Kubernetes dans le fichier monté et l’utilise comme jeton du sujet pour la fédération d’identités de charge de travail.
Authentification avec un jeton projeté de compte de service Kubernetes
Utilisez un émetteur OIDC stable. Son URL doit correspondre à la revendication iss du jeton projeté de compte de service et devrait rester stable lors des mises à niveau du cluster et des opérations de maintenance.
Protégez soigneusement les clés de signature. Toute personne ayant accès aux clés de signature des comptes de service du cluster peut émettre des jetons qu’OpenAI est susceptible d’accepter.
Réservez des comptes de service aux intégrations OpenAI. Évitez de réutiliser des comptes qui donnent également accès à des infrastructures ou à des applications sans rapport avec ces intégrations.
Tenez le JWKS importé à jour. En mode JWKS local, OpenAI valide les jetons d’identité de charge de travail à l’aide du JWKS configuré. Mettez donc à jour le fournisseur d’identités de charge de travail avant de passer à de nouvelles clés de signature.
Limitez la complexité des revendications personnalisées. Privilégiez les critères de correspondance fondés sur des revendications standard telles que sub et aud, ou sur des attributs transformés qui en dérivent directement.
Tenez compte des droits d’administration des espaces de noms dans votre modèle de sécurité. Si les administrateurs d’espaces de noms peuvent créer des comptes de service, délimitez la portée des mappages pour éviter toute élévation de privilèges involontaire.
Surveillez les changements d’émetteur et de clés de signature. Si vous renouvelez les clés de signature sans mettre à jour le JWKS du fournisseur d’identités de charge de travail, l’échange de jetons risque d’échouer.