nimeet Docs
中文

Token Authentication

Token structure, issuing, renewal, verification, uid mapping, IM RTC Provider integration and security best practices.

Version v1.1.0 · Updated 2026-10-26 · revision 1

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

ConceptDescription
App IDThe project's public identifier, written into the token payload as app and strictly bound during validation
App Certificate64-hex signing key, held only server-side or in controlled storage; HMAC-SHA256 signs the whole nrt1.<payload>
Channel bindingPayload ch is the channel name; * means wildcard (demo and integration testing only)
uid bindingPayload uid is 1..2147483647; 0 is never issued (in Agora semantics 0 means server-assigned random)
Privilegespriv array: join_channel, publish_audio, publish_video, publish_data, subscribe
Dual expiryexp (token lifetime) and pexp (privilege window), with pexp ≤ exp
Renewal graceA 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):

FieldTypeDescription
vnumberFormat version, currently 1; a mismatch is rejected outright
appstringApp ID
chstringChannel name or *
uidnumberrtc uid
rolehost \audienceRole
privstring[]Privilege list
iat / exp / pexpepoch secondsIssued at / token expiry / privilege expiry (5 s clock skew tolerated)
saltnumberRandom per issuance, so identical parameters never produce identical tokens
usrstring?Optional IM userId hint, not used for authorization

Role → privileges:

RolePrivileges
hostjoin_channel, publish_audio, publish_video, publish_data, subscribe
audiencejoin_channel, subscribe

Issuing: POST /v1/tokens

Implements getRTCTokenInfo from the IM interface definition. Request body fields:

FieldRequiredDescription
channelName / channelOne of the two (may be omitted when wildcard: true)Channel name, 1–128 characters, [A-Za-z0-9_-]
uidNo1..2147483647; when given together with userId, a mapping is created
userIdNoIM user id; used to derive a stable uid and to write the uid mapping
roleNoDefaults to host
ttlSecondsNo60–86400, finally clamped by the project's tokenTtlSeconds
wildcardNoWhen true the channel is pinned to * and channelName is ignored

Server-side processing order:

  1. Authenticate (Basic) and check that the project is active;
  2. Validate the channel name (invalid → 400 invalid_channel_name);
  3. join_channel ban check (on a hit → 403 banned; no usable credential is issued);
  4. Channel quota check (project maxChannels > 0 and the limit is reached → 503 no_server_resources);
  5. Resolve the uid: explicit uid > userId mapping/derivation > random derivation;
  6. Compute ttl = clamp(ttlSeconds, project.tokenTtlSeconds) and privilegeTtl = min(ttl, project.privilegeTtlSeconds);
  7. Issue the token and register a "privileges about to expire" schedule (used for the 203 token_expiring callback event).

Response:

FieldDescription
appIdProject App ID
channelNameThe channel actually bound (wildcard is *)
rtcTokenOpaque token
rtcUidThe uid used for this issuance
roleRole
privilegesThe effective privilege array
issuedAt / expireAt / privilegeExpireAtISO-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, role and privileges and 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):

OrderSituationValidator rejection reason
1Malformed / wrong prefix or versionmalformed / bad_prefix / bad_version
2Signature does not match the App Certificateinvalid_signature
3App ID mismatchinvalid_signature (the detail says it is the app id)
4Channel mismatch (and not wildcard)channel_mismatch
5uid mismatch (when a uid is supplied for verification)uid_mismatch
6Token expired (5 s clock skew included)expired
7Not yet valid (iat in the future)not_yet_valid
8Privileges expiredprivilege_expired
9Required privilege missingmissing_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:

json
{ "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):

typescript
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;
  • rtcUid stays within [1, 2147483647] and is stable for a given (appId, userId) for the lifetime of the session;
  • local-hmac may 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):

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,
};

/**
 * 实现 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

  1. Never leak them: appCertificate and customerSecret live only on the server or in a secret manager; front ends and mobile apps hold only the App ID and short-lived tokens.
  2. Short TTL plus renewal: set tokenTtlSeconds to a short window that matches real usage (1–2 hours, for example) and rely on renewal rather than a long TTL.
  3. Least privilege: audiences always get role: audience; do not hand everyone host for convenience.
  4. Wildcard tokens are for demos only: once a * channel token leaks it works on any channel, so disable wildcard issuance in production.
  5. Do not trust the usr field: it is only a hint; authorization looks at the signature and at app/ch/uid/priv/exp/pexp only.
  6. 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.
  7. Always verify callback signatures: callbacks carry both SHA1 and SHA256 signatures, see Callback events; do not allowlist by IP alone.
  8. Redact logs: keep tokens and secrets out of logs; correlate troubleshooting with X-Request-Id.

Troubleshooting

SymptomLikely causeAction
401 missing_authorizationNo Authorization headerBasic header format: Basic base64(customerId:customerSecret)
401 invalid_credentialsWrong secret, or the secret was rotatedUse the latest secret; old values stop working immediately after rotation
401 invalid_appidcustomerId does not exist / appid does not match the credentialsCheck the project and the appid in the path
403 bannedMatches a join_channel ban ruleInspect /v1/ban-rules?uid= and lift the rule
400 invalid_channel_nameChannel name contains invalid characters or is too longUse [A-Za-z0-9_-]{1,128}
503 no_server_resourcesChannel count reached the project's maxChannelsAdjust the quota or release channels
Long connection dropped with error -109Token privileges expired and were not renewedImplement token-privilege-will-expire → /v1/tokens/renew
Long connection dropped with error -110Signature mismatch, tampering, or App ID mismatchIssue a new token; check whether credentials from another project were mixed in

Next steps

Generated by the documentation build pipeline · 2026-10-31 01:24:02 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.