在 Okta 中使用 WIF

通过 Workload Identity Federation 将 Okta 服务应用程序标识联合到 Claude API。


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

Note

你必须使用 Okta 自定义授权服务器(包括 default 服务器)。直接由 Okta 组织授权服务器签发的令牌(路径中没有授权服务器 ID 的 /oauth2/v1/token 端点)无法被外部方验证,因为 Okta 不为其发布签名密钥。

配置和认证到 Okta 的方式有很多,超出了本文档的范围。请确保你的配置和认证机制遵循公司的指导和安全实践。

前置条件

  • 熟悉 WIF 概念:服务账号、联合签发方和联合规则。
  • 一个启用了 API Access Management 的 Okta 组织(自定义授权服务器所需)。
  • 拥有在 Claude Console 中为你的 Anthropic 组织创建服务账号、联合签发方和联合规则的权限。
  • 一个能够从 Okta 的 /v1/token 端点请求令牌并能访问 api.anthropic.com 的工作负载。

配置 Okta

在高层次上你需要:

  1. 创建一个 Okta 服务应用程序。
  2. 配置你的默认授权服务器(或创建新的自定义授权服务器),包括受众、范围、访问策略和你想要匹配的任何自定义声明。

具体导航取决于你的 Okta 组织配置和管理控制台版本。下面的编号步骤展示了一条常见路径:

  1. 创建服务应用集成。 在 Okta Admin Console 中,创建一个类型为 API Services(OIDC,机器对机器)的新应用集成。记下生成的 Client ID
  2. 配置客户端认证。 对于无密钥设置,选择 Public key / Private keyprivate_key_jwt)并注册你的工作负载的公共 JWK。或者,如果你的环境可以安全存储客户端密钥,也可以使用客户端密钥。对于以下示例,你可能需要禁用应用程序上的 DPoP 要求;请确保你的生产设置符合组织的安全要求。
  3. 设置受众。 在你的自定义授权服务器上,将受众设置为 https://api.anthropic.com,以便签发的访问令牌携带该 aud 声明。Anthropic 会根据此固定值验证 aud
  4. 授予范围。 在你的自定义授权服务器上,确保存在至少一个服务应用程序被允许请求的范围(例如 anthropic.access)。Okta 会拒绝不包含已授予范围的 client_credentials 请求。
  5. 创建访问策略。 在你的自定义授权服务器上,创建一个访问策略,其中至少包含一个允许你的服务应用程序请求你在第 4 步中授予的范围的规则。
  6. (可选)添加自定义声明。 如果你想匹配客户端 ID 之外的内容,请在授权服务器的 Claims 选项卡中向访问令牌添加声明。

对于使用 client_credentials 的服务应用程序,Okta 将签发的访问令牌的 sub 声明设置为应用程序的 Client ID,将 iss 设置为授权服务器的签发方 URL。

配置 Anthropic

按照设置指南在 Claude Console 中注册联合签发方、创建 Anthropic 服务账号并创建联合规则。使用以下 Okta 特定的值。

联合签发方: 使用你的 Okta 自定义授权服务器 URL 和发现模式。Anthropic 会读取 Okta 的 .well-known/openid-configuration 发现文档,并从其公布的 jwks_uri 获取 JWKS。

{
  "name": "okta-prod",
  "issuer_url": "https://acme.okta.com/oauth2/aus1a2b3c4d5e6f7g8h9",
  "jwks_source": "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. 从 Okta 请求访问令牌(client_credentials + 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. 将 Okta JWT 交换为 Anthropic 访问令牌。
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. 调用 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-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello, Claude"}]}' \
  | jq -r '.content[0].text'
import os
import httpx
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",
            # 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
            "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-sonnet-4-6",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello, Claude"}],
)
print(message.content[0].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",
      // 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
      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-sonnet-4-6",
  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"},
		// 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
		"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.ModelClaudeSonnet4_6,
		MaxTokens: 1024,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock("Hello, Claude")),
		},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(message.Content[0].Text)
}
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.credentials.IdentityTokenProvider;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.Model;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.stream.Collectors;
import static java.nio.charset.StandardCharsets.UTF_8;

void main() {
    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",
                            // 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
                            "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_SONNET_4_6)
            .maxTokens(1024)
            .addUserMessage("Hello, Claude")
            .build());

    IO.println(message.content());
}
using System.Text.Json;
using Anthropic.Models.Messages;
using Anthropic.Oidc;

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 AnthropicOidcClient(credentials);

var message = await client.Messages.Create(new()
{
    Model = Model.ClaudeSonnet4_6,
    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",
            // 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
            ["client_assertion"] = BuildSignedClientAssertion(),
        });
        var response = await Http.PostAsync(
            {{CONTENT}}quot;{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. 从 Okta 请求访问令牌并写入临时文件。
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. 调用 Claude API。CLI 读取 ANTHROPIC_FEDERATION_RULE_ID、
# ANTHROPIC_ORGANIZATION_ID、ANTHROPIC_SERVICE_ACCOUNT_ID、ANTHROPIC_WORKSPACE_ID 和
# ANTHROPIC_IDENTITY_TOKEN_FILE 并执行交换。
ant messages create \
  --model claude-sonnet-4-6 \
  --max-tokens 1024 \
  --message '{role: user, content: "Hello, Claude"}'
<?php
require 'vendor/autoload.php';

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',
            // 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
            '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-sonnet-4-6',
    maxTokens: 1024,
    messages: [['role' => 'user', 'content' => 'Hello, Claude']],
);
echo $message->content[0]->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",
    # 构建用你的 Okta 应用私钥签名的 RFC 7523 client_assertion JWT
    "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-sonnet-4-6",
  max_tokens: 1024,
  messages: [{role: "user", content: "Hello, Claude"}]
)
puts message.content.first.text

每个 SDK 标签都展示了可调用模式:Anthropic SDK 会在 Anthropic 访问令牌接近过期时再次调用你的身份令牌提供者,因此你的 Okta 获取器应该每次调用都返回新令牌,而不是无限期缓存。ant CLI 在每次交换时重新读取 ANTHROPIC_IDENTITY_TOKEN_FILE,因此对于长时间运行的 Shell,请定时刷新该文件。

验证设置

成功交换会返回一个以 sk-ant-oat01- 开头的 access_token 和一个以秒为单位的 expires_in 值。如果出现 400 invalid_grant,请参阅排查交换失败;最常见的 Okta 侧原因是 issuer_url 不匹配(它必须包含 /oauth2/<auth-server-id> 路径;Okta 组织授权服务器不可用)。

限定规则范围

Warning

同一 Okta 授权服务器下的多个服务应用程序共享同一个签发方。省略 subject_prefix 的规则会匹配该服务器上的每个服务应用程序,因此任何能注册服务应用程序的团队都可以获取联合 Anthropic 令牌。

将规则的 match 块锁定到适合你用例的最窄范围:

  • 固定精确的 Client ID:subject_prefix 设置为服务应用程序的完整 Client ID,不带末尾的 *
  • 固定受众: 匹配你在授权服务器上配置的 audience 值,这样为不同受众铸造的令牌会被拒绝。
  • 匹配自定义声明: 为了更细粒度的范围限定,在授权服务器的 Claims 选项卡中添加声明,并使用规则的 claims 映射或 CEL condition 来匹配它们。
  • 为每个服务应用程序使用一个规则: 为每个服务应用程序创建单独的联合规则,而不是跨应用程序共享一个规则。

后续步骤

  • 查阅 WIF 参考文档了解完整的凭证解析顺序和配置文件配置。
  • 参阅 WIF 参考文档了解如何使用 CEL 表达式匹配自定义 Okta 声明。