Spring Boot OAuth2 OIDC - um Authorization Server mínimo in-memory

Foto do autor Nelio Alves
Nelio Alves
Compartilhar
Compartilhar no LinkedInCompartilhar no FacebookCompartilhar no XCompartilhar no WhatsApp
Imagem banner do post

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 (ADMIN e OPERATOR)
  • Um cliente OAuth2 registrado (o futuro frontend Next.js)
  • Fluxo Authorization Code com PKCE
  • Tela de consentimento de scopes
  • 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:

Copiar
┌─────────────┐     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:

  1. Filtros do Spring Security
    • SecurityFilterChain - para endpoints do Authorization Server
    • SecurityFilterChain - para demais rotas
  2. Infraestrutura de usuários
    • UserDetailsService - para acessar usuários
    • PasswordEncoder - para encodar as senhas
  3. Registro de aplicações clientes
    • RegisteredClientRepository - para acessar apps clientes
  4. Infraestrutura JWT
    • JWKSource - para acessar o par de chaves RSA
    • JwtDecoder - para validar o acceess token
  5. Configuração do servidor
    • AuthorizationServerSettings

Começando o projeto

Criando o projeto no Spring Initializr

Acesse start.spring.io e configure:

CampoValor
ProjectMaven
LanguageJava
Spring Boot4.x.x
Groupcom.devsuperior
Artifactauthserver
PackagingJar
Java25

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)
  • Spring Data JPA (spring-boot-starter-data-jpa)
  • H2 Database (h2)

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):

Copiar
<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>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-h2console</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </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:

Copiar
spring.profiles.active=${APP_PROFILE:dev}

spring.jpa.open-in-view=false

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.

Crie o arquivo application-dev.properties com as configurações do banco H2 para desenvolvimento:

Copiar
# Dados de conexao com o banco H2
spring.datasource.url=jdbc:h2:mem:authdb
spring.datasource.username=sa
spring.datasource.password=

# Configuracao do cliente web do banco H2
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Configuracao para mostrar o SQL no console
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

O banco H2 in-memory (jdbc:h2:mem:authdb) é recriado a cada reinício, o que é ideal para desenvolvimento. O console web do H2 em /h2-console permite inspecionar o banco via browser enquanto o servidor está rodando.


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.

Copiar
@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.

Copiar
@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 um RequestMatcher que 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 LoginUrlAuthenticationEntryPoint garante que quando um browser (que envia Accept: text/html) acessar um endpoint protegido, ele seja redirecionado para /login em vez de receber um 401 JSON.

Filter chain 2 — demais rotas

Este filter chain captura tudo que não foi interceptado pela chain 1: a tela de login, o console H2, e qualquer outra rota que possamos criar no futuro.

Copiar
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/h2-console/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults())
        .csrf(csrf -> csrf
            .ignoringRequestMatchers("/h2-console/**")
        )
        .headers(headers -> headers
            .frameOptions(fo -> fo.sameOrigin())
        );

    return http.build();
}
  • .formLogin(Customizer.withDefaults()) ativa a tela de login padrão do Spring Security em /login.
  • O console H2 usa <iframe> internamente, o que conflita com a proteção padrão de X-Frame-Options. A configuração .frameOptions(fo -> fo.sameOrigin()) permite iframes da mesma origem.
  • O CSRF é desabilitado para /h2-console/** porque o console H2 não envia o token CSRF.

2. Infraestrutura de usuários

PasswordEncoder

Copiar
@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.

Copiar
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {

    UserDetails admin = User.builder()
        .username("admin@example.com")
        .password(passwordEncoder.encode("12345678"))
        .roles("ADMIN")
        .build();

    UserDetails operator = User.builder()
        .username("operator@example.com")
        .password(passwordEncoder.encode("12345678"))
        .roles("OPERATOR")
        .build();

    return new InMemoryUserDetailsManager(admin, operator);
}

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.

Copiar
@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
        .scope(OidcScopes.PROFILE)      // nome, foto — opcional (usuário pode negar)
        .scope("products:read")         // leitura de produtos
        .scope("products:write")        // escrita de produtos
        // Exige que o usuário aprove os scopes na tela de consentimento
        .clientSettings(ClientSettings.builder()
            .requireAuthorizationConsent(true)
            .build())
        .build();

    return new InMemoryRegisteredClientRepository(nextjsClient);
}

Alguns pontos a destacar:

  • CLIENT_SECRET_BASIC envia as credenciais do cliente no header Authorization: Basic. CLIENT_SECRET_POST envia 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. O REFRESH_TOKEN permite renovar o access token sem novo login.
  • Os scopes definem o que o cliente pode solicitar. openid e profile são scopes OIDC padrão. products:read e products:write são scopes customizados que criaremos no Resource Server.
  • requireAuthorizationConsent(true) ativa a tela de consentimento onde o usuário aprova (ou nega) cada scope individualmente — aquela tela clássica que você já viu ao "Entrar com Google".

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.

Copiar
@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

Copiar
@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

Copiar
@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.


Testando o servidor

Execute o projeto e verifique os seguintes endpoints no browser:

Discovery document OIDChttp://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.

Copiar
{
  "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 Sethttp://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 loginhttp://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 Document
  • OAuth2 AS Metadata
  • JWK Set (chave pública RSA)

Testando o fluxo Authorization Code

Cole esta URL no browser para iniciar o fluxo completo:

Copiar
http://localhost:9000/oauth2/authorize?response_type=code&client_id=nextjs-client&redirect_uri=http://localhost:9000/authorized&scope=openid%20profile%20products:read%20products:write&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_wW1gFWFOEjXk
  • code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM (= BASE64URL(SHA256(verifier)))

Após o login e a aprovação dos scopes, 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)
  • Tela de consentimento de scopes
  • 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).

Foto do autor Nelio Alves
Nelio Alves
Desenvolvedor e Professor
Olá, meu nome é Nelio Alves. Sou graduado em Ciência da Computação e possuo mestrado e doutorado em Engenharia de Software pela Universidade Federal de Uberlândia. Trabalho como desenvolvedor e professor de programação há mais de 20 anos, e sou um dos educadores de tecnologia mais influentes da Internet com mais de 500 mil alunos online.