Aula 17 · Semana 8 · Terça, 27 de outubro de 2026 · 08:30 às 10:30
Teoria: autenticação em mobile: OAuth2 e OpenID Connect, JWT, tokens de acesso e refresh, biometria. Prática: token guardado em armazenamento seguro, renovação em 401 por um interceptor, e favoritos sincronizados com o servidor entre dispositivos.
Authorization: Bearer ….Para os testes: 401 é autenticação, 403 é autorização; em mobile usam-se tokens Bearer, não cookies.
header.payload.signature.{"alg":"HS256","typ":"JWT"}. Payload: as claims: sub (quem), iss (quem emitiu), iat e exp (quando), e claims próprias como grupo ou scope.{
"sub": "grupo-07",
"iss": "api.pdm.example",
"iat": 1761494400,
"exp": 1761495300,
"scope": "forecast warnings favorites"
}
exp para saber que vai expirar sem fazer um pedido.Para os testes: JWT = header.payload.signature; o payload é legível por todos, não é secreto.
"sub": "grupo-07" para "grupo-01", a assinatura deixa de bater e o servidor rejeita.exp curto (15 min a 1 h): um token roubado vale pouco tempo. Sem exp é um token eterno, e isso é um bug de segurança."alg":"none" aceite pelo servidor, e chaves HS256 fracas quebradas por força bruta. Bibliotecas atuais rejeitam ambos.Para os testes: a assinatura dá integridade, não confidencialidade; nunca se põem dados sensíveis num JWT.
code por deep link, e a app troca o code por tokens.code_verifier aleatório e envia o seu hash (code_challenge). Só quem tem o verifier troca o code. Obrigatório em apps móveis, porque não têm onde guardar um client secret.id_token (JWT) que diz quem é o utilizador. É o "Entrar com Google".flutter_appauth ou o SDK de cada fornecedor. Sempre Custom Tabs / ASWebAuthenticationSession, nunca um webview próprio (o utilizador não deve escrever a password dentro da tua app).Para os testes: apps móveis não guardam passwords nem client secrets; usam authorization code com PKCE no browser do sistema.
| Access token | Refresh token | |
|---|---|---|
| Duração | Minutos a uma hora | Dias a meses |
| Uso | Em cada pedido, como Bearer | Só para pedir um access token novo |
| Onde guardar | Memória; secure storage se tiver de sobreviver ao arranque | Secure storage, sempre |
| Se for roubado | Vale pouco tempo | Grave: revogar no servidor |
Para os testes: access curto em cada pedido; refresh longo em secure storage, só para renovar; logout apaga ambos.
local_auth: Face ID, Touch ID, impressão digital. Devolve só "sim" ou "não" à app. Nunca fala com o servidor.Para os testes: a biometria desbloqueia localmente; a autenticação no servidor continua a ser feita pelo token.
git clone https://github.com/pdm-daw-2026/pdm-aula-17.git
cd pdm-aula-17/starter
flutter pub get
flutter run --dart-define-from-file=.env.json
starter/ é a app da aula 16 (cache e TokenStorage) com TODOs de 1 a 5 e testes a falhar. Cria o .env.json com o PDM_CLIENT_ID e o PDM_SECRET do teu grupo; já está no .gitignore.solucao/ é o estado final: token renovado sozinho, favoritos iguais em dois telemóveis, logout que limpa tudo.ApiClient do starter já tem delete, além de get e post.exp. Converte-o para hora local (DateTime.fromMillisecondsSinceEpoch(exp * 1000)). É daqui a quanto tempo?class AuthService {
AuthService(this._api, this._storage);
final ApiClient _api;
final TokenStorage _storage; // flutter_secure_storage, da aula 16
static const _clientId = String.fromEnvironment('PDM_CLIENT_ID');
static const _secret = String.fromEnvironment('PDM_SECRET');
Future<String> obterToken() async {
final json = await _api.post('/v1/auth/token', body: {
'client_id': _clientId,
'secret': _secret,
});
final token = json['access_token'] as String;
await _storage.guardar(token);
return token;
}
Future<String> tokenAtual() async =>
await _storage.ler() ?? await obterToken();
}
tokenAtual() devolve o que está guardado ou pede um novo. Nunca há dois caminhos para obter token: só este.TokenStorage (Keychain/Keystore), nunca para shared_preferences nem para a base de dados. Foi a matéria da aula 16.String.fromEnvironment é resolvido em compilação: o valor fica no binário. Para a API do curso é aceitável (token de grupo, revogável); em produção o login é do utilizador.exp do payload (base64url do segundo segmento) e renovar antes de expirar. Poupa um 401 por sessão.bool expiraEm(String token, Duration margem) que descodifica o payload e compara exp com DateTime.now(). Usa-o em tokenAtual() para renovar quando faltar menos de um minuto. O teste test/auth_service_test.dart traz tokens de exemplo.AuthClient: Bearer em todos os pedidos, renovação em 401class AuthClient extends http.BaseClient {
AuthClient(this._inner, this._auth);
final http.Client _inner;
final AuthService _auth;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (request.url.path.endsWith('/auth/token')) return _inner.send(request);
var resposta = await _enviarCom(await _auth.tokenAtual(), request);
if (resposta.statusCode == 401) {
resposta = await _enviarCom(await _auth.obterToken(), request); // renova uma vez
}
return resposta;
}
Future<http.StreamedResponse> _enviarCom(String token, http.BaseRequest original) {
final pedido = http.Request(original.method, original.url)
..headers.addAll(original.headers)
..headers['Authorization'] = 'Bearer $token';
if (original is http.Request) pedido.bodyBytes = original.bodyBytes;
return _inner.send(pedido);
}
}
// main.dart: o ApiClient passa a usar o AuthClient por baixo
final api = ApiClient(baseUrl: baseUrl, client: AuthClient(http.Client(), auth));
BaseClient.send é o ponto por onde passam todos os pedidos do http. É o interceptor da aula 12, agora com código.send, tokenAtual() chamava obterToken(), que passava pelo send, que chamava tokenAtual(): recursão infinita.BaseRequest só pode ser enviado uma vez. Por isso _enviarCom cria um pedido novo com os mesmos método, URL, headers e corpo.dio é um QueuedInterceptor com onError: a fila evita que dez pedidos em paralelo com 401 peçam dez tokens novos. Aqui, o AuthService devia guardar o Future em curso para o mesmo efeito.MockClient: a primeira resposta é 401, a segunda 200. Verifica que o AuthClient fez três pedidos no total (original, token, repetição) e que o último tem o header Authorization com o token novo. Depois: dois 401 seguidos têm de resultar em ApiException(statusCode: 401), não num terceiro pedido.FavoritosRepository sobre /v1/me/favoritesabstract class FavoritosRepository {
Future<List<int>> listar();
Future<void> adicionar(int localidadeId);
Future<void> remover(int localidadeId);
}
class FavoritosRepositoryApi implements FavoritosRepository {
FavoritosRepositoryApi(this._api);
final ApiClient _api;
@override
Future<List<int>> listar() async {
final json = await _api.get('/v1/me/favorites') as List<dynamic>;
return json.map((e) => (e as Map<String, dynamic>)['locationId'] as int).toList();
}
@override
Future<void> adicionar(int localidadeId) =>
_api.post('/v1/me/favorites', body: {'locationId': localidadeId});
@override
Future<void> remover(int localidadeId) =>
_api.delete('/v1/me/favorites/$localidadeId');
}
FavoritosNotifier: UI otimista com rollbackclass FavoritosNotifier extends ChangeNotifier {
FavoritosNotifier(this._repo);
final FavoritosRepository _repo;
Set<int> _ids = {};
bool contem(int id) => _ids.contains(id);
Future<void> carregar() async {
_ids = (await _repo.listar()).toSet();
notifyListeners();
}
/// Devolve false se o servidor recusou e a alteração foi desfeita.
Future<bool> alternar(int id) async {
final antes = {..._ids};
final adicionar = !_ids.contains(id);
adicionar ? _ids.add(id) : _ids.remove(id); // 1. a UI muda já
notifyListeners();
try {
await (adicionar ? _repo.adicionar(id) : _repo.remover(id)); // 2. servidor
return true;
} catch (_) {
_ids = antes; // 3. rollback
notifyListeners();
return false;
}
}
}
class BotaoFavorito extends StatelessWidget {
const BotaoFavorito({super.key, required this.localidadeId});
final int localidadeId;
@override
Widget build(BuildContext context) {
final marcado = context.select<FavoritosNotifier, bool>((f) => f.contem(localidadeId));
return IconButton(
icon: Icon(marcado ? Icons.star : Icons.star_border),
tooltip: marcado ? 'Remover dos favoritos' : 'Adicionar aos favoritos',
onPressed: () async {
final ok = await context.read<FavoritosNotifier>().alternar(localidadeId);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Não foi possível guardar o favorito. Tenta de novo.')),
);
}
},
);
}
}
antes); se o servidor recusa, repõe-se e avisa-se. Nunca fica uma estrela acesa que o servidor não conhece.alternar devolve bool em vez de guardar a mensagem no notifier: o widget que disparou a ação é quem mostra o SnackBar, com context.mounted depois do await.context.select reconstrói o botão só quando este favorito muda, não quando qualquer um muda.POST não é repetido pelo ApiClient (aula 14); se falhar, o rollback é a resposta certa. Um DELETE repetido não faz mal: é idempotente.FavoritosRepositoryApi e o notifier no main, chama carregar() no arranque, e escreve o teste "alternar faz rollback quando o repository falha" com um fake cujo adicionar lança.class FavoritosScreen extends StatelessWidget {
const FavoritosScreen({super.key});
@override
Widget build(BuildContext context) {
final favoritos = context.watch<FavoritosNotifier>();
final todas = context.watch<PrevisaoNotifier>().localidades;
final lista = todas.where((l) => favoritos.contem(l.id)).toList();
if (lista.isEmpty) {
return const EstadoVazio(mensagem: 'Marca uma localidade com a estrela para a veres aqui.');
}
return RefreshIndicator(
onRefresh: () => context.read<FavoritosNotifier>().carregar(),
child: ListView(
children: [
for (final l in lista)
ListTile(
title: Text(l.nome),
subtitle: Text(l.ilha),
trailing: BotaoFavorito(localidadeId: l.id),
onTap: () => context.push('/localidade/${l.id}'),
),
],
),
);
}
}
class SessaoNotifier extends ChangeNotifier {
SessaoNotifier(this._storage, this._db, this._favoritos);
final TokenStorage _storage;
final BaseDados _db;
final FavoritosNotifier _favoritos;
Future<void> terminarSessao() async {
await _storage.apagar(); // 1. token fora do Keychain/Keystore
await _db.apagarTudo(); // 2. cache que pode ter dados do grupo
_favoritos.limpar(); // 3. estado em memória
notifyListeners(); // 4. o router leva ao ecrã de entrada
}
}
POST /v1/auth/revoke no servidor. Um token que só se apaga no cliente continua válido até expirar.go_router observa o SessaoNotifier via refreshListenable e o redirect manda para /entrar quando não há sessão.solucao/.FavoritosRepositoryApi, notifier otimista com rollback, ecrã de favoritos com pull-to-refresh.