Auth Gate JWT¶
Construa um app de login com guarda de rota que usa AuthStore, route_guard,
decodificação de JWT offline e um logger de auditoria — tudo em Python puro, sem
HTML nem JavaScript. 🔐
Ao final deste tutorial você terá um app funcional que demonstra o trilho O4
(Observability › Auth) do tempestweb: o AuthStore guarda o token e direciona
qual tela renderizar, o route_guard redireciona quem não está autenticado, tokens
JWT são decodificados sem biblioteca externa, e cada evento relevante (login,
logout, falha) deixa um rastro auditável nos LogRecords do Logger.
O problema¶
Todo app autenticado precisa resolver quatro questões ao mesmo tempo:
- Onde guardar o token? — num lugar observável que dispare re-renders ao mudar.
- Como proteger rotas? — redirecionar para
/loginsem poluir aviewcom condicionais espalhadas. - O que o token diz? —
sub,role,expficam no payload do JWT e o cliente precisa lê-los para decidir se deve renovar o token. - Como auditar eventos? — logar login, logout e falhas de forma estruturada,
sem acoplar a lógica de negócio ao
print.
O tempestweb resolve isso com a superfície de auth do pacote
tempestweb.observability: AuthStore (loja observável), route_guard (guarda
de rota puro), decode_jwt + is_jwt_expired (helpers de JWT offline) e Logger
(log estruturado com sinks plugáveis).
O que você vai exercitar
create_auth_store/AuthStore— criar, popular e observar a loja de token.route_guard— construir um guarda que redireciona requests não autenticados.decode_jwt— decodificar o payload de um JWT sem verificar a assinatura.is_jwt_expired— checar expiração com umnowfixo (determinístico em testes).create_logger/Logger/LogRecord/LoggerSink— registrar eventos com nível, mensagem e campos estruturados.- Construção de JWTs unsigned para demos e testes offline.
Pré-requisitos¶
Certifique-se de ter feito a Instalação e lido o
Tutorial do Counter. Este exemplo assume que você já
conhece App, set_state, make_state e view.
Para entender como o route_guard mapeia para o sistema de navegação completo do
tempestweb, veja Navegação.
O app completo¶
Este é o código exato de
examples/auth-jwt/app.py.
Copie, rode, e depois leia a explicação seção por seção.
"""JWT auth gate — client-side auth with AuthStore, route guard, and JWT helpers.
This example wires the full O4 auth surface into a realistic login-gate pattern:
- An **AuthStore** (created via ``create_auth_store``) is held inside State and
drives which screen renders — a login prompt when logged out, a protected
dashboard when logged in.
- A hand-built unsigned JWT (``header.payload.signature`` with a base64url-
encoded JSON payload) is decoded offline by ``decode_jwt`` and inspected for
expiry via ``is_jwt_expired(token, now=<fixed>)``.
- A **Logger** records every login and logout event for auditability.
- ``route_guard`` decides which screen to show based on auth status.
The ``view`` is transport-agnostic and runs unchanged in both modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
No bridge is needed: the initial mount calls no native capability, so
``build(view(app))`` is green with no bridge installed.
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from typing import Any
from tempestweb._core import App, Widget
from tempestweb._core.style import AlignItems, Color, Edge, FontWeight, Style, TextAlign
from tempestweb._core.widgets import (
Button,
Column,
Input,
KeyboardType,
Row,
Text,
)
from tempestweb._core.widgets.events import TextChangeEvent
from tempestweb.observability import (
AuthStore,
Logger,
LoggerSink,
LogRecord,
create_auth_store,
create_logger,
decode_jwt,
is_jwt_expired,
route_guard,
)
# ---------------------------------------------------------------------------
# JWT helpers — build offline-verifiable tokens for the demo
# ---------------------------------------------------------------------------
# A fixed "now" timestamp used throughout the demo so expiry display is
# deterministic in tests (and in the initial render).
_DEMO_NOW: float = 1_800_000_000.0 # arbitrary fixed epoch, well in the past
def _b64url(obj: dict[str, Any]) -> str:
"""Encode *obj* as a URL-safe base64 string without padding.
Args:
obj: A JSON-serialisable dict.
Returns:
The base64url-encoded JSON without trailing ``=`` characters.
"""
raw: bytes = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def make_jwt(claims: dict[str, Any]) -> str:
"""Build an unsigned compact JWT carrying *claims*.
The signature segment is the literal string ``"sig"`` — it is
intentionally not a real HMAC so the token can be decoded offline by
``decode_jwt`` without a secret. **Never use this pattern in production.**
Args:
claims: Arbitrary JSON-serialisable claims.
Returns:
A ``header.payload.sig`` JWT string.
"""
header: str = _b64url({"alg": "none", "typ": "JWT"})
payload: str = _b64url(claims)
return f"{header}.{payload}.sig"
# Pre-built tokens used by the demo.
# ``exp`` is set relative to _DEMO_NOW so the expiry indicator is stable:
# the "alice" token expires 1 hour after the demo epoch (= not yet expired at
# _DEMO_NOW); the "bob" token expired 1 hour before (= already expired).
_ALICE_TOKEN: str = make_jwt(
{
"sub": "alice",
"name": "Alice Souza",
"role": "admin",
"exp": int(_DEMO_NOW) + 3600, # expires 1 h after the demo epoch
}
)
_BOB_TOKEN: str = make_jwt(
{
"sub": "bob",
"name": "Bob Lima",
"role": "user",
"exp": int(_DEMO_NOW) - 3600, # expired 1 h before the demo epoch
}
)
# Demo credential store (username → (password, JWT)).
_CREDENTIALS: dict[str, tuple[str, str]] = {
"alice": ("secret", _ALICE_TOKEN),
"bob": ("p4ssw0rd", _BOB_TOKEN),
}
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@dataclass
class AuthAppState:
"""All mutable state for the auth-gate app.
Attributes:
store: The observable auth store holding the current token and user.
log: The logger; its records are read by the view.
username: The draft username the user is typing.
password: The draft password the user is typing.
error: A top-level login error message (wrong credentials, etc.).
log_records: Accumulated log records shown in the audit trail.
current_route: The route the app is trying to render.
"""
store: AuthStore = field(default_factory=create_auth_store)
log: Logger = field(init=False)
username: str = ""
password: str = ""
error: str = ""
log_records: list[LogRecord] = field(default_factory=list)
current_route: str = "/dashboard"
def __post_init__(self) -> None:
"""Wire up the logger with an in-state sink so records drive re-renders.
Returns:
None.
"""
def _append_sink(record: LogRecord) -> None:
"""Append a log record to the state list.
Args:
record: The structured record to store.
"""
self.log_records.append(record)
sink: LoggerSink = _append_sink
self.log = create_logger(sinks=[sink], level="INFO")
def make_state() -> AuthAppState:
"""Build the initial, logged-out application state.
Returns:
A fresh :class:`AuthAppState`.
"""
return AuthAppState()
# ---------------------------------------------------------------------------
# Screen helpers
# ---------------------------------------------------------------------------
def _token_badge(token: str) -> Widget:
"""Render a small info card showing decoded JWT claims and expiry.
Args:
token: The JWT to inspect.
Returns:
A widget tree summarising the token claims and whether it is expired.
"""
claims: dict[str, Any] = decode_jwt(token)
expired: bool = is_jwt_expired(token, now=_DEMO_NOW)
sub: str = str(claims.get("sub", "—"))
role: str = str(claims.get("role", "—"))
exp_label: str = "expired" if expired else "valid"
exp_color: Color = (
Color.from_hex("#dc2626") if expired else Color.from_hex("#16a34a")
)
return Column(
key="token-badge",
style=Style(
gap=4.0,
padding=Edge.all(12.0),
background=Color.from_hex("#f0fdf4"),
radius=8.0,
),
children=[
Text(
content="JWT Claims",
style=Style(font_weight=FontWeight.BOLD, font_size=13.0),
key="badge-title",
),
Text(content=f"sub: {sub}", key="badge-sub"),
Text(content=f"role: {role}", key="badge-role"),
Text(
content=f"token: {exp_label}",
style=Style(color=exp_color, font_weight=FontWeight.BOLD),
key="badge-exp",
),
],
)
def _audit_trail(records: list[LogRecord]) -> Widget:
"""Render the last few log records as a compact audit trail.
Args:
records: The accumulated :class:`~tempestweb.observability.LogRecord` list.
Returns:
A widget tree listing the records (newest last), or an empty-state row.
"""
if not records:
return Row(
key="audit-empty",
children=[
Text(
content="No log entries yet.",
style=Style(color=Color.from_hex("#6b7280"), font_size=12.0),
key="audit-empty-text",
)
],
)
entries: list[Widget] = [
Text(
content=f"[{r.level}] {r.message}",
style=Style(font_size=12.0, color=Color.from_hex("#374151")),
key=f"audit-{i}",
)
for i, r in enumerate(records[-5:]) # show at most the last 5
]
return Column(key="audit-records", style=Style(gap=2.0), children=entries)
# ---------------------------------------------------------------------------
# Sub-screens
# ---------------------------------------------------------------------------
def _login_screen(app: App[AuthAppState]) -> Widget:
"""Render the login prompt.
Args:
app: The application handle.
Returns:
A widget tree with username/password inputs and a login button.
"""
def on_username(event: TextChangeEvent) -> None:
"""Update the draft username.
Args:
event: The text change event carrying the new value.
"""
value: str = event.value
def _set(s: AuthAppState) -> None:
s.username = value
s.error = ""
app.set_state(_set)
def on_password(event: TextChangeEvent) -> None:
"""Update the draft password.
Args:
event: The text change event carrying the new value.
"""
value: str = event.value
def _set(s: AuthAppState) -> None:
s.password = value
s.error = ""
app.set_state(_set)
def do_login() -> None:
"""Validate credentials and log in if correct.
Looks the username up in the demo credential store, checks the
password, then calls ``store.login`` with the matching JWT and a
user-info dict. Failures are surfaced via ``state.error``.
Returns:
None.
"""
username: str = app.state.username.strip()
password: str = app.state.password
entry: tuple[str, str] | None = _CREDENTIALS.get(username)
if entry is None or entry[0] != password:
app.state.log.warning("login failed", username=username)
def set_error(s: AuthAppState) -> None:
s.error = "Invalid username or password."
app.set_state(set_error)
return
_pw, token = entry
claims: dict[str, Any] = decode_jwt(token)
app.state.log.info(
"login successful",
username=username,
role=str(claims.get("role", "?")),
)
user_info: dict[str, Any] = {
"sub": username,
"name": claims.get("name", username),
}
app.state.store.login(token, user_info)
def on_logged_in(s: AuthAppState) -> None:
s.error = ""
s.username = ""
s.password = ""
s.current_route = "/dashboard"
app.set_state(on_logged_in)
error_widgets: list[Widget] = []
if app.state.error:
error_widgets.append(
Text(
content=app.state.error,
style=Style(color=Color.from_hex("#dc2626"), font_size=13.0),
key="login-error",
)
)
return Column(
key="login-screen",
style=Style(
gap=16.0,
padding=Edge.all(24.0),
align=AlignItems.CENTER,
),
children=[
Text(
content="Sign in",
style=Style(
font_size=26.0,
font_weight=FontWeight.BOLD,
text_align=TextAlign.CENTER,
),
key="login-heading",
),
Text(
content="Demo users: alice / secret · bob / p4ssw0rd",
style=Style(font_size=12.0, color=Color.from_hex("#6b7280")),
key="login-hint",
),
Input(
value=app.state.username,
placeholder="Username",
keyboard=KeyboardType.TEXT,
on_change=on_username,
key="username-input",
),
Input(
value=app.state.password,
placeholder="Password",
secure=True,
keyboard=KeyboardType.PASSWORD,
on_change=on_password,
key="password-input",
),
*error_widgets,
Button(label="Sign in", on_click=do_login, key="login-btn"),
_audit_trail(app.state.log_records),
],
)
def _dashboard_screen(app: App[AuthAppState]) -> Widget:
"""Render the protected dashboard.
Args:
app: The application handle.
Returns:
A widget tree showing the user's token claims and a logout button.
"""
token: str | None = app.state.store.token
user: dict[str, Any] | None = app.state.store.user
display_name: str = (
str(user.get("name", user.get("sub", "User"))) if user else "User"
)
def do_logout() -> None:
"""Log out and return to the login screen.
Returns:
None.
"""
uname: str = str(user.get("sub", "?")) if user else "?"
app.state.log.info("logout", username=uname)
app.state.store.logout()
def on_logged_out(s: AuthAppState) -> None:
s.current_route = "/login"
app.set_state(on_logged_out)
token_widget: Widget = (
_token_badge(token)
if token is not None
else Text(content="No token.", key="no-token")
)
return Column(
key="dashboard-screen",
style=Style(gap=16.0, padding=Edge.all(24.0)),
children=[
Text(
content=f"Welcome, {display_name}!",
style=Style(
font_size=24.0,
font_weight=FontWeight.BOLD,
color=Color.from_hex("#1d4ed8"),
),
key="dash-heading",
),
Text(
content=(
"This is a protected page. Only authenticated users can see it."
),
style=Style(font_size=14.0, color=Color.from_hex("#374151")),
key="dash-body",
),
token_widget,
Button(label="Log out", on_click=do_logout, key="logout-btn"),
Column(
key="audit-section",
style=Style(gap=4.0),
children=[
Text(
content="Audit trail",
style=Style(font_weight=FontWeight.BOLD, font_size=13.0),
key="audit-title",
),
_audit_trail(app.state.log_records),
],
),
],
)
# ---------------------------------------------------------------------------
# view
# ---------------------------------------------------------------------------
def view(app: App[AuthAppState]) -> Widget:
"""Render the auth-gate app from the current application state.
Uses ``route_guard`` to decide whether to show the login screen or the
protected dashboard. The guard's decision is based on the ``AuthStore``
held inside ``state``.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current auth + route state.
"""
guard = route_guard(app.state.store, redirect_to="/login")
effective_route: str = guard(app.state.current_route)
if effective_route == "/dashboard":
return _dashboard_screen(app)
return _login_screen(app)
Explicando peça por peça¶
1. Construindo JWTs para o demo¶
O app usa dois tokens pré-construídos — um válido (alice) e um expirado (bob) — para
demonstrar decode_jwt e is_jwt_expired de forma determinística em testes e na
renderização inicial.
_DEMO_NOW: float = 1_800_000_000.0 # epoch fixo, bem no passado
def _b64url(obj: dict[str, Any]) -> str:
raw: bytes = json.dumps(obj, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def make_jwt(claims: dict[str, Any]) -> str:
header: str = _b64url({"alg": "none", "typ": "JWT"})
payload: str = _b64url(claims)
return f"{header}.{payload}.sig"
_ALICE_TOKEN: str = make_jwt(
{"sub": "alice", "name": "Alice Souza", "role": "admin",
"exp": int(_DEMO_NOW) + 3600} # válido: 1 h depois do epoch demo
)
_BOB_TOKEN: str = make_jwt(
{"sub": "bob", "name": "Bob Lima", "role": "user",
"exp": int(_DEMO_NOW) - 3600} # expirado: 1 h antes do epoch demo
)
Um JWT compacto tem três partes separadas por .: header.payload.signature.
Aqui a assinatura é literalmente a string "sig" — isso é suficiente para o
decode_jwt funcionar offline, porque ele não verifica a assinatura (esse é
um trabalho do servidor).
Nunca em produção
Tokens sem assinatura real (alg: none) são para demos e testes offline.
Em produção, use tempest_fastapi_sdk.JWTUtils (lado servidor) para emitir
e verificar tokens assinados com HMAC ou RS256. O cliente usa decode_jwt
apenas para ler claims (exibição e decisão de renovação), nunca para
confiar neles como autorização.
A tabela abaixo resume os dois usuários de demo:
| Usuário | Senha | Role | Expiração em _DEMO_NOW |
|---|---|---|---|
| alice | secret | admin | válido (+ 1 h) |
| bob | p4ssw0rd | user | expirado (− 1 h) |
2. O estado: AuthStore + Logger juntos¶
@dataclass
class AuthAppState:
store: AuthStore = field(default_factory=create_auth_store)
log: Logger = field(init=False)
username: str = ""
password: str = ""
error: str = ""
log_records: list[LogRecord] = field(default_factory=list)
current_route: str = "/dashboard"
def __post_init__(self) -> None:
def _append_sink(record: LogRecord) -> None:
self.log_records.append(record)
sink: LoggerSink = _append_sink
self.log = create_logger(sinks=[sink], level="INFO")
Dois objetos especiais vivem no estado:
-
store: AuthStore— criado porcreate_auth_store(). Guarda o token e o payload de usuário. Quando você chamastore.login(token, user_info)oustore.logout(), o store notifica assinantes — o que em um app real dispararia um re-render automaticamente. -
log: Logger— criado em__post_init__com um sink que appenda os records alog_records. Isso fecha o loop: cada chamadalog.info(...)oulog.warning(...)atualizalog_recordsno estado, que aviewlê para renderizar o audit trail.
Dica: sinks são qualquer callable
LoggerSink é um Protocol com __call__(record: LogRecord) -> None.
Isso significa que list.append já é um sink válido. No __post_init__
usamos uma closure para acessar o atributo do dataclass, mas poderíamos
escrever create_logger(sinks=[self.log_records.append]) após o dataclass
estar inicializado se o Python permitisse. A closure é a forma segura.
Por que log é field(init=False)?
O Logger precisa de acesso ao self.log_records, que só existe depois que
o dataclass é construído. field(init=False) garante que o __post_init__
rode após todos os outros campos serem inicializados.
3. create_auth_store e AuthStore¶
create_auth_store() é um construtor conveniente que retorna um AuthStore vazio
(deslogado). O AuthStore expõe:
| Propriedade / Método | O que faz |
|---|---|
store.token |
Retorna o token atual ou None |
store.user |
Retorna o payload de usuário ou None |
store.is_authenticated |
True se há um token presente |
store.login(token, user) |
Armazena o token + usuário, notifica assinantes |
store.logout() |
Limpa token e usuário, notifica assinantes |
store.set_token(token) |
Substitui só o token (ex.: após refresh) |
store.subscribe(fn) |
Registra um listener de mudança; retorna unsubscribe |
No app de demo, o store é consultado pelo route_guard e pelo _dashboard_screen
para exibir nome e token.
4. route_guard: protegendo rotas em uma linha¶
def view(app: App[AuthAppState]) -> Widget:
guard = route_guard(app.state.store, redirect_to="/login")
effective_route: str = guard(app.state.current_route)
if effective_route == "/dashboard":
return _dashboard_screen(app)
return _login_screen(app)
route_guard(store, redirect_to="/login") retorna uma função guard. Quando você
chama guard("/dashboard"):
- Se
store.is_authenticatedéTrue→ devolve"/dashboard"inalterado. - Se
store.is_authenticatedéFalse→ devolve"/login"(oredirect_to). - Se a rota solicitada já é
"/login"→ devolve"/login"sem loop infinito.
O resultado (effective_route) é o que a view usa para decidir qual sub-árvore
renderizar. Toda a lógica de guarda é capturada em uma chamada — não há
condicionais espalhadas pelo código.
Sem dependência de router
route_guard é uma função pura — não depende de nenhum sistema de navegação.
Você pode usá-la isoladamente (como nos testes unitários) ou junto ao router
completo do tempestweb quando precisar de histórico e deep links.
5. decode_jwt e is_jwt_expired¶
O _token_badge usa os dois helpers para inspecionar o token sem fazer nenhuma
chamada de rede:
def _token_badge(token: str) -> Widget:
claims: dict[str, Any] = decode_jwt(token)
expired: bool = is_jwt_expired(token, now=_DEMO_NOW)
sub: str = str(claims.get("sub", "—"))
role: str = str(claims.get("role", "—"))
exp_label: str = "expired" if expired else "valid"
exp_color: Color = (
Color.from_hex("#dc2626") if expired else Color.from_hex("#16a34a")
)
...
decode_jwt(token) divide o JWT em três partes pelo ., pega o segmento do
meio (payload), decodifica o base64url e deserializa o JSON. Devolve um
dict[str, Any] com os claims — sub, role, exp, etc. Se o token for
malformado, lança JWTError.
is_jwt_expired(token, now=_DEMO_NOW) chama decode_jwt internamente, lê o
claim exp e compara com now. O parâmetro now é opcional (padrão:
time.time()); neste app usamos _DEMO_NOW para tornar os testes determinísticos.
Resultado com os tokens de demo
is_jwt_expired(_ALICE_TOKEN, now=_DEMO_NOW)→False(válido)is_jwt_expired(_BOB_TOKEN, now=_DEMO_NOW)→True(expirado)
Ao fazer login com alice, o badge aparece verde ("valid"). Isso acontece porque
_ALICE_TOKEN.exp = _DEMO_NOW + 3600 e _DEMO_NOW < _DEMO_NOW + 3600.
Sem verificação de assinatura
decode_jwt é propositalmente client-side: ele lê os claims para o cliente
decidir quando renovar o token e o que mostrar na UI. A validade
criptográfica do token é responsabilidade do servidor — use
server_decode_jwt(token, secret) no lado FastAPI (Modo B).
6. O Logger e o audit trail¶
O Logger tem métodos de nível convencional: debug, info, warning, error,
critical. Cada um aceita uma mensagem e campos estruturados arbitrários como
**kwargs. O que chega ao sink é um LogRecord:
@dataclass(frozen=True)
class LogRecord:
level: LogLevel # "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL"
message: str
fields: dict[str, Any] # os kwargs passados na chamada
No app, o sink que criamos no __post_init__ appenda cada record a
state.log_records. A _audit_trail renderiza os últimos 5:
def _audit_trail(records: list[LogRecord]) -> Widget:
if not records:
return Row(
key="audit-empty",
children=[
Text(
content="No log entries yet.",
style=Style(color=Color.from_hex("#6b7280"), font_size=12.0),
key="audit-empty-text",
)
],
)
entries: list[Widget] = [
Text(
content=f"[{r.level}] {r.message}",
style=Style(font_size=12.0, color=Color.from_hex("#374151")),
key=f"audit-{i}",
)
for i, r in enumerate(records[-5:])
]
return Column(key="audit-records", style=Style(gap=2.0), children=entries)
Dica: múltiplos sinks em paralelo
create_logger(sinks=[sink_a, sink_b], level="INFO") entrega cada record a
sink_a e sink_b. Se um sink lançar exceção, os demais ainda recebem o
record — um destino quebrado não derruba o logging. Isso é útil para logar
simultaneamente na UI (sink de estado) e num servidor remoto (sink HTTP).
7. O handler de login passo a passo¶
def do_login() -> None:
username: str = app.state.username.strip()
password: str = app.state.password
entry: tuple[str, str] | None = _CREDENTIALS.get(username)
if entry is None or entry[0] != password:
app.state.log.warning("login failed", username=username)
def set_error(s: AuthAppState) -> None:
s.error = "Invalid username or password."
app.set_state(set_error)
return
_pw, token = entry
claims: dict[str, Any] = decode_jwt(token)
app.state.log.info(
"login successful",
username=username,
role=str(claims.get("role", "?")),
)
user_info: dict[str, Any] = {
"sub": username,
"name": claims.get("name", username),
}
app.state.store.login(token, user_info)
def on_logged_in(s: AuthAppState) -> None:
s.error = ""
s.username = ""
s.password = ""
s.current_route = "/dashboard"
app.set_state(on_logged_in)
O fluxo em ordem:
- Busca as credenciais no dicionário
_CREDENTIALS. - Se não encontrou ou a senha não bate →
log.warning(...)+state.error. - Se encontrou →
decode_jwt(token)para ler orole. log.info(...)registra o sucesso com campos estruturados.store.login(token, user_info)— armazena o token e notifica assinantes.app.set_state(on_logged_in)— limpa os campos e setacurrent_route = "/dashboard".
Na próxima chamada da view, o route_guard recebe "/dashboard" e
store.is_authenticated == True, então deixa passar e _dashboard_screen é
renderizado.
8. O handler de logout¶
def do_logout() -> None:
uname: str = str(user.get("sub", "?")) if user else "?"
app.state.log.info("logout", username=uname)
app.state.store.logout()
def on_logged_out(s: AuthAppState) -> None:
s.current_route = "/login"
app.set_state(on_logged_out)
store.logout() limpa _token e _user e notifica assinantes. Em seguida
current_route é setado para "/login". Na próxima view, o route_guard
recebe "/login" com store.is_authenticated == False — mas como "/login" é
o próprio redirect_to, o guard devolve "/login" sem loop — e o login screen
é renderizado.
Por que não basta chamar store.logout()?
O store.logout() limpa o estado interno do store. O current_route é um
campo separado no AuthAppState. Atualizar os dois juntos num único
set_state garante que o próximo render seja consistente: route + auth
mudam atomicamente do ponto de vista do reconciliador.
Rodando o app 🚀¶
Salve o arquivo em examples/auth-jwt/app.py e escolha o modo:
O Pyodide carrega o Python completo no browser. decode_jwt, is_jwt_expired,
AuthStore e Logger rodam inteiramente no tab — sem WebSocket, sem servidor.
Mesmo código, dois modos
O app.py não referencia wasm nem server em nenhum lugar. Não há bridge
necessária: nenhuma capability nativa é chamada na montagem inicial, então
build(view(app)) é verde sem bridge instalada.
Abra o browser em http://localhost:8000. Experimente os cenários:
- Login com alice / secret → dashboard com badge verde ("valid").
- Login com bob / p4ssw0rd → dashboard com badge vermelho ("expired").
- Senha errada → mensagem de erro em vermelho,
state.errorsetado. - Logout → tela de login, audit trail com registro de logout.
- Navegação direta para
/dashboardsem login →route_guardredireciona para/login.
Rodando os testes ✅¶
Os 20 testes cobrem:
| Grupo | O que verifica |
|---|---|
| Montagem inicial | build(view(app)) produz árvore válida; tela é login; store começa deslogado |
| Login com alice | is_authenticated vira True; dashboard renderiza; log record INFO é escrito; diff detecta mudança de árvore |
| JWT / expiração | _ALICE_TOKEN não expirado em _DEMO_NOW; _BOB_TOKEN expirado; decode_jwt devolve claims corretos; badge aparece no dashboard |
| Falha de login | Senha errada mantém is_authenticated = False; state.error preenchido; tela permanece login; log WARNING escrito |
| Logout | is_authenticated vira False; token limpo; tela volta para login; log INFO escrito |
route_guard standalone |
Não autenticado → /login; autenticado → /dashboard; "/login" nunca redireciona |
Recapitulando¶
Neste exemplo você aprendeu:
- ✅
create_auth_store/AuthStore— loja observável que guarda token e usuário;login/logout/is_authenticated/subscribe. - ✅
route_guard— guarda de rota puro que redireciona requests não autenticados; sem loop emredirect_to. - ✅
decode_jwt— decodificação offline do payload do JWT (sem verificação de assinatura); retornadict[str, Any]. - ✅
is_jwt_expired— verifica o claimexpcontra umnowconfigurável; token semexpnunca expira; token malformado trata como expirado. - ✅
create_logger/Logger/LogRecord/LoggerSink— log estruturado com threshold por nível; múltiplos sinks em paralelo; sink quebrado não afeta os demais. - ✅ Sink de estado — fechar o loop entre
Loggereviewusando uma closure que appenda records astate.log_records, fazendo o audit trail re-renderizar automaticamente. - ✅ JWTs unsigned para demos/testes —
header.payload.sigé suficiente para exercitardecode_jwteis_jwt_expiredoffline de forma determinística.
Próximos passos¶
- Leia Formulário de Login para ver validação em três camadas
com
Form+FormField+Banner. - Explore o exemplo de Dashboard Shell para ver como o
AuthStorese integra a um layout com barra lateral e header. - Veja Notification Center para usar
Loggercom um sink que alimenta um painel de notificações ao vivo. - Consulte a referência de
tempestweb.observabilitypara a lista completa de métodos doAuthStoree doRefreshQueue.