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=9000A 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=S256O 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).
