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.
