Recursos & APIs

Autenticação OAuth 2.0 PKCE

Como autenticar usuários com segurança usando PKCE (RFC 7636), State criptográfico e Deep Linking (pandhora-app://).

Autenticação OAuth 2.0 PKCE

O Pandhora fornece uma API nativa e padronizada de OAuth 2.0 com PKCE (Proof Key for Code Exchange — RFC 7636) através de context.oauth.

Esse fluxo elimina totalmente a necessidade de embutir CLIENT_SECRET nos addons e dispensa a abertura de servidores HTTP locais na máquina do usuário.


🔄 Fluxo de Autenticação em 5 Passos

1. Addon (Desktop)
   │ context.oauth.generatePKCE() → gera codeVerifier + codeChallenge (SHA-256)
   │ context.oauth.generateState('discord') → gera state seguro (UUID)
   │ context.oauth.authorize({ authUrl, state })

2. Navegador Web do Usuário
   │ Abre a tela de consentimento do Provedor OAuth (Discord, Google, Jira, etc.)

3. Redirecionamento Web
   │ Provedor redireciona para a Landing Page:
   │ https://pandhoraapp.com.br/oauth/callback?code=AUTH_CODE&state=STATE

4. Deep Link do Sistema Operacional
   │ A Landing Page executa o protocolo seguro:
   │ pandhora-app://oauth/callback?code=AUTH_CODE&state=STATE

5. Addon (Desktop)
   │ AddonLoader valida o state, remove replay e resolve o authorization code
   │ Addon executa a troca segura (POST /token com code_verifier, sem secret)
   │ Salva o token estruturado no storage seguro (Keytar)

🛡️ Por que PKCE é 100% Seguro em Apps Desktop?

  1. Sem Segredos Expostos: Como o código do addon roda no dispositivo do usuário, qualquer CLIENT_SECRET embutido poderia ser extraído. O PKCE substitui o segredo por um desafio criptográfico único gerado em tempo de execução (code_challenge / code_verifier).
  2. Proteção Contra Interceptação de Deep Link: Mesmo que um aplicativo malicioso no sistema operacional capture o Deep Link contendo o code e o state, ele não conseguirá obter o token de acesso porque não possui o code_verifier (que permanece restrito à memória segura do Pandhora).
  3. Prevenção Contra CSRF e Replay: O state é gerado via crypto.randomUUID(), validado estritamente no callback e consumido em modo single-use (qualquer repetição é imediatamente rejeitada).

💻 Exemplo Completo de Implementação

import type { IAddon, AddonContext } from '@pandhora/sdk'

export default class DiscordOAuthAddon implements IAddon {
  public id = 'discord-oauth'
  public name = 'Discord OAuth Integration'
  public version = '1.0.0'

  public async handleLogin(context: AddonContext): Promise<void> {
    // 1. Gera par PKCE (RFC 7636) e state criptográfico
    const { codeVerifier, codeChallenge } = context.oauth.generatePKCE()
    const state = context.oauth.generateState('discord')

    const CLIENT_ID = '1372352088457220126' // Client ID Público
    const REDIRECT_URI =
      process.env.PANDHORA_OAUTH_REDIRECT_URI ||
      'http://localhost:3000/oauth/callback' // Em produção: https://pandhoraapp.com.br/oauth/callback

    const authUrl =
      `https://discord.com/api/oauth2/authorize?client_id=${CLIENT_ID}` +
      `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
      `&response_type=code&scope=identify%20rpc` +
      `&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${state}`

    console.log('[OAuth] Abrindo navegador para autorização...')

    // 2. Dispara a autorização e aguarda o Deep Link (com timeout de segurança)
    const result = await context.oauth.authorize({
      authUrl,
      state,
      timeoutMs: 120000, // 2 minutos
    })

    if (!result.code) {
      throw new Error('Código de autorização não recebido.')
    }

    console.log('[OAuth] Código recebido via Deep Link. Trocando pelo Token...')

    // 3. Troca do Authorization Code pelo Access Token via PKCE
    const params = new URLSearchParams({
      client_id: CLIENT_ID,
      grant_type: 'authorization_code',
      code: result.code,
      code_verifier: codeVerifier, // Prova de posse do desafio original
      redirect_uri: REDIRECT_URI,
    })

    const response = await fetch('https://discord.com/api/oauth2/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: params,
    })

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}))
      throw new Error(
        `Falha na autenticação: ${errorData.error_description || errorData.error || response.status}`,
      )
    }

    const tokenPayload = await response.json()
    if (!tokenPayload.access_token) {
      throw new Error('access_token não retornado pelo provedor OAuth.')
    }

    // 4. Normalização e persistência estruturada no Storage Criptografado
    const storedToken = {
      accessToken: tokenPayload.access_token,
      refreshToken: tokenPayload.refresh_token,
      tokenType: tokenPayload.token_type || 'Bearer',
      expiresAt: tokenPayload.expires_in
        ? new Date(Date.now() + tokenPayload.expires_in * 1000).toISOString()
        : undefined,
      scope: tokenPayload.scope,
    }

    await context.storage.set('tokenData', JSON.stringify(storedToken))
    await context.storage.set('accessToken', storedToken.accessToken)

    console.log(
      '[OAuth] Autenticação concluída e token armazenado com sucesso!',
    )
  }
}