Verificação em outras linguagens
Verificadores completos dos tokens de identidade e de eventos de dados, com as mesmas regras e os mesmos códigos de recusa do SDK para Node.
Para servidores de jogo que não rodam JavaScript. O SDK para Node
(@uloggd/arcade-sdk/server) é a referência: um verificador em outra
linguagem está certo quando aceita os mesmos tokens que ele e recusa os outros
com o mesmo código. As regras de um token de identidade estão em
Verificar em qualquer linguagem.
Esta página publica um verificador completo em Python, e acrescenta os tokens
de eventos de dados, os códigos de recusa na ordem em que o SDK os dá, e o que
conferir quando se usa uma biblioteca JWT.
Antes de copiar
Cada arquivo é testado contra tokens assinados pelo próprio código do uloggd, com cada recusa que o SDK para Node faz. O código desta página está sob a licença MIT, como o SDK: copie-o para o seu servidor.
- Busque as chaves só em
https://uloggd.com/api/v1/arcade/jwks, ou fixe as chaves públicas atuais e siga o calendário de troca. - Guarde o conjunto de chaves num cache do processo, compartilhado por todas as requisições, como o SDK faz: por no máximo 5 minutos, digam o que disserem os cabeçalhos da resposta.
- Aceite os tokens do simulador (
iss: "mock";allowMockno SDK,allow_mockem Python) só no desenvolvimento local; um verificador de produção os recusa commock_refused. - Duas coisas podem ser diferentes do SDK sem mudar a resposta a nenhum
token: recusar uma configuração errada (um id de aplicativo vazio, uma URL
de chaves fora de
https://uloggd.com) ao criar o verificador, em vez de a cada chamada; e dar um prazo de alguns segundos à busca do conjunto de chaves, tratando o estouro comojwks_unavailable. - Guarde cada
jtie cada nonce aceitos por mais tempo do que um token vive, e recuse uma repetição. Nunca elimine duplicados pelo texto do token.
As opções do SDK, em cada verificador publicado:
| Node | Python |
|---|---|
appId | ArcadeVerifier(app_id) |
nonce | nonce= em cada chamada |
allowMock | allow_mock=True |
allowTestMode | allow_test_mode=False |
clockSkewSeconds | clock_skew_seconds |
jwksUrl | jwks_url |
issuer | issuer |
O verificador em Python tem as duas diferenças que a lista acima permite: uma
opção errada lança ValueError ao criar o verificador, e a busca do conjunto
de chaves estoura em 5 segundos. Ele também recusa como malformed um token de
identidade cujo exp cai depois do ano 9999; o uloggd nunca assina um assim.
Python
Python 3.10 ou mais novo, com o pacote cryptography
(packages/arcade-sdk/examples/python/requirements.txt). O verificador
bloqueia enquanto busca as chaves: num servidor assíncrono, chame-o de um
endpoint síncrono ou por asyncio.to_thread.
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 uloggd
"""Verifies uloggd Arcade identity tokens and data event tokens.
A copy-ready port of verifyIdentityToken and verifyEventToken from
@uloggd/arcade-sdk/server: the same rules, in the same order, with the same
refusal codes and the same JWKS cache. Python 3.10 or newer, and only the
`cryptography` package (requirements.txt). No JWT library: the format is
fixed, and a general-purpose library would accept more than uloggd ever
signs.
verifier = ArcadeVerifier("YOUR_APP_ID")
player = verifier.verify_identity_token(token, nonce=nonce)
event = verifier.verify_event_token(event_token)
Both raise ArcadeTokenError, whose .code is one of TOKEN_ERROR_CODES. One
verifier per process: it caches uloggd's keys, and it is safe to share
between threads.
"""
from __future__ import annotations
import base64
import http.client
import json
import math
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable, Optional
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
encode_dss_signature,
)
DEFAULT_JWKS_URL = "https://uloggd.com/api/v1/arcade/jwks"
IDENTITY_TOKEN_TYPE = "uloggd-arcade-id+jwt"
EVENT_TOKEN_TYPE = "uloggd-arcade-event+jwt"
DATA_EVENTS = (
"grant_revoked",
"grant_forgotten",
"nickname_changed",
"account_deleted",
)
TOKEN_ERROR_CODES = (
"malformed",
"unsupported_header",
"unknown_key",
"bad_signature",
"wrong_type",
"wrong_issuer",
"wrong_audience",
"expired",
"not_yet_valid",
"too_old",
"nonce_mismatch",
"mock_refused",
"test_refused",
"jwks_unavailable",
)
_MOCK_ISSUER = "mock"
_MOCK_KID_PREFIX = "mock-"
_IDENTITY_MAX_AGE = 300
_EVENT_MAX_AGE = 7 * 24 * 60 * 60
_DEFAULT_SKEW = 60
_MAX_SKEW = 300
_MAX_TOKEN_LENGTH = 8192
_MAX_JWKS_BYTES = 64 * 1024
_MAX_JWKS_KEYS = 20
# However long the JWKS answer says it may be cached.
_JWKS_MAX_AGE = 300.0
# An unknown kid refetches the JWKS at most this often.
_JWKS_REFETCH = 60.0
# After a failed fetch the JWKS is not asked again for this long: a forged
# token reaches the fetch (its header and iss are unsigned), so without a
# pause each one would cost a request while the JWKS is down.
_JWKS_RETRY = 10.0
_FETCH_TIMEOUT = 5.0
# Checked before any decoder runs: base64.b64decode drops characters outside
# the alphabet instead of refusing them.
_BASE64URL = re.compile(r"[A-Za-z0-9_-]*")
_COORDINATE = re.compile(r"[A-Za-z0-9_-]{43}")
_LOOPBACK_IPV4 = re.compile(r"127(?:\.(?:0|[1-9][0-9]{0,2})){3}")
# JavaScript's \s, spelled out: Python's own \s is a different set.
_SPACE = (
"[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000"
"\ufeff]"
)
# The header is lowercased in ASCII first, so no Unicode case folding
# applies, and \Z because $ also matches before a final newline.
_NO_STORE = re.compile(
"(?:^|,)" + _SPACE + "*(?:no-store|no-cache)" + _SPACE + r"*(?:,|\Z)"
)
_MAX_AGE = re.compile(
"(?:^|,)" + _SPACE + "*max-age" + _SPACE + "*=" + _SPACE + '*"?([0-9]+)"?'
)
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
class ArcadeTokenError(Exception):
"""A refused token. `code` is one of TOKEN_ERROR_CODES."""
def __init__(self, code: str, message: Optional[str] = None) -> None:
super().__init__(message or code)
self.code = code
@dataclass(frozen=True)
class VerifiedIdentity:
# The player's id for your app alone.
subject: str
nickname: str
scopes: tuple[str, ...]
instance_id: str
# "live" or "test".
mode: str
# The token's jti: deduplicate by it (and the nonce), never by the
# token's text.
id: str
issued_at: datetime
expires_at: datetime
@dataclass(frozen=True)
class VerifiedEvent:
subject: str
event: str
# Present only with nickname_changed.
nickname: Optional[str]
id: str
issued_at: datetime
class _KeySet:
def __init__(self, keys, expires_at: float, attempted_at: float) -> None:
self.keys = keys
self.expires_at = expires_at
self.attempted_at = attempted_at
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
# A redirect is a failure: the answer stays a 3xx, never followed.
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _fail(code: str, message: Optional[str] = None):
raise ArcadeTokenError(code, message)
def _too_long(token: str) -> bool:
# The limit counts UTF-16 code units, as JavaScript's length does, so an
# astral character counts twice. Code points never outnumber them, so a
# token already over in code points is refused without encoding it.
return (
len(token) > _MAX_TOKEN_LENGTH
or len(token.encode("utf-16-le", "surrogatepass")) // 2 > _MAX_TOKEN_LENGTH
)
def _decode_base64url(text: str) -> bytes:
if _BASE64URL.fullmatch(text) is None or len(text) % 4 == 1:
_fail("malformed", "not base64url")
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _refuse_constant(name: str):
raise ValueError(f"{name} is not JSON")
def _loads(text: str):
# As JavaScript's JSON.parse: NaN and Infinity are not JSON, and every
# number is a double, however many digits it has.
return json.loads(text, parse_int=float, parse_constant=_refuse_constant)
def _decode_json(segment: str) -> dict:
raw = _decode_base64url(segment)
try:
# utf-8-sig drops one leading byte order mark, as TextDecoder does.
value = _loads(raw.decode("utf-8-sig"))
except (UnicodeDecodeError, ValueError, RecursionError):
_fail("malformed", "not JSON")
if not isinstance(value, dict):
_fail("malformed", "not a JSON object")
return value
def _whole(value) -> Optional[float]:
"""A JSON number with a whole value, or None. Booleans are not numbers."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
number = float(value)
except OverflowError:
return None
if not math.isfinite(number) or not number.is_integer():
return None
return number
def _utc(seconds: float) -> datetime:
try:
return _EPOCH + timedelta(seconds=seconds)
except OverflowError:
# Python's datetime ends with the year 9999; uloggd never signs
# past it (an identity token lives 300 s).
_fail("malformed", "a time beyond the year 9999")
def _is_loopback(host: str) -> bool:
if host in ("localhost", "::1") or host.endswith(".localhost"):
return True
return _LOOPBACK_IPV4.fullmatch(host) is not None and all(
int(part) <= 255 for part in host.split(".")
)
def _trusted_origin(url: str, what: str) -> str:
"""https://uloggd.com, or a loopback origin for tests, as an origin."""
if not isinstance(url, str) or any(
ord(char) <= 0x20 or char in "\\\x7f" for char in url
):
raise ValueError(f"{what} must be on https://uloggd.com")
try:
parts = urllib.parse.urlsplit(url)
port = parts.port
except ValueError:
raise ValueError(f"{what} must be on https://uloggd.com") from None
scheme = parts.scheme.lower()
host = parts.hostname or ""
if parts.username is not None or parts.password is not None:
raise ValueError(f"{what} must not carry a user name or password")
default_port = {"http": 80, "https": 443}.get(scheme)
if scheme == "https" and host == "uloggd.com" and port in (None, 443):
return "https://uloggd.com"
if default_port is not None and _is_loopback(host):
shown = "[::1]" if host == "::1" else host
suffix = "" if port in (None, default_port) else f":{port}"
return f"{scheme}://{shown}{suffix}"
raise ValueError(f"{what} must be on https://uloggd.com")
def _max_age(cache_control: str) -> float:
if not cache_control:
return _JWKS_MAX_AGE
lowered = cache_control.translate(
{code: code + 32 for code in range(ord("A"), ord("Z") + 1)}
)
if _NO_STORE.search(lowered):
return 0.0
match = _MAX_AGE.search(lowered)
return min(float(match.group(1)), _JWKS_MAX_AGE) if match else _JWKS_MAX_AGE
def _point(raw: bytes):
try:
return ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), raw
)
except ValueError:
return None
def _import_jwk(jwk: dict):
x, y = jwk.get("x"), jwk.get("y")
if (
jwk.get("kty") != "EC"
or jwk.get("crv") != "P-256"
or not isinstance(x, str)
or not isinstance(y, str)
or _COORDINATE.fullmatch(x) is None
or _COORDINATE.fullmatch(y) is None
or ("alg" in jwk and jwk["alg"] != "ES256")
or ("use" in jwk and jwk["use"] != "sig")
):
return None
# Only the public coordinates are read, whatever else the entry holds.
return _point(b"\x04" + _decode_base64url(x) + _decode_base64url(y))
def _mock_key(kid: str):
# The mock names its public key in the kid: "mock-" and the base64url
# of the uncompressed point. A kid that is not base64url is malformed,
# as in Node.
raw = (
_decode_base64url(kid[len(_MOCK_KID_PREFIX):])
if kid.startswith(_MOCK_KID_PREFIX)
else None
)
if raw is None or len(raw) != 65 or raw[0] != 4:
_fail("unknown_key", "not a mock key")
key = _point(raw)
if key is None:
_fail("unknown_key", "not a mock key")
return key
class ArcadeVerifier:
"""Verifies uloggd's tokens for one app. Make one, and share it.
jwks_url and issuer accept only https://uloggd.com, or a loopback
origin for tests; leave them out. allow_mock accepts tokens from the
SDK's mock (iss "mock"): local development only. allow_test_mode=False
refuses tokens from the owner's test mode with test_refused. A bad
argument raises ValueError here, rather than on each token.
"""
def __init__(
self,
app_id: str,
*,
jwks_url: str = DEFAULT_JWKS_URL,
issuer: Optional[str] = None,
allow_mock: bool = False,
allow_test_mode: bool = True,
clock_skew_seconds: float = _DEFAULT_SKEW,
clock: Callable[[], float] = time.time,
) -> None:
if not isinstance(app_id, str) or app_id == "":
raise ValueError("app_id is required")
if (
isinstance(clock_skew_seconds, bool)
or not isinstance(clock_skew_seconds, (int, float))
or not 0 <= clock_skew_seconds <= _MAX_SKEW
):
raise ValueError(
f"clock_skew_seconds must be between 0 and {_MAX_SKEW}"
)
jwks_origin = _trusted_origin(jwks_url, "jwks_url")
self._app_id = app_id
self._jwks_url = jwks_url
self._issuer = (
jwks_origin if issuer is None else _trusted_origin(issuer, "issuer")
)
self._allow_mock = allow_mock is True
self._allow_test_mode = allow_test_mode is not False
self._skew = float(clock_skew_seconds)
self._clock = clock
self._opener = urllib.request.build_opener(_RefuseRedirects)
self._state = threading.Lock()
self._fetching = threading.Lock()
self._set: Optional[_KeySet] = None
self._failed_at: Optional[float] = None
# Counts finished fetches, so a thread that waited for another's
# fetch takes its outcome instead of fetching again.
self._fetches = 0
self._last_set: Optional[_KeySet] = None
self._last_error: Optional[str] = None
def verify_identity_token(self, token, *, nonce) -> VerifiedIdentity:
"""Verifies an identity token from getIdentityToken.
Accept each nonce once, bind it to the session that asked for it,
and deduplicate by the returned id.
"""
claims = self._verify(
token, IDENTITY_TOKEN_TYPE, self._skew, _IDENTITY_MAX_AGE, True
)
scope = claims.get("scope")
if (
not isinstance(claims.get("nickname"), str)
or not isinstance(scope, str)
or "identify" not in scope.split(" ")
or not isinstance(claims.get("iid"), str)
or claims.get("mode") not in ("live", "test")
):
_fail(
"malformed",
"nickname, scope with identify, iid and mode are required",
)
claimed = claims.get("nonce")
if (
not isinstance(nonce, str)
or nonce == ""
or not isinstance(claimed, str)
or claimed != nonce
):
_fail("nonce_mismatch")
if claims["mode"] == "test" and not self._allow_test_mode:
_fail("test_refused", "a token from the owner's test mode")
return VerifiedIdentity(
subject=claims["sub"],
nickname=claims["nickname"],
scopes=tuple(part for part in scope.split(" ") if part),
instance_id=claims["iid"],
mode=claims["mode"],
id=claims["jti"],
issued_at=_utc(claims["iat"]),
expires_at=_utc(claims["exp"]),
)
def verify_event_token(self, token) -> VerifiedEvent:
"""Verifies a data event token posted as {"event_token": "<jwt>"}.
Deduplicate by the returned id; act on the event within 15 days.
"""
claims = self._verify(
token, EVENT_TOKEN_TYPE, _DEFAULT_SKEW, _EVENT_MAX_AGE, False
)
event = claims.get("event")
if not isinstance(event, str) or event not in DATA_EVENTS:
_fail("malformed", "unknown event")
renamed = event == "nickname_changed"
if (
not isinstance(claims.get("nickname"), str)
if renamed
else "nickname" in claims
):
_fail("malformed", "nickname comes only with nickname_changed")
return VerifiedEvent(
subject=claims["sub"],
event=event,
nickname=claims["nickname"] if renamed else None,
id=claims["jti"],
issued_at=_utc(claims["iat"]),
)
def _verify(self, token, typ, skew, max_age, allow_mock) -> dict:
if not isinstance(token, str) or _too_long(token):
_fail("malformed", "not a token")
parts = token.split(".")
if len(parts) != 3:
_fail("malformed", "a JWT has three parts")
header = _decode_json(parts[0])
if (
set(header) != {"alg", "kid", "typ"}
or header["alg"] != "ES256"
or not isinstance(header["kid"], str)
):
_fail(
"unsupported_header",
"the header must be exactly {alg: ES256, typ, kid}",
)
if not isinstance(header["typ"], str) or header["typ"] != typ:
_fail("wrong_type", f"typ must be {typ}")
claims = _decode_json(parts[1])
signature = _decode_base64url(parts[2])
if len(signature) != 64:
_fail("bad_signature", "the signature must be raw r||s (64 bytes)")
iss = claims.get("iss")
if isinstance(iss, str) and iss == _MOCK_ISSUER:
if not (allow_mock and self._allow_mock):
_fail("mock_refused", "a token from the SDK's mock")
key = _mock_key(header["kid"])
else:
if not isinstance(iss, str) or iss != self._issuer:
_fail("wrong_issuer", f"iss must be {self._issuer}")
key = self._jwks_key(header["kid"])
der = encode_dss_signature(
int.from_bytes(signature[:32], "big"),
int.from_bytes(signature[32:], "big"),
)
try:
key.verify(
der,
f"{parts[0]}.{parts[1]}".encode("ascii"),
ec.ECDSA(hashes.SHA256()),
)
except InvalidSignature:
_fail("bad_signature")
aud = claims.get("aud")
if not isinstance(aud, str) or aud != self._app_id:
_fail("wrong_audience", "aud is not this app")
iat, exp = _whole(claims.get("iat")), _whole(claims.get("exp"))
if (
iat is None
or exp is None
or exp <= iat
or not isinstance(claims.get("sub"), str)
or not isinstance(claims.get("jti"), str)
):
_fail("malformed", "iat, exp, sub and jti are required")
now = self._clock()
if iat > now + skew:
_fail("not_yet_valid")
if now >= exp + skew:
_fail("expired")
if now - iat > max_age + skew:
_fail("too_old")
return {**claims, "iat": iat, "exp": exp}
def _jwks_key(self, kid: str):
with self._state:
current = self._set
now = self._clock()
expired = current is None or now >= current.expires_at
if expired:
# An expired set is never used, even when the refetch
# fails: a dropped key must stop verifying.
self._set = None
if (
self._failed_at is not None
and now - self._failed_at < _JWKS_RETRY
):
_fail("jwks_unavailable", "the JWKS failed moments ago")
if expired:
current = self._refresh()
key = current.keys.get(kid)
if key is None:
with self._state:
now = self._clock()
# A new kid after a rotation: refetch, but at most once a
# minute, so made-up kids cannot become a flood of requests.
refetch = now - current.attempted_at >= _JWKS_REFETCH
if refetch:
current.attempted_at = now
if refetch:
current = self._refresh()
key = current.keys.get(kid)
if key is None:
_fail("unknown_key", f"no key {kid} in the JWKS")
return key
def _refresh(self) -> _KeySet:
"""One fetch at a time; threads that wait share its outcome."""
with self._state:
seen = self._fetches
with self._fetching:
with self._state:
if self._fetches != seen:
if self._last_error is not None:
_fail("jwks_unavailable", self._last_error)
return self._last_set
try:
fetched = self._fetch()
except ArcadeTokenError as error:
with self._state:
self._failed_at = self._clock()
self._fetches += 1
self._last_error = str(error)
raise
with self._state:
self._failed_at = None
self._set = fetched
self._fetches += 1
self._last_set = fetched
self._last_error = None
return fetched
def _fetch(self) -> _KeySet:
attempted_at = self._clock()
request = urllib.request.Request(
self._jwks_url, headers={"Accept": "application/json"}
)
try:
with self._opener.open(request, timeout=_FETCH_TIMEOUT) as answer:
status = answer.status
cache_control = ", ".join(
answer.headers.get_all("Cache-Control") or []
)
body = answer.read(_MAX_JWKS_BYTES + 1)
except urllib.error.HTTPError as error:
error.close()
_fail("jwks_unavailable", f"the JWKS answered {error.code}")
except (OSError, ValueError, http.client.HTTPException):
_fail("jwks_unavailable", "the JWKS could not be fetched")
if status != 200 or len(body) > _MAX_JWKS_BYTES:
_fail("jwks_unavailable", f"the JWKS answered {status}")
try:
parsed = _loads(body.decode("utf-8-sig", errors="replace"))
except (ValueError, RecursionError):
_fail("jwks_unavailable", "the JWKS is not JSON")
if not isinstance(parsed, dict) or not isinstance(
parsed.get("keys"), list
):
_fail("jwks_unavailable", "the JWKS has no keys array")
keys = {}
for jwk in parsed["keys"][:_MAX_JWKS_KEYS]:
if (
not isinstance(jwk, dict)
or not isinstance(jwk.get("kid"), str)
or jwk["kid"] in keys
):
continue
key = _import_jwk(jwk)
if key is not None:
keys[jwk["kid"]] = key
return _KeySet(
keys, attempted_at + _max_age(cache_control), attempted_at
)
@app.post("/session")
def session():
nonce = take_nonce(request)
body = request.get_json(silent=True)
token = body.get("token") if isinstance(body, dict) else None
try:
player = verifier.verify_identity_token(token, nonce=nonce)
except ArcadeTokenError as error:
return jsonify(error=error.code), 401
# Deduplicate by the token's id, never by its text.
if not remember_once(player.id):
return "", 409
return jsonify(session=open_session(player.subject, player.nickname))Eventos de dados
Um token de evento de dados passa pelas mesmas verificações de um token de identidade, com o mesmo conjunto de chaves (veja O token do evento), com estas diferenças:
typéuloggd-arcade-event+jwt. Um token de identidade enviado como evento éwrong_type, e o contrário também.- Não há
nonce,scope,iidnemmodepara conferir. eventégrant_revoked,grant_forgotten,nickname_changedouaccount_deleted. Qualquer outro valor émalformed.nicknamevem, como string, só comnickname_changed, e falta em todos os outros eventos. O contrário émalformed.- A diferença de relógio aceita é sempre de 60 segundos, e
iatpode ter até 7 dias; depois disso,too_old. - Não há opção para o simulador: um token de evento com
issmocké sempremock_refused.
@app.post("/uloggd-events")
def uloggd_events():
body = request.get_json(silent=True)
token = body.get("event_token") if isinstance(body, dict) else None
try:
event = verifier.verify_event_token(token)
except ArcadeTokenError:
return "", 400
# The same event can arrive twice: the job deduplicates by event.id.
enqueue(event)
return "", 204Responda com um 2xx, como 204, assim que o token verificar e o evento
estiver na sua fila, e com qualquer outro status a uma recusa, como 400. O
uloggd tenta de novo mais tarde (veja
Entrega), o que salva um
evento recusado com jwks_unavailable enquanto o conjunto de chaves não
respondia. O jti é o id do evento e se repete em cada nova tentativa:
elimine duplicados por ele. Recuse corpos acima de 8 KiB.
Códigos de recusa
Os mesmos 14 códigos do ArcadeTokenError do SDK:
| Código | Quando |
|---|---|
malformed | A forma: o token não é uma string de no máximo 8192 caracteres em três segmentos base64url, o cabeçalho ou o conteúdo não é um objeto JSON, ou, num token do simulador, o texto depois de mock- no kid não é base64url. Ou os membros: falta um obrigatório ou ele tem o tipo errado, exp não vem depois de iat, scope não inclui identify, mode não é live nem test, ou event e nickname não combinam. |
unsupported_header | O cabeçalho não tem exatamente alg, typ e kid, alg não é ES256, ou kid não é uma string. |
wrong_type | typ não é o tipo esperado: uloggd-arcade-id+jwt para identidade, uloggd-arcade-event+jwt para eventos. |
bad_signature | A assinatura não tem 64 bytes, ou não confere com a chave. |
mock_refused | iss é mock, e o verificador não aceita o simulador. |
wrong_issuer | iss não é https://uloggd.com. |
jwks_unavailable | O conjunto de chaves não pôde ser buscado ou lido, uma busca falhou há menos de 10 segundos, ou a URL das chaves ou o emissor configurados estão fora de https://uloggd.com (e de uma origem de loopback, nos testes). |
unknown_key | O kid não está no conjunto, nem depois da nova busca que um minuto permite. Num token do simulador, o kid não começa com mock-, ou o que vem depois não é uma chave pública P-256. |
wrong_audience | aud não é o id do seu aplicativo. |
not_yet_valid | iat está no futuro além da diferença de relógio. |
expired | exp mais a diferença de relógio já passou. |
too_old | iat é mais antigo do que a vida do token mais a diferença de relógio: 300 segundos para identidade, 7 dias para eventos. |
nonce_mismatch | Só identidade: o nonce está vazio ou não é o que o seu servidor emitiu. |
test_refused | Só identidade: mode é test, e o verificador recusa o modo de teste. |
Um token com mais de um defeito recebe o código da primeira verificação que falha. O SDK verifica nestas etapas, em ordem:
- a forma e o cabeçalho (
malformed,unsupported_header,wrong_type); - o conteúdo e o tamanho da assinatura (
malformed,bad_signature); - a configuração, o emissor e a chave (
jwks_unavailable,mock_refused,wrong_issuer,unknown_key); - a assinatura (
bad_signature); aud(wrong_audience);- os membros comuns e o tempo (
malformed,not_yet_valid,expired,too_old); - num token de identidade, os seus próprios membros, o nonce e o modo
(
malformed,nonce_mismatch,test_refused); num token de evento,eventenickname(malformed).
Com uma biblioteca JWT
Uma biblioteca JOSE ou JWT de uso geral aceita mais formatos do que o uloggd assina. Se você usar uma, confira cada ponto abaixo no seu próprio código:
- Fixe
ES256. PasseES256como o único algoritmo aceito. Nunca deixe oalgdo token escolher, e recusenonee HMAC. - Leia o cabeçalho você mesmo. Decodifique o primeiro segmento antes de
chamar a biblioteca e exija exatamente
alg,typekid(unsupported_header). Uma biblioteca pode ignorar membros que não conhece, ou seguirjku,jwkoux5uaté outra chave. - Compare
typ. Exatamenteuloggd-arcade-id+jwtouuloggd-arcade-event+jwt, como texto (wrong_type). - Compare
audcomo uma string.audé uma string igual ao id do seu aplicativo (wrong_audience). Não aceite uma lista que contenha o id: o uloggd nunca assina uma. - Busque de novo diante de um
kiddesconhecido no máximo uma vez por minuto. Okidvem num cabeçalho que ninguém assinou, então sem esse limite cada token inventado viraria uma busca. - Recuse um
kidque não está no conjunto, em vez de tentar cada chave (unknown_key). Tentar todas aceitaria um token que o SDK recusa, e faria cada token forjado custar uma verificação por chave. - Confira a idade de
iat.expnão basta:iattem no máximo 300 segundos num token de identidade e 7 dias num de evento, mais a diferença de relógio (too_old), e não está no futuro além dela (not_yet_valid). - Use assinaturas cruas de 64 bytes.
re depoiss, com 32 bytes cada. Se a criptografia da sua plataforma pede DER, converta antes de verificar, e recuse uma assinatura que chegue em DER ou com outro tamanho (bad_signature).
Depois da biblioteca, confira o resto: os membros, o nonce, o scope, o
mode e a repetição, como em
Verificar em qualquer linguagem.