跳到主要内容

OAuth2 客户端凭证认证

适用认证方式:开放接口 authType = oauth2(OAuth2 Client Credentials) 凭证来源:平台「调用凭证」中的 AppKey / AppSecret,client_id = AppKey,client_secret = AppSecret 适用场景:服务端到服务端调用(后端服务、定时任务、Agent、数据集成)

认证方式概览​

authType名称说明需要携带的请求头
none无认证公开接口,直接放行-
app_key简单认证用 X-AppKey 与接口授权应用列表比对X-AppKey
app_secret签名认证AppKey + 时间戳 + 随机数 + HMAC 签名,防篡改、防重放X-AppKey、X-Timestamp、X-Nonce、X-Sign,可选 X-Algorithm
oauth2OAuth2 客户端凭证AppKey/AppSecret 换取短期 access_token,调用时携带 Bearer tokenAuthorization: Bearer <access_token>
jwt企业 IdP JWT企业 IdP 签发 token,平台只验签并按主体绑定识别应用Authorization: Bearer <enterprise_token>
  • OAuth2 下 AppSecret 只在换取 token 时传输一次,之后每次请求只携带短期 token,适合不便实现签名算法的标准 OAuth2 客户端;
  • token 端点与开放接口同源,地址为 {环境访问地址}/oauth2/token;
  • 同一份 access_token 在 REST 开放接口与 MCP 入口通用。

前置准备​

  1. 在 账户中心 → 应用凭证 创建凭证,获得 AppKey 与 AppSecret;
  2. 把需要调用的接口授权给该凭证(订阅审批通过或接口负责人授权),或使用项目级凭证;
  3. 确认接口的认证方式为「OAuth2 客户端凭证」(接口编辑页 → 认证方式)。
与签名认证的关系

OAuth2 凭证与签名认证共用同一套应用凭证,不需要额外创建密钥。切换接口认证方式后,调用方从「计算 X-Sign」改为「换取并携带 token」,其余参数与响应结构不变。

换取 access_token​

端点​

POST {环境访问地址}/oauth2/token
Content-Type: application/x-www-form-urlencoded

请求参数​

参数必填说明
grant_type是固定为 client_credentials
client_id是应用的 AppKey,最长 128 字符
client_secret是应用的 AppSecret,最长 512 字符
scope否预留参数;当前由服务端按应用作用域返回,不采用客户端传值

也支持标准 Basic 认证头,此时请求体无需再带 client_id / client_secret:

Authorization: Basic base64(client_id:client_secret)
凭证必须放在表单体或 Basic 头中

client_id / client_secret 通过 URL 查询参数传递会被直接拒绝(HTTP 400 invalid_request),以避免凭证进入访问日志与代理日志。

成功响应​

HTTP 200:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzMjU2LThhY2MxMjM0In0...",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "api"
}
字段说明
access_token访问令牌,REST 与 MCP 通用
token_type固定 Bearer
expires_in有效期(秒),默认 1800(30 分钟)
scope应用作用域:api(接口级授权)/ project(项目级)

失败响应​

token 端点返回 OAuth2 标准错误体,不是平台 {code, msg} 包装格式:

{
"error": "invalid_client",
"error_description": "客户端凭证校验失败"
}
HTTPerror含义
400invalid_request缺少 grant_type / client_id / client_secret,凭证写在 URL 查询参数,或 Basic 头格式错误
400unsupported_grant_typegrant_type 不是 client_credentials
401invalid_client凭证不匹配、应用已禁用、来源 IP 不在应用白名单
429invalid_client凭证校验失败次数过多(默认 5 次 / 10 分钟),已临时锁定
500server_error服务端异常(如会话写入失败)

调用开放接口​

接口认证方式为 oauth2 时,调用方只需携带 token,不再计算签名:

curl -X GET 'https://api.example.com/open/api/demo?page=1' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs...'

认证失败时返回 HTTP 200 + 业务码(与平台其它认证方式一致):

code含义常见原因
40024Header 中认证参数 Authorization 不能为空未携带 Bearer token,或 Bearer 前缀拼写错误
40025Token 无效或已过期token 过期、被篡改、来自其它环境,或应用已删除
40026Token 已失效,请重新获取token 已被吊销(服务端会话被删除)
40002认证失败,应用无接口权限token 有效,但接口未授权给该应用
40018该 IP 不在认证授权范围内应用配置了 IP 白名单,当前来源 IP 不在其中
40019应用已禁用应用被停用
40001接口不存在或已下线路径与配置不一致

调用 MCP​

MCP 入口同样接受 Bearer token:

POST {环境访问地址}/mcp/{appCode}
Authorization: Bearer <access_token>
Content-Type: application/json
curl -X POST 'https://api.example.com/mcp/myApp' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs...' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
  • 存在 Authorization: Bearer 时按 OAuth2 校验;缺失时回落 X-AppKey(兼容存量客户端);
  • 鉴权通过后仍校验应用启用、MCP 开关、路径中的 {appCode} 与应用是否匹配、应用 IP 白名单;
  • Authorization 属于敏感头,网关消费后不会透传给目标接口。

Token 生命周期​

规则说明
有效期默认 1800 秒;由平台配置,范围 60 秒 ~ 24 小时
续期没有 refresh_token,过期后重新调用 /oauth2/token;客户端应在 expires_in - 30 秒时提前刷新
缓存在进程内缓存 token 并复用,不要每个请求都换取;多实例部署时避免共用同一份 token 文件导致过期竞态
即时吊销应用禁用后立即失效;也可由平台清理该 token 的服务端会话实现即时吊销
作用域项目级凭证(scope = project)可访问所属项目内全部接口;接口级凭证(scope = api)只可访问已授权接口,跨项目调用返回 40002
密钥轮换轮换 AppSecret 后需重新换取 token;旧 token 在有效期内仍可用

调用示例​

cURL​

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://api.example.com"
APP_KEY="ak_1234567890"
APP_SECRET="sk-test-1234567890abcdef"

# 1) 换取 token
TOKEN="$(curl -sS -X POST "${BASE_URL}/oauth2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=${APP_KEY}" \
--data-urlencode "client_secret=${APP_SECRET}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')"

# 2) 调用接口
curl -sS "${BASE_URL}/open/api/demo?page=1" -H "Authorization: Bearer ${TOKEN}"

Java(JDK 11+ HttpClient)​

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.nio.charset.StandardCharsets;
import java.time.Instant;

public class OAuth2ClientDemo {

private static final String BASE_URL = "https://api.example.com";
private static final String APP_KEY = "ak_1234567890";
private static final String APP_SECRET = "sk-test-1234567890abcdef";

/** 进程内缓存:提前 30 秒视为过期 */
private static String cachedToken;
private static Instant cachedExpireAt = Instant.EPOCH;

public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/open/api/demo?page=1"))
.header("Authorization", "Bearer " + token())
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
System.out.println(response.statusCode());
System.out.println(response.body());
}

/** 获取 access_token:缓存未过期时直接复用 */
private static synchronized String token() throws Exception {
if (cachedToken != null && Instant.now().isBefore(cachedExpireAt)) {
return cachedToken;
}
String form = "grant_type=client_credentials"
+ "&client_id=" + URLEncoder.encode(APP_KEY, StandardCharsets.UTF_8)
+ "&client_secret=" + URLEncoder.encode(APP_SECRET, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/oauth2/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
// token 端点是 OAuth2 标准错误体:{"error": "...", "error_description": "..."}
throw new IllegalStateException("换取 token 失败: " + response.body());
}
// 生产环境请使用 Jackson/Gson 解析响应体
String body = response.body();
int index = body.indexOf("\"access_token\":\"") + "\"access_token\":\"".length();
cachedToken = body.substring(index, body.indexOf('"', index));
cachedExpireAt = Instant.now().plusSeconds(1770);
return cachedToken;
}
}

Python(requests)​

import time
import requests

BASE_URL = "https://api.example.com"
APP_KEY = "ak_1234567890"
APP_SECRET = "sk-test-1234567890abcdef"

_token = {"value": "", "expire_at": 0.0}


def fetch_token(force: bool = False) -> str:
"""换取 access_token:缓存未过期时直接复用,提前 30 秒刷新"""
if not force and _token["value"] and time.time() < _token["expire_at"]:
return _token["value"]
resp = requests.post(
f"{BASE_URL}/oauth2/token",
data={
"grant_type": "client_credentials",
"client_id": APP_KEY,
"client_secret": APP_SECRET,
},
timeout=10,
)
resp.raise_for_status()
payload = resp.json()
_token["value"] = payload["access_token"]
_token["expire_at"] = time.time() + max(payload.get("expires_in", 1800) - 30, 10)
return _token["value"]


def call_api() -> requests.Response:
headers = {"Authorization": f"Bearer {fetch_token()}"}
resp = requests.get(f"{BASE_URL}/open/api/demo", params={"page": 1}, headers=headers, timeout=10)
# token 过期或被吊销:40025 / 40026,重新换取后重试一次
if resp.status_code == 200 and resp.json().get("code") in (40025, 40026):
headers = {"Authorization": f"Bearer {fetch_token(force=True)}"}
resp = requests.get(f"{BASE_URL}/open/api/demo", params={"page": 1}, headers=headers, timeout=10)
return resp

Node.js​

const BASE_URL = 'https://api.example.com'
const APP_KEY = 'ak_1234567890'
const APP_SECRET = 'sk-test-1234567890abcdef'

let cached = { token: '', expireAt: 0 }

async function fetchToken(force = false) {
if (!force && cached.token && Date.now() < cached.expireAt) {
return cached.token
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: APP_KEY,
client_secret: APP_SECRET,
})
const res = await fetch(`${BASE_URL}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const text = await res.text()
if (!res.ok) {
throw new Error(`换取 token 失败: ${res.status} ${text}`)
}
const json = JSON.parse(text)
cached = {
token: json.access_token,
// 提前 30 秒视为过期
expireAt: Date.now() + Math.max((json.expires_in || 1800) - 30, 10) * 1000,
}
return cached.token
}

async function callApi() {
const token = await fetchToken()
const res = await fetch(`${BASE_URL}/open/api/demo?page=1`, {
headers: { Authorization: `Bearer ${token}` },
})
console.log(res.status, await res.text())
}

Go(net/http)​

package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)

const (
baseURL = "https://api.example.com"
appKey = "ak_1234567890"
appSecret = "sk-test-1234567890abcdef"
)

var (
mu sync.Mutex
token string
expireAt time.Time
)

// fetchToken 换取 access_token:缓存未过期时直接复用(提前 30 秒视为过期)
func fetchToken(force bool) (string, error) {
mu.Lock()
defer mu.Unlock()
if !force && token != "" && time.Now().Before(expireAt) {
return token, nil
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {appKey},
"client_secret": {appSecret},
}
resp, err := http.Post(baseURL+"/oauth2/token", "application/x-www-form-urlencoded",
strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
// token 端点是 OAuth2 标准错误体
return "", fmt.Errorf("换取 token 失败: %d %s", resp.StatusCode, body)
}
var payload struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
token = payload.AccessToken
expireAt = time.Now().Add(time.Duration(payload.ExpiresIn-30) * time.Second)
return token, nil
}

func main() {
t, err := fetchToken(false)
if err != nil {
panic(err)
}
req, _ := http.NewRequest(http.MethodGet, baseURL+"/open/api/demo?page=1", nil)
req.Header.Set("Authorization", "Bearer "+t)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, string(body))
}

与签名认证对比​

项app_secret 签名认证oauth2 客户端凭证
每次请求是否传输 AppSecret否(只用于计算签名)否(仅换取 token 时传输一次)
请求头X-AppKey、X-Timestamp、X-Nonce、X-SignAuthorization: Bearer <access_token>
防重放时间戳窗口 + noncetoken 短时效 + 服务端会话
即时吊销需禁用应用禁用应用立即生效,也可清理服务端会话
客户端实现成本需实现 SM3 / HMAC-SM3标准 OAuth2,各语言 SDK 通用
适用场景已有国密签名能力的内部系统标准 OAuth2 客户端、Agent、后端服务

常见错误与排查​

现象排查方向
400 invalid_requestContent-Type 是否为 application/x-www-form-urlencoded;grant_type 与 client_id / client_secret 是否齐全;是否把凭证写进了 URL 查询参数
400 unsupported_grant_typegrant_type 必须为 client_credentials
401 invalid_clientAppKey/AppSecret 是否属于同一应用且未被重置;应用是否启用;来源 IP 是否在白名单;client_id 是否超过 128 字符、client_secret 是否超过 512 字符
429 invalid_client失败次数超过上限,等待锁定窗口结束(默认 10 分钟)后重试,勿持续盲目重试
500 server_error平台侧异常,稍后重试;持续出现请联系管理员
开放接口返回 40024请求未带 Authorization: Bearer <token>,或缺少 Bearer 前缀
开放接口返回 40025token 已过期或无效,重新换取;注意 token 与本环境同源签发
开放接口返回 40026token 已被吊销,重新换取即可
开放接口返回 40002token 有效但接口未授权给该应用,补充订阅或授权
开放接口返回 40018来源 IP 不在应用白名单内

排查顺序:先用 /oauth2/token 确认能拿到 access_token,再调用任一已授权接口确认 200,最后排查业务参数。

安全建议​

  1. AppSecret 只放在服务端,禁止写入浏览器、App 包、前端配置或代码仓库;
  2. 全程 HTTPS:token 请求与 Bearer 头都承载凭证,仅靠 TLS 提供机密性;
  3. 缓存并复用 token,在 expires_in - 30 秒后刷新,避免每次请求都换取;
  4. 按 IP 白名单收敛来源:应用配置 ips 后,token 签发与接口调用两侧都会校验;
  5. 最小授权:接口级凭证只授权必要接口;项目级凭证等同项目内通行证,需严格控制发放范围;
  6. 轮换 AppSecret 后立即重新换取 token,并及时通知调用方;
  7. 失败不要盲目重试:invalid_client 多为凭证或配置问题,连续失败会触发临时锁定。

速查卡​

① 准备:应用凭证 AppKey / AppSecret;把接口授权给该应用
② 换 token:POST {环境地址}/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=<AppKey>&client_secret=<AppSecret>
③ 响应:{ access_token, token_type: "Bearer", expires_in: 1800, scope }
④ 调用 REST:Authorization: Bearer <access_token>
调用 MCP :POST /mcp/{appCode} + Authorization: Bearer <access_token>
⑤ 续期:expires_in - 30 秒后重新换取;过期 / 吊销返回 40025 / 40026
⑥ 吊销:应用禁用立即生效

相关文档​