Este post faz parte de uma série sobre a construção de um sistema de referência para autenticação e autorização com OAuth2 e OpenID Connect (OIDC) usando Spring Boot e Next.js. O objetivo da série é cobrir as principais funcionalidades de uma aplicação web típica: recursos públicos, recursos protegidos por login, controle de acesso por perfil, e login social via Google (SSO). Neste post vamos abordar o ponto de partida: a construção de um Authorization Server mínimo, com recursos in-memory.
Visão geral - o que você vai aprender
Neste primeiro post, vamos construir o Authorization Server (AS) — o componente central do ecossistema OAuth2, responsável por autenticar usuários e emitir os tokens que controlam o acesso ao sistema.
Vamos utilizar, por enquanto, recursos in-memory para simplificar a implementação, tais como usuários, aplicações clientes e chave RSA. Em estudos futuros, vamos adicionar os devidos repositórios para lidar com esses recursos.
Ao final deste post, teremos um AS mínimo e funcional com:
- Servidor rodando na porta 9000
- Tela de login padrão do Spring Security
- Dois usuários in-memory (maria e alex)
- Um cliente OAuth2 registrado (o futuro frontend Next.js)
- Fluxo Authorization Code com PKCE
- Suporte a OpenID Connect (OIDC)
- Discovery document (
/.well-known/openid-configuration) - Emissão de JWTs assinados com RSA
Contexto: o papel do Authorization Server no ecossistema
Antes de começar a codar, vale entender o lugar do AS na arquitetura. O sistema completo será composto por quatro partes:
┌─────────────┐ Authorization Code + PKCE ┌─────────────────────┐ │ Next.js │ ─────────────────────────────────► │ Authorization │ │ (Cliente) │ ◄───────────────────────────────── │ Server │ └─────────────┘ access_token + id_token └─────────────────────┘ │ ▲ │ Bearer access_token │ federa com ▼ ▼ ┌─────────────┐ ┌─────────────────────┐ │ Resource │ │ Google (OIDC) │ │ Server │ │ │ └─────────────┘ └─────────────────────┘
O AS é o único componente que conhece os usuários e suas senhas. O Resource Server nunca vê senhas — ele só valida o JWT emitido pelo AS. O Next.js nunca fala diretamente com o Google — ele sempre passa pelo AS.
Estrutura da implementação do Authorization Server com Spring Security
A estrutura de implementação possui as seguintes partes:
- Filtros do Spring Security
- SecurityFilterChain - para endpoints do Authorization Server
- SecurityFilterChain - para demais rotas
- Infraestrutura de usuários
- UserDetailsService - para acessar usuários
- PasswordEncoder - para encodar as senhas
- Registro de aplicações clientes
- RegisteredClientRepository - para acessar apps clientes
- Infraestrutura JWT
- JWKSource - para acessar o par de chaves RSA
- JwtDecoder - para validar o acceess token
- Configuração do servidor
- AuthorizationServerSettings
Começando o projeto
Criando o projeto no Spring Initializr
Acesse start.spring.io e configure:
| Campo | Valor |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | 4.x.x |
| Group | com.devsuperior |
| Artifact | authserver |
| Packaging | Jar |
| Java | 25 |
Adicione as seguintes dependências:
- Spring Web (
spring-boot-starter-webmvc) - Spring Security (
spring-boot-starter-security) - OAuth2 Authorization Server (
spring-boot-starter-security-oauth2-authorization-server)
Clique em Generate, descompacte o arquivo e abra o projeto na IDE.
A estrutura inicial do pom.xml gerado ficará assim (dependências de test omitidas):
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId> </dependency> </dependencies>
Configurando os profiles e propriedades
O AS precisa rodar na porta 9000 para não colidir com a porta padrão 8080 (que será usada pelo Resource Server).
Vamos usar o padrão de profiles do Spring Boot para separar configurações de ambiente. Edite o application.properties gerado pelo Initializr:
spring.profiles.active=${APP_PROFILE:dev} server.port=9000
A expressão ${APP_PROFILE:dev} significa: use a variável de ambiente APP_PROFILE se ela existir; caso contrário, use dev como padrão. Isso permite que em produção você defina APP_PROFILE=prod sem alterar o código.
Criando a classe de configuração do Authorization Server
Toda a configuração do AS ficará em uma única classe: AuthorizationServerConfig. Crie-a no pacote com.devsuperior.authserver.config.
Vamos construir essa classe parte por parte.
@Configuration @EnableWebSecurity public class AuthorizationServerConfig { // ... }
@EnableWebSecurity é necessária para que o Spring Security processe os beans SecurityFilterChain que vamos definir.
1. Filtros do Spring Security
O Spring Security usa uma pilha de filter chains. Cada chain tem um securityMatcher que define para quais URLs ela se aplica. O AS precisa de dois:
Filter chain 1 — endpoints do Authorization Server
Este filter chain intercepta todas as URLs padrão do protocolo OAuth2 e OIDC: /oauth2/authorize, /oauth2/token, /oauth2/jwks, /userinfo, /.well-known/openid-configuration, etc.
@Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer(); http .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .with(authorizationServerConfigurer, as -> as // Habilita suporte a OpenID Connect (ID token, /userinfo, discovery) .oidc(Customizer.withDefaults()) ) .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) // Redireciona para /login quando o browser acessa um endpoint protegido .exceptionHandling(ex -> ex .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ); return http.build(); }
Alguns pontos importantes:
getEndpointsMatcher()retorna umRequestMatcherque cobre exatamente os endpoints registrados pelo protocolo — não é necessário listá-los manualmente..oidc(Customizer.withDefaults())habilita o suporte a OpenID Connect. Sem isso, o AS funciona apenas como OAuth2 puro, sem emitir ID tokens nem expor o discovery document.- O
LoginUrlAuthenticationEntryPointgarante que quando um browser (que enviaAccept: text/html) acessar um endpoint protegido, ele seja redirecionado para/loginem vez de receber um401JSON.
Filter chain 2 — demais rotas
Este filter chain captura tudo que não foi interceptado pela chain 1: a tela de login e qualquer outra rota que possamos criar no futuro.
@Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); }
.formLogin(Customizer.withDefaults())ativa a tela de login padrão do Spring Security em/login.
2. Infraestrutura de usuários
PasswordEncoder
@Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }
O DelegatingPasswordEncoder é o encoder padrão recomendado pelo Spring Security. Ele funciona com prefixos no hash armazenado: {bcrypt}$2a$..., {noop}senha, etc. Isso é fundamental porque o Spring Authorization Server usa este mesmo bean para verificar o client secret do cliente registrado — um BCryptPasswordEncoder puro não consegue processar o prefixo {noop} e causaria erro de autenticação do cliente.
UserDetailsService
Vamos criar o componente UserDetailsService (padrão do Spring Security) para acessar os usuários. Para este primeiro estágio, os usuários são armazenados em memória. Em um post futuro, migraremos para banco de dados.
@Bean public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { UserDetails maria = User.builder() .username("maria@example.com") .password(passwordEncoder.encode("12345678")) .authorities(List.of()) .build(); UserDetails alex = User.builder() .username("alex@example.com") .password(passwordEncoder.encode("12345678")) .authorities(List.of()) .build(); return new InMemoryUserDetailsManager(maria, alex); }
O método passwordEncoder.encode("12345678") produz um hash no formato {bcrypt}$2a$10$..., que o DelegatingPasswordEncoder sabe verificar no momento do login.
3. Registro de aplicações clientes
No OAuth2, um cliente é uma aplicação que solicita acesso a recursos em nome do usuário. No nosso caso, o cliente é o frontend Next.js.
@Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient nextjsClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("nextjs-client") // {noop} = sem encoding de senha, apenas para desenvolvimento .clientSecret("{noop}nextjs-secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // URL de callback do Next.js (Auth.js) .redirectUri("http://localhost:3000/api/auth/callback/spring") // URL de callback para testes manuais (browser) .redirectUri("http://localhost:9000/authorized") .postLogoutRedirectUri("http://localhost:3000") // Scopes disponíveis para este cliente .scope(OidcScopes.OPENID) // obrigatório para OIDC / ID token .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(false) .build()) .build(); return new InMemoryRegisteredClientRepository(nextjsClient); }
Alguns pontos a destacar:
CLIENT_SECRET_BASICenvia as credenciais do cliente no headerAuthorization: Basic.CLIENT_SECRET_POSTenvia no corpo do form. Ambos são suportados para flexibilidade de teste.AUTHORIZATION_CODEé o único grant type seguro para aplicações com interface de usuário. OREFRESH_TOKENpermite renovar o access token sem novo login.- O scope
openidé obrigatório para o fluxo OIDC — ele instrui o AS a emitir um ID token além do access token. Scopes de negócio (comoproducts:read) são responsabilidade dos Resource Servers, não do AS. requireAuthorizationConsent(false)desativa a tela de consentimento — o fluxo Authorization Code redireciona direto para o callback sem pedir aprovação de scopes ao usuário.
4. Infraestrutura JWT
JWKSource - para acessar o par de chaves RSA
Os JWTs emitidos pelo AS são assinados com uma chave privada RSA e verificados com a chave pública correspondente. O Resource Server busca a chave pública via endpoint /oauth2/jwks e valida os tokens localmente — sem precisar chamar o AS a cada requisição.
@Bean public JWKSource<SecurityContext> jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); return new ImmutableJWKSet<>(new JWKSet(rsaKey)); } private static KeyPair generateRsaKey() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); return generator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } }
Atenção: O par de chaves gerado aqui é efêmero — é criado em memória a cada inicialização do servidor. Isso significa que todos os tokens emitidos anteriormente se tornam inválidos após um restart. Em produção, as chaves devem ser persistidas externamente (ex: AWS KMS, HashiCorp Vault, ou um keystore em disco). Isso será abordado em um post futuro.
JwtDecoder - para validar o acceess token
@Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); }
Este JwtDecoder é usado internamente pelo próprio AS — por exemplo, para validar o access token quando o cliente chama o endpoint /userinfo ou /oauth2/introspect. Ele não é o mesmo JwtDecoder que será configurado no Resource Server.
5. Configuração do servidor
@Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .issuer("http://localhost:9000") .build(); }
O issuer é a URL base do AS. Ela aparece na claim iss de todos os tokens emitidos e no discovery document. O Resource Server usará essa URL para descobrir automaticamente os endpoints do AS via /.well-known/openid-configuration.
Fluxo Authorization Code em detalhes
Suponha um App frontend (por exemplo uma aplicação Next) executando o fluxo authorization code. A sequência é esta:
- Em algum lugar do App vai haver um botão "Entrar".
- Quando o usuário clicar no botão "Entrar", será chamada uma rota do App que inicia o processo de login, por exemplo
{App}/api/auth/login. - O App prepara todos parâmetros (client_id, response_type, redirect_uri, scope, state, code_challenge, code_challenge_method) e redireciona para o Authorization Server em GET
{AS}/oauth2/authorize?.... - O Authorization Server apresenta sua tela de login ao usuário, que informa suas credenciais.
- O Authorization Server valida tudo e redireciona para a callback do App passando code e state, por exemplo
{App}/api/auth/callback/spring?code=aaa&state=bbb. - O App valida tudo e, via servidor, envia a requisição ao Authorization Server para trocar o code pelos tokens: POST
{AS}/oauth2/token. - O App recebe a resposta da requisição (contendo os tokens).
A seguir eu mostro dois diagramas de sequência, para ilustrar de forma didática como este fluxo ocorre. Cole o código no Visualizador do Mermaid.
Fluxo com algoritmo detalhado
sequenceDiagram actor U as Usuário participant B as Browser participant N as Next.js<br/>(localhost:3000) participant AS as Authorization Server<br/>(localhost:9000) U->>B: Clica em "Entrar" B->>N: GET /api/auth/login Note over N: Gera code_verifier (32 bytes aleatórios)<br/>Calcula code_challenge = SHA-256(verifier)<br/>Gera state (16 bytes aleatórios) N->>B: Set-Cookie: pkce_verifier, oauth_state (HttpOnly) N->>B: 302 → /oauth2/authorize?response_type=code<br/>&client_id=nextjs-client<br/>&redirect_uri=.../callback/spring<br/>&scope=openid<br/>&state=...&code_challenge=...&code_challenge_method=S256 B->>AS: GET /oauth2/authorize?... AS->>B: 302 → /login (tela de login do AS) B->>U: Exibe formulário de login U->>B: Preenche e-mail + senha e submete B->>AS: POST /login (username, password) Note over AS: Autentica o usuário via<br/>InMemoryUserDetailsManager Note over AS: Valida code_challenge<br/>Gera authorization code AS->>B: 302 → /api/auth/callback/spring?code=...&state=... B->>N: GET /api/auth/callback/spring?code=...&state=... Note over N: Lê pkce_verifier e oauth_state dos cookies<br/>Valida state recebido === state salvo N->>AS: POST /oauth2/token<br/>Authorization: Basic (client_id:secret)<br/>grant_type=authorization_code<br/>code=...&code_verifier=...&redirect_uri=... Note over AS: Valida authorization code<br/>Verifica code_verifier contra code_challenge AS->>N: { access_token, id_token, ... } Note over N: Decodifica id_token (JWT)<br/>Extrai claim "sub" (e-mail do usuário)<br/>Apaga cookies pkce_verifier e oauth_state<br/>Define cookie user_email (HttpOnly) N->>B: Delete-Cookie: pkce_verifier, oauth_state<br/>Set-Cookie: user_email (HttpOnly)<br/>302 → / B->>N: GET / Note over N: Lê cookie user_email N->>B: Página "Logado com maria@example.com" B->>U: Exibe "Logado com maria@example.com"
Fluxo somente com requisições e valores reais
sequenceDiagram actor U as Usuário participant B as Browser participant N as Next.js<br/>(localhost:3000) participant AS as Authorization Server<br/>(localhost:9000) U->>B: Clica em "Entrar" B->>N: GET http://localhost:3000/api/auth/login N->>B: HTTP/1.1 302 Found<br/>Set-Cookie: pkce_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk#59; HttpOnly#59; SameSite=Lax<br/>Set-Cookie: oauth_state=rT2hJ9kLmN4pQwXv#59; HttpOnly#59; SameSite=Lax<br/>Location: http://localhost:9000/oauth2/authorize?response_type=code<br/>&client_id=nextjs-client<br/>&redirect_uri=http://localhost:3000/api/auth/callback/spring<br/>&scope=openid<br/>&state=rT2hJ9kLmN4pQwXv<br/>&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM<br/>&code_challenge_method=S256 B->>AS: GET http://localhost:9000/oauth2/authorize?response_type=code<br/>&client_id=nextjs-client<br/>&redirect_uri=http://localhost:3000/api/auth/callback/spring<br/>&scope=openid<br/>&state=rT2hJ9kLmN4pQwXv<br/>&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM<br/>&code_challenge_method=S256 AS->>B: HTTP/1.1 302 Found<br/>Location: http://localhost:9000/login B->>AS: GET http://localhost:9000/login AS->>B: HTTP/1.1 200 OK (HTML — formulário de login) B->>U: Exibe formulário de login U->>B: Preenche e-mail + senha e submete B->>AS: POST http://localhost:9000/login<br/>Content-Type: application/x-www-form-urlencoded<br/><br/>username=maria%40example.com&password=12345678 AS->>B: HTTP/1.1 302 Found<br/>Location: http://localhost:3000/api/auth/callback/spring?code=jx8KpLmN2qRtUvWx&state=rT2hJ9kLmN4pQwXv B->>N: GET http://localhost:3000/api/auth/callback/spring?code=jx8KpLmN2qRtUvWx&state=rT2hJ9kLmN4pQwXv<br/>Cookie: pkce_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk#59; oauth_state=rT2hJ9kLmN4pQwXv N->>AS: POST http://localhost:9000/oauth2/token<br/>Authorization: Basic bmV4dGpzLWNsaWVudDpuZXh0anMtc2VjcmV0<br/>Content-Type: application/x-www-form-urlencoded<br/><br/>grant_type=authorization_code<br/>&code=jx8KpLmN2qRtUvWx<br/>&redirect_uri=http://localhost:3000/api/auth/callback/spring<br/>&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk AS->>N: HTTP/1.1 200 OK<br/>Content-Type: application/json<br/><br/>{"access_token":"eyJhbGci...","id_token":"eyJhbGci...eyJzdWIiOiJtYXJpYUBleGFtcGxlLmNvbSJ9...","token_type":"Bearer","expires_in":300} N->>B: HTTP/1.1 302 Found<br/>Set-Cookie: pkce_verifier=#59; Max-Age=0<br/>Set-Cookie: oauth_state=#59; Max-Age=0<br/>Set-Cookie: user_email=maria@example.com#59; HttpOnly#59; SameSite=Lax<br/>Location: http://localhost:3000/ B->>N: GET http://localhost:3000/<br/>Cookie: user_email=maria@example.com N->>B: HTTP/1.1 200 OK (HTML — "Logado com maria@example.com") B->>U: Exibe "Logado com maria@example.com"
Testando o servidor
Execute o projeto e verifique os seguintes endpoints no browser:
Discovery document OIDC — http://localhost:9000/.well-known/openid-configuration
Retorna um JSON com todos os endpoints do AS, algoritmos suportados, scopes, etc. Este documento é consumido automaticamente pelo Resource Server e pelo cliente Next.js para auto-configuração.
{ "issuer": "http://localhost:9000", "authorization_endpoint": "http://localhost:9000/oauth2/authorize", "token_endpoint": "http://localhost:9000/oauth2/token", "jwks_uri": "http://localhost:9000/oauth2/jwks", "userinfo_endpoint": "http://localhost:9000/userinfo", ... }
JWK Set — http://localhost:9000/oauth2/jwks
Expõe as chaves públicas RSA em formato JSON. O Resource Server busca este endpoint uma vez e faz cache da chave para validar os JWTs localmente.
Tela de login — http://localhost:9000/login
A tela de login padrão do Spring Security. Futuramente será substituída por uma tela customizada.
Testando no Postman
Por favor acesse a pasta do projeto no Github e baixe a collection Postman, que é o arquivo authserver.postman_collection.json localizado na pasta acima.
Importe a collection no seu Postman. Depois de importar a collection no seu Postman, navegue nela para a pasta 1. Discovery & JWK e teste os endpoints:
OIDC Discovery DocumentOAuth2 AS MetadataJWK Set (chave pública RSA)
Testando o fluxo Authorization Code
Cole esta URL no browser para iniciar o fluxo completo:
http://localhost:9000/oauth2/authorize?response_type=code&client_id=nextjs-client&redirect_uri=http://localhost:9000/authorized&scope=openid&state=xyz&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
O par PKCE utilizado neste exemplo de teste é fixo para conveniência:
code_verifier:dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkcode_challenge:E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM(= BASE64URL(SHA256(verifier)))
Após o login, o browser redirecionará para http://localhost:9000/authorized?code=XXXX&state=xyz. O endpoint /authorized não existe, então a página dará 404 — isso é esperado. Copie o code da URL.
Em seguida, copie o valor do argumento o code no resultado acima na barra de URL do seu navegador, depois acesse a collection -> Variables, e copie o valor para o parâmetro authorization_code.
Depois de copiar o valor de authorization_code, acesse a pasta 2. Fluxo Authorization Code da collection e execute a requisição:
[2] Trocar code por tokens
A resposta conterá access_token, refresh_token e id_token. Cole o access_token em jwt.io para inspecionar o payload.
Código completo para baixar
O código completo deste post está disponível no Github do blog.
Conclusão
Construímos um Authorization Server mínimo e funcional com Spring Boot 4 e Spring Authorization Server 7. Com apenas uma classe de configuração e dois arquivos de properties, temos:
- Um servidor OAuth2/OIDC completo rodando na porta 9000
- Suporte a Authorization Code com PKCE (o único fluxo recomendado pelo OAuth 2.1 para aplicações com usuário)
- Emissão de JWTs assinados com RS256
- Discovery document OIDC totalmente funcional
A implementação in-memory é intencional neste ponto — ela mantém o código mínimo e focado no que importa: entender a estrutura e o fluxo do protocolo.
Em estudos futuros, faremos incrementos na direção de se construir uma aplicação completa de referência para os principais recursos para autenticação e autorização com OAuth2 e OpenID Connect (OIDC).

