OAuth 2.1 e PKCE: fluxo de autorização moderno para SPAs e apps mobile

1. Por que OAuth 2.1? Principais mudanças em relação ao OAuth 2.0

O OAuth 2.1 consolida anos de experiência prática com o protocolo OAuth 2.0, eliminando fluxos inseguros e padronizando práticas que já eram recomendadas. As mudanças mais significativas incluem:

Eliminação do Implicit Grant — O fluxo implícito, que retornava tokens diretamente na URL de redirecionamento, foi removido por ser vulnerável a vazamento de tokens via histórico do navegador, logs de servidor e ataques de interceptação. Qualquer aplicação que antes usava Implicit Grant deve migrar para Authorization Code com PKCE.

Proibição do Resource Owner Password Credentials Grant — Este fluxo, que exigia que o usuário fornecesse suas credenciais diretamente ao cliente, foi removido por incentivar práticas inseguras e violar o princípio de delegação do OAuth. Aplicações legadas precisam adotar fluxos baseados em redirecionamento.

PKCE obrigatório para clients públicos — O Proof Key for Code Exchange, antes opcional, tornou-se requisito obrigatório para todos os clients que não podem armazenar um client secret com segurança (SPAs e apps mobile). Isso elimina a falsa sensação de segurança de usar um client secret embutido no código-fonte.

2. PKCE: o coração da segurança em clients públicos

O PKCE adiciona uma camada de proteção contra ataques de interceptação do código de autorização. O fluxo funciona com dois valores críticos:

Geração do code_verifier e code_challenge — O cliente gera uma string aleatória de 43 a 128 caracteres (o code_verifier). Em seguida, calcula o code_challenge usando o método S256 (SHA-256 hash + Base64URL) ou o método plain (apenas para ambientes restritos):

// Geração do code_verifier (43-128 caracteres alfanuméricos)
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

// Método S256: SHA-256 hash do code_verifier, codificado em Base64URL
code_challenge = base64url(sha256(code_verifier))
// Resultado: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

Fluxo passo a passo — O cliente envia o code_challenge na requisição de autorização. O servidor de autorização o armazena temporariamente. Ao trocar o código pelos tokens, o cliente envia o code_verifier original. O servidor recalcula o hash e compara com o code_challenge armazenado. Se coincidirem, a troca é autorizada.

Por que PKCE substituiu o Client Secret — Em SPAs e apps mobile, o client secret está inevitavelmente exposto (no código JavaScript baixado pelo navegador ou no binário do app). O PKCE prova que o mesmo cliente que iniciou o fluxo está completando a troca, sem depender de segredos estáticos.

3. Fluxo completo do Authorization Code Grant com PKCE

Início da autorização — O cliente redireciona o usuário para o endpoint de autorização com os parâmetros obrigatórios:

GET /authorize?
  response_type=code
  &client_id=seu-client-id
  &redirect_uri=https://seuapp.com/callback
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &state=xyz123protecaocsrf
  &scope=openid%20profile%20email

Troca do código pelos tokens — Após o usuário autorizar, o servidor redireciona para a redirect_uri com o código de autorização. O cliente então faz uma requisição POST para o token endpoint:

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=oCodigoDeAutorizacao
&redirect_uri=https://seuapp.com/callback
&client_id=seu-client-id
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Tratamento de erros comuns — O servidor pode retornar erros como:
- invalid_grant: código expirado (geralmente 10 minutos) ou já utilizado
- expired_code: o código ultrapassou o tempo de vida configurado
- mismatch: o code_verifier não corresponde ao code_challenge original

4. Implementação prática em SPAs

Armazenamento seguro do code_verifier — Nunca armazene o code_verifier em localStorage ou sessionStorage. Mantenha-o em memória (variável JavaScript) ou, se necessário, em um cookie HTTP-only com curta duração:

// Exemplo de fluxo completo em SPA
// 1. Gerar code_verifier e code_challenge
const generateCodeVerifier = () => {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64url(array);
};

const generateCodeChallenge = async (verifier) => {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64url(new Uint8Array(digest));
};

// 2. Iniciar autorização
const startAuth = async () => {
  const codeVerifier = generateCodeVerifier();
  sessionStorage.setItem('code_verifier', codeVerifier); // Apenas para exemplo
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'seu-client-id',
    redirect_uri: window.location.origin + '/callback',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: crypto.randomUUID()
  });

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

// 3. Trocar código por tokens
const exchangeCode = async (code) => {
  const codeVerifier = sessionStorage.getItem('code_verifier');
  sessionStorage.removeItem('code_verifier');

  const response = await fetch('https://auth.server.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: window.location.origin + '/callback',
      client_id: 'seu-client-id',
      code_verifier: codeVerifier
    })
  });

  return response.json(); // { access_token, refresh_token, id_token }
};

Proteção CSRF com state — O parâmetro state deve ser um valor único e imprevisível, armazenado em memória antes do redirecionamento. Ao receber o callback, compare o state retornado com o armazenado para evitar ataques de falsificação.

5. Implementação prática em Apps Mobile

iOS com ASWebAuthenticationSession — A Apple fornece uma API nativa que gerencia o fluxo de autenticação em uma sessão separada do navegador principal:

// Swift - iOS
import AuthenticationServices

let authURL = URL(string: "https://auth.server.com/authorize?response_type=code&client_id=seu-client-id&redirect_uri=meuapp://callback&code_challenge=...&code_challenge_method=S256&state=...")!

let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "meuapp") { callbackURL, error in
    guard let callbackURL = callbackURL else { return }
    // Extrair o código de autorização da URL de callback
    let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)
    let code = components?.queryItems?.first(where: { $0.name == "code" })?.value
    // Trocar código por tokens usando URLSession
}

session.prefersEphemeralWebBrowserSession = true
session.start()

Android com Chrome Custom Tabs — No Android, a abordagem recomendada é usar Custom Tabs com AppAuth:

// Kotlin - Android com AppAuth
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationService

val authRequest = AuthorizationRequest.Builder(
    serviceConfiguration,
    "seu-client-id",
    ResponseTypeValues.CODE,
    Uri.parse("meuapp://callback")
)
.setCodeVerifier(codeVerifier) // Gerado previamente
.setCodeVerifierChallenge(codeChallenge)
.setCodeVerifierChallengeMethod("S256")
.setState(state)
.build()

val authService = AuthorizationService(this)
val intent = authService.getAuthorizationRequestIntent(authRequest)
startActivityForResult(intent, AUTH_REQUEST_CODE)

Gerenciamento de deep links — Configure o app para receber callbacks via deep link (ex: meuapp://callback?code=...). No iOS, configure o URL scheme no Info.plist. No Android, adicione um intent filter no AndroidManifest.xml para capturar o URI personalizado.

6. Refresh Tokens e renovação de sessão sem fricção

Rotação de refresh tokens — Sempre que um refresh token é usado para obter novos access tokens, o servidor deve emitir um novo refresh token e invalidar o anterior. Isso limita o impacto de vazamentos:

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=oRefreshTokenAtual
&client_id=seu-client-id

Detecção de reuso — Se um refresh token já rotacionado for usado novamente (indicando possível roubo), o servidor deve invalidar todos os tokens da sessão, forçando nova autenticação.

Estratégias para evitar logout indesejado — Em mobile, armazene o refresh token no Keychain (iOS) ou Keystore (Android). Em SPAs, use um cookie HTTP-only com flag Secure e SameSite=Strict. Configure o refresh token com expiração longa (dias ou semanas) enquanto o access token expira em minutos.

7. Boas práticas de segurança e armadilhas comuns

Validação de iss e aud no JWT — Ao decodificar o access token (se for JWT), sempre verifique:
- iss (issuer): deve corresponder ao domínio do servidor de autorização esperado
- aud (audience): deve conter o client_id da sua aplicação

Proteção contra interceptação de redirect URIs — Registre URIs exatas no servidor de autorização, sem curingas. Evite padrões como https://*.meusite.com/callback que permitem subdomínios maliciosos.

Mitigação de ataques comuns:
- Mix-up attack: sempre valide o issuer no token endpoint
- Downgrade attack: force o uso de HTTPS e rejeite conexões inseguras
- Token leakage: nunca logue tokens completos; registre apenas os últimos 4 caracteres para debugging

A adoção do OAuth 2.1 com PKCE representa o estado da arte em segurança para aplicações modernas. Ao eliminar fluxos inseguros e padronizar proteções como PKCE e rotação de refresh tokens, o protocolo oferece uma base sólida para autenticação e autorização em SPAs e apps mobile.

Referências