GitHub が発行した OIDC トークンを有効期間の短い OpenAI アクセストークンと交換すると、GitHub Actions をワークロード ID プロバイダーとして使用できます。これにより、有効期間の長い API キーを GitHub のシークレットに保存せずに、ワークフローから OpenAI API の認証を受けられます。
GitHub は、id-token: write 権限を持つワークフロージョブが ID トークンをリクエストすると、署名付きの OIDC JWT を発行できます。OpenAI は、トークンの発行者、オーディエンス、署名、マッピング属性を検証してから、OpenAI アクセストークンを発行します。
GitHub OIDC トークンをリクエストする権限をワークフローまたはジョブに付与します。
123permissions:
id-token: write
contents: read
id-token: write 権限があると、ジョブは OIDC JWT をリクエストできます。この権限ではリポジトリの内容に書き込むことはできません。contents: read 権限は actions/checkout に必要です。
トークンをリクエストするときは、OpenAI のワークロード ID プロバイダーに設定したオーディエンスと完全に一致する値を指定します。カスタム JavaScript アクションでは core.getIDToken("your-wif-audience") を呼び出せます。シェルステップでは GitHub の OIDC リクエスト URL を直接呼び出せます。https://api.openai.com/v1 のように URL の予約文字を含むオーディエンス値は、リクエスト URL に追加する前に URL エンコードしてください。
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
GitHub OIDC の主なクレームは次のとおりです。
iss:トークンの発行者です。GitHub Actions の場合は https://token.actions.githubusercontent.com です。
aud:ワークフローがリクエストしたオーディエンス値です。your-wif-audience や https://api.openai.com/v1 など、リクエスト時に指定する値との完全一致を求めるように OpenAI を設定します。
sub:トークンの主体を表す文字列です。GitHub は、リポジトリ、ブランチ、タグ、pull request、環境などのワークフローメタデータからこの文字列を生成します。
repository:ワークフローを実行するリポジトリです。たとえば my-org/my-repo のような値です。
repository_owner:リポジトリを所有する組織またはユーザーです。たとえば my-org のような値です。
ref:ワークフローの実行をトリガーした Git ref です。例として refs/heads/main や refs/tags/v1.0.0 があります。
workflow:ワークフローを表すクレームです。GitHub が実際に出力した値を使用します。たとえば、ジョブのワークフロークレームの値が deploy なら、その値を使用します。
workflow_ref:ワークフローファイルのパスと ref を組み合わせた値です。たとえば my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main のような形式です。
environment:ジョブが GitHub 環境を使用する場合の環境名です。たとえば production のような値です。
run_id、run_number、run_attempt、job_workflow_ref:ワークフローの実行やジョブを識別する値です。監査や、より細かい信頼ルールの設定に使用できます。
クレームの一覧とサブジェクトの形式については、GitHub の OpenID Connect リファレンスを参照してください。
ワークロード ID フェデレーションを設定する前に、GitHub OIDC トークンを環境変数 TOKEN としてエクスポートし、ワークフローのランナーで次のスクリプトを実行してクレームを確認します。
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)
このコマンドは JWT ペイロードをデコードしますが、トークンの署名は検証しません。本番環境のトークンにはローカルのデコーダーを使用し、サードパーティ製ツールに貼り付けることは避けてください。GitHub OIDC トークンそのものや、交換で取得した OpenAI アクセストークンは、決してログに記録しないでください。
GitHub Actions の OIDC トークンをデコードすると、次のようなペイロードが得られます。
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"
}
デコードしたペイロードの値を、OpenAI に設定した発行者、オーディエンス、マッピングの値と照合します。設定上の問題の多くは、トークンを交換する前に iss、aud、repository、ref、workflow_ref の各クレームを確認すると見つかります。
OpenAI で GitHub Actions 用のワークロード ID プロバイダーを作成し、信頼する GitHub ワークフローのクレームに一致するサービスアカウントマッピングを追加します。
まずワークロード ID プロバイダーを設定し、次にサービスアカウントマッピングを作成します。
-
ワークロード ID プロバイダーを作成します。 名前に github-actions-prod などの一意の値を入力します。説明には Production GitHub Actions workflows など、管理者がプロバイダーの用途を識別できる説明を入力します。
-
発行者とオーディエンスを設定します。 OIDC 発行者 URL に https://token.actions.githubusercontent.com を指定します。オーディエンスには、your-wif-audience や https://api.openai.com/v1 など、ワークフローがリクエストするオーディエンスと完全に一致する値を指定します。
-
GitHub の OIDC ディスカバリを使用します。 アップロードした JWKS をトークンの検証に使用は無効のままにします。OpenAI は GitHub の OIDC ディスカバリメタデータと JWKS を使用して、GitHub が署名したトークンを検証します。
-
マッピングに派生属性が必要な場合のみ、属性変換を追加します。 repository、ref、workflow などの GitHub クレームは、マッピングの一致条件にそのまま使用できます。派生属性を作成すると、ダッシュボードが openai. プレフィックスを自動的に付加します。たとえば、属性名に github_repository、式に assertion.repository を入力すると、openai.github_repository が作成されます。元のトークンに含まれる openai. で始まるクレームは、対応する属性変換が設定されていない限り、openai. で始まるマッピングキーの照合には使用されません。
-
サービスアカウントマッピングを作成します。 名前に github-actions-main-deploy など、そのワークロード ID プロバイダー内で一意の値を入力します。説明には Production deploy workflow on main など、このマッピングを使用できるワークフローがわかる説明を入力します。
-
クレームの完全一致条件を追加します。 一致を必須とする GitHub クレームごとに、キーと値の行を 1 行ずつ追加します。OpenAI は、設定したすべての行が一致した場合にのみアクセストークンを発行します。本番環境へのデプロイ用ワークフローでは、たとえば次の条件を使用します。
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"
強い権限を付与するマッピングでは、workflow_ref を workflow より優先してください。通常、管理者が信頼の対象とするのは、特定のワークフローファイルのパスと ref の組み合わせです。ワークフロー名は変更でき、複数のワークフローファイルに同じ名前を付けることもできます。
マッピング画面では、各条件のキーと値を 1 行ずつ入力します。たとえば、キー repository に値 my-org/my-repo、キー ref に値 refs/heads/main、キー workflow_ref に値 my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main を指定します。ジョブが GitHub 環境を使用する場合は、キー environment と値 production の行も追加します。
注意: repository_owner == "my-org" だけを信頼条件にするなど、対象が広すぎるマッピングは避けてください。この条件だけでよいのは、その所有者の名前空間にあるすべてのリポジトリに OpenAI アクセストークンの発行を許可する場合です。
-
OpenAI 側の対象を選択します。 プロジェクトには、対象のサービスアカウントが属する OpenAI プロジェクトを指定します。サービスアカウントには、github-actions-prod-deploy など、GitHub ワークフローが使用できる OpenAI サービスアカウントを指定します。
-
必要に応じて API 権限を絞り込みます。 権限で api.model.request や api.vector_store.read などの適切な権限を選択すると、このマッピングで発行されるアクセストークンの権限をさらに制限できます。WIF 固有のスコープ制限を追加しない場合は、権限を空欄にします。その場合も、トークンにはマッピング先のサービスアカウントの権限が適用されます。
GitHub OIDC トークンをリクエストし、OpenAI が発行するアクセストークンと交換するように OpenAI SDK クライアントを設定します。
ワークフローでは、id-token: write 権限を付与し、ワークロード ID フェデレーションの設定を SDK を使用するコードに渡す必要があります。SDK は、GitHub がジョブに提供する環境変数 ACTIONS_ID_TOKEN_REQUEST_URL と ACTIONS_ID_TOKEN_REQUEST_TOKEN を使用して GitHub OIDC トークンをリクエストします。次に、交換で取得した OpenAI アクセストークンを使用して API リクエストを認証します。
たとえば、次のようなワークフローからアプリケーションコードを実行します。
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
OPENAI_WIF_AUDIENCE、OPENAI_IDENTITY_PROVIDER_ID、OPENAI_SERVICE_ACCOUNT_ID は GitHub Actions の変数として保存します。これらはプロバイダーとサービスアカウントを識別するための値であり、Bearer 認証情報ではありません。
次の例では、カスタムのサブジェクトトークンプロバイダーを指定して OpenAI クライアントを初期化します。このプロバイダーは、設定されたオーディエンスを指定して GitHub OIDC トークンをリクエストし、取得したトークンをワークロード ID フェデレーションのサブジェクトトークンとして使用します。
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)
- 本番環境へのデプロイには環境保護ルールを使用します。ワークフローが本番の OpenAI リソースにアクセスする前に承認を必須とするか、アクセスできるブランチを制限してください。
- マッピングの対象をリポジトリ単位で制限します。組織内のすべてのリポジトリにアクセスを許可せず、可能な限りリポジトリ固有のクレームを照合してください。
- マッピングの対象をブランチまたはワークフロー単位で制限します。トークンの発行対象を絞るために、
repository、ref、environment、workflow_ref などのクレームを照合することを検討してください。
- CI/CD と本番ワークロードには別々の OpenAI サービスアカウントを使用します。ビルドパイプラインとデプロイ済みのアプリケーションでは、必要な権限が異なることがよくあります。
- 信頼できないフォークからの pull requests にアクセス権を付与しないでください。フォークからの pull requests では攻撃者が用意したコードが実行される可能性があるため、本番環境の認証情報を渡さないでください。
- 有効期間の短いアクセストークンと交換します。GitHub OIDC トークンは一時的な認証用なので、必要なときだけ交換してください。
- リポジトリの所有者変更を監査します。リポジトリの移管、名前変更、権限変更は、既存のマッピングが前提とするセキュリティ条件に影響する可能性があります。
- クレームの完全一致を優先します。組織全体を対象とする信頼関係に依存せず、
repository、ref、environment などのクレームを照合してください。