Verificación en otros lenguajes
Verificadores completos de los tokens de identidad y de eventos de datos, con las mismas reglas y los mismos códigos de rechazo que el SDK para Node.
Para servidores de juego que no ejecutan JavaScript. El SDK para Node
(@uloggd/arcade-sdk/server) es la referencia: un verificador en otro
lenguaje es correcto cuando acepta los mismos tokens que él y rechaza los
demás con el mismo código. Las reglas de un token de identidad están en
Verificar en cualquier lenguaje.
Esta página publica un verificador completo en Python, y añade los tokens de
eventos de datos, los códigos de rechazo en el orden en que el SDK los da, y
qué comprobar cuando usas una biblioteca JWT.
Antes de copiar
Cada archivo se prueba con tokens firmados por el propio código de uloggd, con cada rechazo que hace el SDK para Node. El código de esta página tiene licencia MIT, como el SDK: cópialo en tu servidor.
- Descarga las llaves solo de
https://uloggd.com/api/v1/arcade/jwks, o fija las llaves públicas actuales y sigue el calendario de cambio. - Guarda el conjunto de llaves en una caché del proceso, compartida por todas las peticiones, como hace el SDK: como mucho 5 minutos, digan lo que digan las cabeceras de la respuesta.
- Acepta los tokens del simulador (
iss: "mock";allowMocken el SDK,allow_mocken Python) solo en desarrollo local; un verificador de producción los rechaza conmock_refused. - Dos cosas pueden ser distintas del SDK sin cambiar la respuesta a ningún
token: rechazar una configuración errónea (un id de aplicación vacío, una URL
de llaves fuera de
https://uloggd.com) al crear el verificador, en vez de en cada llamada; y dar a la descarga del conjunto de llaves un plazo de pocos segundos, tratando su vencimiento comojwks_unavailable. - Guarda cada
jtiy cada nonce aceptados durante más tiempo del que vive un token, y rechaza una repetición. Nunca elimines duplicados por el texto del token.
Las opciones del SDK, en cada verificador publicado:
| Node | Python |
|---|---|
appId | ArcadeVerifier(app_id) |
nonce | nonce= en cada llamada |
allowMock | allow_mock=True |
allowTestMode | allow_test_mode=False |
clockSkewSeconds | clock_skew_seconds |
jwksUrl | jwks_url |
issuer | issuer |
El verificador en Python tiene las dos diferencias que la lista de arriba
permite: una opción errónea lanza ValueError al crear el verificador, y la
descarga del conjunto de llaves vence a los 5 segundos. También rechaza como
malformed un token de identidad cuyo exp cae después del año 9999; uloggd
nunca firma uno así.
Python
Python 3.10 o más reciente, con el paquete cryptography
(packages/arcade-sdk/examples/python/requirements.txt). El verificador
bloquea mientras descarga las llaves: en un servidor asíncrono, llámalo desde
un endpoint síncrono o con 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 datos
Un token de evento de datos pasa por las mismas comprobaciones que un token de identidad, con el mismo conjunto de llaves (consulta El token del evento), con estas diferencias:
typesuloggd-arcade-event+jwt. Un token de identidad enviado como evento eswrong_type, y lo contrario también.- No hay
nonce,scope,iidnimodeque comprobar. eventesgrant_revoked,grant_forgotten,nickname_changedoaccount_deleted. Cualquier otro valor esmalformed.nicknamellega, como cadena, solo connickname_changed, y falta en todos los demás eventos. Lo contrario esmalformed.- La diferencia de reloj aceptada es siempre de 60 segundos, e
iatpuede tener hasta 7 días; después,too_old. - No hay opción para el simulador: un token de evento con
issmockes siempremock_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 "", 204Responde con un 2xx, como 204, en cuanto el token verifique y el evento
esté en tu cola, y con cualquier otro estado a un rechazo, como 400. uloggd
vuelve a intentarlo más tarde (consulta
Entrega), lo que salva un
evento rechazado con jwks_unavailable mientras el conjunto de llaves no
respondía. El jti es el id del evento y se repite en cada nuevo intento:
elimina duplicados por él. Rechaza cuerpos de más de 8 KiB.
Códigos de rechazo
Los mismos 14 códigos del ArcadeTokenError del SDK:
| Código | Cuándo |
|---|---|
malformed | La forma: el token no es una cadena de como mucho 8192 caracteres en tres segmentos base64url, la cabecera o el contenido no es un objeto JSON, o, en un token del simulador, el texto tras mock- en el kid no es base64url. O los miembros: falta uno obligatorio o tiene el tipo equivocado, exp no va después de iat, scope no incluye identify, mode no es live ni test, o event y nickname no encajan. |
unsupported_header | La cabecera no tiene exactamente alg, typ y kid, alg no es ES256, o kid no es una cadena. |
wrong_type | typ no es el tipo esperado: uloggd-arcade-id+jwt para identidad, uloggd-arcade-event+jwt para eventos. |
bad_signature | La firma no tiene 64 bytes, o no coincide con la llave. |
mock_refused | iss es mock, y el verificador no acepta el simulador. |
wrong_issuer | iss no es https://uloggd.com. |
jwks_unavailable | El conjunto de llaves no se pudo descargar o leer, una descarga falló hace menos de 10 segundos, o la URL de las llaves o el emisor configurados están fuera de https://uloggd.com (y de un origen de loopback, en las pruebas). |
unknown_key | El kid no está en el conjunto, ni siquiera tras la nueva descarga que un minuto permite. En un token del simulador, el kid no empieza por mock-, o lo que sigue no es una llave pública P-256. |
wrong_audience | aud no es el id de tu aplicación. |
not_yet_valid | iat está en el futuro más allá de la diferencia de reloj. |
expired | exp más la diferencia de reloj ya ha pasado. |
too_old | iat es más antiguo que la vida del token más la diferencia de reloj: 300 segundos para identidad, 7 días para eventos. |
nonce_mismatch | Solo identidad: el nonce está vacío o no es el que emitió tu servidor. |
test_refused | Solo identidad: mode es test, y el verificador rechaza el modo de prueba. |
Un token con más de un defecto recibe el código de la primera comprobación que falla. El SDK comprueba en estas etapas, en orden:
- la forma y la cabecera (
malformed,unsupported_header,wrong_type); - el contenido y el tamaño de la firma (
malformed,bad_signature); - la configuración, el emisor y la llave (
jwks_unavailable,mock_refused,wrong_issuer,unknown_key); - la firma (
bad_signature); aud(wrong_audience);- los miembros comunes y el tiempo (
malformed,not_yet_valid,expired,too_old); - en un token de identidad, sus propios miembros, el nonce y el modo
(
malformed,nonce_mismatch,test_refused); en un token de evento,eventynickname(malformed).
Con una biblioteca JWT
Una biblioteca JOSE o JWT de uso general acepta más formatos de los que uloggd firma. Si usas una, comprueba cada punto de abajo en tu propio código:
- Fija
ES256. PasaES256como el único algoritmo aceptado. Nunca dejes que elalgdel token elija, y rechazanoney HMAC. - Lee la cabecera tú mismo. Decodifica el primer segmento antes de llamar
a la biblioteca y exige exactamente
alg,typykid(unsupported_header). Una biblioteca puede ignorar miembros que no conoce, o seguirjku,jwkox5uhasta otra llave. - Compara
typ. Exactamenteuloggd-arcade-id+jwtouloggd-arcade-event+jwt, como texto (wrong_type). - Compara
audcomo una cadena.audes una cadena igual al id de tu aplicación (wrong_audience). No aceptes una lista que contenga el id: uloggd nunca firma una. - Vuelve a descargar ante un
kiddesconocido como mucho una vez por minuto. Elkidllega en una cabecera que nadie firmó, así que sin ese límite cada token inventado se convertiría en una descarga. - Rechaza un
kidque no está en el conjunto, en vez de probar cada llave (unknown_key). Probarlas todas aceptaría un token que el SDK rechaza, y haría que cada token falsificado costara una verificación por llave. - Comprueba la edad de
iat.expno basta:iattiene como mucho 300 segundos en un token de identidad y 7 días en uno de evento, más la diferencia de reloj (too_old), y no está en el futuro más allá de ella (not_yet_valid). - Usa firmas en bruto de 64 bytes.
ry luegos, de 32 bytes cada una. Si la criptografía de tu plataforma pide DER, convierte antes de verificar, y rechaza una firma que llegue en DER o con otro tamaño (bad_signature).
Después de la biblioteca, comprueba el resto: los miembros, el nonce, el
scope, el mode y las repeticiones, como en
Verificar en cualquier lenguaje.