Okta에서 WIF 사용하기
Okta에서 WIF 사용하기
Okta는 OAuth 2.0 client_credentials 부여를 통해 서비스 애플리케이션에 OIDC 접근 토큰을 발행하는 워크로드 아이덴티티 공급자로 작동할 수 있어요. 워크로드는 Okta에 인증하고(보통 private_key_jwt를 사용해 공유 시크릿을 저장하지 않음), 서명된 JSON Web Token(JWT)을 받아 그 JWT를 Anthropic과 교환해 수명이 짧은 접근 토큰을 얻어요.
Okta 권한 부여 서버의 발급자 URL은 https://<your-domain>.okta.com/oauth2/<auth-server-id> 형태예요. 내장 기본 서버를 사용하면 경로는 /oauth2/default예요.
참고 Okta 커스텀 권한 부여 서버를 사용해야 해요(
default포함). Okta 조직 권한 부여 서버가 직접 발행한 토큰(경로에 권한 부여 서버 ID가 없는/oauth2/v1/token엔드포인트)은 Okta가 그것들에 대한 서명 키를 게시하지 않으므로 외부 주체가 검증할 수 없어요.
Okta를 구성하고 인증하는 방법은 이 문서 범위 밖의 여러 가지가 있어요. 내 구성과 인증 메커니즘이 회사의 지침과 보안 관행을 따르는지 확인하세요.
출처: 문서
본문
사전 요구사항
- WIF 개념에 익숙해야 해요. 서비스 계정, 페더레이션 발급자, 페더레이션 규칙.
- API Access Management가 활성화된 Okta 조직(커스텀 권한 부여 서버에 필요).
- Anthropic 조직의 Claude Console에서 서비스 계정, 페더레이션 발급자, 페더레이션 규칙을 만들 권한.
- Okta의
/v1/token엔드포인트에서 토큰을 요청하고api.anthropic.com에 도달할 수 있는 워크로드.
Okta 구성하기
높은 수준에서 다음이 필요해요:
- Okta 서비스 애플리케이션을 만드세요.
- 기본 권한 부여 서버를 구성하거나(또는 새 커스텀 권한 부여 서버를 만들어) audience, scope, 접근 정책, 그리고 일치시키려는 커스텀 클레임을 설정하세요.
정확한 탐색은 내 Okta 조직 구성과 admin console 버전에 따라 달라져요. 다음 번호 단계가 한 가지 흔한 경로를 안내해요:
- 서비스 앱 통합 만들기. Okta Admin Console에서 API Services(OIDC, machine-to-machine) 타입의 새 앱 통합을 만드세요. 생성된 Client ID를 기록하세요.
- 클라이언트 인증 구성. 키리스 설정에는 Public key / Private key(
private_key_jwt)를 선택하고 워크로드의 공개 JWK를 등록하세요. 또는 환경이 하나를 안전하게 저장할 수 있으면 클라이언트 시크릿을 사용하세요. 다음 예시에서는 애플리케이션에서 DPoP 요구사항을 비활성화해야 할 수 있어요. 프로덕션 설정이 조직의 보안 요구사항을 따르는지 확인하세요. - audience 설정. 커스텀 권한 부여 서버에서 audience를
https://api.anthropic.com으로 설정해 발행되는 접근 토큰이 그aud클레임을 나르게 하세요. Anthropic은aud를 이 고정 값으로 검증해요. - scope 부여. 커스텀 권한 부여 서버에서 서비스 앱이 요청할 수 있는 scope가 하나 이상 존재하는지 확인하세요(예:
anthropic.access). Okta는 부여된 scope를 포함하지 않는client_credentials요청을 거부해요. - 접근 정책 만들기. 커스텀 권한 부여 서버에서 4단계에서 부여한 scope를 서비스 앱이 요청할 수 있게 하는 규칙이 하나 이상 있는 접근 정책을 만드세요.
- (선택) 커스텀 클레임 추가. 클라이언트 ID가 아닌 다른 것으로 일치시키려면 권한 부여 서버의 Claims 탭에서 접근 토큰에 클레임을 추가하세요.
client_credentials를 사용하는 서비스 앱의 경우 Okta는 발행된 접근 토큰의 sub 클레임을 애플리케이션의 Client ID로, iss를 권한 부여 서버의 발급자 URL로 설정해요.
Anthropic 구성하기
Claude Console에서 Settings → Workload identity를 열고 Connect workload를 클릭한 뒤 Custom OIDC를 선택하세요. 마법사가 발급자 등록, 서비스 계정 만들기, 페더레이션 규칙 만들기를 안내해요.
마법사가 이 리소스들을 만들어 줘요. 마법사에 입력하든 Admin API로 보내든 다음 값을 사용하세요:
페더레이션 발급자: Okta 커스텀 권한 부여 서버 URL과 discovery 모드를 사용하세요. Anthropic은 Okta의 .well-known/openid-configuration discovery 문서를 읽고 그것이 광고하는 jwks_uri에서 JWKS를 가져와요.
{
"name": "okta-prod",
"issuer_url": "https://acme.okta.com/oauth2/aus1a2b3c4d5e6f7g8h9",
"jwks": { "type": "discovery" }
}
페더레이션 규칙: Okta sub 클레임, 즉 서비스 앱의 Client ID에 일치시키세요. Okta에서 커스텀 클레임을 정의했다면 claims 맵이나 CEL condition으로 그것들에 일치시킬 수도 있어요.
{
"name": "okta-pipeline",
"issuer_id": "fdis_...",
"match": {
"subject_prefix": "0oa1b2c3d4e5f6g7h8i9",
"audience": "https://api.anthropic.com"
},
"target": { "type": "service_account", "service_account_id": "svac_..." },
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}
토큰 획득과 Claude API 호출
플랫폼 네이티브 공급자(AWS, Google Cloud, Kubernetes)는 워크로드 런타임 안에서 토큰을 사용 가능하게 만들지만(프로젝티드 파일이나 로컬 메타데이터 엔드포인트를 통해), Okta는 그렇지 않아요. 워크로드는 Okta의 토큰 엔드포인트를 호출해 JWT를 얻은 뒤 그 JWT를 Anthropic SDK에 아이덴티티 토큰으로 전달해야 해요.
# 1. Request an access token from Okta (client_credentials with private_key_jwt).
OKTA_JWT=$(curl -sS "https://acme.okta.com/oauth2/aus1a2b3c4d5e6f7g8h9/v1/token" \
-d grant_type=client_credentials \
-d scope=anthropic.access \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion="$SIGNED_CLIENT_ASSERTION" \
| jq -r .access_token)
# 2. Exchange the Okta JWT for an Anthropic access token.
ACCESS_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \
-H "content-type: application/json" \
-d @- <<JSON | jq -r .access_token
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "$OKTA_JWT",
"federation_rule_id": "$ANTHROPIC_FEDERATION_RULE_ID",
"organization_id": "$ANTHROPIC_ORGANIZATION_ID",
"service_account_id": "$ANTHROPIC_SERVICE_ACCOUNT_ID",
"workspace_id": "$ANTHROPIC_WORKSPACE_ID"
}
JSON
)
# 3. Call the Claude API.
curl https://api.anthropic.com/v1/messages \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model": "claude-opus-5-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello, Claude"}]}' \
| jq -r '.content[] | select(.type == "text") | .text'
import os
import anthropic
from anthropic import WorkloadIdentityCredentials
def fetch_okta_token() -> str:
response = httpx.post(
f"{os.environ['OKTA_ISSUER']}/v1/token",
data={
"grant_type": "client_credentials",
"scope": "anthropic.access",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
# Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
"client_assertion": build_signed_client_assertion(),
},
)
response.raise_for_status()
return response.json()["access_token"]
client = anthropic.Anthropic(
credentials=WorkloadIdentityCredentials(
identity_token_provider=fetch_okta_token,
federation_rule_id=os.environ["ANTHROPIC_FEDERATION_RULE_ID"],
organization_id=os.environ["ANTHROPIC_ORGANIZATION_ID"],
service_account_id=os.environ["ANTHROPIC_SERVICE_ACCOUNT_ID"],
workspace_id=os.environ.get("ANTHROPIC_WORKSPACE_ID"),
),
)
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
)
print(next(block.text for block in message.content if block.type == "text"))
import Anthropic from "@anthropic-ai/sdk";
import { oidcFederationProvider } from "@anthropic-ai/sdk/lib/credentials/oidc-federation";
async function fetchOktaToken(): Promise<string> {
const response = await fetch(`${process.env.OKTA_ISSUER}/v1/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "anthropic.access",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
// Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
client_assertion: buildSignedClientAssertion()
})
});
const body = (await response.json()) as { access_token: string };
return body.access_token;
}
const client = new Anthropic({
credentials: oidcFederationProvider({
identityTokenProvider: fetchOktaToken,
federationRuleId: process.env.ANTHROPIC_FEDERATION_RULE_ID!,
organizationId: process.env.ANTHROPIC_ORGANIZATION_ID!,
serviceAccountId: process.env.ANTHROPIC_SERVICE_ACCOUNT_ID,
workspaceId: process.env.ANTHROPIC_WORKSPACE_ID,
baseURL: "https://api.anthropic.com",
fetch
})
});
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello, Claude" }]
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
}
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
)
func fetchOktaToken(ctx context.Context) (string, error) {
form := url.Values{
"grant_type": {"client_credentials"},
"scope": {"anthropic.access"},
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
// Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
"client_assertion": {buildSignedClientAssertion()},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
os.Getenv("OKTA_ISSUER")+"/v1/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("content-type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var body struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", err
}
return body.AccessToken, nil
}
func main() {
client := anthropic.NewClient(
option.WithFederationTokenProvider(option.IdentityTokenFunc(fetchOktaToken), option.FederationOptions{
FederationRuleID: os.Getenv("ANTHROPIC_FEDERATION_RULE_ID"),
OrganizationID: os.Getenv("ANTHROPIC_ORGANIZATION_ID"),
ServiceAccountID: os.Getenv("ANTHROPIC_SERVICE_ACCOUNT_ID"),
WorkspaceID: os.Getenv("ANTHROPIC_WORKSPACE_ID"),
}),
)
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Hello, Claude")),
},
})
if err != nil {
panic(err)
}
for _, block := range message.Content {
if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok {
fmt.Println(textBlock.Text)
break
}
}
}
IdentityTokenProvider fetchOktaToken = () -> {
try {
var form = Map.of(
"grant_type", "client_credentials",
"scope", "anthropic.access",
"client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
// Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
"client_assertion", buildSignedClientAssertion())
.entrySet().stream()
.map(entry -> entry.getKey() + "=" + URLEncoder.encode(entry.getValue(), UTF_8))
.collect(Collectors.joining("&"));
var request = HttpRequest.newBuilder(URI.create(System.getenv("OKTA_ISSUER") + "/v1/token"))
.header("content-type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
return new ObjectMapper().readTree(response.body()).get("access_token").asText();
} catch (Exception e) {
throw new RuntimeException(e);
}
};
AnthropicClient client = AnthropicOkHttpClient.builder()
.federationTokenProvider(
fetchOktaToken,
System.getenv("ANTHROPIC_FEDERATION_RULE_ID"),
System.getenv("ANTHROPIC_ORGANIZATION_ID"),
System.getenv("ANTHROPIC_SERVICE_ACCOUNT_ID"))
.build();
var message = client.messages().create(MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.addUserMessage("Hello, Claude")
.build());
IO.println(message.content());
using Anthropic.Credentials;
// ...
var credentials = new WorkloadIdentityCredentials(new WorkloadIdentityOptions
{
FederationRuleId = Environment.GetEnvironmentVariable("ANTHROPIC_FEDERATION_RULE_ID")!,
OrganizationId = Environment.GetEnvironmentVariable("ANTHROPIC_ORGANIZATION_ID"),
ServiceAccountId = Environment.GetEnvironmentVariable("ANTHROPIC_SERVICE_ACCOUNT_ID"),
WorkspaceId = Environment.GetEnvironmentVariable("ANTHROPIC_WORKSPACE_ID"),
IdentityTokenProvider = new OktaTokenProvider(),
});
using var client = new AnthropicClient(new ClientOptions { Credentials = credentials });
var message = await client.Messages.Create(new()
{
Model = Model.ClaudeOpus5_5,
MaxTokens = 1024,
Messages = [new() { Role = Role.User, Content = "Hello, Claude" }],
});
foreach (var block in message.Content)
{
if (block.Value is TextBlock textBlock)
{
Console.WriteLine(textBlock.Text);
}
}
class OktaTokenProvider : IIdentityTokenProvider
{
private static readonly HttpClient Http = new();
public async Task<string> GetIdentityTokenAsync(CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["scope"] = "anthropic.access",
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
// Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
["client_assertion"] = BuildSignedClientAssertion(),
});
var response = await Http.PostAsync(
$"{Environment.GetEnvironmentVariable("OKTA_ISSUER")}/v1/token", form, ct);
response.EnsureSuccessStatusCode();
using var json = await JsonDocument.ParseAsync(
await response.Content.ReadAsStreamAsync(ct), default, ct);
return json.RootElement.GetProperty("access_token").GetString()!;
}
}
# 1. Request an access token from Okta and write it to a temp file.
ANTHROPIC_IDENTITY_TOKEN_FILE=$(mktemp)
curl -sS "$OKTA_ISSUER/v1/token" \
-d grant_type=client_credentials \
-d scope=anthropic.access \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion="$SIGNED_CLIENT_ASSERTION" \
| jq -r .access_token > "$ANTHROPIC_IDENTITY_TOKEN_FILE"
export ANTHROPIC_IDENTITY_TOKEN_FILE
# 2. Call the Claude API. The CLI reads ANTHROPIC_FEDERATION_RULE_ID,
# ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and
# ANTHROPIC_IDENTITY_TOKEN_FILE and performs the exchange.
ant messages create \
--model claude-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello, Claude"}'
use Anthropic\Client;
use Anthropic\Credentials\WorkloadIdentityCredentials;
function fetchOktaToken(): string
{
$ch = curl_init(getenv('OKTA_ISSUER') . '/v1/token');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'scope' => 'anthropic.access',
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
// Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
'client_assertion' => buildSignedClientAssertion(),
]),
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
return $body['access_token'];
}
$client = new Client(
credentials: new WorkloadIdentityCredentials(
identityTokenProvider: fetchOktaToken(...),
federationRuleId: getenv('ANTHROPIC_FEDERATION_RULE_ID'),
organizationId: getenv('ANTHROPIC_ORGANIZATION_ID'),
serviceAccountId: getenv('ANTHROPIC_SERVICE_ACCOUNT_ID'),
workspaceId: getenv('ANTHROPIC_WORKSPACE_ID') ?: null,
),
);
$message = $client->messages->create(
model: 'claude-opus-5-5',
maxTokens: 1024,
messages: [['role' => 'user', 'content' => 'Hello, Claude']],
);
echo array_find($message->content, static fn ($block): bool => $block->type === 'text')->text, PHP_EOL;
require "anthropic"
require "json"
require "net/http"
def fetch_okta_token
uri = URI("#{ENV.fetch('OKTA_ISSUER')}/v1/token")
response = Net::HTTP.post_form(
uri,
"grant_type" => "client_credentials",
"scope" => "anthropic.access",
"client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
# Build the RFC 7523 client_assertion JWT signed with your Okta app's private key
"client_assertion" => build_signed_client_assertion
)
JSON.parse(response.body).fetch("access_token")
end
client = Anthropic::Client.new(
credentials: Anthropic::WorkloadIdentityCredentials.new(
identity_token_provider: -> { fetch_okta_token },
federation_rule_id: ENV.fetch("ANTHROPIC_FEDERATION_RULE_ID"),
organization_id: ENV.fetch("ANTHROPIC_ORGANIZATION_ID"),
service_account_id: ENV.fetch("ANTHROPIC_SERVICE_ACCOUNT_ID"),
workspace_id: ENV["ANTHROPIC_WORKSPACE_ID"]
)
)
message = client.messages.create(
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "Hello, Claude"}]
)
puts message.content.find { it.type == :text }.text
각 SDK 탭은 callable 패턴을 보여줘요. Anthropic SDK는 Anthropic 접근 토큰이 만료에 가까워질 때마다 내 아이덴티티 토큰 공급자를 다시 호출하므로, Okta 가져오기는 토큰을 무기한 캐시하기보다 매 호출마다 새 토큰을 반환해야 해요. ant CLI는 매 교환마다 ANTHROPIC_IDENTITY_TOKEN_FILE을 다시 읽으므로, 오래 실행되는 셸에서 타이머로 그 파일을 갱신하세요.
설정 검증하기
성공한 교환은 sk-로 시작하는 access_token과 초 단위의 expires_in 값을 반환해요. 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 교환이 실패하면 인증 기록 페이지에서 거부 이유를 확인하고 실패한 교환 문제 해결을 참고하세요. 가장 흔한 Okta 측 원인은 issuer_url 불일치(/oauth2/<auth-server-id> 경로를 포함해야 하며 Okta 조직 권한 부여 서버는 사용할 수 없음)예요.
규칙 범위 지정하기
경고 같은 Okta 권한 부여 서버 아래의 여러 서비스 앱은 같은 발급자를 공유해요.
subject_prefix를 생략한 규칙은 그 서버의 모든 서비스 앱에 일치하므로, 하나를 등록할 수 있는 어떤 팀이든 페더레이션 Anthropic 토큰을 얻을 수 있어요.
규칙의 match 블록을 사용 사례에 맞는 가장 좁은 범위로 잠그세요:
- 정확한 Client ID 고정:
subject_prefix를 서비스 앱의 전체 Client ID로 끝자리*없이 설정하세요. - audience 고정: 권한 부여 서버에 구성한
audience값에 일치시켜 다른 audience용으로 발행된 토큰이 거부되게 하세요. - 커스텀 클레임에 일치: 더 세밀한 범위를 위해 권한 부여 서버의 Claims 탭에 클레임을 추가하고 규칙의
claims맵이나 CELcondition으로 일치시키세요. - 서비스 앱마다 규칙 하나: 앱 전체에 하나의 규칙을 공유하기보다 각 서비스 앱마다 별도 페더레이션 규칙을 만드세요.
다음 단계
- WIF reference를 검토해 전체 자격 증명 결정 순서와 프로필 구성을 확인하세요.
- CEL 표현식으로 Okta 커스텀 클레임에 일치시키려면 WIF reference를 참고하세요.