M2M 组织级 Token
本文档介绍如何让 M2M(Machine-to-Machine)应用获取组织范围的令牌,以便在特定组织的上下文中执行操作。
在多租户 B2B 场景中,M2M 应用可能需要代表某个组织访问其资源或调用组织级别的 API。Code Bird Cloud 提供了两种路径来实现这一需求。
概念说明
组织级 Token 与普通 M2M Token 的区别
| 对比项 | 普通 M2M Token | 组织级 Token |
|---|---|---|
| 权限范围 | 应用级别(全局角色) | 组织级别(组织内角色) |
| audience | urn:codebird:api | urn:codebird:organization:{orgID} 或自定义 API 资源 |
| scope 来源 | 应用的全局角色 Scope | 应用在组织内的角色 Scope |
| 额外 Claims | 无 | 包含 organization_id |
| 请求参数 | 无需 organization_id | 必须指定 organization_id |
两种路径
Code Bird Cloud 为 M2M 组织级 Token 提供了两种路径:
- 路径 A:组织 Scope Token -- 获取组织内定义的 Scope 权限,audience 为组织标识
- 路径 B:组织级 API 资源 Token -- 获取组织内某个特定 API 资源的 Scope 权限,audience 为 API 资源标识
┌─────────────────────────────┐
│ M2M 应用请求组织级 Token │
└──────────────┬──────────────┘
│
是否指定 resource 参数?
┌───────────┴───────────┐
│ │
未指定 resource 指定了 resource
(或 resource = (自定义 API 资源)
urn:codebird:resource:
organizations)
│ │
┌────▼─────┐ ┌─────▼────┐
│ 路径 A │ │ 路径 B │
│ 组织 Scope │ │ API 资源 │
│ Token │ │ Token │
└────┬─────┘ └─────┬────┘
│ │
audience: audience:
urn:codebird: {resourceIndicator}
organization:{orgID}
scope: scope:
组织角色中的 组织角色中的
组织 Scope API 资源 Scope前置条件
在请求组织级 Token 之前,必须完成以下配置:
1. 将 M2M 应用绑定到组织
M2M 应用需要先绑定到目标组织。通过管理 API 操作:
http
POST /api/v1/organizations/:id/applications
Content-Type: application/json
{
"applicationId": "app_xxxxxxxx"
}2. 为 M2M 应用分配组织角色
绑定后,需要为 M2M 应用在组织内分配角色:
http
PUT /api/v1/organizations/:id/applications/:appId/roles
Content-Type: application/json
{
"roleIds": ["role_111", "role_222"]
}注意: 这里的角色是组织角色(Organization Role),不是全局角色。组织角色关联了组织 Scope 和/或 API 资源 Scope。
路径 A:组织 Scope Token
当请求中包含 organization_id 但不指定 resource(或 resource 为 urn:codebird:resource:organizations)时,系统返回组织 Scope Token。
请求示例
http
POST /oidc/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
organization_id=org_xxxxxxxx参数说明:
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 是 | 固定值 client_credentials |
client_id | 是 | M2M 应用的 Client ID |
client_secret | 是 | 应用的 Client Secret |
organization_id | 是 | 目标组织的 ID |
JWT Claims 结构
json
{
"sub": "app_xxxxxxxx",
"iss": "https://your-domain.com",
"aud": ["urn:codebird:organization:org_xxxxxxxx"],
"client_id": "app_xxxxxxxx",
"token_type": "m2m",
"organization_id": "org_xxxxxxxx",
"scope": ["read:members", "manage:settings"],
"iat": 1700000000,
"exp": 1700003600
}关键差异:
| Claim | 说明 |
|---|---|
aud | 值为 urn:codebird:organization:{orgID},标识这是一个组织范围的令牌 |
organization_id | 目标组织的 ID |
scope | 来自 M2M 应用在该组织内角色所关联的组织 Scope |
路径 B:组织级 API 资源 Token
当请求中同时包含 organization_id 和 resource 参数时,系统返回组织级 API 资源 Token。
请求示例
http
POST /oidc/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
organization_id=org_xxxxxxxx&
resource=https://api.example.com参数说明:
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 是 | 固定值 client_credentials |
client_id | 是 | M2M 应用的 Client ID |
client_secret | 是 | 应用的 Client Secret |
organization_id | 是 | 目标组织的 ID |
resource | 是 | API 资源的 Resource Indicator(URI) |
JWT Claims 结构
json
{
"sub": "app_xxxxxxxx",
"iss": "https://your-domain.com",
"aud": ["https://api.example.com"],
"client_id": "app_xxxxxxxx",
"token_type": "m2m",
"organization_id": "org_xxxxxxxx",
"scope": ["read:orders", "write:orders"],
"iat": 1700000000,
"exp": 1700003600
}关键差异:
| Claim | 说明 |
|---|---|
aud | 值为请求中指定的 resource URI,标识目标 API 资源 |
organization_id | 目标组织的 ID |
scope | 来自 M2M 应用在该组织内角色所关联的 API 资源 Scope |
代码示例
cURL -- 路径 A(组织 Scope Token)
bash
# 请求组织 Scope Token
ACCESS_TOKEN=$(curl -s -X POST https://your-domain.com/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "organization_id=org_xxxxxxxx" | jq -r '.access_token')
echo "Organization Token: ${ACCESS_TOKEN}"cURL -- 路径 B(组织级 API 资源 Token)
bash
# 请求组织级 API 资源 Token
ACCESS_TOKEN=$(curl -s -X POST https://your-domain.com/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "organization_id=org_xxxxxxxx" \
-d "resource=https://api.example.com" | jq -r '.access_token')
echo "Organization API Token: ${ACCESS_TOKEN}"Node.js
javascript
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
/**
* Request an organization-scoped M2M token.
* @param {string} organizationId - The target organization ID
* @param {string} [resource] - Optional API resource indicator (Path B)
*/
async function getOrgM2MToken(organizationId, resource) {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
organization_id: organizationId,
});
// If resource is specified, use Path B; otherwise Path A
if (resource) {
params.set('resource', resource);
}
const response = await fetch(`${OIDC_ISSUER}/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token request failed: ${error.error} - ${error.error_description}`);
}
const data = await response.json();
return data.access_token;
}
// Path A: Get organization scope token
const orgToken = await getOrgM2MToken('org_xxxxxxxx');
// Path B: Get organization API resource token
const apiToken = await getOrgM2MToken('org_xxxxxxxx', 'https://api.example.com');Go
go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
)
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// getOrgM2MToken requests an organization-scoped M2M token.
// If resource is empty, it returns a Path A (org scope) token.
// If resource is specified, it returns a Path B (API resource) token.
func getOrgM2MToken(organizationID, resource string) (string, error) {
issuer := os.Getenv("OIDC_ISSUER")
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {os.Getenv("CLIENT_ID")},
"client_secret": {os.Getenv("CLIENT_SECRET")},
"organization_id": {organizationID},
}
if resource != "" {
data.Set("resource", resource)
}
resp, err := http.Post(
issuer+"/oidc/token",
"application/x-www-form-urlencoded",
strings.NewReader(data.Encode()),
)
if err != nil {
return "", fmt.Errorf("failed to request token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
return "", fmt.Errorf("token request failed: %s - %s",
errResp["error"], errResp["error_description"])
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
return tokenResp.AccessToken, nil
}Python
python
import os
import requests
OIDC_ISSUER = os.environ["OIDC_ISSUER"]
CLIENT_ID = os.environ["CLIENT_ID"]
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
def get_org_m2m_token(organization_id: str, resource: str | None = None) -> str:
"""
Request an organization-scoped M2M token.
Args:
organization_id: The target organization ID.
resource: Optional API resource indicator (Path B).
If omitted, returns a Path A (org scope) token.
"""
data = {
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"organization_id": organization_id,
}
if resource:
data["resource"] = resource
response = requests.post(f"{OIDC_ISSUER}/oidc/token", data=data)
response.raise_for_status()
return response.json()["access_token"]
# Path A: Organization scope token
org_token = get_org_m2m_token("org_xxxxxxxx")
# Path B: Organization API resource token
api_token = get_org_m2m_token("org_xxxxxxxx", resource="https://api.example.com")错误排查
access_denied -- 应用未绑定到组织
json
{
"error": "access_denied",
"error_description": "应用未绑定到该组织"
}原因: M2M 应用尚未绑定到请求中指定的组织。
解决: 使用管理 API 将 M2M 应用绑定到目标组织:
http
POST /api/v1/organizations/:id/applications
Content-Type: application/json
{
"applicationId": "app_xxxxxxxx"
}Token 中 scope 为空
原因: M2M 应用在组织内没有分配角色,或角色中没有关联 Scope。
解决:
- 检查应用是否已在组织内分配角色
- 检查角色是否关联了正确的 Scope(组织 Scope 或 API 资源 Scope)
- 确认路径选择正确 -- 路径 A 使用组织 Scope,路径 B 使用 API 资源 Scope
invalid_client -- 身份验证失败
同 M2M 认证指南中的说明。
安全注意事项
- 最小权限原则 -- 为 M2M 应用在每个组织内仅分配必要的角色和 Scope
- 应用绑定审计 -- 定期审查 M2M 应用的组织绑定关系,移除不再需要的绑定
- Token 范围隔离 -- 组织级 Token 的权限严格限定在指定组织范围内,不同组织的权限互不影响
- 令牌缓存 -- 按组织 ID(+ 资源标识)分别缓存令牌,避免混用不同组织的令牌
相关文档
- M2M 认证指南 -- M2M 基础认证教程
- 传统 Web 应用集成 -- Authorization Code Flow 参考
- 集成概览 -- 所有集成方式的总览
