Verwende GitHub Actions als Anbieter für Workload-Identitäten, indem du ein von GitHub ausgestelltes OIDC-Token gegen ein kurzlebiges OpenAI-Zugriffstoken austauschst. So können sich Arbeitsabläufe bei der OpenAI API authentifizieren, ohne einen langlebigen API-Schlüssel in GitHub-Secrets zu speichern.
Für Codex kannst du mithilfe dieser Seite das GitHub-Token abrufen und prüfen. Konfiguriere anschließend die Workload-Identität für Codex, um das Token in eine Datei zu schreiben und Codex auf diese Datei zu verweisen. Die Dienstkontozuordnung und die SDK-Beispiele auf dieser Seite gelten für die OpenAI API.
GitHub kann ein signiertes OIDC-JWT für einen Job in einem Ablauf ausstellen, wenn dieser die Berechtigung id-token: write hat und ein Identitätstoken anfordert. OpenAI prüft den Aussteller, die Audience und die Signatur des Tokens sowie die Zuordnungsattribute, bevor ein OpenAI-Zugriffstoken ausgestellt wird.
Erteile dem Ablauf oder Job die Berechtigung, ein GitHub-OIDC-Token anzufordern:
123permissions:
id-token: write
contents: read
Mit der Berechtigung id-token: write kann der Job ein OIDC-JWT anfordern. Sie gewährt keinen Schreibzugriff auf die Inhalte des Repositorys. Die Berechtigung contents: read wird von actions/checkout benötigt.
Fordere das Token mit genau der Audience an, die in deinem OpenAI-Anbieter für Workload-Identitäten konfiguriert ist. Benutzerdefinierte JavaScript-Aktionen können core.getIDToken("your-wif-audience") aufrufen; Shell-Schritte können die OIDC-Anfrage-URL von GitHub direkt aufrufen. Audience-Werte mit reservierten URL-Zeichen, wie etwa https://api.openai.com/v1, sollten URL-kodiert werden, bevor sie an die Anfrage-URL angehängt werden:
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
Zu den wichtigen OIDC-Claims von GitHub gehören:
iss: Der Aussteller des Tokens. Für GitHub Actions ist dies https://token.actions.githubusercontent.com.
aud: Der vom Ablauf angeforderte Audience-Wert. Konfiguriere OpenAI so, dass genau der von dir angeforderte Wert erforderlich ist, zum Beispiel your-wif-audience oder https://api.openai.com/v1.
sub: Die zentrale Subject-Zeichenfolge. GitHub setzt sie aus Metadaten des Ablaufs zusammen, etwa Repository, Branch, Tag, Pull Request oder Umgebung.
repository: Das Repository, in dem der Ablauf ausgeführt wird, zum Beispiel my-org/my-repo.
repository_owner: Die Organisation oder Person, der das Repository gehört, zum Beispiel my-org.
ref: Die Git-Referenz, die den Ablauf ausgelöst hat, zum Beispiel refs/heads/main oder refs/tags/v1.0.0.
workflow: Der Claim für den Ablauf. Verwende den tatsächlichen Claim-Wert, den GitHub ausgibt, zum Beispiel deploy, wenn dies der Claim für den Ablauf in deinem Job ist.
workflow_ref: Der Dateipfad und die Referenz des Ablaufs, zum Beispiel my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main.
environment: Der Name der GitHub-Umgebung, zum Beispiel production, wenn der Job eine Umgebung verwendet.
run_id, run_number, run_attempt und job_workflow_ref: Kennungen für Ausführungen und Jobs, die bei Audits oder komplexeren Vertrauensregeln hilfreich sein können.
Die vollständige Liste der Claims und die Subject-Formate findest du in der OpenID-Connect-Referenz von GitHub.
Bevor du die Föderation von Workload-Identitäten konfigurierst, exportiere das GitHub-OIDC-Token als TOKEN. Führe dann dieses Skript im Runner des Ablaufs aus, um die Claims zu prüfen:
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)
Dieser Befehl dekodiert die JWT-Nutzdaten, ohne die Signatur des Tokens zu überprüfen. Verwende für Tokens aus dem Produktivbetrieb einen lokalen Decoder und füge sie nicht in Tools von Drittanbietern ein. Protokolliere niemals das ursprüngliche GitHub-OIDC-Token oder das durch den Austausch erhaltene OpenAI-Zugriffstoken.
Ein dekodiertes OIDC-Token von GitHub Actions sieht etwa so aus:
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"
}
Vergleiche anhand der dekodierten Nutzdaten das erhaltene Token mit den in OpenAI konfigurierten Werten für Aussteller, Audience und Zuordnung. Die meisten Konfigurationsprobleme lassen sich bereits vor dem Tokenaustausch in den Claims iss, aud, repository, ref und workflow_ref erkennen.
Erstelle in OpenAI einen Anbieter für Workload-Identitäten für GitHub Actions. Füge anschließend eine Dienstkontozuordnung hinzu, die mit den Claims der GitHub-Arbeitsabläufe übereinstimmt, denen du vertraust.
Konfiguriere zuerst den Anbieter für Workload-Identitäten und erstelle anschließend die Dienstkontozuordnung.
-
Erstelle den Anbieter für Workload-Identitäten. Gib unter Name einen eindeutigen Wert ein, zum Beispiel github-actions-prod. Gib unter Beschreibung etwa Production GitHub Actions workflows ein, damit Admins den Anbieter leichter identifizieren können.
-
Lege Aussteller und Audience fest. Setze OIDC-Aussteller-URL auf https://token.actions.githubusercontent.com. Setze Audience auf genau die Audience, die dein Ablauf anfordert, zum Beispiel your-wif-audience oder https://api.openai.com/v1.
-
Verwende die OIDC-Discovery von GitHub. Lass Hochgeladene JWKS zur Tokenüberprüfung verwenden deaktiviert. OpenAI verwendet die OIDC-Discovery-Metadaten und JWKS von GitHub, um das von GitHub signierte Token zu überprüfen.
-
Füge Attributtransformationen nur hinzu, wenn du abgeleitete Zuordnungsattribute benötigst. Unveränderte GitHub-Claims wie repository, ref und workflow können direkt in Zuordnungsbedingungen verwendet werden. Wenn du abgeleitete Attribute erstellst, fügt das Dashboard automatisch das Präfix openai. hinzu. Gib zum Beispiel github_repository mit dem Ausdruck assertion.repository ein, um openai.github_repository zu erstellen. Unveränderte Token-Claims, die bereits mit openai. beginnen, werden für Zuordnungsschlüssel mit dem Präfix openai. ignoriert, sofern keine passende Transformation konfiguriert ist.
-
Erstelle eine Dienstkontozuordnung. Gib unter Name einen Wert ein, der innerhalb des Anbieters für Workload-Identitäten eindeutig ist, zum Beispiel github-actions-main-deploy. Erläutere unter Beschreibung, welcher Ablauf die Zuordnung verwenden kann, zum Beispiel mit Production deploy workflow on main.
-
Füge Bedingungen für die exakte Übereinstimmung von Claims hinzu. Füge für jeden GitHub-Claim, der übereinstimmen muss, eine Zeile mit Schlüssel und Wert hinzu. OpenAI stellt ein Zugriffstoken nur aus, wenn jede konfigurierte Zeile übereinstimmt. Verwende für einen Ablauf zur Bereitstellung im Produktivbetrieb beispielsweise folgende Bedingungen:
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"
Verwende für Zuordnungen mit erweiterten Berechtigungen vorzugsweise workflow_ref statt workflow, da Admins in der Regel einem bestimmten Dateipfad und einer bestimmten Referenz des Ablaufs vertrauen möchten. Die Namen von Arbeitsabläufen lassen sich ändern, und mehrere Ablaufdateien können denselben Namen haben.
Trage diese Angaben in der Zuordnungsoberfläche als Schlüssel-Wert-Zeilen ein, zum Beispiel Schlüssel repository mit Wert my-org/my-repo, Schlüssel ref mit Wert refs/heads/main und Schlüssel workflow_ref mit Wert my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main. Wenn der Job eine GitHub-Umgebung verwendet, füge außerdem Schlüssel environment mit Wert production hinzu.
Achtung: Vermeide zu weit gefasste Zuordnungen, etwa wenn nur repository_owner == "my-org" geprüft wird. Eine solche Zuordnung ist nur sinnvoll, wenn sich jedes Repository in diesem Eigentümer-Namensraum OpenAI-Zugriffstoken ausstellen lassen können soll.
-
Wähle das Ziel in OpenAI aus. Wähle unter Projekt das OpenAI-Projekt aus, zu dem das gewünschte Dienstkonto gehört. Wähle unter Dienstkonto das OpenAI-Dienstkonto aus, das der GitHub-Ablauf verwenden darf, zum Beispiel github-actions-prod-deploy.
-
Schränke die API-Berechtigungen bei Bedarf ein. Wähle geeignete Berechtigungen wie api.model.request und api.vector_store.read aus, um den Zugriff durch die mit dieser Zuordnung ausgestellten Tokens weiter einzuschränken. Lass die Berechtigungen leer, wenn du keine WIF-spezifische Einschränkung des Berechtigungsumfangs hinzufügen möchtest. Das Token autorisiert weiterhin den Zugriff als zugeordnetes Dienstkonto.
Konfiguriere deinen Client für das OpenAI SDK so, dass er ein GitHub-OIDC-Token anfordert und gegen ein von OpenAI ausgestelltes Zugriffstoken austauscht.
Der Ablauf muss die Berechtigung id-token: write erteilen und die Einstellungen für die Föderation von Workload-Identitäten an den SDK-Code übergeben. Das SDK fordert das GitHub-OIDC-Token mithilfe der Umgebungsvariablen ACTIONS_ID_TOKEN_REQUEST_URL und ACTIONS_ID_TOKEN_REQUEST_TOKEN an, die GitHub dem Job bereitstellt. Anschließend verwendet es das durch den Austausch erhaltene OpenAI-Zugriffstoken, um API-Anfragen zu authentifizieren.
Du kannst deinen Anwendungscode zum Beispiel in einem Ablauf wie diesem ausführen:
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
Speichere OPENAI_WIF_AUDIENCE, OPENAI_IDENTITY_PROVIDER_ID und OPENAI_SERVICE_ACCOUNT_ID als Variablen in GitHub Actions. Sie identifizieren den Anbieter und das Dienstkonto, sind aber keine Bearer-Zugangsdaten.
Die folgenden Beispiele initialisieren einen OpenAI-Client mit einem benutzerdefinierten Anbieter für Subject-Tokens. Der Anbieter fordert ein GitHub-OIDC-Token für die konfigurierte Audience an und verwendet es als Subject-Token für die Föderation von Workload-Identitäten.
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)
- Verwende Schutzregeln für Umgebungen bei Bereitstellungen im Produktivbetrieb. Verlange Genehmigungen oder Branch-Einschränkungen, bevor Arbeitsabläufe auf OpenAI-Ressourcen im Produktivbetrieb zugreifen können.
- Beschränke Zuordnungen auf bestimmte Repositorys. Prüfe nach Möglichkeit repositoryspezifische Claims, statt den Zugriff aus allen Repositorys einer Organisation zu erlauben.
- Beschränke Zuordnungen auf bestimmte Branches oder Arbeitsabläufe. Ziehe in Betracht, Claims wie
repository, ref, environment oder workflow_ref auf Übereinstimmung zu prüfen, um die Tokenausstellung einzuschränken.
- Verwende separate OpenAI-Dienstkonten für CI/CD und Workloads im Produktivbetrieb. Build-Pipelines benötigen häufig andere Berechtigungen als bereitgestellte Anwendungen.
- Gewähre Pull Requests aus nicht vertrauenswürdigen Forks möglichst keinen Zugriff. Pull Requests aus Forks können Code ausführen, den Angreifende kontrollieren, und sollten keine Zugangsdaten für den Produktivbetrieb erhalten.
- Nutze den Tokenaustausch für kurzlebige Zugriffstoken. GitHub-OIDC-Tokens sind für die kurzzeitige Authentifizierung vorgesehen und sollten nur bei Bedarf ausgetauscht werden.
- Überprüfe Änderungen an den Eigentumsverhältnissen von Repositorys. Übertragungen, Umbenennungen und Berechtigungsänderungen bei Repositorys können sich auf die Sicherheitsannahmen auswirken, auf denen bestehende Zuordnungen beruhen.
- Bevorzuge die exakte Übereinstimmung von Claims. Prüfe Claims wie
repository, ref und environment, statt dich auf organisationsweite Vertrauensbeziehungen zu verlassen.