Token Authentication
Token structure, issuing, renewal, verification, uid mapping, IM RTC Provider integration and security best practices.
A token answers "who, in which role, joins which channel, and until when". ni-meet's token semantics align with Agora AccessToken2 (App ID + App Certificate, channel-scoped or wildcard, per-privilege expiry, renewal before expiry), but the byte format is our own implementation: nrt1.<base64url(payload)>.<base64url(hmac-sha256)>. ni-meet does not claim byte compatibility with Agora (a closed-source protocol cannot be verified), so the SDK and your application must always treat a token as an opaque string.
Prerequisites
- A project created in the console with
appId/customerId/customerSecret/appCertificate(see Enable services and get credentials). - A reachable API base URL; server-side calls use the Basic credentials.
Token model
| Concept | Description |
|---|---|
| App ID | The project's public identifier, written into the token payload as app and strictly bound during validation |
| App Certificate | 64-hex signing key, held only server-side or in controlled storage; HMAC-SHA256 signs the whole nrt1.<payload> |
| Channel binding | Payload ch is the channel name; * means wildcard (demo and integration testing only) |
| uid binding | Payload uid is 1..2147483647; 0 is never issued (in Agora semantics 0 means server-assigned random) |
| Privileges | priv array: join_channel, publish_audio, publish_video, publish_data, subscribe |
| Dual expiry | exp (token lifetime) and pexp (privilege window), with pexp ≤ exp |
| Renewal grace | A token can still be renewed within 300 seconds after its privileges expire; past that it must be issued again |
Payload fields (TokenPayload in the shared token types):
| Field | Type | Description | |
|---|---|---|---|
v | number | Format version, currently 1; a mismatch is rejected outright | |
app | string | App ID | |
ch | string | Channel name or * | |
uid | number | rtc uid | |
role | host \ | audience | Role |
priv | string[] | Privilege list | |
iat / exp / pexp | epoch seconds | Issued at / token expiry / privilege expiry (5 s clock skew tolerated) | |
salt | number | Random per issuance, so identical parameters never produce identical tokens | |
usr | string? | Optional IM userId hint, not used for authorization |
Role → privileges:
| Role | Privileges |
|---|---|
host | join_channel, publish_audio, publish_video, publish_data, subscribe |
audience | join_channel, subscribe |
Issuing: POST /v1/tokens
Implements getRTCTokenInfo from the IM interface definition. Request body fields:
| Field | Required | Description |
|---|---|---|
channelName / channel | One of the two (may be omitted when wildcard: true) | Channel name, 1–128 characters, [A-Za-z0-9_-] |
uid | No | 1..2147483647; when given together with userId, a mapping is created |
userId | No | IM user id; used to derive a stable uid and to write the uid mapping |
role | No | Defaults to host |
ttlSeconds | No | 60–86400, finally clamped by the project's tokenTtlSeconds |
wildcard | No | When true the channel is pinned to * and channelName is ignored |
Server-side processing order:
- Authenticate (Basic) and check that the project is
active; - Validate the channel name (invalid →
400 invalid_channel_name); join_channelban check (on a hit →403 banned; no usable credential is issued);- Channel quota check (project
maxChannels > 0and the limit is reached →503 no_server_resources); - Resolve the uid: explicit
uid>userIdmapping/derivation > random derivation; - Compute
ttl = clamp(ttlSeconds, project.tokenTtlSeconds)andprivilegeTtl = min(ttl, project.privilegeTtlSeconds); - Issue the token and register a "privileges about to expire" schedule (used for the
203 token_expiringcallback event).
Response:
| Field | Description |
|---|---|
appId | Project App ID |
channelName | The channel actually bound (wildcard is *) |
rtcToken | Opaque token |
rtcUid | The uid used for this issuance |
role | Role |
privileges | The effective privilege array |
issuedAt / expireAt / privilegeExpireAt | ISO-8601 timestamps |
Renewal: POST /v1/tokens/renew
Request body { "token": "<old token>" }.
- Succeeds when the signature is valid and the privilege window is still inside the grace period (
pexp + 300s ≥ now); - The new token keeps
channelName,uid,roleandprivilegesand only refreshes the validity window — renewal can never escalate privileges; - Whole-token expiry beyond the grace period →
401 token_expired; corrupted or forged signature →401 invalid_token.
Client-side coordination: the SDK fires token-privilege-will-expire 30 seconds before privileges expire, which is when you call renewal; the server emits the 203 callback at the same point so your backend can reissue the token ahead of time.
Verification: GET /v1/tokens/verify?token=&channelName=
Returns { valid: true, payload }; failures use the unified error envelope.
Decision order inside the underlying validator (for implementers):
| Order | Situation | Validator rejection reason |
|---|---|---|
| 1 | Malformed / wrong prefix or version | malformed / bad_prefix / bad_version |
| 2 | Signature does not match the App Certificate | invalid_signature |
| 3 | App ID mismatch | invalid_signature (the detail says it is the app id) |
| 4 | Channel mismatch (and not wildcard) | channel_mismatch |
| 5 | uid mismatch (when a uid is supplied for verification) | uid_mismatch |
| 6 | Token expired (5 s clock skew included) | expired |
| 7 | Not yet valid (iat in the future) | not_yet_valid |
| 8 | Privileges expired | privilege_expired |
| 9 | Required privilege missing | missing_privilege |
channelName is optional: when supplied, the channel binding is validated (a wildcard token passes for any channel).
uid mapping: POST /v1/uids:resolve
Implements getUserIdsWithRTCUids from the IM interface definition:
{ "rtcUids": [10001, 10002, 2147483647], "channelName": "weekly-sync" }The response contains known uids only: { "10001": "user_alice", "10002": "user_bob" }. Partial results are valid results: unknown uids are omitted, duplicate uids are ignored, an empty array returns {}, and the call never fails as a whole because an individual uid is unknown.
Mappings are created by passing userId at issuance time (they are stored as uid mappings); the IM side can also fall back on its own through the interface definition's local-cache / identity-echo modes.
RTC provider interface definition with IM
The only coupling point is the RTC provider interface definition (v1.0.0, frozen):
getRTCTokenInfo({ channelName, userId?, role?, ttlSeconds? })
→ { appId, rtcToken, rtcUid, expireAt? }
getUserIdsWithRTCUids(rtcUids: number[], channelName?)
→ Record<string /* uid */, string /* IM userId */>Hard constraints:
- The two methods are configured and fell back independently:
tokenInfo: nimeet-http → local-hmac → disabled,uidMapping: nimeet-http → local-cache → identity-echo → disabled; - Disabling or failing one method must not take the other down;
rtcUidstays within[1, 2147483647]and is stable for a given(appId, userId)for the lifetime of the session;local-hmacmay only be used by a trusted backend that holds the App Certificate (a client must never receive the Certificate);- Breaking changes → a new v2 provider interface definition, dual-issued for 90 days.
Full implementation (three scenarios: the primary path is available, the primary path is unavailable and falls back to local HMAC, and the two methods do not affect each other):
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,
};
/**
* 实现 the public API definition 的两个方法,并演示“各自独立配置、各自独立回退”:
*
* 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 签发:与 the control plane 的 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 } },
};
}Security best practices
- Never leak them:
appCertificateandcustomerSecretlive only on the server or in a secret manager; front ends and mobile apps hold only the App ID and short-lived tokens. - Short TTL plus renewal: set
tokenTtlSecondsto a short window that matches real usage (1–2 hours, for example) and rely on renewal rather than a long TTL. - Least privilege: audiences always get
role: audience; do not hand everyonehostfor convenience. - Wildcard tokens are for demos only: once a
*channel token leaks it works on any channel, so disable wildcard issuance in production. - Do not trust the
usrfield: it is only a hint; authorization looks at the signature and atapp/ch/uid/priv/exp/pexponly. - Have a key rotation plan:
POST /console/projects/{projectId}/keys/rotate(scope: customer | certificate | all) invalidates old credentials immediately, so dual-issue or canary before rotating. - Always verify callback signatures: callbacks carry both SHA1 and SHA256 signatures, see Callback events; do not allowlist by IP alone.
- Redact logs: keep tokens and secrets out of logs; correlate troubleshooting with
X-Request-Id.
Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
401 missing_authorization | No Authorization header | Basic header format: Basic base64(customerId:customerSecret) |
401 invalid_credentials | Wrong secret, or the secret was rotated | Use the latest secret; old values stop working immediately after rotation |
401 invalid_appid | customerId does not exist / appid does not match the credentials | Check the project and the appid in the path |
403 banned | Matches a join_channel ban rule | Inspect /v1/ban-rules?uid= and lift the rule |
400 invalid_channel_name | Channel name contains invalid characters or is too long | Use [A-Za-z0-9_-]{1,128} |
503 no_server_resources | Channel count reached the project's maxChannels | Adjust the quota or release channels |
Long connection dropped with error -109 | Token privileges expired and were not renewed | Implement token-privilege-will-expire → /v1/tokens/renew |
Long connection dropped with error -110 | Signature mismatch, tampering, or App ID mismatch | Issue a new token; check whether credentials from another project were mixed in |
Next steps
- REST API reference: complete endpoints, parameters and curl.
- Error codes: locate failures by
code/ HTTP status. - FAQ: quotas, limits and troubleshooting.