Skip to content

M2M 组织级 Token

本文档介绍如何让 M2M(Machine-to-Machine)应用获取组织范围的令牌,以便在特定组织的上下文中执行操作。

在多租户 B2B 场景中,M2M 应用可能需要代表某个组织访问其资源或调用组织级别的 API。Code Bird Cloud 提供了两种路径来实现这一需求。

概念说明

组织级 Token 与普通 M2M Token 的区别

对比项普通 M2M Token组织级 Token
权限范围应用级别(全局角色)组织级别(组织内角色)
audienceurn:codebird:apiurn: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(或 resourceurn: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_idM2M 应用的 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_idresource 参数时,系统返回组织级 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_idM2M 应用的 Client ID
client_secret应用的 Client Secret
organization_id目标组织的 ID
resourceAPI 资源的 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。

解决:

  1. 检查应用是否已在组织内分配角色
  2. 检查角色是否关联了正确的 Scope(组织 Scope 或 API 资源 Scope)
  3. 确认路径选择正确 -- 路径 A 使用组织 Scope,路径 B 使用 API 资源 Scope

invalid_client -- 身份验证失败

M2M 认证指南中的说明

安全注意事项

  1. 最小权限原则 -- 为 M2M 应用在每个组织内仅分配必要的角色和 Scope
  2. 应用绑定审计 -- 定期审查 M2M 应用的组织绑定关系,移除不再需要的绑定
  3. Token 范围隔离 -- 组织级 Token 的权限严格限定在指定组织范围内,不同组织的权限互不影响
  4. 令牌缓存 -- 按组织 ID(+ 资源标识)分别缓存令牌,避免混用不同组织的令牌

相关文档

Released under the MIT License.