A maioria dos projetos modernos delega autenticação para serviços externos como Auth0, Okta ou Keycloak - e não há nada de errado nisso, é o caminho mais curto para começar. O problema é que, sem entender o que está acontecendo por baixo, fica difícil tomar decisões informadas: como integrar com um SPA? Por que o ID token não deveria carregar o email do usuário? Quando preciso de Authorization Code + PKCE e quando Client Credentials basta? O Spring Authorization Server (Spring AS) é a peça que permite construir o seu próprio AS em Java/Spring, expondo os endpoints OAuth2/OIDC padrão (/oauth2/authorize, /oauth2/token, /userinfo, /.well-known/openid-configuration, etc.) com todos os ajustes que você quiser. Neste post, partindo de uma baseline mínima in-memory, vamos evoluir um AS educativo (dsbooks-as) em cinco passos incrementais até chegar a um modelo persistido em banco, com login customizado, multi-clients cadastrados, e um modelo de usuário OIDC-compliant que respeita LGPD/GDPR - tudo sem mágica de bibliotecas terceiras.
Passo 1: externalizar valores e reorganização do Postman
Conforme mencionado, vamos partir de uma implementação baseline onde temos um Authorization Server mínimo com recursos in-memory, capaz executar o fluxo de login e emitir tokens. Esta baseline foi explorada em outro post deste blog, que serviu para compreendermos em detalhes como funciona o fluxo authorization code do OAuth2.
A baseline tem o issuer URL do AS hardcoded dentro de AuthorizationServerConfig. Antes de mexer em qualquer coisa estrutural, vale "limpar a casa" para o aluno aprender o padrão de configuração externalizada que será reutilizado em todo o resto.
Apenas o issuer URL vai para o application.properties neste passo. As demais configurações, tais como usuários e clients, não entram aqui porque vão direto para o banco nos passos mais adiante.
O application.properties final deste passo fica assim:
spring.profiles.active=${APP_PROFILE:test} spring.jpa.open-in-view=false server.port=${SERVER_PORT:9000} server.servlet.session.cookie.name=AS_DSBOOKS_SESSION app.security.issuer=${APP_ISSUER_URL:http://localhost:9000}
E em AuthorizationServerConfig:
@Value("${app.security.issuer}") private String issuer; @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .issuer(issuer) .build(); }
A segunda parte deste passo é reorganizar a coleção do Postman para ser sustentável. Em vez de variáveis presas dentro da própria collection (que viajam quando você exporta/compartilha), criamos um environment chamado dsbooks-dev, em arquivo separado na raiz do projeto:
| Variável | Valor |
|---|---|
as-host | http://localhost:9000 |
client-id | manual-client |
client-secret | my-client-secret |
scopes | openid profile email |
redirect-uri | https://oauth.pstmn.io/v1/callback |
code-verifier | dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk |
state | xyz |
Na collection, o fluxo OAuth2 (Authorization Code com PKCE) passa a ser configurado na aba Authorization da collection inteira - clicar em "Get New Access Token" dispara o fluxo completo. As requisições individuais ficam apenas com o que importa: GET OIDC Discovery Document, GET OAuth2 AS Metadata, GET JWK Set, GET UserInfo. As três primeiras usam No Auth (são endpoints públicos); o UserInfo herda a autorização da collection (Inherit auth from parent).
O ganho não é só estético: ao apontar a collection para outra instância do AS (ex.: ambiente de homologação), basta trocar de environment - nada na collection precisa ser editado.
Passo 2: formulário de login customizado com Thymeleaf
O formLogin(Customizer.withDefaults()) da baseline mostra a tela de login genérica do Spring Security. Para um AS de verdade, queremos identidade visual própria - afinal, é a única tela que o usuário final realmente vê do AS.
Adicionamos a dependência:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
E criamos um controller mínimo só para mapear GET /login para o template:
@Controller public class LoginController { @GetMapping("/login") public String login() { return "login"; } }
O template (src/main/resources/templates/login.html) carrega CSS externo, mostra o título "Login com DSBooks", trata mensagens de erro/logout via ${param.error} e ${param.logout}, e tem dois cuidados específicos:
- O atributo
autocomplete="new-password"no input de senha. Evita que o navegador injete senhas previamente memorizadas no momento em que o usuário clica no campo - comum em telas de login compartilhadas com outros formulários do mesmo domínio. - Um botão de toggle de visualização da senha, com dois SVGs inline (eye / eye-off, estilo feather-icons). A visibilidade dos dois é controlada por uma classe CSS (
.is-hidden), e um JS pequeno alterna entretype="password"etype="text"ao clicar.
O CSS adota dark mode usando variáveis CSS no :root:
:root { --color-bg: #0e1116; --color-surface: #161b22; --color-border: #30363d; --color-text: #e6edf3; --color-primary: #58a6ff; --space-md: 16px; --radius-md: 8px; /* ... etc ... */ }
A vantagem das variáveis é que mudar a paleta inteira ou criar um modo claro depois passa a ser trivial - uma única regra :root (ou [data-theme="light"]) e tudo segue junto.
No defaultSecurityFilterChain, três alterações: apontar para a página customizada, liberar /login, /css/**, /js/** para acesso público, e remover o formLogin(Customizer.withDefaults()) no lugar de:
.formLogin(form -> form .loginPage("/login") .permitAll() );
Detalhe importante sobre CSRF: como o template usa th:action, o RequestDataValueProcessor do Spring automaticamente injeta o input hidden do _csrf no <form>. Não escreva manualmente o <input name="_csrf" ...> - você acaba com dois (um do Thymeleaf, outro seu) e a IDE não reclama.
Passo 3: persistir usuários no banco de dados
A baseline usa InMemoryUserDetailsManager com Maria e Alex hardcoded. É hora de mover para JPA + H2.
Três dependências entram no pom.xml:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-h2console</artifactId> </dependency>
A última merece atenção: no Spring Boot 4 o H2ConsoleAutoConfiguration foi extraído para um módulo próprio (spring-boot-h2console). Sem essa dependência, a propriedade spring.h2.console.enabled=true é silenciosamente ignorada e o console nunca é registrado.
A entidade nesta etapa é deliberadamente simples - só id, username (será trocado por email no Passo 5) e password. Implementa UserDetails com getAuthorities() retornando List.of(), porque nosso AS não fará controle de roles/authorities do usuário:
@Entity @Table(name = "tb_user") public class UserEntity implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; @Column(nullable = false) private String password; // getters/setters omitidos @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(); } }
Repository é JpaRepository<UserEntity, Long> com findByUsername. O service implementa UserDetailsService, que é o componente padrão do Spring Security para recuperar usuários por meio do método UserDetails loadUserByUsername(String username):
@Service public class UserService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) { return userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); } }
O seed dos usuários é programático, via CommandLineRunner - não via import.sql. A motivação não é o encoding da senha (que pode tranquilamente ser {noop}12345678 em SQL), e sim antecipatória: um redesign futuro pode introduzir criptografia simétrica em campos sensíveis (email, telefone), via JPA AttributeConverter. Como o ciphertext muda a cada execução (vetor de inicialização aleatório), não dá para hardcodar valores em SQL - o seed precisa rodar em código Java, com os converters JPA ativos. Adotando CommandLineRunner desde já evitamos retrabalho.
@Configuration public class DatabaseSeeder implements CommandLineRunner { @Autowired private UserRepository userRepository; @Override public void run(String... args) { if (userRepository.count() > 0) return; seedUser("maria@example.com", "{noop}12345678"); seedUser("alex@example.com", "{noop}12345678"); } private void seedUser(String username, String password) { UserEntity user = new UserEntity(); user.setUsername(username); user.setPassword(password); userRepository.save(user); } }
Para o H2 console funcionar acessível pelo navegador, três ajustes no defaultSecurityFilterChain por causa de uma particularidade do Spring Security 7: o requestMatchers(String...) padrão resolve para um MvcRequestMatcher, que só casa rotas servidas pelo DispatcherServlet - e o H2 console é um servlet separado. Solução: usar PathPatternRequestMatcher explicitamente. Além disso, o console envia POST sem CSRF token e usa <iframe>:
PathPatternRequestMatcher h2Console = PathPatternRequestMatcher.pathPattern("/h2-console/**"); http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/login", "/css/**", "/js/**").permitAll() .requestMatchers(h2Console).permitAll() .anyRequest().authenticated() ) .csrf(csrf -> csrf.ignoringRequestMatchers(h2Console)) .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) .formLogin(...)
O AntPathRequestMatcher que aparece em tutoriais antigos foi removido no Spring Security 7 - o substituto é o PathPatternRequestMatcher.
Com isso, o InMemoryUserDetailsManager sai do AuthorizationServerConfig, e o login continua funcionando exatamente igual - só que agora os usuários vêm do H2.
Passo 4: persistir clients no banco de dados
O manual-client que a gente usou no Passo 1 ainda está hardcoded num bean RegisteredClient dentro do AuthorizationServerConfig. Hora de mover para o banco e, de quebra, cadastrar os múltiplos clients que um ecossistema real costuma ter.
A entidade OAuthClient (tabela tb_oauth_client) representa um RegisteredClient do Spring AS. Decisão deliberada: as coleções (redirect_uris, scopes, grant_types, auth_methods, post_logout_redirect_uris) ficam como strings separadas por vírgula num único campo cada, em vez de tabelas filhas. Mantém o esquema simples e suficiente para volumes pequenos:
@Entity @Table(name = "tb_oauth_client") public class OAuthClient { @Id private String id; @Column(unique = true, nullable = false) private String clientId; // Pode ser null para public clients (sem secret) private String clientSecret; @Column(nullable = false) private String clientName; /** Redirect URIs separadas por vírgula */ @Column(nullable = false, length = 1000) private String redirectUris; /** Post-logout redirect URIs separadas por vírgula (null = nenhuma) */ @Column(length = 1000) private String postLogoutRedirectUris; /** Scopes separados por vírgula — ex: "openid,profile,email" */ @Column(nullable = false, length = 500) private String scopes; /** Grant types separados por vírgula — ex: "authorization_code,refresh_token" */ @Column(nullable = false, length = 200) private String authorizationGrantTypes; /** Métodos de autenticação do client separados por vírgula */ @Column(nullable = false, length = 200) private String clientAuthenticationMethods; /** Validade do access token em segundos (default: 1 hora) */ @Column(nullable = false) private long accessTokenTtlSeconds = 3600L; /** Validade do refresh token em segundos (default: 30 dias) */ @Column(nullable = false) private long refreshTokenTtlSeconds = 2592000L; /** Se exige tela de consent para o usuário. False para apps first-party. */ @Column(nullable = false) private boolean requireConsent = true; // ... }
Para o Spring AS enxergar esses dados como RegisteredClient, criamos uma implementação custom de RegisteredClientRepository. Ela carrega a entidade via JPA e converte de/para o modelo do framework:
@Component public class CustomRegisteredClientRepository implements RegisteredClientRepository { @Autowired private OAuthClientRepository clients; @Override public RegisteredClient findByClientId(String clientId) { return clients.findByClientId(clientId).map(this::toDomain).orElse(null); } private RegisteredClient toDomain(OAuthClient e) { RegisteredClient.Builder b = RegisteredClient.withId(e.getId()) .clientId(e.getClientId()) .clientSecret(e.getClientSecret()) .clientName(e.getClientName()); Arrays.stream(e.getAuthorizationGrantTypes().split(",")) .map(String::trim) .forEach(t -> b.authorizationGrantType(new AuthorizationGrantType(t))); // (e assim por diante para auth methods, redirect URIs, scopes...) b.tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofSeconds(e.getAccessTokenTtlSeconds())) .refreshTokenTimeToLive(Duration.ofSeconds(e.getRefreshTokenTtlSeconds())) .build()); return b.build(); } // toEntity() para o save() inverso }
O seed dos clients vai via import.sql (clients não têm campos sensíveis a serem encriptados, então SQL basta - diferente do seed de usuários). Três clients são cadastrados, representando atores reais distintos:
| Client | Tipo | Para quê |
|---|---|---|
my-client-id | Auth Code + Refresh, sem consent | App Next.js de catálogo de livros |
my-backend | Client Credentials, só client_secret_basic | Backend Spring para chamadas m2m (introspecção, jobs, m2m com outros serviços) |
manual-client | Auth Code + Refresh | Testes manuais no Postman |
Por que três clients separados? Cada um tem seu redirect URI legítimo e papel semântico próprio. Manter manual-client separado de my-client-id evita que o callback do Postman fique whitelisted num client de produção (vetor de ataque clássico). E em logs/auditoria, o aud do JWT fica autoexplicativo: my-client-id = tráfego de usuário real; manual-client = teste manual; my-backend = m2m.
Como o manual-client faz só o fluxo de usuário (Auth Code), o Postman precisa de uma forma adicional para testar m2m. Adicionamos três variáveis ao environment (backend-client-id, backend-client-secret, backend-access-token) e uma nova requisição POST Token (Client Credentials) dentro da pasta Auth:
- Auth: HTTP Basic com
username = {{backend-client-id}},password = {{backend-client-secret}}(my-backendaceita sóclient_secret_basic). - Body (form-urlencoded):
grant_type=client_credentials,scope=openid. - Test script (post-response): salva
access_tokenembackend-access-token.
Quando precisar testar uma rota protegida estilo m2m, basta sobrescrever o Authorization da request específica com Bearer {{backend-access-token}}.
Passo 5: redesign do usuário conforme OIDC
Até aqui, o sub dos tokens emitidos pelo AS é o próprio email do usuário - porque getUsername() retorna o email e o Spring AS usa isso como subject por padrão. Isso é uma violação de LGPD/GDPR: dado pessoal trafegando em tokens que circulam por sistemas intermediários, gateways, logs estruturados e até mesmo aparecendo em URLs de fluxos OAuth2.
A correção é redesenhar a entidade conforme os scopes do OIDC: sub vira um UUID opaco, e dados pessoais ficam disponíveis apenas via /userinfo, sob demanda.
A nova UserEntity:
@Entity @Table(name = "tb_user") public class UserEntity implements UserDetails { // Internos @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String password; // Scope openid @Column(unique = true, nullable = false) private String sub; // UUID - não vaza dado pessoal // Scope profile private String name; private String picture; // Scope email @Column(unique = true, nullable = false) private String email; @Column(name = "email_verified", nullable = false) private boolean emailVerified = false; @Override public String getUsername() { return email; // UserDetails exige um identificador; email é o que o usuário digita } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(); } // ... }
O UserService.loadUserByUsername agora busca por email; o seed gera UUIDs fixos (para facilitar debug) e popula nome/foto/email_verified; e o template login.html muda o input para name="email". Em defaultSecurityFilterChain, adicionamos .usernameParameter("email") no formLogin para Spring Security ler o campo correto.
Faltam dois componentes para fechar o redesign:
1. Sobrescrever o sub dos tokens. Por padrão o Spring AS usa Authentication.getName() como subject - que, no nosso caso, é o email. Precisamos que seja o UUID. Um OAuth2TokenCustomizer<JwtEncodingContext> resolve:
@Component public class JwtSubClaimCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> { @Override public void customize(JwtEncodingContext context) { Object principal = context.getPrincipal().getPrincipal(); if (principal instanceof UserEntity user) { context.getClaims().subject(user.getSub()); } // Para Client Credentials (principal = RegisteredClient), o sub natural // já é o client_id - não precisa alterar. } }
O Spring AS detecta automaticamente esse bean e o aplica ao montar todo JWT (access token e ID token).
2. Servir email/name/picture só via /userinfo. A esta altura, o ID token e o access token já estão limpos (só sub UUID + claims técnicos como iss, aud, exp). Mas precisamos que /userinfo retorne os dados pessoais quando o client tiver os scopes apropriados. Implementamos um userInfoMapper customizado:
@Component public class CustomOidcUserInfoMapper implements Function<OidcUserInfoAuthenticationContext, OidcUserInfo> { @Autowired private UserRepository userRepository; @Override public OidcUserInfo apply(OidcUserInfoAuthenticationContext context) { OAuth2Authorization authorization = context.getAuthorization(); Set<String> scopes = context.getAccessToken().getScopes(); String email = authorization.getPrincipalName(); UserEntity user = userRepository.findByEmail(email) .orElseThrow(() -> new OAuth2AuthenticationException( new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "User no longer exists; token is stale", null))); OidcUserInfo.Builder b = OidcUserInfo.builder().subject(user.getSub()); if (scopes.contains(OidcScopes.PROFILE)) { if (user.getName() != null) b.name(user.getName()); if (user.getPicture() != null) b.picture(user.getPicture()); } if (scopes.contains(OidcScopes.EMAIL)) { b.email(user.getEmail()); b.emailVerified(user.isEmailVerified()); } return b.build(); } }
E plugamos no filter chain:
.with(authorizationServerConfigurer, as -> as .oidc(oidc -> oidc .userInfoEndpoint(userInfo -> userInfo .userInfoMapper(userInfoMapper) ) ) )
Detalhe sobre o tratamento de "user not found": no fluxo do /userinfo, o Spring AS já valida assinatura/expiração do token antes de chamar o mapper - token inválido vira 401 sozinho. O cenário de user inexistente só acontece se o usuário foi deletado (ou teve email mudado) entre o login e a chamada ao /userinfo. Lançar OAuth2AuthenticationException com invalid_token faz o Spring AS responder 401 com o header WWW-Authenticate: Bearer error="invalid_token" - semanticamente correto, dizendo ao cliente "esse token é stale, refaça login".
Ao final do passo, decodificando os tokens emitidos, vê-se que tanto o access_token quanto o id_token carregam apenas:
{ "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "aud": "manual-client", "iss": "http://localhost:9000", "scope": ["openid", "profile", "email"], "exp": 1777334129, ... }
E o /userinfo com Bearer token (e os 3 scopes) responde:
{ "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Maria Silva", "picture": "https://i.pravatar.cc/300?img=1", "email": "maria@example.com", "email_verified": false }
Mesmo sub em todos os lugares; dado pessoal só onde precisa estar.
Projeto para baixar
Você pode ver o projeto correspondente a este post no Github da Devsuperior, no branch aula-primeiros-passos:
https://github.com/devsuperior/dsbooks-as
Conclusão
Saímos de um Authorization Server mínimo, in-memory, com tela de login default e um único client hardcoded - e chegamos em um AS persistido em banco H2, com formulário customizado em dark mode, três clients de papéis distintos cadastrados, e um modelo de usuário OIDC-compliant que protege dados pessoais. Mais importante que o resultado: cada passo introduziu uma única ideia nova, deixando claro o porquê de cada peça. Externalizar configs antes de mexer em qualquer coisa estrutural. Trocar UI antes de mexer em infra. Pôr usuários no banco antes de pôr clients. E só no final, quando todo o resto está estável, encarar o redesign do modelo de identidade - que é onde os erros de LGPD/GDPR mais aparecem em projetos reais.
O AS resultante é educativo, mas ainda há vários passos para se tornar profissional. Os principais que ficam de fora deste post:
- Persistência das
OAuth2Authorizationno banco. Hoje elas vivem em memória - um restart invalida todos os refresh tokens em circulação. Em produção isso é inaceitável: implementarOAuth2AuthorizationServiceeOAuth2AuthorizationConsentServicebaseados em JPA é o próximo passo natural. - Persistência das chaves RSA. Hoje o
JWKSourcegera um novo par a cada startup, o que invalida todos os tokens emitidos antes. Em produção: chave privada armazenada (idealmente cifrada com chave mestra) e rotacionada de forma controlada via uma tabelatb_signing_key. - Login federado (Google, GitHub, IdP corporativo). O Spring Security já entrega quase tudo via
OAuth2LoginConfigurer; o que dá trabalho é mapear oFederatedUserpara a suaUserEntitylocal e cuidar do logout em cascata para o IdP upstream. - Logout corretamente cascateado. O OIDC define um
end_session_endpoint; cliente faz logout, AS limpa sessão local e - se houver IdP federado - repassa o logout adiante. - Auditoria estruturada. Cada login, cada token emitido, cada
/userinfoacessado, cada falha de autenticação deveria gerar um registro estruturado para SIEM/forense. Spring AS tem hooks (AuthenticationSuccessHandler,AuthenticationFailureHandler, response handlers do token endpoint) que facilitam. - Encriptação de campos sensíveis na
UserEntity(email, telefone, CPF). OCommandLineRunnerque adotamos no Passo 3 já está preparado para isso - falta oAttributeConverterem si. - Consent screen para clients third-party. Os três clients deste post são todos first-party (sem consent), mas em um AS público você quer mostrar a tela "App X quer acessar seu nome e email - autorizar?".
- CRUD de usuário com revogação ativa de tokens. Ao deletar um usuário, todos os refresh tokens em circulação para aquele principal precisam ser invalidados na hora.
A boa notícia: cada um desses pontos é uma evolução natural da arquitetura que construímos aqui - não um redesign. Você pode parar em qualquer ponto e ter um AS que funciona, em vez de ficar olhando para um único monolito gigante e tentando entender tudo de uma vez. É essa a diferença entre estudar OAuth2/OIDC pela leitura das RFCs e estudar pelo código que evolui passo a passo.

