Token 鉴权
Token 结构、签发/续期/校验、uid 映射、IM RTC Provider 双方法集成与安全最佳实践。
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 Certificate | 64 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 字段(控制面组件):
| 字段 | 类型 | 说明 | |
|---|---|---|---|
v | number | 格式版本,当前 1;不匹配直接拒绝 | |
app | string | App ID | |
ch | string | 频道名或 * | |
uid | number | rtc uid | |
role | host \ | audience | 角色 |
priv | string[] | 权限列表 | |
iat / exp / pexp | epoch 秒 | 签发时间 / Token 过期 / 权限过期(允许 5s 时钟偏差) | |
salt | number | 每次签发随机,避免同参数 Token 完全相同 | |
usr | string? | 可选的 IM userId 提示,不参与鉴权 |
角色 → 权限:
| 角色 | 权限 |
|---|---|
host | join_channel、publish_audio、publish_video、publish_data、subscribe |
audience | join_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 |
服务端处理顺序:
- 鉴权(Basic)并校验项目
active; - 校验频道名(非法 →
400 invalid_channel_name); join_channel封禁检查(命中 →403 banned,不会签发可用凭证);- 频道配额检查(项目
maxChannels > 0且已达上限 →503 no_server_resources); - 解析 uid:显式
uid>userId映射/派生 > 随机派生; - 计算
ttl = clamp(ttlSeconds, project.tokenTtlSeconds)、privilegeTtl = min(ttl, project.privilegeTtlSeconds); - 签发并登记一条「权限即将过期」调度(用于事件
203 token_expiring)。
响应:
| 字段 | 说明 |
|---|---|
appId | 项目 App ID |
channelName | 实际绑定的频道(通配时为 *) |
rtcToken | 不透明 Token |
rtcUid | 本次签发使用的 uid |
role | 角色 |
privileges | 实际权限数组 |
issuedAt / expireAt / privilegeExpireAt | ISO-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 |
| 3 | App ID 不匹配 | invalid_signature(detail 说明是 app id) |
| 4 | 频道不匹配(且非通配) | channel_mismatch |
| 5 | uid 不匹配(指定 uid 校验时) | uid_mismatch |
| 6 | Token 过期(含 5s 时钟偏差) | expired |
| 7 | 尚未生效(iat 在未来) | not_yet_valid |
| 8 | 权限过期 | privilege_expired |
| 9 | 缺少所需权限 | missing_privilege |
channelName 为可选参数:传入时会校验频道绑定(通配 Token 对任何频道都通过)。
uid 映射:POST /v1/uids:resolve
实现 IM 接口定义的 getUserIdsWithRTCUids:
{ "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,已固定):
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、两方法互不影响):
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 } },
};
}安全最佳实践
- 永不外泄:
appCertificate与customerSecret只存在于服务端或 secret manager;前端/移动端只持有 App ID 与短期 Token。 - 短 TTL + 续期:把
tokenTtlSeconds设为与实际使用相符的短窗口(例如 1–2 小时),依赖续期而不是长 TTL。 - 最小权限:观众一律
role: audience;不要为了省事给所有人host。 - 通配 Token 只用于演示:
*频道 Token 一旦泄露可用于任意频道,生产环境应禁用。 - 不要信任
usr字段:它只是提示,鉴权只认签名与app/ch/uid/priv/exp/pexp。 - 轮换密钥要有预案:
POST /console/projects/{projectId}/keys/rotate(scope: customer | certificate | all)会立即让旧凭证失效,轮换前先双发/灰度。 - 回调签名必校验:回调带 SHA1 与 SHA256 两套签名,见 回调事件;不要只按 IP 放行。
- 日志脱敏:Token 与 secret 不要进日志;排障用
X-Request-Id关联。
失败排查
| 现象 | 可能原因 | 动作 |
|---|---|---|
401 missing_authorization | 没带 Authorization | Basic 头格式:Basic base64(customerId:customerSecret) |
401 invalid_credentials | secret 错误或已轮换 | 用最新 secret;轮换后旧值立即失效 |
401 invalid_appid | customerId 不存在 / 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 | 调整配额或释放频道 |
长连接被断开,错误号 -109 | Token 权限过期且未续期 | 实现 token-privilege-will-expire → /v1/tokens/renew |
长连接被断开,错误号 -110 | 签名不符/被篡改/App ID 不匹配 | 重新签发;检查是否混用了别的项目凭证 |
下一步
- REST API 参考:完整端点、参数与 curl。
- 错误码:失败时按
code/ HTTP 状态定位。 - 常见问题:配额、限制与排障。