nimeet 文档中心
English

Token 鉴权

Token 结构、签发/续期/校验、uid 映射、IM RTC Provider 双方法集成与安全最佳实践。

版本 v1.1.0 · 更新于 2026-10-26 · 修订 1

Token 决定「谁、能以什么角色、进入哪个频道、到什么时候为止」。ni-meet 的 Token 语义对齐声网 AccessToken2(App ID + App Certificate、频道级或通配、按权限过期、到期前续期),但字节格式是自有实现:nrt1.<base64url(payload)>.<base64url(hmac-sha256)>。ni-meet 不声称与声网字节兼容(闭源协议无法验证),SDK 与业务必须把 Token 当作不透明字符串。

前置条件

  • 已在控制台创建项目并拿到 appId / customerId / customerSecret / appCertificate(见 开通服务与凭证)。
  • 已有可用的 API 基址;服务端调用使用 Basic 凭证。

Token 模型

概念说明
App ID项目公开标识,写进 token payload 的 app,校验时强绑定
App Certificate64 hex 签名密钥,只在服务端/受控存储中;HMAC-SHA256 对整个 nrt1.<payload> 签名
频道绑定payload ch 为频道名;* 表示通配(仅演示/联调用)
uid 绑定payload uid 为 1..2147483647;0 永不签发(声网语义里 0 = 服务端随机分配)
权限priv 数组:join_channel、publish_audio、publish_video、publish_data、subscribe
双过期exp(Token 生命周期)与 pexp(权限窗口),pexp ≤ exp
续期宽限权限过期后 300 秒内仍可续期;超过必须重新签发

payload 字段(控制面组件):

字段类型说明
vnumber格式版本,当前 1;不匹配直接拒绝
appstringApp ID
chstring频道名或 *
uidnumberrtc uid
rolehost \audience角色
privstring[]权限列表
iat / exp / pexpepoch 秒签发时间 / Token 过期 / 权限过期(允许 5s 时钟偏差)
saltnumber每次签发随机,避免同参数 Token 完全相同
usrstring?可选的 IM userId 提示,不参与鉴权

角色 → 权限:

角色权限
hostjoin_channel、publish_audio、publish_video、publish_data、subscribe
audiencejoin_channel、subscribe

签发:POST /v1/tokens

实现 IM 接口定义的 getRTCTokenInfo。请求体字段:

字段必填说明
channelName / channel二选一(wildcard: true 时可不传)频道名 1–128,[A-Za-z0-9_-]
uid否1..2147483647;与 userId 同时给定时会建立映射
userId否IM 用户 id;用于派生稳定 uid 并写入 uid 映射
role否默认 host
ttlSeconds否60–86400,最终被项目 tokenTtlSeconds 夹紧
wildcard否true 时频道固定为 *,忽略 channelName

服务端处理顺序:

  1. 鉴权(Basic)并校验项目 active;
  2. 校验频道名(非法 → 400 invalid_channel_name);
  3. join_channel 封禁检查(命中 → 403 banned,不会签发可用凭证);
  4. 频道配额检查(项目 maxChannels > 0 且已达上限 → 503 no_server_resources);
  5. 解析 uid:显式 uid > userId 映射/派生 > 随机派生;
  6. 计算 ttl = clamp(ttlSeconds, project.tokenTtlSeconds)、privilegeTtl = min(ttl, project.privilegeTtlSeconds);
  7. 签发并登记一条「权限即将过期」调度(用于事件 203 token_expiring)。

响应:

字段说明
appId项目 App ID
channelName实际绑定的频道(通配时为 *)
rtcToken不透明 Token
rtcUid本次签发使用的 uid
role角色
privileges实际权限数组
issuedAt / expireAt / privilegeExpireAtISO-8601 时间

续期:POST /v1/tokens/renew

请求体 { "token": "<旧 Token>" }。

  • 签名有效且权限窗口未超过宽限(pexp + 300s ≥ now)时成功;
  • 新 Token 保持 channelName、uid、role、privileges,只刷新有效期——续期不可能提权;
  • Token 整体过期超过宽限 → 401 token_expired;签名损坏/伪造 → 401 invalid_token。

客户端协同:SDK 在权限到期前 30 秒触发 token-privilege-will-expire,此时调用续期;服务端也会在同样的时间点产生 203 回调,方便后端提前换发。

校验:GET /v1/tokens/verify?token=&channelName=

返回 { valid: true, payload };失败走统一错误信封。

底层校验器的判定顺序(供实现参考):

顺序情况校验器拒绝原因
1格式错误 / 前缀、版本不符malformed / bad_prefix / bad_version
2签名与 App Certificate 不匹配invalid_signature
3App ID 不匹配invalid_signature(detail 说明是 app id)
4频道不匹配(且非通配)channel_mismatch
5uid 不匹配(指定 uid 校验时)uid_mismatch
6Token 过期(含 5s 时钟偏差)expired
7尚未生效(iat 在未来)not_yet_valid
8权限过期privilege_expired
9缺少所需权限missing_privilege

channelName 为可选参数:传入时会校验频道绑定(通配 Token 对任何频道都通过)。

uid 映射:POST /v1/uids:resolve

实现 IM 接口定义的 getUserIdsWithRTCUids:

json
{ "rtcUids": [10001, 10002, 2147483647], "channelName": "weekly-sync" }

响应只包含已知 uid:{ "10001": "user_alice", "10002": "user_bob" }。部分结果是合法结果:未知 uid 省略、重复 uid 忽略、空数组返回 {},绝不因为个别 uid 未知而整体失败。

映射由「签发时带 userId」建立(写入 uidMappings);也可通过接口定义里的 local-cache / identity-echo 在 IM 侧自行兜底。

与 IM 的 RTC Provider 接口定义

唯一耦合点是 接口定义(v1.0.0,已固定):

typescript
getRTCTokenInfo({ channelName, userId?, role?, ttlSeconds? })
  → { appId, rtcToken, rtcUid, expireAt? }

getUserIdsWithRTCUids(rtcUids: number[], channelName?)
  → Record<string /* uid */, string /* IM userId */>

硬约束:

  • 两个方法独立配置、独立回退:tokenInfo: nimeet-http → local-hmac → disabled,uidMapping: nimeet-http → local-cache → identity-echo → disabled;
  • 一个方法禁用/失败不得拖垮另一个;
  • rtcUid 在 [1, 2147483647],同一 (appId, userId) 会话期内稳定;
  • local-hmac 只允许在持有 App Certificate 的可信后端使用(客户端永远拿不到 Certificate);
  • 破坏性变更 → 新建 rtc-provider.v2.json,双发 90 天。

完整实现(含三种场景:主通道可用、主通道不可用回退到本地 HMAC、两方法互不影响):

javascript
import { createHash, createHmac } from 'node:crypto';
import { basicAuth } from './_lib.mjs';

export const meta = {
  id: '11-im-provider',
  title: 'IM RTC Provider 双方法 + 独立回退',
  safe: true,
  needsApi: true,
};

/**
 * 实现 公开接口定义 的两个方法,并演示“各自独立配置、各自独立回退”:
 *
 *   getRTCTokenInfo:        nimeet-http → local-hmac → disabled
 *   getUserIdsWithRTCUids:  nimeet-http → local-cache → identity-echo → disabled
 *
 * 三个场景:
 *   1. 全部可用:走 nimeet-http,Token 由服务端签发;
 *   2. 主通道不可用:Token 回退到本地 HMAC 签名(App Certificate 只在可信后端持有),
 *      并用 /v1/tokens/verify 证明本地签出的 Token 与服务端签名方案一致;
 *   3. 方法独立:tokenInfo 全链路失败时,uidMapping 仍能照常返回(反之亦然)。
 */

/** base64url,无 padding。 */
function base64Url(buffer) {
  return Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

/** 本地 HMAC 签发:与 控制面 的 nrt1 格式一致。 */
function signLocalToken({ appId, appCertificate, channelName, uid, role, ttlSeconds, userRef }) {
  const now = Math.floor(Date.now() / 1000);
  const privileges =
    role === 'audience'
      ? ['join_channel', 'subscribe']
      : ['join_channel', 'publish_audio', 'publish_video', 'publish_data', 'subscribe'];
  const payload = {
    v: 1,
    app: appId,
    ch: channelName || '*',
    uid,
    role,
    priv: privileges,
    iat: now,
    exp: now + ttlSeconds,
    pexp: now + ttlSeconds,
    salt: Math.floor(Math.random() * 2147483646) + 1,
    ...(userRef ? { usr: userRef } : {}),
  };
  const body = base64Url(JSON.stringify(payload));
  const signingInput = `nrt1.${body}`;
  const signature = base64Url(createHmacSha256(appCertificate, signingInput));
  return { rtcToken: `${signingInput}.${signature}`, expireAt: new Date((now + ttlSeconds) * 1000).toISOString(), payload };
}

function createHmacSha256(secret, input) {
  return createHmac('sha256', secret).update(input).digest();
}

/** 稳定派生 uid(仅 local-hmac 回退使用;同一 (appId,userId) 恒定)。 */
function deriveUid(appId, userId) {
  const digest = createHash('sha256').update(`${appId}:${userId}`).digest();
  const value = digest.readUInt32BE(0) % 2147483647;
  return value === 0 ? 1 : value;
}

function providerError(code, message) {
  const error = new Error(message ?? code);
  error.code = code;
  return error;
}

export function createRtcProvider({ credentials, config }) {
  const uidCache = new Map(); // rtcUid(string) -> userId

  async function httpJson(baseUrl, pathname, body, timeoutMs) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const response = await fetch(new URL(pathname, baseUrl), {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          authorization: basicAuth(credentials),
        },
        body: JSON.stringify(body),
        signal: controller.signal,
      });
      const text = await response.text();
      let json = null;
      try {
        json = JSON.parse(text);
      } catch {
        json = null;
      }
      if (!response.ok) {
        const code =
          response.status === 403 ? 'RTC_TOKEN_DENIED' : response.status === 400 ? 'RTC_TOKEN_INVALID_REQUEST' : 'RTC_PROVIDER_UNAVAILABLE';
        throw providerError(code, `${pathname} → ${response.status} ${json?.code ?? text.slice(0, 120)}`);
      }
      return json;
    } catch (error) {
      if (error.code) throw error;
      throw providerError('RTC_PROVIDER_UNAVAILABLE', `${pathname} → ${error.message}`);
    } finally {
      clearTimeout(timer);
    }
  }

  async function issueVia(provider, input, settings) {
    const { channelName, userId, role = settings.defaultRole ?? 'host', ttlSeconds = settings.defaultTtlSeconds ?? 3600 } = input;
    if (provider === 'disabled') throw providerError('RTC_PROVIDER_DISABLED', 'tokenInfo provider is disabled');
    if (provider === 'nimeet-http') {
      const issued = await httpJson(
        settings.baseUrl,
        '/v1/tokens',
        { channelName, ...(userId ? { userId } : {}), role, ttlSeconds },
        settings.timeoutMs ?? 2000,
      );
      if (userId) uidCache.set(String(issued.rtcUid), userId);
      return { appId: issued.appId, rtcToken: issued.rtcToken, rtcUid: issued.rtcUid, expireAt: issued.expireAt };
    }
    if (provider === 'local-hmac') {
      if (!settings.appCertificate || !settings.appId) {
        throw providerError('RTC_PROVIDER_DISABLED', 'local-hmac requires appId + appCertificate in a trusted backend');
      }
      const uid = userId ? deriveUid(settings.appId, userId) : Math.floor(Math.random() * 2147483646) + 1;
      const signed = signLocalToken({
        appId: settings.appId,
        appCertificate: settings.appCertificate,
        channelName,
        uid,
        role,
        ttlSeconds,
        userRef: userId,
      });
      if (userId) uidCache.set(String(uid), userId);
      return { appId: settings.appId, rtcToken: signed.rtcToken, rtcUid: uid, expireAt: signed.expireAt };
    }
    throw providerError('RTC_PROVIDER_UNAVAILABLE', `unknown token provider ${provider}`);
  }

  async function getRTCTokenInfo(input) {
    const settings = { ...config.tokenInfo };
    const chain = [settings.provider, ...(settings.fallback ?? [])];
    let lastError = null;
    for (const provider of chain) {
      try {
        return await issueVia(provider, input, settings);
      } catch (error) {
        lastError = error;
        if (error.code === 'RTC_PROVIDER_DISABLED') throw error; // 配置级禁用:不再尝试
      }
    }
    throw lastError ?? providerError('RTC_PROVIDER_UNAVAILABLE', 'no token provider configured');
  }

  async function resolveVia(provider, uids, channelName, settings) {
    if (provider === 'disabled') throw providerError('RTC_PROVIDER_DISABLED', 'uidMapping provider is disabled');
    if (provider === 'nimeet-http') {
      const mapping = await httpJson(
        settings.baseUrl,
        '/v1/uids:resolve',
        { rtcUids: uids, ...(channelName ? { channelName } : {}) },
        settings.timeoutMs ?? 2000,
      );
      for (const [uid, userId] of Object.entries(mapping)) uidCache.set(String(uid), userId);
      return mapping;
    }
    if (provider === 'local-cache') {
      const mapping = {};
      for (const uid of uids) if (uidCache.has(String(uid))) mapping[String(uid)] = uidCache.get(String(uid));
      return mapping;
    }
    if (provider === 'identity-echo') {
      const mapping = {};
      for (const uid of uids) mapping[String(uid)] = String(uid); // 降级但永不失败
      return mapping;
    }
    throw providerError('RTC_PROVIDER_UNAVAILABLE', `unknown uid provider ${provider}`);
  }

  /** 部分结果语义:未知 uid 省略,绝不整体失败。 */
  async function getUserIdsWithRTCUids(input) {
    const settings = { ...config.uidMapping };
    const pending = [...new Set(input.rtcUids ?? [])];
    const result = {};
    let lastError = null;
    for (const provider of [settings.provider, ...(settings.fallback ?? [])]) {
      const missing = pending.filter((uid) => result[String(uid)] === undefined);
      if (missing.length === 0) break;
      try {
        const mapping = await resolveVia(provider, missing, input.channelName, settings);
        for (const uid of missing) {
          if (mapping[String(uid)] !== undefined) result[String(uid)] = mapping[String(uid)];
        }
      } catch (error) {
        lastError = error;
        if (error.code === 'RTC_PROVIDER_DISABLED') break;
      }
    }
    void lastError; // 完全解析不到时仍返回 {}(调用方渲染兜底显示 uid)
    return result;
  }

  return { getRTCTokenInfo, getUserIdsWithRTCUids };
}

export async function run(ctx) {
  const { credentials, request } = ctx;
  const appId = credentials.appId ?? null;
  ctx.assert(appId, '需要 appId(演示凭证包含)');

  // 场景 1:全部可用,主通道 nimeet-http。
  const provider = createRtcProvider({
    credentials,
    config: {
      tokenInfo: {
        provider: 'nimeet-http',
        fallback: ['local-hmac'],
        baseUrl: ctx.baseUrl,
        appId,
        appCertificate: credentials.appCertificate ?? null,
        timeoutMs: 2000,
      },
      uidMapping: { provider: 'nimeet-http', fallback: ['local-cache', 'identity-echo'], baseUrl: ctx.baseUrl, timeoutMs: 2000 },
    },
  });

  const tokenInfo = await provider.getRTCTokenInfo({ channelName: 'weekly-sync', userId: 'im_user_1', role: 'host', ttlSeconds: 600 });
  ctx.assert(tokenInfo.appId === appId, 'appId 应一致');
  ctx.assert(Number.isInteger(tokenInfo.rtcUid) && tokenInfo.rtcUid >= 1, 'rtcUid 必须是 [1, 2147483647] 内整数');
  ctx.assert(typeof tokenInfo.rtcToken === 'string' && tokenInfo.rtcToken.startsWith('nrt1.'), 'rtcToken 应为 nrt1 Token');

  const mapping = await provider.getUserIdsWithRTCUids({ rtcUids: [tokenInfo.rtcUid, 2147483647], channelName: 'weekly-sync' });
  ctx.assert(mapping[String(tokenInfo.rtcUid)] === 'im_user_1', '主通道应解析出 uid → IM userId');
  // 该配置带 identity-echo 兜底:未知 uid 会降级映射为十进制字符串(接口定义允许的降级语义)。
  ctx.assert(mapping['2147483647'] === '2147483647', 'identity-echo 兜底应把未知 uid 映射为其十进制字符串');

  // 严格模式(不带 identity-echo):未知 uid 必须被省略。
  const strict = createRtcProvider({
    credentials,
    config: {
      tokenInfo: { provider: 'nimeet-http', fallback: [], baseUrl: ctx.baseUrl, timeoutMs: 2000 },
      uidMapping: { provider: 'nimeet-http', fallback: [], baseUrl: ctx.baseUrl, timeoutMs: 2000 },
    },
  });
  const strictMapping = await strict.getUserIdsWithRTCUids({ rtcUids: [tokenInfo.rtcUid, 2147483647], channelName: 'weekly-sync' });
  ctx.assert(strictMapping[String(tokenInfo.rtcUid)] === 'im_user_1', '严格模式仍应解析已知 uid');
  ctx.assert(strictMapping['2147483647'] === undefined, '严格模式下未知 uid 必须省略(部分结果语义)');

  // 场景 2:主通道不可用 → local-hmac 回退(仅当后端持有 App Certificate)。
  let fallbackSummary = '未配置 appCertificate,跳过 local-hmac 回退验证';
  if (credentials.appCertificate) {
    const degraded = createRtcProvider({
      credentials,
      config: {
        tokenInfo: {
          provider: 'nimeet-http',
          fallback: ['local-hmac'],
          baseUrl: 'http://127.0.0.1:1', // 模拟主通道不可达
          appId,
          appCertificate: credentials.appCertificate,
          timeoutMs: 300,
        },
        uidMapping: {
          provider: 'nimeet-http',
          fallback: ['local-cache', 'identity-echo'],
          baseUrl: 'http://127.0.0.1:1',
          timeoutMs: 300,
        },
      },
    });
    const local = await degraded.getRTCTokenInfo({ channelName: 'weekly-sync', userId: 'im_user_2', role: 'host', ttlSeconds: 600 });
    ctx.assert(local.rtcToken.startsWith('nrt1.'), '回退后仍应得到 nrt1 Token');

    // 用服务端 verify 证明本地签名与 @nimeet/shared 的校验一致。
    const verification = await request('GET', '/v1/tokens/verify', { query: { token: local.rtcToken } });
    ctx.assert(verification.json?.valid === true, `本地签发的 Token 应能通过服务端校验: ${verification.text.slice(0, 200)}`);
    const cached = await degraded.getUserIdsWithRTCUids({ rtcUids: [local.rtcUid] });
    ctx.assert(cached[String(local.rtcUid)] === 'im_user_2', 'uidMapping 应能从 local-cache 回退');
    fallbackSummary = `主通道不可达时 local-hmac 签发成功(服务端 verify 通过),uidMapping 从 local-cache 命中`;
  }

  // 场景 3:方法独立 —— tokenInfo 全链路失败不影响 uidMapping。
  const independent = createRtcProvider({
    credentials,
    config: {
      tokenInfo: { provider: 'nimeet-http', fallback: [], baseUrl: 'http://127.0.0.1:1', timeoutMs: 300 },
      uidMapping: { provider: 'nimeet-http', fallback: [], baseUrl: ctx.baseUrl, timeoutMs: 2000 },
    },
  });
  let tokenFailure = null;
  try {
    await independent.getRTCTokenInfo({ channelName: 'weekly-sync', userId: 'im_user_3' });
  } catch (error) {
    tokenFailure = error.code ?? error.message;
  }
  ctx.assert(tokenFailure === 'RTC_PROVIDER_UNAVAILABLE', `tokenInfo 失败应带 RTC_PROVIDER_UNAVAILABLE,实际 ${tokenFailure}`);
  const stillWorks = await independent.getUserIdsWithRTCUids({ rtcUids: [tokenInfo.rtcUid] });
  ctx.assert(stillWorks[String(tokenInfo.rtcUid)] === 'im_user_1', 'tokenInfo 失败不得影响 uidMapping');

  return {
    summary:
      `主通道 uid=${tokenInfo.rtcUid} → im_user_1(identity-echo 下未知 uid 映射为十进制串,严格模式下省略);` +
      `${fallbackSummary};tokenInfo 失败(${tokenFailure}) 时 uidMapping 仍返回 ${JSON.stringify(stillWorks)}`,
    details: { tokenInfo, mapping, strictMapping, independence: { tokenFailure, stillWorks } },
  };
}

安全最佳实践

  1. 永不外泄:appCertificate 与 customerSecret 只存在于服务端或 secret manager;前端/移动端只持有 App ID 与短期 Token。
  2. 短 TTL + 续期:把 tokenTtlSeconds 设为与实际使用相符的短窗口(例如 1–2 小时),依赖续期而不是长 TTL。
  3. 最小权限:观众一律 role: audience;不要为了省事给所有人 host。
  4. 通配 Token 只用于演示:* 频道 Token 一旦泄露可用于任意频道,生产环境应禁用。
  5. 不要信任 usr 字段:它只是提示,鉴权只认签名与 app/ch/uid/priv/exp/pexp。
  6. 轮换密钥要有预案:POST /console/projects/{projectId}/keys/rotate(scope: customer | certificate | all)会立即让旧凭证失效,轮换前先双发/灰度。
  7. 回调签名必校验:回调带 SHA1 与 SHA256 两套签名,见 回调事件;不要只按 IP 放行。
  8. 日志脱敏:Token 与 secret 不要进日志;排障用 X-Request-Id 关联。

失败排查

现象可能原因动作
401 missing_authorization没带 AuthorizationBasic 头格式:Basic base64(customerId:customerSecret)
401 invalid_credentialssecret 错误或已轮换用最新 secret;轮换后旧值立即失效
401 invalid_appidcustomerId 不存在 / appid 与凭证不匹配检查项目与路径里的 appid
403 banned命中 join_channel 封禁查看 /v1/ban-rules?uid= 并解除规则
400 invalid_channel_name频道名含非法字符/超长使用 [A-Za-z0-9_-]{1,128}
503 no_server_resources频道数达到项目 maxChannels调整配额或释放频道
长连接被断开,错误号 -109Token 权限过期且未续期实现 token-privilege-will-expire → /v1/tokens/renew
长连接被断开,错误号 -110签名不符/被篡改/App ID 不匹配重新签发;检查是否混用了别的项目凭证

下一步

本页由文档构建流水线生成 · 2026-10-31 01:24:02 UTC · 文档中心 v1.1.0.

内容依据公开 API 定义.

ni-meet 是自建 RTC PaaS,媒体面基于 LiveKit(Apache-2.0),并与声网 API 兼容。