客户端凭据(client_credentials)
本页说明没有用户参与时,一个后端服务如何用自己的凭据向 wicket 换取访问令牌,以及被调用的 API 如何校验这枚令牌。本页的请求与响应来自 快速开始 示例程序(examples/quickstart)在 v0.1.4 上的实际运行结果;关于密钥 Expiration 的结论来自在同一装配上加了测试客户端的实验程序。
适用场景
- 定时任务、消息消费者、内部微服务之间互相调用;
- 令牌代表的是客户端本身,令牌里没有
sub,也不会签发id_token或refresh_token。
需要代表某个用户访问资源时,请改用 授权码 + PKCE 或 CIBA。
1. 定义客户端与资源
以下片段摘自快速开始示例,完整装配(签名密钥、hosting.ScopeMiddleware 等)见 快速开始:
// API 作用域与 API 资源
api := models.NewApiScope()
api.Name = "orders.read"
apiSecret := models.NewSecret()
apiSecret.Value = utils.Sha256("api-secret") // 资源自身的密钥,内省时使用
orders := models.NewApiResource()
orders.Name = "orders"
orders.Scopes = []string{"orders.read"}
orders.ApiSecrets = []*models.Secret{apiSecret}
// 机器客户端
secret := models.NewSecret()
secret.Value = utils.Sha256("change-me")
client := models.NewClient()
client.ClientId = "billing-worker"
client.ClientSecrets = []*models.Secret{secret}
client.AllowedScopes = []string{"orders.read"}
if err := client.SetAllowedGrantTypes(wicket.GrantTypesClientCredentials); err != nil {
log.Fatal(err)
}
cfg := root.ServerConfig()
cfg.SetApiScopes([]*models.ApiScope{api})
cfg.SetApiResources([]*models.ApiResource{orders})
cfg.SetClients([]*models.Client{client})要点:
- 密钥存哈希。
Secret.Value存的是utils.Sha256(明文)(SHA-256 后 Base64),客户端请求时发送明文。不要把明文写进配置或数据库。 - 一个客户端可以同时挂多个密钥,任一匹配即通过。轮换密钥时先追加新密钥,客户端切换完成后再移除旧密钥。
- 授权类型通过
SetAllowedGrantTypes设置(字段本身不可直接赋值),它会拒绝重复项和含空格的值。wicket.GrantTypesClientCredentials等是预定义的组合。 NewClient()的默认值:RequireClientSecret = true、AccessTokenLifetime = 3600秒、AccessTokenType为 JWT。
2. 请求令牌
令牌端点是 POST /connect/token,表单编码:
curl -s -u billing-worker:change-me \
-d grant_type=client_credentials \
-d scope=orders.read \
http://localhost:5000/connect/token实际响应(令牌已截断):
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InVBdm9RSHVz...",
"expires_in": 3600,
"id_token": "",
"refresh_token": "",
"scope": "orders.read",
"token_type": "Bearer"
}响应里的空字段
v0.1.4 的令牌响应总会带上 id_token 与 refresh_token 两个键,未签发时值为空字符串。客户端应按“值为空即不存在”处理,不要只判断键是否存在。
省略 scope 参数时,wicket 会按客户端 AllowedScopes 中全部可用的作用域签发(本例结果同样是 orders.read)。
3. 令牌里有什么
访问令牌是 JWT,解码后的头部与载荷(实际运行结果):
{ "alg": "RS256", "kid": "uAvoQHusrZOFYuv6MBTpWlh6Av1iJlt38tmDkQ6-d0s", "typ": "at+jwt" }{
"aud": ["orders"],
"client_id": "billing-worker",
"exp": 1794542360,
"iat": 1794538760,
"iss": "http://localhost:5000",
"nbf": 1794538760,
"scope": ["orders.read"]
}typ为at+jwt,资源服务可据此区分访问令牌与id_token。aud来自作用域所属的 API 资源名(orders),不是作用域名。scope在 JWT 里是数组;在内省响应里是空格分隔的字符串(见下文)。kid与/.well-known/openid-configuration/jwks中发布的公钥一致,资源服务可以离线验签,详见 本地验签与服务端交换。
4. 客户端认证方式
发现文档中 token_endpoint_auth_methods_supported 为 client_secret_basic 与 client_secret_post,两种都由内置的密钥解析器处理。
| 方式 | 写法 | 说明 |
|---|---|---|
client_secret_basic | Authorization: Basic base64(client_id:client_secret),即 curl -u | 推荐;凭据不进入表单与访问日志 |
client_secret_post | 表单中带 client_id 与 client_secret | 适合无法设置请求头的环境 |
client_secret_post 的等价请求(实际返回 200 与访问令牌):
curl -s \
-d client_id=billing-worker \
-d client_secret=change-me \
-d grant_type=client_credentials \
http://localhost:5000/connect/token只带 client_id、不带密钥的请求会得到 invalid_client,因为该客户端 RequireClientSecret 为 true。
v0.1.4:不要给密钥设置 Expiration
v0.1.4 对密钥 Expiration 的处理有误:设置了 Expiration 的密钥可能被错误地拒绝(返回 invalid_client)。在修复版本发布前,请保持 Expiration 为零值,用“增删密钥”的方式做轮换;停用的密钥要直接从配置中删除,不要依赖 Expiration 让它失效。
5. API 端用内省验证令牌
不想在 API 侧做 JWT 验签时,可以调用内省端点 POST /connect/introspect。调用方是 API 资源,不是客户端:用 ApiResource.Name 作为用户名、ApiSecrets 中的明文作为密码做 Basic 认证。没有配置 ApiSecrets 的资源无法调用内省端点。
curl -s -u orders:api-secret \
-d token="$ACCESS_TOKEN" \
http://localhost:5000/connect/introspect实际响应:
{
"active": true,
"aud": ["orders"],
"client_id": "billing-worker",
"exp": 1794542360,
"iat": 1794538760,
"iss": "http://localhost:5000",
"nbf": 1794538760,
"scope": "orders.read"
}其他情况(均为实际结果):
| 请求 | 结果 |
|---|---|
token 是无法识别的字符串 | 200,{"active":false} |
API 资源密钥错误(-u orders:wrong) | 401,空响应体 |
用客户端凭据调用(-u billing-worker:change-me) | 401,空响应体 |
内省不是授权
active: true 只说明令牌有效。API 仍需自行检查 scope 是否包含当前接口要求的权限。
6. 常见错误
以下均为实际运行结果。注意 v0.1.4 的错误响应状态码统一为 400(包括 invalid_client),Content-Type 为 text/plain; charset=utf-8,响应体是 JSON 并带有一个 custom 字段:
{"custom":{},"error":"invalid_client","error_description":""}error | 触发条件 | 排查方向 |
|---|---|---|
invalid_client | 客户端不存在、密钥错误、需要密钥但未提供 | 确认 Secret.Value 存的是 utils.Sha256(明文),请求发的是明文;确认没有给密钥设置 Expiration(见上文警告) |
invalid_scope | 请求了客户端 AllowedScopes 之外或服务端未定义的作用域(实测 orders.write、openid 均返回此错误) | 检查 AllowedScopes 与已注册的 ApiScope;客户端凭据流没有用户,不要请求 openid 等身份作用域 |
unauthorized_client | 客户端没有被允许使用该 grant_type | 检查 SetAllowedGrantTypes |
invalid_request | 用 GET 访问令牌端点、缺少必需参数、表单中含控制字符 | 令牌端点只接受 POST 表单 |
客户端代码的兼容写法
标准建议 invalid_client 使用 401。如果你的客户端库只在 401 时才判定凭据错误,请同时按响应体中的 error 字段判断。