Login em um site estático com OpenID Connect, OAuth 2.0 e PKCE

por Frank de Alcantara em 19/08/2026

Login em um site estático com OpenID Connect, OAuth 2.0 e PKCE

Colocar em uma página dois botões, Entrar com Google e Entrar com Microsoft, é fácil. O trabalho começa quando perguntamos o que acontecerá depois do clique. Quem confirmará a identidade da visitante? Onde viverá a conta usada pelo nosso aplicativo? Como o navegador provará, nos pedidos seguintes, que já existe uma sessão? E qual parte desse sistema pode existir em um site cujos arquivos são públicos?

Essas perguntas pertencem a camadas diferentes. Misturá-las produz soluções aparentemente simples, como guardar um token no localStorage ou publicar um Client Secret junto com o JavaScript. Nenhuma delas sobrevive a uma inspeção séria.

Vamos começar, portanto, antes do OAuth 2.0. Primeiro compararemos as alternativas de login e de sessão. Depois escolheremos a arquitetura adequada ao nosso caso: o GitHub Pages continuará publicando HTML, CSS e JavaScript, enquanto um Cloudflare Worker executará o protocolo OpenID Connect, manterá sessões em um banco D1 e entregará ao navegador apenas um cookie opaco. Google e Microsoft confirmarão a identidade. O Worker, não o navegador, guardará os segredos dos clientes.

Trilha do laboratório: a mesma fronteira em um único projeto

O artigo conservará essa arquitetura como referência de produção. No laboratório, aplicaremos o mesmo contrato de segurança com uma implantação mais curta: os arquivos públicos ficarão no Cloudflare Pages, as rotas dinâmicas serão Pages Functions e ambos compartilharão https://NOME-DO-PROJETO.pages.dev. O D1, os cookies opacos, PKCE, state, nonce e os dois provedores não mudam. Muda apenas a forma de hospedar e publicar. A integração entre GitHub e Pages substituirá o uso local de Node.js, npm, npx e Wrangler.

Não haverá uma lista artificial de exercícios no final. O próprio artigo será o exercício: cada seção acrescentará uma peça verificável ao sistema, e o tutorial terminará com testes que distinguem um botão bonito de uma autenticação funcional.

No exemplo, substitua equipe-exemplo, projetoexemplo.com.br, NOME-DO-PROJETO e todos os identificadores ilustrativos pelos valores do seu projeto. Nunca reutilize segredos mostrados em uma aula, captura de tela ou repositório.

1. Antes do protocolo: quais formas de login existem?

Quando uma visitante diz que deseja entrar, ela comprime três trabalhos em uma palavra. A autenticação confirma quem ela é. A conta do aplicativo liga essa identidade a dados e permissões locais. A sessão permite que os pedidos seguintes sejam reconhecidos sem repetir a autenticação a cada página.

Um cookie pode participar do terceiro trabalho. Um banco de dados pode participar do segundo e do terceiro. Nenhum dos dois, sozinho, prova que a pessoa controla uma conta Google, conhece uma senha ou possui uma chave de acesso.

1.1 Contas e senhas mantidas pelo próprio aplicativo

A alternativa clássica consiste em receber e-mail e senha, guardar no banco apenas um resumo criptográfico resistente a ataques de força bruta e criar uma sessão depois da conferência. Essa arquitetura oferece controle total sobre cadastro, papéis e políticas. O preço acompanha o controle: o aplicativo assume recuperação de senha, verificação de e-mail, proteção contra tentativas automatizadas, autenticação multifator, vazamentos de credenciais e atendimento a usuárias bloqueadas.

Para um sistema cujo negócio depende de credenciais próprias, esse custo pode ser necessário. Para um projeto didático publicado como site estático, ele acrescentaria uma superfície de segurança muito maior que o problema que pretendemos resolver.

1.2 Autenticação gerenciada por um serviço especializado

Serviços de identidade gerenciada oferecem telas de entrada, recuperação de conta, autenticação multifator, chaves de acesso e integração com provedores sociais. O site usa uma biblioteca ou um redirecionamento do fornecedor, enquanto o serviço mantém a parte sensível da identidade.

Essa opção reduz a quantidade de código próprio, mas não elimina decisões. Ainda precisamos escolher onde ficam os dados da conta do aplicativo, como a sessão chega ao navegador, quais domínios participam do fluxo, como exportar usuárias e quanto custa ultrapassar a faixa gratuita. Também passamos a depender da disponibilidade e das regras comerciais de outro fornecedor.

1.3 Login federado diretamente no navegador

Uma aplicação executada inteiramente no navegador pode atuar como cliente público. Nesse modelo, ela usa o fluxo com código de autorização e PKCE, troca o código por tokens e chama APIs diretamente. Não existe um segredo do cliente, chamado Client Secret nas interfaces dos provedores, porque qualquer segredo entregue ao navegador deixa de ser segredo.

O modelo é legítimo quando o provedor oferece o tipo de cliente, os redirecionamentos e o compartilhamento de recursos entre origens, ou CORS, de Cross-Origin Resource Sharing, necessário à troca. Contudo, os tokens ficam ao alcance do ambiente JavaScript. Um código malicioso executado na mesma origem pode roubar os tokens existentes ou iniciar outro fluxo. A RFC 10017 recomenda uma arquitetura com componente de servidor para aplicações sensíveis ou que tratem dados pessoais, exatamente para reduzir essa exposição.

Há ainda uma dificuldade concreta no nosso par de provedores. O fluxo de servidor documentado pelo Google exige um identificador do cliente, chamado Client ID, e um Client Secret. A Microsoft também exige segredo ou certificado quando o redirecionamento é do tipo Web e a troca ocorre em uma aplicação confidencial. Cadastrar uma aplicação Web e simplesmente omitir o segredo, como fazem alguns tutoriais, não transforma o Worker em cliente público. Apenas cria uma troca inválida.

1.4 Login federado com uma fronteira dinâmica

Podemos conservar o site estático e acrescentar uma fronteira dinâmica pequena. O navegador inicia o login no Worker. O Worker redireciona ao provedor, recebe o código, autentica o próprio cliente, valida o token de identidade e abre uma sessão local. Os tokens do provedor e os segredos permanentes nunca entram no JavaScript da página.

Essa organização é próxima do padrão backend for frontend, ou BFF, descrito pela RFC 10017. Como este artigo usa os provedores apenas para login e não chama Gmail, OneDrive ou Microsoft Graph, o Worker não precisa encaminhar uma API inteira. Ele assume somente as responsabilidades de OpenID Connect e de sessão.

1.5 Gateway de acesso diante do site

Também seria possível colocar um gateway de identidade diante de todo o domínio ou de caminhos específicos. Cloudflare Access e produtos equivalentes autenticam a visitante antes de entregar a aplicação. Essa solução é excelente para painéis internos e turmas fechadas, porque quase não exige código no site.

Entretanto, ela muda a experiência e a fronteira do projeto. O gateway protege a entrega da página. Nosso objetivo é manter a página pública e acrescentar uma identidade que os recursos dinâmicos possam reconhecer. Por isso não escolheremos o gateway.

As opções podem ser resumidas sem fingir que são equivalentes:

Arquitetura Quem verifica a identidade Onde existe estado do aplicativo Principal vantagem Principal custo
Senha própria O nosso backend Banco de usuárias e sessões Controle completo Operar todo o ciclo de credenciais
Serviço gerenciado Um fornecedor de identidade No fornecedor e, se necessário, no nosso banco Menos código sensível Dependência, limites e custo do serviço
Cliente público no navegador Google ou Microsoft Memória ou armazenamento do navegador Não exige backend próprio Tokens expostos ao ambiente JavaScript
Worker com OpenID Connect Google ou Microsoft Sessão e conta local sob nosso controle Tokens fora do navegador Exige uma pequena aplicação dinâmica
Gateway de acesso O gateway e o provedor configurado No gateway Protege um site inteiro com pouco código Menor controle sobre a experiência pública

A Figura 1 separa as duas decisões que mais costumam ser confundidas. Primeiro escolhemos quem comprova a identidade. Depois escolhemos onde o nosso site conservará a sessão resultante.

O mapa separa a origem da identidade das três formas de manter uma sessão. O caminho escolhido liga Google ou Microsoft ao Worker e termina em um cookie opaco no navegador associado a uma sessão no D1. Figura 1: O provedor confirma a identidade, mas a sessão pertence ao nosso site. O cookie transporta um identificador opaco e o D1 conserva o estado correspondente.

O navegador precisa enviar alguma credencial nos pedidos posteriores. Um cookie é um mecanismo apropriado para isso, especialmente quando recebe Secure, HttpOnly e uma política SameSite. A decisão seguinte será o significado do valor guardado nesse cookie.

Em uma sessão autocontida, o cookie carrega dados assinados e, quando houver informação sensível, também criptografados. O servidor não consulta um registro a cada pedido. Em compensação, revogar uma sessão antes da expiração é difícil, o tamanho do cookie é limitado e uma simples assinatura não esconde o conteúdo.

Em uma sessão mantida no servidor, o cookie leva somente um valor aleatório. O Worker calcula um resumo desse valor e procura a sessão em um banco. Podemos revogar uma sessão imediatamente, listar dispositivos e manter os dados pessoais fora do navegador. O custo é uma consulta ao armazenamento.

Uma terceira possibilidade é guardar tokens do provedor em localStorage, sessionStorage ou IndexedDB. Ela pertence ao modelo de cliente público no navegador e herda o risco de código malicioso na mesma origem. Não a usaremos.

Para o projeto, escolheremos um cookie opaco e uma sessão no D1. O D1 é um banco SQL gerenciado pela Cloudflare, com semântica de SQLite e acesso direto pelo Worker. O navegador receberá o valor aleatório. O banco guardará apenas o resumo desse valor, a identidade local e a expiração.

1.7 A decisão do projeto

Nosso desenho será, portanto:

  1. O GitHub Pages publicará todos os arquivos estáticos.
  2. Um Worker responderá apenas por /oauth/* e /api/*.
  3. Google e Microsoft executarão a autenticação por OpenID Connect.
  4. O Worker será um cliente confidencial, autenticado por valores guardados no armazenamento de segredos do Worker.
  5. O fluxo usará um código de autorização com PKCE, state e nonce.
  6. Uma transação curta e a sessão final serão mantidas no D1.
  7. O navegador receberá somente cookies opacos, nunca um token do Google ou da Microsoft.

Há uma consequência que merece um parágrafo próprio.

Login não torna privado um arquivo já publicado pelo GitHub Pages.

Qualquer HTML, PDF, imagem ou JSON na origem estática continua acessível por URL. A autenticação poderá personalizar a interface e proteger dados devolvidos pelo Worker. Para proteger o próprio documento estático, precisaríamos mover sua entrega para uma fronteira autenticada ou colocar um gateway diante dele.

Como ler a palavra Worker daqui em diante

Um Worker independente e uma Pages Function executam no ambiente do Cloudflare Workers. Por isso, os princípios sobre clientes confidenciais, Web Crypto, D1, segredos e cookies valem para as duas formas de implantação. Quando a diferença operacional importar, chamaremos explicitamente de Worker independente a tecnologia do tutorial principal e de Pages Function a tecnologia da prática.

2. OAuth 2.0 autoriza. OpenID Connect autentica

Agora podemos dar nome ao protocolo sem pedir que ele faça um trabalho para o qual não foi criado. OAuth 2.0 autoriza um cliente a acessar um recurso em nome de alguém. OpenID Connect, ou OIDC, acrescenta uma camada de identidade sobre OAuth 2.0. Como o nosso objetivo é saber quem entrou, usaremos OIDC com os escopos openid email profile.

2.1 Os papéis do fluxo

A visitante controla a conta e decide se continuará. O cliente é o Worker, porque é ele quem inicia e conclui o protocolo. O servidor de autorização é Google ou Microsoft. Um servidor de recursos seria uma API protegida, como Google UserInfo ou Microsoft Graph, mas não precisamos chamá-lo para estabelecer a sessão deste tutorial.

O GitHub Pages não assume nenhum desses papéis. Ele apenas entrega os arquivos que desenham a interface.

Essa separação impede que a senha atravesse o nosso sistema. A visitante autentica-se no domínio do provedor. O Worker recebe um código de autorização de uso único e o troca por tokens em uma comunicação de servidor para servidor.

2.2 Autenticação, conta local e autorização local

Depois de validar o token de identidade, ainda precisamos decidir quem pode fazer o quê no nosso site. O identificador local não será o e-mail, porque o e-mail pode mudar. Para o Google, a chave estável será a combinação do emissor com sub. Para a Microsoft multilocatária, a chave deverá incluir também o locatário indicado por tid, porque o mesmo valor de sujeito só tem significado dentro do emissor correspondente.

O e-mail e o nome servem para exibição. Papéis como estudante, professora ou administradora pertencem ao nosso banco. O provedor prova a identidade. O nosso aplicativo concede permissões.

2.3 Os tokens não são intercambiáveis

Objeto Pergunta respondida Destino neste projeto
id_token Quem se autenticou e para qual cliente? Validado pelo Worker e descartado depois de criar a sessão
access_token Qual API aceita quais operações? Não será exposto ao navegador nem usado neste tutorial
refresh_token Como obter novos tokens sem repetir o login? Não será solicitado
Cookie de sessão Qual sessão local acompanha este pedido? Navegador, como valor opaco protegido

Decodificar a parte central de um token Web JSON, ou JWT, não valida o token. O Worker só aceitará o id_token depois de verificar assinatura, emissor, audiência, expiração e nonce. Até essa verificação terminar, o conteúdo do token será dado não confiável.

3. Cliente público, cliente confidencial e segredos

Um cliente público executa em um ambiente que não consegue proteger uma credencial permanente, como o navegador ou um aplicativo instalado no dispositivo da usuária. Um cliente confidencial executa em um ambiente controlado pela equipe e consegue autenticar-se perante o provedor com um segredo ou uma chave privada.

Nosso HTML é público. Nosso Worker não é.

O Client ID pode aparecer em uma URL e no código, porque apenas identifica o cliente. O Client Secret autentica o cliente e deve existir somente no armazenamento de segredos do Worker. PKCE não muda essa classificação. Ele associa o código de autorização à transação que o iniciou, mas não substitui a credencial exigida pelo Google e pela Microsoft para clientes Web confidenciais.

3.1 O computador compartilhado continua sendo um risco

O cadastro dos clientes exige uma sessão administrativa no Google Cloud Console ou no Microsoft Entra. Em um computador de laboratório, a sessão, o histórico, a área de transferência e os arquivos baixados podem sobreviver à aula.

Por isso, faça o cadastro em uma janela privativa, não baixe arquivos de credenciais, envie os segredos diretamente ao Worker e encerre as sessões administrativas ao terminar. Para desenvolvimento local, use .dev.vars apenas em uma máquina confiável e mantenha .dev.vars* no .gitignore.

O segredo do aplicativo pode ser rotacionado. Uma senha pessoal vazada, uma sessão administrativa esquecida e um arquivo de credenciais abandonado são incidentes diferentes. O procedimento precisa impedir os três.

4. PKCE, state e nonce: três vínculos diferentes

No fluxo com código de autorização, o provedor retorna ao callback um code curto e de uso único. Esse código não é a sessão do site. Ele é uma credencial temporária que o Worker apresenta ao ponto de terminação de tokens.

PKCE, sigla de Proof Key for Code Exchange, liga esse código à transação que começou o fluxo. O Worker gera um code_verifier aleatório e calcula o desafio que será enviado ao provedor. A equação será dada por:

\[\text{code\_challenge} = \operatorname{Base64URL}\!\left(\operatorname{SHA256}(\text{code\_verifier})\right).\]

O método será S256. A codificação Base64URL remove o preenchimento = e usa caracteres seguros para URL.

O code_challenge segue no pedido de autorização. O code_verifier fica no registro temporário do D1. Na troca, o Worker envia o verificador. O provedor repete o cálculo e só aceita a troca quando o resultado coincide com o desafio original.

O state vincula a resposta ao navegador e à transação que iniciaram o pedido. O nonce, quando devolvido dentro do id_token, vincula o token de identidade à mesma transação. Embora as proteções se sobreponham em alguns modelos de ataque, vamos manter as três. Elas tornam o protocolo auditável e evitam depender de uma única hipótese do provedor.

A Figura 2 mostra o detalhe que a leitura serial esconde. O desafio percorre o canal visível do redirecionamento. O verificador permanece no servidor e reaparece apenas na troca direta com o provedor.

A sequência mostra o navegador pedindo login ao Worker, o Worker guardando o verificador no D1 e enviando apenas o desafio ao provedor. No retorno, o código só é trocado quando o Worker apresenta o verificador associado. Figura 2: Interceptar o código não basta. A troca exige o verificador que permaneceu associado à transação no Worker.

PKCE não valida o id_token, não substitui HTTPS, não concede permissões e não torna seguro um redirecionamento curinga. Também não elimina o Client Secret de um cliente Web confidencial.

5. A arquitetura completa do site

O mesmo hospedeiro público, www.projetoexemplo.com.br, apresentará duas origens lógicas. Pedidos de conteúdo continuarão até o GitHub Pages. Pedidos sob /oauth/* e /api/* serão interceptados por uma rota do Worker no Cloudflare.

A Figura 3 mostra as duas cadeias sem transformar o Worker em uma segunda hospedagem do site. Observe também onde os dados sensíveis param: segredos e sessões ficam do lado do Worker. O navegador conserva apenas identificadores opacos.

O navegador recebe arquivos estáticos do GitHub Pages através do Cloudflare. As rotas de autenticação seguem ao Worker, que conversa com Google ou Microsoft, lê segredos protegidos e associa cookies opacos a registros no D1. Figura 3: A publicação continua estática, mas a autenticação ganha uma fronteira dinâmica. Tokens e segredos não atravessam essa fronteira em direção ao navegador.

O fluxo completo terá estes passos:

  1. A visitante abre a página estática e escolhe um provedor.
  2. O navegador navega para /oauth/login/google ou /oauth/login/microsoft.
  3. O Worker cria state, nonce, code_verifier e um identificador de transação. Ele guarda os três primeiros no D1 e entrega ao navegador apenas o identificador opaco em um cookie temporário.
  4. O Worker redireciona a visitante ao provedor com code_challenge, state, nonce e o redirect_uri exato.
  5. O provedor autentica a visitante e retorna um code ao Worker.
  6. O Worker encontra a transação pelo cookie, confere state e troca o código usando code_verifier e Client Secret.
  7. O Worker valida o id_token, cria uma sessão no D1 e apaga a transação.
  8. O navegador recebe o cookie de sessão e volta à página estática.
  9. A página consulta /api/me. O Worker resolve o cookie no D1 e devolve apenas o perfil necessário à interface.

Esse é o contrato que os tutoriais seguintes implementarão.

5.1 Duas implantações para o mesmo contrato

A arquitetura da Figura 3 mantém o GitHub Pages como origem e coloca um Worker independente diante de caminhos específicos de um domínio controlado pela equipe. Essa separação é útil quando o site já está publicado e não queremos mover seus arquivos. Ela exige, contudo, uma zona DNS na Cloudflare e a configuração das rotas que interceptarão /oauth/* e /api/*.

O laboratório reduzirá a quantidade de infraestrutura sem alterar o protocolo. O próprio Cloudflare Pages servirá os arquivos da pasta public, enquanto a pasta functions produzirá as Pages Functions. Como os dois lados usarão o mesmo endereço pages.dev, as URLs relativas, o cookie restrito ao hospedeiro e a conferência de Origin continuarão funcionando sem CORS.

Decisão Referência de produção Trilha do laboratório
Conteúdo estático GitHub Pages Cloudflare Pages
Código dinâmico Worker independente Pages Functions
Endereço público Domínio da equipe Subdomínio gratuito pages.dev
Publicação Wrangler e rotas do Worker Integração Git entre GitHub e Pages
Configuração do D1 e dos segredos Arquivo e comandos locais Painel da Cloudflare
Validação do JWT Biblioteca jose Web Crypto e JWKS, sem dependências externas

Trilha do laboratório: o endereço precisa ser estável

Usaremos somente a implantação de produção, por exemplo https://oauth-aula-equipe-07.pages.dev. As implantações de prévia recebem outros hospedeiros. Cada uma exigiria novas URLs de retorno no Google e na Microsoft, portanto elas não participarão dos testes avaliados.

6. Tutorial I: registrar o cliente no Google

Abra o Google Cloud Console em uma janela privativa e crie um projeto para o aplicativo. Configure primeiro a tela de consentimento. Para um exercício com contas pessoais, use o público externo e mantenha o aplicativo em teste enquanto a turma ainda estiver desenvolvendo. Cadastre explicitamente as contas de teste.

Solicitaremos apenas openid, email e profile. Não peça Gmail, Drive ou Calendar para uma página que só precisa identificar a visitante. Cada permissão adicional aumenta o conteúdo da tela de consentimento e o impacto de um incidente.

Crie um cliente OAuth do tipo Aplicativo da Web. Cadastre os redirecionamentos exatos:

Ambiente URI de redirecionamento
Produção https://www.projetoexemplo.com.br/oauth/callback/google
Desenvolvimento http://localhost:8787/oauth/callback/google

Trilha do laboratório: retorno do Google pelo Pages

Cadastre apenas https://NOME-DO-PROJETO.pages.dev/oauth/callback/google, substituindo o nome pelo endereço real fornecido ao projeto. Não haverá retorno para localhost, porque a prática será testada na implantação HTTPS. O tipo continuará sendo Aplicativo da Web, e o Client Secret continuará existindo apenas no servidor.

Usaremos callbacks distintos por provedor. Além de simplificar o código, essa escolha ajuda a impedir ataques de confusão entre servidores de autorização.

Anote o Client ID. Copie o Client Secret uma única vez para o gerenciador de segredos do Worker. Não o coloque no HTML, no wrangler.jsonc, no repositório nem em uma mensagem da equipe.

O Google publica sua configuração OIDC em https://accounts.google.com/.well-known/openid-configuration. Uma biblioteca de OIDC deve obter dali o emissor, o ponto de terminação de tokens e o endereço das chaves públicas, respeitando os cabeçalhos de cache.

7. Tutorial II: registrar o cliente na Microsoft

Abra o Microsoft Entra admin center e crie um registro de aplicativo. A escolha de contas aceitas define a autoridade usada pelo fluxo:

Público desejado Autoridade
Apenas contas do diretório da instituição Identificador fixo do locatário
Contas de qualquer organização organizations
Contas organizacionais e pessoais common
Apenas contas pessoais Microsoft consumers

Para o exercício aberto, usaremos common. Em Authentication, adicione uma plataforma Web, não SPA, com estes redirecionamentos:

Ambiente URI de redirecionamento
Produção https://www.projetoexemplo.com.br/oauth/callback/microsoft
Desenvolvimento http://localhost:8787/oauth/callback/microsoft

Trilha do laboratório: retorno da Microsoft pelo Pages

Cadastre apenas https://NOME-DO-PROJETO.pages.dev/oauth/callback/microsoft como plataforma Web. O endereço deverá coincidir exatamente com o domínio de produção do Pages. A autoridade common, organizations, consumers ou o identificador do locatário continuará obedecendo ao público escolhido.

Crie um Client Secret e envie seu valor diretamente ao armazenamento de segredos do Worker. A Microsoft recomenda certificados para clientes confidenciais de maior exigência. O segredo simétrico mantém o tutorial curto, mas precisa de data de expiração, responsável e procedimento de rotação.

Em API permissions, mantenha apenas as permissões de OpenID Connect necessárias ao login. Não pediremos offline_access, porque não queremos um token de renovação, identificado como refresh_token. Também não pediremos permissões do Microsoft Graph.

Uma aplicação multilocatária precisa validar mais que a assinatura. O tid deve ser um GUID, o iss deve coincidir exatamente com https://login.microsoftonline.com/{tid}/v2.0, e o emissor associado à chave de assinatura também precisa ser compatível com esse locatário. Use uma biblioteca que implemente a validação dos metadados multilocatários da Microsoft. Não aceite apenas qualquer emissor que comece com login.microsoftonline.com.

8. Tutorial III: criar o Worker e o banco de sessões

O tutorial usará TypeScript porque esse é o ambiente nativo do Worker e porque os tipos gerados pelo Wrangler ajudam a detectar uma variável ou uma ligação D1 ausente. O código do Worker pertence à implementação do site. Ele não será executado pelo GitHub Pages.

Trilha do laboratório: não execute os comandos desta seção

As Seções 8.1 a 8.9 documentam a implementação com um Worker independente, TypeScript, Wrangler e jose. Elas permanecem no artigo porque representam uma opção válida para um projeto mantido por uma equipe de desenvolvimento. Na aula prática, usaremos JavaScript sem pacotes externos, a integração Git do Pages e o painel da Cloudflare. A Seção 8.10 fará a correspondência entre as duas implementações.

8.1 Criar o projeto

Em uma máquina de desenvolvimento confiável, crie um Worker do tipo Worker only, em TypeScript, sem publicar imediatamente:

npm create cloudflare@latest -- oauth-worker
cd oauth-worker
npm install jose
npx wrangler@latest d1 create oauth-sessions

Quando o Wrangler oferecer a inclusão da ligação ao D1, aceite e use o nome DB. O arquivo wrangler.jsonc terá esta estrutura, com o identificador real devolvido pelo comando:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "oauth-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-12",
  "compatibility_flags": ["nodejs_compat"],
  "vars": {
    "PUBLIC_BASE_URL": "https://www.projetoexemplo.com.br",
    "GOOGLE_CLIENT_ID": "000000000000-exemplo.apps.googleusercontent.com",
    "MICROSOFT_CLIENT_ID": "11111111-2222-3333-4444-555555555555",
    "MICROSOFT_AUTHORITY": "common"
  },
  "secrets": {
    "required": ["GOOGLE_CLIENT_SECRET", "MICROSOFT_CLIENT_SECRET"]
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "oauth-sessions",
      "database_id": "SUBSTITUA-PELO-ID-DO-D1"
    }
  ],
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  }
}

Os Client IDs e a URL pública são configuração, não segredo. Os Client Secrets serão ligações secretas. Depois de ajustar o arquivo, gere novamente os tipos:

npx wrangler types

8.2 Criar as tabelas

Crie uma migração:

npx wrangler d1 migrations create oauth-sessions criar_sessoes_oauth

No arquivo SQL gerado, crie duas tabelas. A primeira dura apenas o tempo do redirecionamento. A segunda conserva a sessão local:

CREATE TABLE oauth_transactions (
  id_hash TEXT PRIMARY KEY,
  provider TEXT NOT NULL CHECK (provider IN ('google', 'microsoft')),
  state_hash TEXT NOT NULL,
  nonce TEXT NOT NULL,
  code_verifier TEXT NOT NULL,
  expires_at INTEGER NOT NULL
);

CREATE INDEX oauth_transactions_expiry ON oauth_transactions (expires_at);

CREATE TABLE sessions (
  id_hash TEXT PRIMARY KEY,
  issuer TEXT NOT NULL,
  subject TEXT NOT NULL,
  tenant_id TEXT,
  email TEXT,
  display_name TEXT,
  expires_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL
);

CREATE INDEX sessions_expiry ON sessions (expires_at);

Teste a migração localmente antes de aplicá-la à base remota:

npx wrangler d1 migrations apply oauth-sessions --local
npx wrangler d1 migrations apply oauth-sessions --remote

O banco não guarda o valor bruto dos cookies. Guarda SHA-256 desses valores. Se alguém ler uma cópia da tabela, ainda precisará encontrar a pré-imagem aleatória para fabricar um cookie utilizável.

8.3 Cadastrar os segredos

Execute os comandos separadamente e cole cada valor no prompt protegido:

npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler secret put MICROSOFT_CLIENT_SECRET

O valor não deve aparecer na própria linha de comando. Para o desenvolvimento local, crie .dev.vars com os mesmos nomes e adicione .dev.vars* e .env* ao .gitignore antes do primeiro teste.

8.4 Gerar os valores aleatórios

O verificador PKCE pode ser a representação Base64URL de 32 bytes aleatórios. O resultado tem 43 caracteres, dentro do intervalo definido pela RFC 7636:

function base64url(bytes: Uint8Array): string {
  let binary = "";
  for (const value of bytes) binary += String.fromCharCode(value);
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
}

function randomToken(): string {
  return base64url(crypto.getRandomValues(new Uint8Array(32)));
}

async function sha256(value: string): Promise<Uint8Array> {
  return new Uint8Array(await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(value)
  ));
}

async function hashForDatabase(value: string): Promise<string> {
  return Array.from(await sha256(value), (byte) =>
    byte.toString(16).padStart(2, "0")
  ).join("");
}

async function pkceChallenge(verifier: string): Promise<string> {
  return base64url(await sha256(verifier));
}

Essa construção evita escolher caracteres com uma redução modular enviesada e produz diretamente um verificador válido.

8.5 Começar o login

Ao receber /oauth/login/{provedor}, o Worker gerará o verificador, o desafio, state, nonce e um identificador opaco de transação. O banco receberá o resumo do identificador, o resumo de state, nonce, o verificador e uma expiração de dez minutos.

O cookie temporário deverá ser semelhante a:

Set-Cookie: __Host-oauth-tx=VALOR_ALEATORIO; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600

Usaremos SameSite=Lax somente nesse cookie, porque ele precisa voltar em uma navegação GET iniciada no domínio do provedor. Não definiremos Domain.

O pedido de autorização conterá:

const authorizeUrl = new URL(provider.authorizationEndpoint);
authorizeUrl.searchParams.set("client_id", provider.clientId);
authorizeUrl.searchParams.set("redirect_uri", provider.redirectUri);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("scope", "openid email profile");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("nonce", nonce);
authorizeUrl.searchParams.set("code_challenge", challenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");

O redirect_uri será específico do provedor e deverá coincidir caractere a caractere com o cadastro.

8.6 Receber o callback e trocar o código

No callback, o Worker seguirá uma ordem rígida:

  1. Recusará respostas que contenham error ou não contenham code e state.
  2. Exigirá o cookie __Host-oauth-tx.
  3. Calculará o resumo do cookie e carregará uma transação ainda não expirada.
  4. Comparará o resumo de state com o valor guardado.
  5. Apagará a transação antes de concluir o fluxo, impedindo reutilização local.
  6. Enviará o código ao ponto de terminação de tokens com o verificador e o Client Secret do provedor correto.

O corpo da troca terá esta forma:

const body = new URLSearchParams({
  grant_type: "authorization_code",
  code,
  client_id: provider.clientId,
  client_secret: provider.clientSecret,
  redirect_uri: provider.redirectUri,
  code_verifier: transaction.codeVerifier
});

const response = await fetch(provider.tokenEndpoint, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body
});

Não registre body, code, tokens nem cookies nos logs. Um erro pode registrar o provedor, a etapa, um identificador novo de correlação e o código público devolvido pelo provedor.

8.7 Validar o token de identidade

Use uma biblioteca OIDC ou JWT mantida, como jose, com as chaves obtidas do documento de descoberta. Essas chaves costumam ser publicadas em um conjunto Web de chaves JSON, ou JWKS, de JSON Web Key Set. A validação deverá exigir, no mínimo:

  1. Uma assinatura aceita e uma chave compatível com o emissor.
  2. O algoritmo de assinatura esperado pelo provedor.
  3. O emissor exato segundo a configuração usada.
  4. A audiência igual ao Client ID daquele provedor.
  5. exp no futuro e tratamento coerente de iat.
  6. O nonce idêntico ao registro da transação.
  7. Para a Microsoft multilocatária, a coerência entre iss, tid e o emissor da chave.

Somente depois dessa sequência o Worker poderá usar sub, email e name. O e-mail não concederá um papel administrativo por comparação de texto. A autorização local consultará a identidade estável armazenada pelo aplicativo.

8.8 Criar a sessão opaca

Depois da validação, gere outro randomToken(), calcule seu resumo e grave uma sessão de oito horas no D1. O cookie levará o valor bruto:

Set-Cookie: __Host-session=VALOR_ALEATORIO; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800

A rota /api/me calculará o resumo do cookie, buscará uma sessão não expirada e devolverá apenas os campos necessários. A rota /oauth/logout validará que o cabeçalho Origin coincide exatamente com PUBLIC_BASE_URL, apagará a linha do D1 e expirará o cookie.

Use Cache-Control: no-store em callbacks, respostas de sessão e erros de autenticação. Limpe também o cookie temporário depois de qualquer sucesso ou falha terminal. Registros expirados no D1 podem ser removidos periodicamente por uma tarefa agendada.

8.9 Ligar a página estática à sessão

A página não executará OAuth. Ela navegará ao Worker para iniciar o login e consultará o próprio domínio para descobrir se existe sessão:

<!doctype html>
<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Projeto da equipe</title>
</head>
<body>
  <main>
    <h1>Projeto da equipe</h1>
    <p id="status">Verificando a sessão...</p>
    <p>
      <a href="/oauth/login/google">Entrar com Google</a>
      <a href="/oauth/login/microsoft">Entrar com Microsoft</a>
    </p>
    <form method="post" action="/oauth/logout">
      <button type="submit">Sair</button>
    </form>
  </main>
  <script>
    fetch("/api/me", { credentials: "same-origin" })
      .then((response) => response.ok ? response.json() : null)
      .then((user) => {
        const status = document.getElementById("status");
        status.textContent = user
          ? `Sessão de ${user.email ?? user.displayName}.`
          : "Nenhuma sessão neste navegador.";
      });
  </script>
</body>
</html>

O formulário de saída muda estado. Na implementação final, o Worker deverá conferir Origin. Se o site aceitar pedidos de outras origens, acrescente uma defesa contra falsificação de requisição entre sites, ou CSRF, de Cross-Site Request Forgery, em vez de relaxar essa verificação.

Sair encerra a sessão do nosso site. Não encerra a sessão global da visitante no Google nem na Microsoft. Em um computador compartilhado, ela ainda precisa sair do provedor e fechar a janela privativa.

8.10 Implementar a mesma fronteira com Pages Functions

No laboratório, criaremos um repositório GitHub sem package.json, node_modules ou wrangler.jsonc. A pasta public conterá a página, e a pasta functions ficará ao lado dela na raiz. O Cloudflare Pages transforma o caminho de cada arquivo de função em uma rota dinâmica:

oauth-pages-lab/
├── public/
│   ├── index.html
│   ├── app.js
│   └── styles.css
└── functions/
    ├── _shared/
    │   ├── crypto.js
    │   ├── cookies.js
    │   ├── providers.js
    │   └── oidc.js
    ├── api/
    │   ├── health.js
    │   └── me.js
    └── oauth/
        ├── login/
        │   └── [provider].js
        ├── callback/
        │   └── [provider].js
        └── logout.js

O arquivo functions/oauth/login/[provider].js responderá tanto por /oauth/login/google quanto por /oauth/login/microsoft. A Pages Function encontrará o trecho variável em context.params.provider. A mesma regra valerá para os dois caminhos de retorno. Os módulos em _shared não criarão rotas públicas. Eles concentrarão a codificação Base64URL, os cookies, a configuração dos provedores e a validação OIDC.

O banco continuará usando exatamente as tabelas oauth_transactions e sessions da Seção 8.2. A diferença será operacional: criaremos o D1 em Storage & Databases > D1 SQL Database, colaremos o esquema no Console e ligaremos o banco ao projeto em Settings > Bindings. A ligação receberá o nome DB, portanto o código a acessará por context.env.DB.

Também moveremos a configuração para Settings > Variables and Secrets. PUBLIC_BASE_URL, GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID e MICROSOFT_AUTHORITY serão variáveis comuns. GOOGLE_CLIENT_SECRET e MICROSOFT_CLIENT_SECRET serão marcados como valores criptografados. Uma nova implantação será necessária depois de criar ou alterar essas ligações.

Trilha do laboratório: nenhuma dependência de Node.js

A validação não importará jose. O módulo oidc.js obterá o documento de descoberta e o JWKS com fetch(), selecionará a chave pelo kid, importará a JWK com crypto.subtle.importKey() e verificará a assinatura RS256 com crypto.subtle.verify(). Depois da assinatura, ainda precisará conferir o algoritmo permitido, o emissor, a audiência, a expiração, o instante de emissão e o nonce. Para a Microsoft multilocatária, também conferirá a relação entre iss, tid e o emissor anunciado. Decodificar o JWT sem completar essa sequência não autentica ninguém.

Os valores aleatórios, os resumos SHA-256 e o desafio PKCE também usarão Web Crypto. Assim, a implementação trocará uma biblioteca de alto nível por primitivas nativas. Essa escolha reduz os pré-requisitos da aula, mas aumenta a responsabilidade didática: cada verificação que a biblioteca faria precisa aparecer explicitamente no código e nos testes.

O que não muda entre as duas trilhas

A transação continuará expirando em dez minutos, a sessão continuará durando oito horas, o D1 continuará guardando resumos dos cookies e os atributos Secure, HttpOnly e SameSite continuarão obrigatórios. A implantação muda. O contrato de segurança, não.

9. Tutorial IV: publicar, rotear e testar

Antes de publicar, valide a configuração sem alterar a versão em produção:

npx wrangler types --check
npx tsc --noEmit
npx wrangler deploy --dry-run

Depois aplique as migrações remotas, cadastre os segredos e publique o Worker. No painel do Cloudflare, associe as rotas específicas:

www.projetoexemplo.com.br/oauth/*
www.projetoexemplo.com.br/api/*

Essas rotas precisam preceder a origem estática. A página / continuará no GitHub Pages. Um pedido a /oauth/login/google não procurará um arquivo inexistente no repositório. Ele chegará ao Worker.

9.1 Publicar a trilha do laboratório

No laboratório, conectaremos o repositório pelo painel em Workers & Pages > Create application > Pages > Connect to Git. A ramificação de produção será main, o conjunto de ferramentas será None, o comando de construção ficará vazio e o diretório publicado será public. Cada alteração confirmada em main iniciará uma nova implantação e conservará o mesmo endereço pages.dev.

Não usaremos o envio por arrastar e soltar. Esse método publica arquivos estáticos, mas não compila a pasta functions. A integração Git reconhecerá as Pages Functions e manterá o código dinâmico junto da versão correspondente da página.

Trilha do laboratório: publique primeiro, teste depois

Aguarde o estado Success no painel antes de abrir o endereço de produção. Confira se as Functions foram reconhecidas, se a ligação DB existe e se as variáveis pertencem ao ambiente de produção. Os testes ocorrerão no navegador, com Network e Preserve log ativados. Não haverá servidor local nem callback em localhost.

9.2 Testar o início do fluxo

Primeiro confirme que a origem continua disponível:

curl.exe -I https://www.projetoexemplo.com.br/

Depois inspecione o início do login sem seguir o redirecionamento:

curl.exe -I https://www.projetoexemplo.com.br/oauth/login/google
curl.exe -I https://www.projetoexemplo.com.br/oauth/login/microsoft

Cada resposta deverá ser 302, definir __Host-oauth-tx e apontar ao provedor correto. A URL em Location deverá conter response_type=code, code_challenge_method=S256, openid e o callback específico do provedor. Ela não deverá conter Client Secret nem code_verifier.

9.3 Testar falhas, não apenas o caminho feliz

Um callback sem o cookie temporário deverá falhar. Um state alterado deverá falhar. Reutilizar a mesma transação deverá falhar. Um token com audiência de outro Client ID deverá falhar. Uma sessão expirada deverá produzir 401 em /api/me.

Depois do login real, confirme no navegador que __Host-session possui Secure, HttpOnly, SameSite=Strict, Path=/ e nenhum atributo Domain. Confirme também que id_token, access_token e Client Secrets não aparecem no armazenamento Web, no HTML, na URL, no histórico nem nos logs do Worker.

Por fim, teste o logout. A linha correspondente deverá desaparecer do D1, o cookie deverá expirar e /api/me deverá voltar a responder 401. Reutilizar o cookie antigo não poderá restaurar a sessão.

10. Diagnóstico por fronteira

Erros de autenticação ficam menos misteriosos quando os classificamos pela fronteira em que ocorreram:

Sintoma Fronteira provável O que conferir
redirect_uri_mismatch Cadastro e pedido de autorização Correspondência exata do callback e do tipo Web
invalid_client na troca Worker e provedor Client ID, Client Secret, expiração e codificação
invalid_grant Transação PKCE Código reutilizado, verificador, expiração e URI de redirecionamento
state inválido Navegador, cookie e D1 Cookie temporário, expiração e transação correta
Assinatura ou emissor inválido Validação OIDC Descoberta, JWKS, iss, aud, kid, tid e relógio
/api/me retorna 401 Sessão local Cookie, resumo no D1 e expiração
/api/health retorna 404 no laboratório Implantação do Pages Pasta functions na raiz e implantação de main concluída
DB está indefinido no laboratório Ligação do Pages Nome DB, ambiente correto e nova implantação
A página estática abre sem login Hospedagem Isso é esperado. Arquivos do GitHub Pages continuam públicos

Esperar alguns minutos pode ser necessário enquanto uma configuração externa se propaga. Esperar não corrige um callback digitado de forma diferente, um segredo expirado ou uma audiência errada.

11. Critérios de aceitação

O sistema estará completo quando outra pessoa, sem usar a sessão administrativa da equipe, conseguir verificar todos estes resultados:

  • A página pública continua sendo servida pelo GitHub Pages.
  • Cada botão chega ao provedor correto com um callback próprio e PKCE S256.
  • O Worker usa o Client Secret correspondente sem expô-lo ao repositório ou ao navegador.
  • O callback recusa uma transação ausente, expirada, adulterada ou reutilizada.
  • O id_token só produz sessão depois da validação completa.
  • O cookie de sessão é opaco, Secure, HttpOnly, SameSite=Strict, restrito ao hospedeiro e associado a um registro no D1.
  • /api/me devolve apenas o perfil necessário e usa Cache-Control: no-store.
  • O logout remove a sessão do D1 e não aceita uma origem diferente.
  • Nenhum arquivo estático é tratado como privado apenas porque a interface escondeu um link.
  • As sessões administrativas do Google e da Microsoft são encerradas no computador compartilhado.

Esses critérios demonstram o protocolo e as fronteiras de confiança. Uma captura dos botões, sozinha, demonstra apenas CSS.

Critérios adicionais da trilha do laboratório

O endereço público termina em .pages.dev. Os arquivos estáticos e as Pages Functions compartilham a mesma origem. A publicação parte do GitHub, sem instalação local de Node.js, npm, npx ou Wrangler. O D1, as variáveis e os segredos são configurados no painel. A validação do JWT usa Web Crypto e não aceita o conteúdo antes de verificar a assinatura e as declarações esperadas.

12. O que construímos

Começamos separando decisões que pareciam uma só. Google e Microsoft confirmam a identidade. O Worker, independente ou produzido por Pages Functions, autentica o cliente, valida o resultado e aplica as regras do nosso aplicativo. O D1 conserva transações e sessões. O cookie apenas transporta um identificador opaco. Na referência de produção, o GitHub Pages continua publicando arquivos públicos. Na prática, o Cloudflare Pages assume essa entrega em um endereço pages.dev, sem mudar a natureza pública dos arquivos.

Também abandonamos uma promessa tentadora: a de que PKCE eliminaria qualquer Client Secret. Isso é verdade para clientes públicos, mas não para os clientes Web confidenciais usados neste desenho. Aqui, PKCE e o segredo defendem fronteiras diferentes. O segredo autentica o Worker perante o provedor. PKCE vincula o código à transação que o iniciou.

O resultado é pequeno o bastante para uma disciplina e explícito o bastante para uma auditoria. Não inventamos um servidor de senhas, não expusemos tokens ao navegador e não confundimos a sessão do site com a sessão do provedor.

A comparação entre as duas implantações também deixa uma regra útil. Ferramentas diferentes podem preservar a mesma arquitetura quando conservam as fronteiras. O Worker independente oferece controle de publicação e roteamento para um site que continuará no GitHub Pages. As Pages Functions reduzem os pré-requisitos do laboratório ao reunir página e rotas dinâmicas em uma única origem. Em ambas, o provedor prova a identidade e o nosso aplicativo continua responsável por sua própria sessão.

Uma página pode continuar estática. A confiança ao redor dela, não.

Referências

CLOUDFLARE. Bindings. Cloudflare Pages Docs, 2026. Disponível em: https://developers.cloudflare.com/pages/functions/bindings/. Acesso em: 13 set. 2026.

CLOUDFLARE. Cloudflare D1. Cloudflare Docs, 2026. Disponível em: https://developers.cloudflare.com/d1/. Acesso em: 12 set. 2026.

CLOUDFLARE. Functions: get started. Cloudflare Pages Docs, 2026. Disponível em: https://developers.cloudflare.com/pages/functions/get-started/. Acesso em: 13 set. 2026.

CLOUDFLARE. Git integration. Cloudflare Pages Docs, 2026. Disponível em: https://developers.cloudflare.com/pages/get-started/git-integration/. Acesso em: 13 set. 2026.

CLOUDFLARE. Secrets. Cloudflare Workers Docs, 2026. Disponível em: https://developers.cloudflare.com/workers/configuration/secrets/. Acesso em: 12 set. 2026.

CLOUDFLARE. Web Crypto. Cloudflare Workers Docs, 2026. Disponível em: https://developers.cloudflare.com/workers/runtime-apis/web-crypto/. Acesso em: 13 set. 2026.

CLOUDFLARE. Workers best practices. Cloudflare Workers Docs, 2026. Disponível em: https://developers.cloudflare.com/workers/best-practices/workers-best-practices/. Acesso em: 12 set. 2026.

GOOGLE. OpenID Connect. Google for Developers, 2026. Disponível em: https://developers.google.com/identity/openid-connect/openid-connect. Acesso em: 12 set. 2026.

IETF. RFC 6749: The OAuth 2.0 Authorization Framework. 2012. Disponível em: https://www.rfc-editor.org/rfc/rfc6749. Acesso em: 12 set. 2026.

IETF. RFC 7636: Proof Key for Code Exchange by OAuth Public Clients. 2015. Disponível em: https://www.rfc-editor.org/rfc/rfc7636. Acesso em: 12 set. 2026.

IETF. RFC 9700: Best Current Practice for OAuth 2.0 Security. 2025. Disponível em: https://www.rfc-editor.org/rfc/rfc9700. Acesso em: 12 set. 2026.

IETF. RFC 10017: OAuth 2.0 for Browser-Based Applications. 2026. Disponível em: https://www.rfc-editor.org/rfc/rfc10017. Acesso em: 12 set. 2026.

MICROSOFT. Access tokens in the Microsoft identity platform. Microsoft Learn, 2026. Disponível em: https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens. Acesso em: 12 set. 2026.

MICROSOFT. Microsoft identity platform and OAuth 2.0 authorization code flow. Microsoft Learn, 2026. Disponível em: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow. Acesso em: 12 set. 2026.

OPENID FOUNDATION. OpenID Connect Core 1.0 incorporating errata set 2. 2023. Disponível em: https://openid.net/specs/openid-connect-core-1_0.html. Acesso em: 12 set. 2026.

(Updated: )