Segurança

OAuth 2.0

Guia completo de OAuth 2.0 e OpenID Connect: grant types, tokens, PKCE, Keycloak, Spring Security e Node.js

O que é OAuth 2.0?

OAuth 2.0 (RFC 6749) é um framework de autorização que permite que uma aplicação acesse recursos em nome de um usuário sem precisar das suas credenciais. Ele delega a decisão de acesso ao dono do recurso.

Importante: OAuth 2.0 é sobre autorização (o que você pode fazer), não autenticação (quem você é). Para autenticação, use OpenID Connect (OIDC) em cima do OAuth 2.0.


Papéis (Roles)

PapelDescriçãoExemplo
Resource OwnerUsuário dono dos dadosVocê, o usuário final
ClientAplicação que quer acessar os dadosSeu app React/mobile
Authorization ServerEmite tokens após autenticaçãoKeycloak, Auth0, Google
Resource ServerAPI que protege os recursosSua API Spring Boot

Tokens

Access Token

  • Credencial de curta duração para acessar recursos protegidos
  • Enviado no header Authorization: Bearer <token>
  • Pode ser opaco (random string) ou estruturado (JWT)
  • Vida típica: 5-15 minutos

Refresh Token

  • Credencial de longa duração para obter novos access tokens sem novo login
  • Nunca enviado para a Resource Server
  • Fica no Authorization Server
  • Vida típica: dias a meses

ID Token (OIDC)

  • JWT que contém informações de identidade do usuário
  • Retornado junto com o access token no fluxo Authorization Code com scope: openid
  • Destinado ao client, não à API
  • Contém: sub, name, email, picture, email_verified, etc.

Token Response Típico

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "8xLOxBtZp8",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "scope": "openid profile email"
}

Escopos (Scopes)

Escopos definem o nível de acesso solicitado. O usuário aprova os escopos na tela de consentimento.

Escopos OIDC padrão:

ScopeClaims retornadas
openidsub (obrigatório para OIDC)
profilename, family_name, given_name, picture, locale
emailemail, email_verified
addressaddress
phonephone_number, phone_number_verified
offline_accessHabilita refresh token

Escopos customizados (sua API):

read:users         # leitura de usuários
write:users        # criação/atualização de usuários
admin              # acesso administrativo
orders:read        # leitura de pedidos

Grant Types

Quando usar qual?

CenárioGrant Type
App web/mobile com usuário logandoAuthorization Code + PKCE
Comunicação máquina-a-máquina (M2M)Client Credentials
TV, CLI, dispositivo sem browserDevice Authorization
Usuário confia totalmente no clientResource Owner Password (depreciado)
Apenas frontend (SPA legado)Implicit (depreciado — use Authorization Code + PKCE)

Authorization Code + PKCE (Fluxo Completo)

Este é o fluxo principal para qualquer aplicação que envolva usuário. PKCE (Proof Key for Code Exchange) protege contra interceptação do código.

Passo a Passo

1. GERAR CODE VERIFIER E CHALLENGE (client)
   code_verifier = base64url(random(32 bytes))
   code_challenge = base64url(SHA-256(code_verifier))

2. REDIRECIONAR PARA O AUTHORIZATION SERVER
   GET https://auth.server.com/authorize?
     response_type=code
     &client_id=meu-app
     &redirect_uri=https://app.com/callback
     &scope=openid profile email
     &state=RANDOM_STATE_VALUE
     &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
     &code_challenge_method=S256

3. USUÁRIO AUTENTICA NO AUTHORIZATION SERVER
   (login, MFA, consentimento)

4. AUTHORIZATION SERVER REDIRECIONA DE VOLTA
   GET https://app.com/callback?
     code=AUTH_CODE_123
     &state=RANDOM_STATE_VALUE

5. TROCAR CÓDIGO POR TOKENS (server-side)
   POST https://auth.server.com/token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code
   &code=AUTH_CODE_123
   &redirect_uri=https://app.com/callback
   &client_id=meu-app
   &client_secret=SECRET  (apenas para confidential clients)
   &code_verifier=CODE_VERIFIER_ORIGINAL

6. RECEBER TOKENS
   {
     "access_token": "...",
     "refresh_token": "...",
     "id_token": "...",
     "expires_in": 900
   }

Implementação em JavaScript (SPA)

import crypto from 'crypto'; // Node.js — no browser use window.crypto

// Passo 1: Gerar PKCE
async function generatePKCE() {
  // Code verifier: string aleatória de 43-128 chars
  const verifier = base64UrlEncode(crypto.randomBytes(32));

  // Code challenge: SHA-256 do verifier, encodado em base64url
  const challenge = base64UrlEncode(
    crypto.createHash('sha256').update(verifier).digest()
  );

  return { verifier, challenge };
}

function base64UrlEncode(buffer: Buffer): string {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Passo 2: Iniciar fluxo de login
async function login() {
  const { verifier, challenge } = await generatePKCE();
  const state = base64UrlEncode(crypto.randomBytes(16));

  // Salvar para usar no callback
  sessionStorage.setItem('pkce_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'meu-spa',
    redirect_uri: 'https://app.com/callback',
    scope: 'openid profile email offline_access',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `https://auth.server.com/authorize?${params}`;
}

// Passo 4-6: Processar callback
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
  const error = params.get('error');

  if (error) {
    throw new Error(`OAuth erro: ${error} - ${params.get('error_description')}`);
  }

  // Validar state para prevenir CSRF
  const savedState = sessionStorage.getItem('oauth_state');
  if (state !== savedState) {
    throw new Error('State inválido — possível ataque CSRF');
  }

  const verifier = sessionStorage.getItem('pkce_verifier');
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');

  // Trocar código por tokens (via backend para não expor client_secret)
  const response = await fetch('/api/auth/callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, code_verifier: verifier }),
  });

  const tokens = await response.json();
  // Armazenar access_token em memória, refresh_token em httpOnly cookie
  return tokens;
}

Servidor Node.js — Troca do código

import express from 'express';
import axios from 'axios';

const router = express.Router();

router.post('/api/auth/callback', async (req, res) => {
  const { code, code_verifier } = req.body;

  try {
    // Troca código por tokens no authorization server
    const tokenResponse = await axios.post(
      'https://auth.server.com/token',
      new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: 'https://app.com/callback',
        client_id: process.env.OAUTH_CLIENT_ID!,
        client_secret: process.env.OAUTH_CLIENT_SECRET!, // apenas confidential clients
        code_verifier,
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    const { access_token, refresh_token, id_token, expires_in } = tokenResponse.data;

    // Refresh token em httpOnly cookie
    res.cookie('refresh_token', refresh_token, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
      path: '/api/auth',
    });

    // Retorna access token e id token para o cliente
    res.json({ access_token, id_token, expires_in });

  } catch (err) {
    res.status(400).json({ error: 'Falha ao trocar código por tokens' });
  }
});

Client Credentials (Machine-to-Machine)

Para comunicação entre serviços sem usuário envolvido.

Serviço A (Client)              Authorization Server
     |                                   |
     |-- POST /token ------------------>|
     |   grant_type=client_credentials  |
     |   client_id=servico-a            |
     |   client_secret=SECRET           |
     |   scope=read:orders              |
     |                                  |-- valida credenciais
     |<-- access_token -----------------|
     |                                  |

Serviço A                       Serviço B (Resource Server)
     |                                   |
     |-- GET /orders ------------------->|
     |   Authorization: Bearer <token>   |
     |                                  |-- valida token (introspect ou JWT)
     |<-- 200 OK + dados ---------------|

Implementação em Java (WebClient)

import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.web.reactive.function.client.WebClient;

@Service
public class OrderServiceClient {

    private final WebClient webClient;
    private final OAuth2AuthorizedClientManager authorizedClientManager;

    // Spring Security gerencia automaticamente o token e o refresh
    public List<Order> getOrders() {
        OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
            .withClientRegistrationId("servico-b")  // definido em application.yaml
            .principal("servico-a")
            .build();

        var authorizedClient = authorizedClientManager.authorize(request);
        String token = authorizedClient.getAccessToken().getTokenValue();

        return webClient.get()
            .uri("https://servico-b/orders")
            .headers(h -> h.setBearerAuth(token))
            .retrieve()
            .bodyToFlux(Order.class)
            .collectList()
            .block();
    }
}
# application.yaml — configuração do client credentials
spring:
  security:
    oauth2:
      client:
        registration:
          servico-b:
            provider: keycloak
            client-id: servico-a
            client-secret: ${OAUTH_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: read:orders
        provider:
          keycloak:
            token-uri: https://keycloak.example.com/realms/meu-realm/protocol/openid-connect/token

Implementação em Python (httpx)

import httpx
from datetime import datetime, timedelta
import threading

class OAuth2ClientCredentials:
    """Gerencia tokens para client credentials flow com cache automático."""

    def __init__(self, token_url: str, client_id: str, client_secret: str, scopes: list[str]):
        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.scopes = scopes
        self._token: str | None = None
        self._expires_at: datetime | None = None
        self._lock = threading.Lock()

    def get_token(self) -> str:
        with self._lock:
            # Renova 60 segundos antes de expirar
            if self._token and self._expires_at and datetime.now() < self._expires_at - timedelta(seconds=60):
                return self._token

            self._refresh_token()
            return self._token  # type: ignore

    def _refresh_token(self):
        response = httpx.post(
            self.token_url,
            data={
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "scope": " ".join(self.scopes),
            },
        )
        response.raise_for_status()
        data = response.json()

        self._token = data["access_token"]
        self._expires_at = datetime.now() + timedelta(seconds=data["expires_in"])

# Uso
client = OAuth2ClientCredentials(
    token_url="https://auth.server.com/token",
    client_id="meu-servico",
    client_secret="SECRET",
    scopes=["read:orders", "write:invoices"],
)

with httpx.Client() as http:
    response = http.get(
        "https://api.exemplo.com/orders",
        headers={"Authorization": f"Bearer {client.get_token()}"},
    )

Device Authorization Grant (Device Flow)

Para dispositivos sem browser ou com entrada limitada (TV, CLI, IoT).

1. Dispositivo solicita device code
   POST /device/authorize
   client_id=meu-tv-app
   scope=openid profile

   Resposta:
   {
     "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
     "user_code": "WDJB-MJHT",
     "verification_uri": "https://auth.server.com/device",
     "expires_in": 1800,
     "interval": 5
   }

2. Dispositivo mostra para o usuário:
   "Acesse https://auth.server.com/device e insira o código WDJB-MJHT"

3. Dispositivo faz polling enquanto espera
   POST /token
   grant_type=urn:ietf:params:oauth:grant-type:device_code
   device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
   client_id=meu-tv-app

   → Resposta durante espera: {"error": "authorization_pending"}
   → Após aprovação: { access_token, refresh_token, ... }
import httpx, time

def device_flow_login(token_url: str, client_id: str, scope: str = "openid profile"):
    # Passo 1: obter device code
    resp = httpx.post(
        token_url.replace("/token", "/device/authorize"),
        data={"client_id": client_id, "scope": scope},
    )
    resp.raise_for_status()
    device = resp.json()

    print(f"\nAcesse: {device['verification_uri']}")
    print(f"Código: {device['user_code']}\n")

    # Passo 3: polling
    interval = device.get("interval", 5)
    while True:
        time.sleep(interval)

        token_resp = httpx.post(
            token_url,
            data={
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "device_code": device["device_code"],
                "client_id": client_id,
            },
        )
        data = token_resp.json()

        if "access_token" in data:
            return data

        error = data.get("error")
        if error == "authorization_pending":
            continue
        elif error == "slow_down":
            interval += 5  # aumenta o intervalo
        elif error == "expired_token":
            raise TimeoutError("Código expirou. Tente novamente.")
        elif error == "access_denied":
            raise PermissionError("Acesso negado pelo usuário.")
        else:
            raise RuntimeError(f"Erro inesperado: {data}")

OpenID Connect (OIDC)

OIDC é uma camada de identidade em cima do OAuth 2.0. Adiciona:

  • ID Token (JWT com dados do usuário)
  • UserInfo Endpoint (mais dados do usuário via access token)
  • Discovery Document (.well-known/openid-configuration)

ID Token — Estrutura

{
  "iss": "https://auth.server.com/realms/meu-realm",
  "sub": "user-uuid-123",
  "aud": "meu-client-id",
  "exp": 1700003600,
  "iat": 1700000000,
  "auth_time": 1699999900,
  "nonce": "n-0S6_WzA2Mj",
  "acr": "1",
  "at_hash": "MTIzNDU2Nzg5MA",
  "name": "Rafael Marques",
  "given_name": "Rafael",
  "family_name": "Marques",
  "email": "rafael@example.com",
  "email_verified": true,
  "locale": "pt-BR",
  "picture": "https://example.com/avatar.jpg"
}

O ID Token é para o client identificar quem o usuário é. Não deve ser enviado para a API como access token.

UserInfo Endpoint

# Obter informações adicionais do usuário
GET https://auth.server.com/userinfo
Authorization: Bearer <access_token>

# Resposta
{
  "sub": "user-uuid-123",
  "name": "Rafael Marques",
  "email": "rafael@example.com",
  "email_verified": true,
  "updated_at": 1699999900
}

Discovery Document

# Autodiscovery — encontrar todos os endpoints automaticamente
GET https://auth.server.com/realms/meu-realm/.well-known/openid-configuration

# Resposta inclui:
{
  "issuer": "https://auth.server.com/realms/meu-realm",
  "authorization_endpoint": "https://auth.server.com/..../authorize",
  "token_endpoint": "https://auth.server.com/..../token",
  "userinfo_endpoint": "https://auth.server.com/..../userinfo",
  "jwks_uri": "https://auth.server.com/..../certs",
  "end_session_endpoint": "https://auth.server.com/..../logout",
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "id_token_signing_alg_values_supported": ["RS256", "ES256"]
}

Token Introspection e Validação

Para tokens opacos (não-JWT), o Resource Server precisa perguntar ao Authorization Server se o token é válido.

Token Introspection (RFC 7662)

POST https://auth.server.com/introspect
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

token=ACCESS_TOKEN_AQUI

# Resposta
{
  "active": true,
  "sub": "user-uuid-123",
  "username": "rafael",
  "email": "rafael@example.com",
  "scope": "read:orders write:orders",
  "client_id": "meu-app",
  "exp": 1700003600,
  "iat": 1700000000
}

# Ou se inválido/expirado
{ "active": false }

Validação Local de JWT (JWKS)

Para tokens JWT, o Resource Server pode validar localmente usando as chaves públicas:

// Spring Security — configuração do Resource Server
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwkSetUri("https://auth.server.com/realms/meu-realm/protocol/openid-connect/certs")
                )
            );

        return http.build();
    }

    // Customizar extração de roles do JWT
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthoritiesClaimName("realm_access.roles");
        converter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
        return jwtConverter;
    }
}

Keycloak — Configuração Completa

Estrutura do Keycloak

Keycloak Instance
└── Realm (tenant isolado)
    ├── Clients (suas aplicações)
    │   ├── meu-spa (public, Authorization Code + PKCE)
    │   ├── meu-backend-api (bearer-only ou confidential)
    │   └── servico-worker (confidential, client_credentials)
    ├── Users
    ├── Groups
    ├── Roles
    │   ├── Realm Roles (ex: admin, user, moderador)
    │   └── Client Roles (ex: meu-backend:read, meu-backend:write)
    └── Identity Providers (Google, GitHub, LDAP, SAML)

Criando Realm via Admin API

# Autenticar como admin
curl -X POST https://keycloak.example.com/realms/master/protocol/openid-connect/token \
  -d "client_id=admin-cli&username=admin&password=admin&grant_type=password" \
  | jq .access_token

# Criar realm
curl -X POST https://keycloak.example.com/admin/realms \
  -H "Authorization: Bearer ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "realm": "meu-produto",
    "enabled": true,
    "displayName": "Meu Produto",
    "accessTokenLifespan": 900,
    "refreshTokenMaxReuse": 0,
    "revokeRefreshToken": true
  }'

Configuração de Client para SPA

{
  "clientId": "meu-spa",
  "enabled": true,
  "publicClient": true,
  "standardFlowEnabled": true,
  "implicitFlowEnabled": false,
  "directAccessGrantsEnabled": false,
  "rootUrl": "https://app.exemplo.com",
  "redirectUris": [
    "https://app.exemplo.com/callback",
    "http://localhost:5173/callback"
  ],
  "webOrigins": [
    "https://app.exemplo.com",
    "http://localhost:5173"
  ],
  "attributes": {
    "pkce.code.challenge.method": "S256"
  }
}

Configuração de Client para Backend API (Resource Server)

{
  "clientId": "meu-backend-api",
  "enabled": true,
  "publicClient": false,
  "bearerOnly": true,
  "standardFlowEnabled": false
}

Configuração de Client para M2M (Client Credentials)

{
  "clientId": "servico-worker",
  "enabled": true,
  "publicClient": false,
  "serviceAccountsEnabled": true,
  "standardFlowEnabled": false,
  "directAccessGrantsEnabled": false
}

Mapper de Roles no Token

Por padrão o Keycloak inclui roles assim no token:

{
  "realm_access": {
    "roles": ["admin", "default-roles-meu-produto"]
  },
  "resource_access": {
    "meu-backend-api": {
      "roles": ["read", "write"]
    }
  }
}

Para incluir roles como lista flat (mais simples):

// Adicionar Protocol Mapper ao client:
{
  "name": "roles-flat",
  "protocol": "openid-connect",
  "protocolMapper": "oidc-usermodel-realm-role-mapper",
  "config": {
    "claim.name": "roles",
    "jsonType.label": "String",
    "multivalued": "true",
    "access.token.claim": "true"
  }
}

Spring Security + OAuth2

Resource Server Completo

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // habilita @PreAuthorize
public class SecurityConfig {

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuerUri;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").hasAuthority("SCOPE_read:products")
                .requestMatchers(HttpMethod.POST, "/api/products/**").hasAuthority("SCOPE_write:products")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );

        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder
            .withIssuerLocation(issuerUri)
            .build();

        // Validações adicionais
        OAuth2TokenValidator<Jwt> defaultValidator = JwtValidators.createDefaultWithIssuer(issuerUri);
        OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
            JwtClaimNames.AUD,
            aud -> aud != null && aud.contains("meu-backend-api")
        );

        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            defaultValidator, audienceValidator
        ));

        return decoder;
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter rolesConverter = new JwtGrantedAuthoritiesConverter();
        // Extrai roles do campo customizado
        rolesConverter.setAuthoritiesClaimName("roles");
        rolesConverter.setAuthorityPrefix("ROLE_");

        JwtGrantedAuthoritiesConverter scopesConverter = new JwtGrantedAuthoritiesConverter();
        // scopesConverter usa "scope" por padrão com prefixo "SCOPE_"

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(
            jwt -> {
                var roles = rolesConverter.convert(jwt);
                var scopes = scopesConverter.convert(jwt);
                var all = new java.util.ArrayList<>(roles);
                all.addAll(scopes);
                return all;
            }
        );

        return converter;
    }
}

Usando @PreAuthorize

@RestController
@RequestMapping("/api/users")
public class UserController {

    // Requer ROLE_admin
    @GetMapping
    @PreAuthorize("hasRole('admin')")
    public List<UserDTO> listUsers() { ... }

    // Requer scope read:users
    @GetMapping("/{id}")
    @PreAuthorize("hasAuthority('SCOPE_read:users')")
    public UserDTO getUser(@PathVariable String id) { ... }

    // Combinação: admin OU dono do recurso
    @GetMapping("/{id}/profile")
    @PreAuthorize("hasRole('admin') or #id == authentication.name")
    public ProfileDTO getProfile(@PathVariable String id) { ... }

    // Acesso ao JWT completo
    @GetMapping("/me")
    public UserDTO getMe(@AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();
        String email = jwt.getClaimAsString("email");
        List<String> roles = jwt.getClaimAsStringList("roles");
        return userService.findById(userId);
    }
}

application.yaml para Resource Server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/meu-realm
          # jwk-set-uri: alternativa se issuer-uri não funcionar
          # jwk-set-uri: https://keycloak.example.com/realms/meu-realm/protocol/openid-connect/certs

Implementação Node.js

express-openid-connect (Okta/Auth0 SDK — simples)

npm install express-openid-connect express
const { auth, requiresAuth } = require('express-openid-connect');
const express = require('express');

const app = express();

app.use(auth({
  authRequired: false,  // permite rotas públicas
  auth0Logout: true,
  baseURL: 'http://localhost:3000',
  clientID: process.env.CLIENT_ID,
  issuerBaseURL: process.env.ISSUER_BASE_URL,  // ex: https://auth.server.com
  secret: process.env.SESSION_SECRET,
  clientSecret: process.env.CLIENT_SECRET,
  authorizationParams: {
    scope: 'openid profile email offline_access',
    audience: 'https://api.exemplo.com',
  },
}));

// Rota pública
app.get('/', (req, res) => {
  res.send(req.oidc.isAuthenticated() ? 'Logado!' : 'Deslogado');
});

// Rota protegida
app.get('/profile', requiresAuth(), (req, res) => {
  res.json(req.oidc.user);
});

passport-oauth2 (mais controle)

npm install passport passport-oauth2 express-session
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import session from 'express-session';
import express from 'express';

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
}));

app.use(passport.initialize());
app.use(passport.session());

passport.use(new OAuth2Strategy(
  {
    authorizationURL: 'https://auth.server.com/authorize',
    tokenURL: 'https://auth.server.com/token',
    clientID: process.env.CLIENT_ID!,
    clientSecret: process.env.CLIENT_SECRET!,
    callbackURL: 'http://localhost:3000/auth/callback',
    scope: ['openid', 'profile', 'email'],
    pkce: true,  // ativa PKCE automaticamente
    state: true, // ativa state anti-CSRF
  },
  (accessToken, refreshToken, profile, done) => {
    // Aqui você busca/cria o usuário no banco
    return done(null, { accessToken, refreshToken, profile });
  }
));

passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user: any, done) => done(null, user));

app.get('/auth/login', passport.authenticate('oauth2'));

app.get('/auth/callback',
  passport.authenticate('oauth2', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/')
);

app.get('/protected',
  (req, res, next) => req.isAuthenticated() ? next() : res.redirect('/auth/login'),
  (req, res) => res.json(req.user)
);

Validação de JWT Bearer Token (Middleware Node.js)

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: 'https://auth.server.com/realms/meu-realm/protocol/openid-connect/certs',
  cache: true,
  cacheMaxAge: 600000,  // 10 min
});

function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
  client.getSigningKey(header.kid, (err, key) => {
    const signingKey = key?.getPublicKey();
    callback(err, signingKey);
  });
}

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization;

  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Token ausente' });
  }

  const token = auth.slice(7);

  jwt.verify(
    token,
    getKey,
    {
      algorithms: ['RS256'],
      issuer: 'https://auth.server.com/realms/meu-realm',
      audience: 'https://api.exemplo.com',
    },
    (err, decoded) => {
      if (err) {
        return res.status(401).json({ error: err.message });
      }
      (req as any).user = decoded;
      next();
    }
  );
}

PKCE em Detalhes

PKCE (Proof Key for Code Exchange — RFC 7636) foi criado para proteger o Authorization Code Flow em clients públicos (SPAs, apps mobile) que não podem guardar um client_secret.

Por que é necessário?

Sem PKCE — Ataque de interceptação:
1. App solicita authorization code
2. Código é retornado via redirect_uri
3. Malware/outro app intercepta o redirect e captura o código
4. Usa o código para obter tokens (sem precisar de client_secret)

Com PKCE — Impossível reutilizar o código:
1. App gera code_verifier (secreto, fica no app)
2. App envia apenas o hash (code_challenge) para o authorization server
3. Código interceptado é inútil — o interceptor não tem o code_verifier original
4. Apenas quem tem o code_verifier consegue trocar o código por tokens

Implementação Detalhada

import os
import hashlib
import base64
import re

def generate_code_verifier(length: int = 64) -> str:
    """
    RFC 7636 §4.1: code_verifier deve ter entre 43 e 128 chars
    Charset: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
    """
    # Gera bytes aleatórios e converte para base64url (sem padding)
    token = base64.urlsafe_b64encode(os.urandom(length)).decode('utf-8')
    # Remove o padding '='
    return token.rstrip('=')[:128]

def generate_code_challenge(verifier: str) -> str:
    """
    RFC 7636 §4.2: S256 method
    code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
    """
    digest = hashlib.sha256(verifier.encode('ascii')).digest()
    return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')

# Exemplo
verifier = generate_code_verifier()
challenge = generate_code_challenge(verifier)

print(f"verifier: {verifier}")
print(f"challenge: {challenge}")

# No authorization request:
# ?code_challenge={challenge}&code_challenge_method=S256

# No token request:
# &code_verifier={verifier}

Boas Práticas e Armadilhas

Boas Práticas

1. Sempre use PKCE em authorization code flow (obrigatório para public clients)
2. Valide o parâmetro "state" no callback para prevenir CSRF
3. Mantenha access tokens com vida curta (≤ 15 min)
4. Use "offline_access" scope explicitamente para receber refresh tokens
5. Implemente rotação de refresh tokens no authorization server
6. Valide iss e aud em todo token recebido pela API
7. Use discovery document (.well-known) para autodescobrir endpoints
8. Cache as chaves públicas (JWKS) — não busque a cada request
9. Implemente logout adequado: limpe tokens E encerre sessão no auth server
10. Para SPAs: access token em memória, refresh token em httpOnly cookie

Armadilhas Comuns

❌ Usar Implicit Flow em SPAs novas
   → Use Authorization Code + PKCE (implicit está depreciado no OAuth 2.1)

❌ Não validar state no callback
   → Permite CSRF na autenticação

❌ Aceitar redirect_uri dinâmicos sem validação
   → Registre os redirect URIs exatos no authorization server

❌ Usar access token como id token
   → São para públicos diferentes. Access token é para a API, id token para o client.

❌ Não verificar audience (aud) na API
   → Token emitido para outro client seria aceito pela sua API

❌ Colocar client_secret em apps mobile/SPA
   → São apps públicos. Use PKCE sem client_secret.

❌ Não implementar logout no auth server
   → Usuário "deslogar" no app, mas sessão SSO continua ativa no Keycloak

❌ Fazer introspection em todo request sem cache
   → Enorme latência. Cache o resultado por alguns segundos ou use JWT local.

Checklist de Implementação

Authorization Server (Keycloak):

  • Realm configurado com access token lifetime ≤ 15 min
  • Refresh token rotation habilitada
  • Clients com redirect URIs exatos registrados
  • Clients públicos sem client_secret com PKCE obrigatório
  • Roles e scopes mapeados corretamente nos tokens
  • HTTPS obrigatório (disallow HTTP exceto localhost)

Resource Server (API):

  • Valida assinatura JWT via JWKS endpoint
  • Valida iss, aud, exp
  • Cache de chaves públicas (JWKS)
  • Autorização granular por scope e role
  • Não aceita tokens sem Bearer prefix

Client (Frontend/App):

  • PKCE implementado (code_verifier gerado com entropia suficiente)
  • State validado no callback
  • Access token em memória (não localStorage)
  • Refresh token em httpOnly cookie (gerenciado pelo backend)
  • Logout limpa tokens E encerra sessão no auth server
  • Tratamento de erros de token expirado com refresh automático