Utilisez GitHub Actions comme fournisseur d’identités de charge de travail en échangeant un token OIDC émis par GitHub contre un jeton d’accès OpenAI à courte durée de vie. Les workflows peuvent ainsi s’authentifier auprès de l’API OpenAI sans stocker de clé API à longue durée de vie dans les secrets GitHub.
Pour Codex, suivez cette page pour obtenir et inspecter le token GitHub. Ensuite, configurez l’identité de charge de travail de Codex pour écrire ce token dans un fichier et indiquer ce fichier à Codex. Le mappage de compte de service et les exemples de SDK de cette page concernent l’API OpenAI.
GitHub peut émettre un JWT OIDC signé pour un job de workflow qui dispose de l’autorisation id-token: write et demande un token d’identité. OpenAI valide l’émetteur, l’audience et la signature du token, ainsi que les attributs de mappage, avant d’émettre un jeton d’accès OpenAI.
Accordez au workflow ou au job l’autorisation de demander un token OIDC GitHub :
123permissions:
id-token: write
contents: read
L’autorisation id-token: write permet au job de demander un JWT OIDC. Elle n’accorde pas d’accès en écriture au contenu du dépôt. L’autorisation contents: read est nécessaire pour actions/checkout.
Demandez le token avec exactement l’audience configurée dans votre fournisseur d’identités de charge de travail OpenAI. Les actions JavaScript personnalisées peuvent appeler core.getIDToken("your-wif-audience") ; les étapes shell peuvent appeler directement l’URL de requête OIDC de GitHub. Les valeurs d’audience contenant des caractères réservés dans les URL, comme https://api.openai.com/v1, doivent être encodées pour les URL avant d’être ajoutées à l’URL de requête :
123456AUDIENCE="https://api.openai.com/v1"
ENCODED_AUDIENCE=$(jq -rn --arg audience "$AUDIENCE" '$audience | @uri')
TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ENCODED_AUDIENCE}" | jq -r .value)
export TOKEN
Les principales revendications OIDC de GitHub comprennent :
iss : l’émetteur du token. Pour GitHub Actions, il s’agit de https://token.actions.githubusercontent.com.
aud : la valeur d’audience demandée par le workflow. Configurez OpenAI pour exiger exactement la valeur que vous demandez, comme your-wif-audience ou https://api.openai.com/v1.
sub : la chaîne principale du sujet. GitHub la construit à partir des métadonnées du workflow, comme le dépôt, la branche, le tag, la pull request ou l’environnement.
repository : le dépôt qui exécute le workflow, comme my-org/my-repo.
repository_owner : l’organisation ou l’utilisateur propriétaire du dépôt, comme my-org.
ref : la référence Git qui a déclenché le workflow, comme refs/heads/main ou refs/tags/v1.0.0.
workflow : la revendication du workflow. Utilisez la valeur réellement émise par GitHub, par exemple deploy si c’est la valeur de cette revendication dans votre job.
workflow_ref : le chemin du fichier de workflow et sa référence, comme my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main.
environment : le nom de l’environnement GitHub, comme production, lorsque le job utilise un environnement.
run_id, run_number, run_attempt et job_workflow_ref : des identifiants d’exécution et de job qui peuvent faciliter les audits ou la définition de règles de confiance plus avancées.
Pour connaître la liste complète des revendications et les formats du sujet, consultez la référence OpenID Connect de GitHub.
Avant de configurer la fédération d’identités de charge de travail, exportez le token OIDC GitHub dans TOKEN, puis exécutez ce script sur le runner du workflow pour inspecter ses revendications :
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)
Cette commande décode la charge utile du JWT sans vérifier la signature du token. Utilisez un décodeur local pour les tokens de production et évitez de les coller dans des outils tiers. Ne consignez jamais dans les journaux le token OIDC GitHub brut ni le jeton d’accès OpenAI obtenu en échange.
Un token OIDC GitHub Actions décodé ressemble à ceci :
12345678910111213{
"iss": "https://token.actions.githubusercontent.com",
"aud": "https://api.openai.com/v1",
"sub": "repo:my-org/my-repo:environment:production",
"repository": "my-org/my-repo",
"repository_owner": "my-org",
"ref": "refs/heads/main",
"workflow": "deploy",
"workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main",
"environment": "production",
"run_id": "1234567890",
"run_attempt": "1"
}
Utilisez la charge utile décodée pour comparer le token reçu aux valeurs d’émetteur, d’audience et de mappage configurées dans OpenAI. La plupart des problèmes de configuration sont visibles dans les revendications iss, aud, repository, ref et workflow_ref avant l’échange du token.
Créez un fournisseur d’identités de charge de travail dans OpenAI pour GitHub Actions, puis ajoutez un mappage de compte de service correspondant aux revendications de workflow GitHub auxquelles vous faites confiance.
Configurez d’abord le fournisseur d’identités de charge de travail, puis créez le mappage de compte de service.
-
Créez le fournisseur d’identités de charge de travail. Définissez Nom sur une valeur unique, comme github-actions-prod. Renseignez Description, par exemple avec Production GitHub Actions workflows, pour aider les administrateurs à identifier le fournisseur.
-
Définissez l’émetteur et l’audience. Définissez URL de l’émetteur OIDC sur https://token.actions.githubusercontent.com. Définissez Audience sur exactement l’audience demandée par votre workflow, comme your-wif-audience ou https://api.openai.com/v1.
-
Utilisez la découverte OIDC de GitHub. Laissez l’option Utiliser le JWKS importé pour vérifier les tokens désactivée. OpenAI utilise les métadonnées de découverte OIDC et le JWKS de GitHub pour vérifier le token signé par GitHub.
-
N’ajoutez des transformations d’attributs que si vous avez besoin d’attributs de mappage dérivés. Les revendications GitHub brutes comme repository, ref et workflow peuvent être utilisées directement dans les assertions de mappage. Si vous créez des attributs dérivés, le tableau de bord applique automatiquement le préfixe openai. ; par exemple, saisissez github_repository avec l’expression assertion.repository pour créer openai.github_repository. Les revendications brutes du token qui commencent déjà par openai. sont ignorées pour les clés de mappage openai., sauf si une transformation correspondante est configurée.
-
Créez un mappage de compte de service. Définissez Nom sur une valeur unique au sein du fournisseur d’identités de charge de travail, comme github-actions-main-deploy. Renseignez Description, par exemple avec Production deploy workflow on main, pour expliquer quel workflow peut utiliser ce mappage.
-
Ajoutez des assertions de correspondance exacte des revendications. Ajoutez une ligne Clé et Valeur pour chaque revendication GitHub à vérifier. OpenAI exige que chaque ligne configurée corresponde avant d’émettre un jeton d’accès. Pour un workflow de déploiement en production, utilisez des assertions comme celles-ci :
iss == "https://token.actions.githubusercontent.com"
aud == "https://api.openai.com/v1"
repository == "my-org/my-repo"
ref == "refs/heads/main"
workflow_ref == "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main"
Privilégiez workflow_ref plutôt que workflow pour les mappages accordant des privilèges élevés, car les administrateurs souhaitent généralement faire confiance à un chemin de fichier de workflow et à une référence précis. Les workflows peuvent être renommés et plusieurs fichiers de workflow peuvent partager le même nom.
Dans l’interface de mappage, saisissez ces éléments sous forme de lignes clé/valeur, par exemple Clé repository avec Valeur my-org/my-repo, Clé ref avec Valeur refs/heads/main, et Clé workflow_ref avec Valeur my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main. Si le job utilise un environnement GitHub, ajoutez également Clé environment avec Valeur production.
Attention : évitez les mappages trop larges, par exemple ceux qui ne vérifient que repository_owner == "my-org", sauf si tous les dépôts de l’espace de noms de ce propriétaire doivent pouvoir obtenir des jetons d’accès OpenAI.
-
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 le workflow GitHub peut utiliser, comme github-actions-prod-deploy.
-
Restreignez les autorisations de l’API si nécessaire. Sélectionnez les Autorisations appropriées, comme api.model.request et api.vector_store.read, pour limiter davantage les droits des jetons d’accès émis à partir de ce mappage. Laissez les autorisations vides pour ne pas ajouter de restriction de portée propre à WIF ; le token continue d’accorder les droits du compte de service associé.
Configurez votre client du SDK OpenAI pour demander un token OIDC GitHub et l’échanger contre un jeton d’accès émis par OpenAI.
Le workflow doit accorder l’autorisation id-token: write et transmettre les paramètres de fédération d’identités de charge de travail au code utilisant le SDK. Le SDK demande le token OIDC GitHub à l’aide des variables d’environnement ACTIONS_ID_TOKEN_REQUEST_URL et ACTIONS_ID_TOKEN_REQUEST_TOKEN que GitHub met à la disposition du job, puis utilise le jeton d’accès OpenAI obtenu en échange pour authentifier les requêtes API.
Par exemple, exécutez le code de votre application depuis un workflow comme celui-ci :
12345678910111213141516171819202122232425name: deploy
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Run OpenAI SDK code
env:
OPENAI_WIF_AUDIENCE: ${{ vars.OPENAI_WIF_AUDIENCE }}
OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.OPENAI_IDENTITY_PROVIDER_ID }}
OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.OPENAI_SERVICE_ACCOUNT_ID }}
run: node ./scripts/call-openai.js
Stockez OPENAI_WIF_AUDIENCE, OPENAI_IDENTITY_PROVIDER_ID et OPENAI_SERVICE_ACCOUNT_ID en tant que variables GitHub Actions. Ces valeurs identifient le fournisseur et le compte de service, mais ne constituent pas des identifiants d’authentification au porteur.
Les exemples suivants initialisent un client OpenAI avec un fournisseur personnalisé de tokens de sujet. Ce fournisseur demande un token OIDC GitHub pour l’audience configurée et l’utilise comme token de sujet pour la fédération d’identités de charge de travail.
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
65import OpenAI from "openai";
const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;
const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;
const audience = process.env.OPENAI_WIF_AUDIENCE;
const requestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (
!identityProviderId ||
!serviceAccountId ||
!audience ||
!requestURL ||
!requestToken
) {
throw new Error(
"Set OPENAI_IDENTITY_PROVIDER_ID, OPENAI_SERVICE_ACCOUNT_ID, OPENAI_WIF_AUDIENCE, and run inside GitHub Actions with id-token: write"
);
}
function githubActionsOIDCTokenProvider(requestURL, requestToken, audience) {
return {
tokenType: "jwt",
getToken: async () => {
const url = new URL(requestURL);
url.searchParams.set("audience", audience);
const response = await fetch(url, {
headers: { Authorization: `bearer ${requestToken}` },
});
if (!response.ok) {
throw new Error(
`Failed to request GitHub OIDC token: ${response.status} ${response.statusText}`
);
}
const body = await response.json();
if (!body.value) {
throw new Error("GitHub OIDC token response did not include a value.");
}
return body.value;
},
};
}
const client = new OpenAI({
workloadIdentity: {
identityProviderId,
serviceAccountId,
provider: githubActionsOIDCTokenProvider(
requestURL,
requestToken,
audience
),
},
});
const response = await client.responses.create({
model: "gpt-5.6-terra",
input: "Say hello from GitHub Actions 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
52import json
import os
import urllib.parse
import urllib.request
from openai import OpenAI
from openai.auth import SubjectTokenProvider
def github_actions_oidc_token_provider(audience: str) -> SubjectTokenProvider:
request_url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]
request_token = os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
def get_token() -> str:
parsed_url = urllib.parse.urlparse(request_url)
query = dict(urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True))
query["audience"] = audience
url = urllib.parse.urlunparse(
parsed_url._replace(query=urllib.parse.urlencode(query))
)
request = urllib.request.Request(
url,
headers={"Authorization": f"bearer {request_token}"},
)
with urllib.request.urlopen(request) as response:
payload = json.loads(response.read().decode("utf-8"))
token = payload.get("value")
if not token:
raise RuntimeError("GitHub OIDC token response did not include a value.")
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": github_actions_oidc_token_provider(
os.environ["OPENAI_WIF_AUDIENCE"]
),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from GitHub Actions 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
110
111
112
113
114
115
116package 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"
)
type githubActionsOIDCTokenProvider struct {
requestURL string
requestToken string
audience string
}
func (p githubActionsOIDCTokenProvider) TokenType() auth.SubjectTokenType {
return auth.SubjectTokenTypeJWT
}
func (p githubActionsOIDCTokenProvider) GetToken(ctx context.Context, httpClient auth.HTTPDoer) (string, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}
oidcURL, err := url.Parse(p.requestURL)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to parse GitHub OIDC request URL",
Cause: err,
}
}
query := oidcURL.Query()
query.Set("audience", p.audience)
oidcURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, oidcURL.String(), nil)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to create GitHub OIDC token request",
Cause: err,
}
}
req.Header.Set("Authorization", "bearer "+p.requestToken)
resp, err := httpClient.Do(req)
if err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to request GitHub OIDC token",
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: fmt.Sprintf("GitHub OIDC token request failed with status %s", resp.Status),
}
}
var body struct {
Value string `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "failed to decode GitHub OIDC token response",
Cause: err,
}
}
if body.Value == "" {
return "", &auth.SubjectTokenProviderError{
Provider: "github-actions",
Message: "GitHub OIDC token response did not include a value",
}
}
return body.Value, 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: githubActionsOIDCTokenProvider{
requestURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
requestToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
audience: os.Getenv("OPENAI_WIF_AUDIENCE"),
},
}),
)
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: openai.ChatModelGPT4_1Mini,
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Say hello from GitHub Actions 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
108
109
110
111
112
113import 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.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 GitHubActionsWorkloadIdentityExample {
private GitHubActionsWorkloadIdentityExample() {}
static final class GitHubActionsOidcTokenProvider implements SubjectTokenProvider {
private final String requestUrl;
private final String requestToken;
private final String audience;
GitHubActionsOidcTokenProvider(String requestUrl, String requestToken, String audience) {
this.requestUrl = requestUrl;
this.requestToken = requestToken;
this.audience = audience;
}
@Override
public SubjectTokenType tokenType() {
return SubjectTokenType.JWT;
}
@Override
public String getToken(com.openai.core.http.HttpClient httpClient, JsonMapper jsonMapper) {
try {
String separator = requestUrl.contains("?") ? "&" : "?";
URI uri =
URI.create(
requestUrl
+ separator
+ "audience="
+ URLEncoder.encode(audience, StandardCharsets.UTF_8));
HttpRequest request =
HttpRequest.newBuilder(uri)
.header("Authorization", "bearer " + requestToken)
.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(
"github-actions",
"GitHub OIDC token request failed with status " + response.statusCode(),
null);
}
JsonNode payload = jsonMapper.readTree(response.body());
String token = payload.path("value").asText("");
if (token.isEmpty()) {
throw new SubjectTokenProviderException(
"github-actions", "GitHub OIDC token response did not include a value", null);
}
return token;
} catch (SubjectTokenProviderException e) {
throw e;
} catch (Exception e) {
throw new SubjectTokenProviderException(
"github-actions", "failed to request GitHub OIDC token", e);
}
}
@Override
public CompletableFuture<String> getTokenAsync(
com.openai.core.http.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 GitHubActionsOidcTokenProvider(
System.getenv("ACTIONS_ID_TOKEN_REQUEST_URL"),
System.getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
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 GitHub Actions 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
76
77require "json"
require "net/http"
require "openai"
require "uri"
class GitHubActionsOIDCTokenProvider
include OpenAI::Auth::SubjectTokenProvider
def initialize(request_url:, request_token:, audience:)
@request_url = request_url
@request_token = request_token
@audience = audience
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
uri = URI(@request_url)
params = URI.decode_www_form(uri.query || "")
params.reject! { |key, _| key == "audience" }
params << ["audience", @audience]
uri.query = URI.encode_www_form(params)
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "bearer #{@request_token}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "GitHub OIDC token request failed with status #{response.code}",
provider: "github-actions"
)
end
token = JSON.parse(response.body).fetch("value", "").to_s
if token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "GitHub OIDC token response did not include a value",
provider: "github-actions"
)
end
token
rescue JSON::ParserError, SystemCallError => e
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to request GitHub OIDC token: #{e.message}",
provider: "github-actions",
cause: e
)
end
end
provider = GitHubActionsOIDCTokenProvider.new(
request_url: ENV.fetch("ACTIONS_ID_TOKEN_REQUEST_URL"),
request_token: ENV.fetch("ACTIONS_ID_TOKEN_REQUEST_TOKEN"),
audience: 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 GitHub Actions workload identity federation."
)
puts(response.output_text)
- Utilisez les protections d’environnement pour les déploiements en production. Exigez des approbations ou des restrictions de branche avant que les workflows puissent accéder aux ressources OpenAI de production.
- Restreignez les mappages par dépôt. Vérifiez autant que possible des revendications propres au dépôt plutôt que d’autoriser l’accès depuis tous les dépôts d’une organisation.
- Restreignez les mappages par branche ou par workflow. Envisagez de vérifier des revendications comme
repository, ref, environment ou workflow_ref pour limiter l’émission de tokens.
- Utilisez des comptes de service OpenAI distincts pour les charges de travail de CI/CD et de production. Les pipelines de build nécessitent souvent des autorisations différentes de celles des applications déployées.
- Évitez d’accorder l’accès aux pull requests provenant de dépôts forkés non fiables. Les pull requests provenant de dépôts forkés peuvent exécuter du code contrôlé par un attaquant et ne doivent pas recevoir d’identifiants d’authentification de production.
- Privilégiez les échanges à courte durée de vie. Les tokens OIDC GitHub sont destinés à une authentification éphémère et ne doivent être échangés qu’en cas de besoin.
- Auditez les changements de propriétaire des dépôts. Les transferts, les changements de nom et les modifications d’autorisations des dépôts peuvent remettre en cause les hypothèses de sécurité sur lesquelles reposent les mappages existants.
- Privilégiez la correspondance exacte des revendications. Vérifiez des revendications comme
repository, ref et environment plutôt que de vous appuyer sur des relations de confiance à l’échelle de l’organisation.