Use o GitHub Actions como provedor de identidade de cargas de trabalho, trocando um token OIDC emitido pelo GitHub por um token de acesso da OpenAI de curta duração. Isso permite que os fluxos de trabalho se autentiquem na API da OpenAI sem armazenar uma chave de API de longa duração nos segredos do GitHub.
Para o Codex, use esta página para obter e inspecionar o token do GitHub. Depois, configure a identidade de cargas de trabalho do Codex para gravar esse token em um arquivo e indicar esse arquivo ao Codex. O mapeamento de contas de serviço e os exemplos de SDK desta página se aplicam à API da OpenAI.
O GitHub pode emitir um JWT OIDC assinado para um trabalho de fluxo de trabalho que tenha a permissão id-token: write e solicite um token de identidade. A OpenAI valida o emissor, o público-alvo, a assinatura e os atributos de mapeamento do token antes de emitir um token de acesso da OpenAI.
Conceda ao fluxo de trabalho ou ao trabalho permissão para solicitar um token OIDC do GitHub:
123permissions:
id-token: write
contents: read
A permissão id-token: write permite que o trabalho solicite um JWT OIDC. Ela não concede acesso de escrita ao conteúdo do repositório. A permissão contents: read é necessária para actions/checkout.
Solicite o token com o público-alvo exato configurado no seu provedor de identidade de cargas de trabalho da OpenAI. Ações JavaScript personalizadas podem chamar core.getIDToken("your-wif-audience"); etapas de shell podem chamar diretamente a URL de solicitação OIDC do GitHub. Valores de público-alvo que contenham caracteres reservados de URL, como https://api.openai.com/v1, devem ser codificados para URL antes de serem acrescentados à URL de solicitação:
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
As principais declarações OIDC do GitHub incluem:
iss: O emissor do token. Para o GitHub Actions, é https://token.actions.githubusercontent.com.
aud: O valor de público-alvo solicitado pelo fluxo de trabalho. Configure a OpenAI para exigir o valor exato que você solicita, como your-wif-audience ou https://api.openai.com/v1.
sub: A string principal que identifica o sujeito. O GitHub a constrói a partir de metadados do fluxo de trabalho, como repositório, branch, tag, pull request ou ambiente.
repository: O repositório que executa o fluxo de trabalho, como my-org/my-repo.
repository_owner: A organização ou o usuário proprietário do repositório, como my-org.
ref: A referência Git que acionou o fluxo de trabalho, como refs/heads/main ou refs/tags/v1.0.0.
workflow: A declaração do fluxo de trabalho. Use o valor real da declaração emitida pelo GitHub, como deploy, se essa for a declaração do fluxo de trabalho no seu trabalho.
workflow_ref: O caminho do arquivo do fluxo de trabalho e sua referência, como my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main.
environment: O nome do ambiente do GitHub, como production, quando o trabalho usa um ambiente.
run_id, run_number, run_attempt e job_workflow_ref: Identificadores de execução e de trabalho que podem ajudar em auditorias ou em regras de confiança mais avançadas.
Para ver a lista completa de declarações e os formatos de sujeito, consulte a referência do OpenID Connect do GitHub.
Antes de configurar a federação de identidades de cargas de trabalho, exporte o token OIDC do GitHub como TOKEN e execute este script no executor do fluxo de trabalho para inspecionar suas declarações:
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)
Este comando decodifica o payload do JWT sem verificar a assinatura do token. Use um decodificador local para tokens de produção e evite colá-los em ferramentas de terceiros. Nunca registre em logs o token OIDC bruto do GitHub nem o token de acesso da OpenAI obtido na troca.
Um token OIDC decodificado do GitHub Actions será semelhante a:
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"
}
Use o payload decodificado para comparar o token recebido com os valores de emissor, público-alvo e mapeamento configurados na OpenAI. A maioria dos problemas de configuração pode ser identificada nas declarações iss, aud, repository, ref e workflow_ref antes de você trocar o token.
Crie um provedor de identidade de cargas de trabalho na OpenAI para o GitHub Actions e adicione um mapeamento de conta de serviço que corresponda às declarações de fluxo de trabalho do GitHub nas quais você confia.
Configure primeiro o provedor de identidade de cargas de trabalho e depois crie o mapeamento de conta de serviço.
-
Crie o provedor de identidade de cargas de trabalho. Defina Nome como um valor exclusivo, como github-actions-prod. Use Descrição, com um valor como Production GitHub Actions workflows, para ajudar os administradores a identificar o provedor.
-
Defina o emissor e o público-alvo. Defina URL do emissor OIDC como https://token.actions.githubusercontent.com. Defina Público-alvo como o público-alvo exato que seu fluxo de trabalho solicita, como your-wif-audience ou https://api.openai.com/v1.
-
Use a descoberta OIDC do GitHub. Mantenha Usar JWKS enviado para verificação de tokens desativado. A OpenAI usa os metadados de descoberta OIDC e o JWKS do GitHub para verificar o token assinado pelo GitHub.
-
Adicione transformações de atributos apenas se precisar de atributos de mapeamento derivados. Declarações brutas do GitHub, como repository, ref e workflow, podem ser usadas diretamente nas asserções de mapeamento. Se você criar atributos derivados, o painel aplicará o prefixo openai. automaticamente; por exemplo, insira github_repository com a expressão assertion.repository para criar openai.github_repository. Declarações brutas do token que já começam com openai. são ignoradas para chaves de mapeamento openai., a menos que uma transformação correspondente esteja configurada.
-
Crie um mapeamento de conta de serviço. Defina Nome como um valor exclusivo dentro do provedor de identidade de cargas de trabalho, como github-actions-main-deploy. Use Descrição, com um valor como Production deploy workflow on main, para explicar qual fluxo de trabalho pode usar o mapeamento.
-
Adicione asserções de correspondência exata de declarações. Adicione uma linha com Chave e Valor para cada declaração do GitHub que deve corresponder. A OpenAI exige que todas as linhas configuradas correspondam antes de emitir um token de acesso. Para um fluxo de trabalho de implantação em produção, use asserções como:
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"
Prefira workflow_ref a workflow em mapeamentos privilegiados, pois os administradores geralmente pretendem confiar em um caminho de arquivo de fluxo de trabalho e em uma referência específicos. Os nomes dos fluxos de trabalho podem ser alterados, e vários arquivos de fluxo de trabalho podem ter o mesmo nome.
Na interface de mapeamento, insira esses dados como linhas de chave/valor, como Chave repository com Valor my-org/my-repo, Chave ref com Valor refs/heads/main e Chave workflow_ref com Valor my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main. Se o trabalho usar um ambiente do GitHub, adicione também Chave environment com Valor production.
Atenção: Evite mapeamentos amplos demais, como confiar apenas em repository_owner == "my-org", a menos que todos os repositórios no namespace desse proprietário devam poder emitir tokens de acesso da OpenAI.
-
Escolha o destino na OpenAI. Defina Projeto como o projeto da OpenAI ao qual pertence a conta de serviço de destino. Defina Conta de serviço como a conta de serviço da OpenAI que o fluxo de trabalho do GitHub pode usar, como github-actions-prod-deploy.
-
Restrinja as permissões da API, se necessário. Selecione as Permissões adequadas, como api.model.request e api.vector_store.read, para restringir ainda mais o acesso dos tokens emitidos a partir desse mapeamento. Deixe as permissões em branco para não adicionar uma restrição de escopo específica de WIF; o token ainda autoriza o acesso como a conta de serviço mapeada.
Configure seu cliente do OpenAI SDK para solicitar um token OIDC do GitHub e trocá-lo por um token de acesso emitido pela OpenAI.
O fluxo de trabalho deve conceder a permissão id-token: write e passar as configurações de federação de identidades de cargas de trabalho ao código do SDK. O SDK solicita o token OIDC do GitHub usando as variáveis do ambiente ACTIONS_ID_TOKEN_REQUEST_URL e ACTIONS_ID_TOKEN_REQUEST_TOKEN que o GitHub disponibiliza ao trabalho e, em seguida, usa o token de acesso da OpenAI obtido na troca para autenticar as solicitações à API.
Por exemplo, execute o código do seu aplicativo a partir de um fluxo de trabalho como este:
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
Armazene OPENAI_WIF_AUDIENCE, OPENAI_IDENTITY_PROVIDER_ID e OPENAI_SERVICE_ACCOUNT_ID como variáveis do GitHub Actions. Elas identificam o provedor e a conta de serviço, mas não são credenciais de portador.
Os exemplos a seguir inicializam um cliente da OpenAI com um provedor personalizado de tokens de sujeito. O provedor solicita um token OIDC do GitHub para o público-alvo configurado e o usa como token de sujeito para a federação de identidades de cargas de trabalho.
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)
- Use proteções de ambiente para implantações em produção. Exija aprovações ou restrições de branch antes que os fluxos de trabalho possam acessar recursos de produção da OpenAI.
- Restrinja os mapeamentos por repositório. Sempre que possível, exija correspondência de declarações específicas do repositório, em vez de permitir acesso a partir de todos os repositórios de uma organização.
- Restrinja os mapeamentos por branch ou fluxo de trabalho. Considere exigir correspondência de declarações como
repository, ref, environment ou workflow_ref para limitar a emissão de tokens.
- Use contas de serviço da OpenAI separadas para CI/CD e cargas de trabalho de produção. Pipelines de build geralmente exigem permissões diferentes das dos aplicativos implantados.
- Evite conceder acesso a pull requests de forks não confiáveis. Pull requests originados de forks podem executar código controlado por invasores e não devem receber credenciais de produção.
- Use trocas de tokens de curta duração. Os tokens OIDC do GitHub se destinam à autenticação efêmera e devem ser trocados apenas quando necessário.
- Audite as mudanças de propriedade dos repositórios. Transferências de repositórios, alterações de nome e mudanças de permissões podem afetar as premissas de segurança dos mapeamentos existentes.
- Prefira a correspondência exata de declarações. Exija correspondência de declarações como
repository, ref e environment, em vez de depender de relações de confiança que abranjam toda a organização.