將 Oracle Identity Cloud Service (IDCS) 存取 Token 交換為短效的 OpenAI 存取 Token,即可使用 Oracle Cloud Infrastructure (OCI) 作為工作負載身分提供者。OCI 執行個體主體會簽署傳送至同一租用戶內身分網域的 Token 交換請求。OpenAI 會驗證取得的 Token,並授權 OCI 工作負載以對應的 OpenAI 服務帳戶身分執行操作。
若要搭配 Codex 使用,請依照本頁說明取得並檢查 Oracle Token。接著設定 Codex 工作負載身分,將該 Token 寫入檔案,並讓 Codex 指向該檔案。本頁的服務帳戶對應設定與 SDK 範例適用於 OpenAI API。
此設定不需要 OpenAI API 金鑰、自訂 Oracle OAuth 資源應用程式,也不需要授予動態群組存取自訂應用程式的權限。
在具備執行個體主體的 OCI Compute 執行個體上執行工作負載。若使用 Oracle Kubernetes Engine (OKE),請確認簽署請求所用的身分:標準執行個體主體簽署器通常代表工作節點,而非個別 Kubernetes pod。
簽署器會從 OCI 執行個體中繼資料服務取得憑證。請確認工作負載可以連線至連結本機中繼資料端點:
123curl --fail --silent \
--header "Authorization: Bearer Oracle" \
http://169.254.169.254/opc/v2/instance/id
工作負載也必須能夠向其租用戶內的身分網域發出對外 HTTPS 請求。中繼資料端點本身不需要 NAT 閘道或網際網路連線。
使用 OCI Python SDK 中的 InstancePrincipalsSecurityTokenSigner,簽署傳送至身分網域的 OAuth Token 交換請求:
POST https://<identity-domain>/oauth2/v1/token
Content-Type: application/x-www-form-urlencoded;charset=utf-8
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
scope=urn:opc:idm:__myscopes__
requested_token_type=urn:ietf:params:oauth:token-type:access_token
urn:opc:idm:__myscopes__ 範圍會使用執行個體主體的既有授權。請將傳回的 IDCS 存取 Token 用作 OpenAI 工作負載身分聯合的主體 Token。不要將 Oracle Token 的對象替換為 https://api.openai.com/v1;請以實際 Oracle Token 中出現的對象設定 OpenAI 提供者。
將 TOKEN 設為實際 OCI 工作負載產生的存取 Token,然後使用現有的本機 JWT 解碼器檢查其宣告:
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)
解碼器會檢查 Token,但不會驗證其簽章。請將原始 Token 視為敏感資料,不要記錄在日誌中,也不要將正式環境的 Token 貼入第三方 JWT 解碼器。
解碼後的 Oracle 存取 Token 可能包含下列宣告:
123456789101112131415{
"iss": "https://identity.oraclecloud.com/",
"aud": [
"https://idcs-example.us-phoenix-1.identity.oraclecloud.com",
"https://idcs-example.identity.oraclecloud.com"
],
"sub_type": "instance",
"ipst_instance": "ocid1.instance.oc1.phx.<instance-id>",
"ipst_compartment": "ocid1.compartment.oc1..<compartment-id>",
"domain_id": "ocid1.domain.oc1..<domain-id>",
"ca_ocid": "ocid1.tenancy.oc1..<tenancy-id>",
"tenant": "idcs-example",
"exp": 1782369434,
"iat": 1782365834
}
請以您自己的身分網域核發的 Token 為準。設定時,請填入完全相符的 iss 值,以及該 Token 的其中一個 aud 值。授權工作負載時,優先使用不可變的 ipst_instance、ipst_compartment、domain_id 和 ca_ocid 宣告。
為您的 Oracle 身分網域建立工作負載身分提供者,然後為可使用目標 OpenAI 服務帳戶的 OCI 執行個體或區間新增對應。
-
建立工作負載身分提供者。 將 名稱 設為唯一值,例如 oracle-cloud-prod。使用 描述來識別受信任的工作負載,例如 Production OCI instance principal。
-
設定核發者與對象。 將 OIDC 核發者 URL 設為 Token 的 iss 宣告值,例如 https://identity.oraclecloud.com/。將 對象 設為同一個 Token 中的其中一個 aud 值。
-
若可用,請設定租用戶專屬的 OIDC 探索。 如果 進階下方顯示 使用自訂 URL 進行 OIDC 探索 ,請啟用此選項。將 自訂 OIDC 探索 URL 設為租用戶專屬的身分網域,例如 https://idcs-example.identity.oraclecloud.com。OpenAI 會擷取 https://idcs-example.identity.oraclecloud.com/.well-known/openid-configuration,再使用探索文件中的 jwks_uri 取得租用戶的公開簽署金鑰。如果未顯示自訂探索選項,請改為啟用 使用已上傳的 JWKS 驗證 Token ,並上傳來自 https://<identity-domain>/admin/v1/SigningCert/jwk 的公開 JWKS。
-
只有在需要衍生屬性時,才新增屬性轉換。 您可以直接在服務帳戶對應斷言中使用原始 Oracle 宣告,例如 ipst_instance、ipst_compartment、domain_id 和 ca_ocid。若要明確衍生執行個體屬性,請輸入 instance 並搭配運算式 assertion.ipst_instance,以建立 openai.instance。
Oracle 的 OpenID Connect 探索參考資料說明了自訂探索的重要性:探索文件可以宣告全域核發者 https://identity.oraclecloud.com/,同時在租用戶專屬的身分網域上發布 Token 端點與 jwks_uri。請在 OIDC 核發者 URL 中保留全域核發者,並將租用戶網域用於 自訂 OIDC 探索 URL。
如果您的身分網域在 Token 核發者位址發布探索中繼資料,
請保持自訂探索停用,並使用標準 OIDC 探索。如果 OpenAI
無法存取租用戶探索文件或簽署金鑰端點,請停用
自訂探索,啟用 使用已上傳的 JWKS 驗證 Token,並
上傳租用戶的公開 JWKS,來源為
https://<identity-domain>/admin/v1/SigningCert/jwk。自訂探索與
已上傳的 JWKS 無法同時啟用。當
Oracle 輪替簽署憑證時,請更新已上傳的金鑰。
-
建立服務帳戶對應。 將 名稱 設為唯一值,例如 oracle-instance-prod,並新增描述,以識別受信任的 OCI 工作負載。
-
比對範圍最小且穩定的 OCI 身分。 若要授予單一執行個體存取權,請將 鍵 設為 ipst_instance,並將 值 設為已驗證 Token 中完全相符的執行個體 OCID。若要授予同一區間內的執行個體存取權,請將 鍵 設為 ipst_compartment,並將 值 設為完全相符的區間 OCID。
-
視需要新增網域與租用戶邊界限制。 為 domain_id 或 ca_ocid 新增對應列,將工作負載限制在特定 Oracle 身分網域或租用戶內。如果 Token 包含 sub_type 宣告,且您想要求使用執行個體主體,請新增該宣告並將值設為 instance。所有對應列都必須符合。
-
選擇 OpenAI 目標。 將 專案 設為服務帳戶所屬的專案,然後選取受信任的 OCI 工作負載可使用的 服務帳戶 。
-
視需要縮小 API 權限範圍。 只選取工作負載所需的 權限 。對應權限可以限制所選服務帳戶的權限,但無法授予該服務帳戶原本未具備的權限。
使用標準執行個體主體簽署器的 OKE 工作負載會繼承
工作節點的身分。執行個體層級的對應會授權該節點,而不只是
單一 pod。如果需要隔離共用工作節點的 pod,
請使用受支援且範圍更精確的 OCI 工作負載身分。
安裝 OpenAI、OCI 和 Requests Python 套件:
pip install openai oci requests
若使用 Ruby,請安裝 OpenAI 和 OCI gem:
gem install openai oci
將 OCI_IDENTITY_DOMAIN_URL 設為與工作負載位於同一租用戶內的身分網域基底 URL。將 OPENAI_IDENTITY_PROVIDER_ID 和 OPENAI_SERVICE_ACCOUNT_ID 分別設為 OpenAI 提供者與服務帳戶對應中的 ID。
下列範例會使用 OCI 執行個體主體簽署 Oracle Token 交換請求,將 IDCS 存取 Token 傳回 OpenAI SDK,並讓 SDK 在需要時將其交換為短效的 OpenAI 存取 Token:
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
53import os
import oci
import requests
from openai import OpenAI
from openai.auth import SubjectTokenProvider
def oracle_instance_principal_token_provider(
identity_domain_url: str,
) -> SubjectTokenProvider:
def get_token() -> str:
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
response = requests.post(
f"{identity_domain_url.rstrip('/')}/oauth2/v1/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"scope": "urn:opc:idm:__myscopes__",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
headers={
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
auth=signer,
timeout=30,
)
response.raise_for_status()
token = response.json().get("access_token")
if not isinstance(token, str) or not token:
raise RuntimeError("Oracle IDCS did not return an access token.")
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": oracle_instance_principal_token_provider(
os.environ["OCI_IDENTITY_DOMAIN_URL"]
),
},
)
response = client.responses.create(
model="gpt-5.6-terra",
input="Say hello from Oracle Cloud Infrastructure 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
105require "json"
require "net/http"
require "oci"
require "openai"
require "uri"
class OracleInstancePrincipalTokenProvider
include OpenAI::Auth::SubjectTokenProvider
def initialize(identity_domain_url:)
@identity_domain_url = identity_domain_url.sub(%r{/+\z}, "")
end
def token_type
OpenAI::Auth::TokenType::JWT
end
def get_token
uri = URI("#{@identity_domain_url}/oauth2/v1/token")
unless uri.is_a?(URI::HTTPS)
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Oracle identity domain URL must use HTTPS",
provider: "oracle-instance-principal"
)
end
body = URI.encode_www_form(
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
scope: "urn:opc:idm:__myscopes__",
requested_token_type: "urn:ietf:params:oauth:token-type:access_token"
)
headers = {
"content-type": "application/x-www-form-urlencoded;charset=utf-8"
}
signer = OCI::Auth::Signers::InstancePrincipalsSecurityTokenSigner.new
signer.sign(:post, uri.to_s, headers, body)
request = Net::HTTP::Post.new(uri)
headers.each { |name, value| request[name.to_s] = value }
request.body = body
response = Net::HTTP.start(
uri.hostname,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 30
) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Oracle identity token request failed with status #{response.code}",
provider: "oracle-instance-principal"
)
end
token = JSON.parse(response.body).fetch("access_token")
unless token.is_a?(String) && !token.empty?
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Oracle identity domain did not return an access token",
provider: "oracle-instance-principal"
)
end
token
rescue JSON::ParserError
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Oracle identity token response was not valid JSON",
provider: "oracle-instance-principal"
), cause: nil
rescue KeyError
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Oracle identity domain did not return an access token",
provider: "oracle-instance-principal"
), cause: nil
rescue SystemCallError, Timeout::Error => error
raise OpenAI::Errors::SubjectTokenProviderError.new(
message: "Failed to request Oracle identity token: #{error.message}",
provider: "oracle-instance-principal",
cause: error
)
end
end
provider = OracleInstancePrincipalTokenProvider.new(
identity_domain_url: ENV.fetch("OCI_IDENTITY_DOMAIN_URL")
)
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 Oracle Cloud Infrastructure workload identity federation."
)
puts(response.output_text)
當 OpenAI SDK 需要更新工作負載身分憑證時,主體 Token 提供者會請求新的 Oracle Token。絕不要輸出或持久儲存 Oracle 主體 Token 或取得的 OpenAI 存取 Token。
- 若應僅允許一個工作負載存取,請使用
ipst_instance 對應單一執行個體。
- 只有當該區間內所有符合資格的執行個體都應共用對應時,才使用
ipst_compartment。
- 新增
domain_id 或 ca_ocid,以強制實施身分網域與租用戶邊界限制。
- 為每個應用程式與環境使用個別的 OpenAI 服務帳戶。
- 在依賴 pod 層級的隔離之前,請確認 OKE Token 是否代表工作節點。
- 使用已核發 Oracle Token 中的對象,不要假設它使用 OpenAI 專屬的對象。
- 如果您的身分網域無法使用 OIDC 探索,請在 Oracle 輪替簽署金鑰時,一併輪替已上傳的公開金鑰。