Autenticação
Não há senha nem segredo compartilhado com o Voice. O seu backend tem duas chaves privadas (uma por uso) e assina JWT; o Voice confere com as chaves públicas que você cadastrou. Tudo vai em Authorization: Bearer, nunca em URL.
Chaves
| Uso | Assina | Quem guarda a privada |
|---|---|---|
server | a asserção do servidor (rotas de servidor) | só o seu backend |
client | os tokens de aparelho que o seu backend entrega aos apps | só o seu backend (o app recebe o token, nunca a chave) |
- Algoritmos: EdDSA (Ed25519) ou ES256 (P-256), um por
kid.HS*,RS*enonesão recusados; chave privada colada no cadastro também. - Cada
kidtem uso, ambiente (sandboxouproduction) e situação (active,rotating,revoked). Chave de sandbox nunca abre a produção. - Origem da pública: colada (JWK) ou o seu JWKS publicado em HTTPS na porta 443 (cache de 10 min;
kidpublicado e não cadastrado é recusado e auditado). jku,x5uejwkno cabeçalho são ignorados: vale só o que está cadastrado.
Rotação sem parar
- cadastre o
kidnovo; - marque o velho como
rotating(ele continua valendo) e passe a assinar com o novo; - marque o velho como
revoked: ele para de valer na próxima requisição.
Asserção do servidor
Uma por requisição. Cabeçalho {"typ": "voice+server", "alg", "kid"}.
| Claim | Valor |
|---|---|
iss | o seu emissor cadastrado |
aud | https://api.voice.moraviq.com.br/v2 (no sandbox, o que o console ou o subir.sh mostram) |
iat, exp | vida de até 60 s (use 50) |
jti | 8 a 128 caracteres, uso único: repetir dá 401 |
scope | texto separado por espaço, só o que a rota pede: sites:write, calls:create, calls:read, calls:end, devices:revoke, subjects:manage |
Token de aparelho
O seu backend entrega à pessoa logada (portaria ou morador). Cabeçalho {"typ": "voice", "alg", "kid"} com o kid de uso client.
| Claim | Valor |
|---|---|
ver | 2 |
iss, aud | como na asserção |
sub | o ext_id da pessoa no seu sistema (até 64 de [A-Za-z0-9._:-]) |
site | o ext_id de um site já cadastrado |
dev | sha256(installation_id) em hex: amarra o token ao app instalado |
scope | lista: call:receive, call:answer, call:place |
grp | até 20 grupos (ext_id); guardado, ainda sem efeito |
jti, iat, nbf, exp | vida de até 600 s (use 300); o SDK renova a 30 s do fim e no 401 |
// Assinatura dos JWT do parceiro para o Moraviq Voice, só com node:crypto.
//
// - asserção do servidor (typ voice+server): vida até 60 s, jti de uso único,
// scope em texto separado por espaço; vai no Authorization das rotas de servidor.
// - token de aparelho (typ voice, ver 2): vida até 600 s (use 300), sub e site
// são os seus ext_id, dev é o sha256 do installation_id do aparelho.
//
// EdDSA (Ed25519) e ES256 (P-256) são os dois algoritmos aceitos; um por kid.
import { createHash, createPrivateKey, randomUUID, sign } from 'node:crypto';
const b64url = (buf) => Buffer.from(buf).toString('base64url');
/** Assina um JWT compacto com a chave privada em JWK (OKP Ed25519 ou EC P-256). */
export function assinarJwt(cabecalho, claims, jwkPrivada) {
const chave = createPrivateKey({ key: jwkPrivada, format: 'jwk' });
const alg = jwkPrivada.kty === 'OKP' ? 'EdDSA' : 'ES256';
const h = b64url(JSON.stringify({ ...cabecalho, alg, kid: jwkPrivada.kid }));
const c = b64url(JSON.stringify(claims));
const dados = Buffer.from(`${h}.${c}`);
// ES256 em JWT é r||s (64 bytes), não DER: dsaEncoding ieee-p1363.
const assinatura = alg === 'EdDSA'
? sign(null, dados, chave)
: sign('sha256', dados, { key: chave, dsaEncoding: 'ieee-p1363' });
return `${h}.${c}.${b64url(assinatura)}`;
}
/** Asserção do servidor do parceiro: uma por requisição (o jti não se repete). */
export function assercaoDoServidor({ iss, aud, scope, chave, vidaS = 50 }) {
const agora = Math.floor(Date.now() / 1000);
return assinarJwt({ typ: 'voice+server' }, {
iss, aud, iat: agora, exp: agora + vidaS, jti: randomUUID(), scope,
}, chave);
}
/** Token de aparelho para a portaria ou o morador logado no seu sistema. */
export function tokenDoAparelho({ iss, aud, sub, site, installationId, scope, grupos = [], chave, vidaS = 300 }) {
const agora = Math.floor(Date.now() / 1000);
return assinarJwt({ typ: 'voice' }, {
iss, aud, ver: 2, sub, site,
dev: createHash('sha256').update(installationId).digest('hex'),
scope, grp: grupos, jti: randomUUID(), iat: agora, nbf: agora, exp: agora + vidaS,
}, chave);
}Python (PyJWT)
def chave_privada(nome: str):
jwk = json.loads((CHAVES / f"{nome}.priv.json").read_text())
alg = "EdDSA" if jwk["kty"] == "OKP" else "ES256"
return jwt.PyJWK(jwk, algorithm=alg), alg
SERVIDOR, ALG = chave_privada("servidor")
def assercao(scope: str) -> str:
"""Uma por requisição: vida até 60 s e jti de uso único."""
agora = int(time.time())
claims = {
"iss": ISS,
"aud": AUD,
"iat": agora,
"exp": agora + 50,
"jti": uuid.uuid4().hex,
"scope": scope,
}
return jwt.encode(
claims, SERVIDOR.key, algorithm=ALG, headers={"kid": SERVIDOR.key_id, "typ": "voice+server"}
)
def h(scope: str, **extra) -> dict:
return {"Authorization": f"Bearer {assercao(scope)}"} | extra
Credencial do aparelho e token curto da chamada
App fechado acordado por push precisa atender sem esperar o seu backend. Para isso, POST /v2/devices devolve um device_secret (32 bytes em base64url) que o SDK nativo guarda no Keychain ou no Keystore. Com ele, o aparelho troca o call_id do push por um token de 120 s que só vale para aquela chamada:
POST /v2/calls/{call_id}/device-token (sem Authorization)
{"device_id": "...", "ts": 1763000000, "nonce": "<hex de 16 a 64>",
"proof": hex(HMAC-SHA256(base64url_decode(device_secret), "v2\n{call_id}\n{ts}\n{nonce}"))}
200 {"token": "vct1.…", "expires_in": 120}
- Janela de 60 s e nonce de uso único. Prova errada, janela vencida ou nonce repetido: o mesmo
401. - O token curto só abre
accept,reject,ring-ack,hangup,ice-credentialsews-ticketdaquela chamada. Em outra rota:403. - Registrar o aparelho de novo troca o segredo; revogar o aparelho derruba segredo e token na hora.
- Os SDKs fazem isso sozinhos. Vetores de teste da prova:
sdk/conformidade/vetores/logica.json.
Revogação e kill switch
- Logout ou pessoa removida:
DELETE /v2/users/{user}/devices(escopodevices:revoke) revoga os aparelhos, encerra as chamadas dela e recusa token dela emitido antes. - Um aparelho:
DELETE /v2/devices/{device_id}com o token do próprio aparelho. - Conta suspensa: toda requisição recebe
403 partner_disablede os sockets fecham com4403.