SSO no Spring Boot com OpenID Connect: Spring Security, Resource Server e Spring Cloud Gateway com Casdoor
Para adicionar single sign-on ao Spring Boot, registre cada aplicativo como uma aplicação em um provedor OpenID Connect, como o Casdoor, aponte o Spring Security para a URL do emissor do provedor e escolha o módulo Spring que corresponde ao aplicativo: oauth2Login() para um aplicativo web renderizado no servidor, oauth2ResourceServer().jwt() para uma API REST que recebe tokens bearer e Spring Cloud Gateway com o filtro TokenRelay quando um gateway autentica usuários para um conjunto de microsserviços. Os usuários obtêm SSO porque todos os aplicativos os enviam para o mesmo Casdoor, que lembra a sessão: o segundo aplicativo redireciona, o Casdoor já conhece o usuário e eles retornam diretamente sem um formulário de login. Este post configura as três formas contra um único Casdoor, cada uma com um application.yml funcional e um SecurityFilterChain personalizado.
As três formas em um relance
| Forma | Dependência Spring | Quem mantém o login | Endpoints do Casdoor usados | Cadeia de filtros |
|---|---|---|---|---|
| Aplicativo Web | spring-boot-starter-oauth2-client | O aplicativo (sessão HTTP) | discovery, authorize, token, userinfo, JWKS, logout | SecurityFilterChain com oauth2Login() |
| API REST | spring-boot-starter-oauth2-resource-server | Ninguém, cada solicitação carrega um token bearer | JWKS (e discovery se você usar issuer-uri) | SecurityFilterChain com oauth2ResourceServer() |
| Gateway + microsserviços | spring-cloud-starter-gateway-server-webflux + spring-boot-starter-oauth2-client | O gateway (sessão WebFlux) | Mesmo que o aplicativo web; serviços atrás dele usam JWKS | SecurityWebFilterChain com oauth2Login(), TokenRelay nas rotas |
Suposições de versão: Spring Boot 3.5 com Spring Security 6.5, Java 17 e Spring Cloud 2025.0 (Spring Cloud Gateway 4.3). No Spring Boot 4, os dois starters de Security são chamados spring-boot-starter-security-oauth2-client e spring-boot-starter-security-oauth2-resource-server. O Gateway 4.3 e 5.0 usam ambos o prefixo de propriedade spring.cloud.gateway.server.webflux.* usado abaixo; o prefixo mais antigo spring.cloud.gateway.* e o artefato spring-cloud-starter-gateway estão obsoletos desde o Spring Cloud 2025.0.
O que o Casdoor oferece ao Spring
No console administrativo do Casdoor, crie uma aplicação (ou reutilize uma existente) e copie seu Client ID e Client secret. Adicione todas as callbacks que os aplicativos Spring usarão às Redirect URLs. O padrão do Spring Security é {baseUrl}/login/oauth2/code/{registrationId}, então, com o ID de registro casdoor e um aplicativo na porta 8080, a URL é http://localhost:8080/login/oauth2/code/casdoor. Se você usar o logout OIDC, adicione também a página onde os usuários chegam depois, por exemplo http://localhost:8080/; o Casdoor recusa um post_logout_redirect_uri que não esteja na lista.
O Casdoor publica um documento de descoberta padrão em /.well-known/openid-configuration, então o Spring só precisa do emissor. Estes são os endpoints anunciados, retirados do código-fonte do Casdoor:
| Campo de metadados | Caminho do Casdoor |
|---|---|
issuer | A origem do backend, ex.: https://door.example.com (sem barra final) |
authorization_endpoint | /login/oauth/authorize |
token_endpoint | /api/login/oauth/access_token |
userinfo_endpoint | /api/userinfo |
jwks_uri | /.well-known/jwks |
end_session_endpoint | /api/logout |
Os escopos suportados são openid, email, profile, address, phone e offline_access, além de quaisquer escopos personalizados definidos na aplicação. Os tokens são assinados com RS256 por padrão e carregam o nome do certificado como kid. Cada aplicação também tem seu próprio documento de descoberta em /.well-known/<application>/openid-configuration; veja Conectar um cliente OIDC padrão para os detalhes e a lista completa de endpoints OAuth 2.0.
Duas coisas nos tokens do Casdoor importam para o Spring:
- O emissor deve corresponder exatamente. O Spring compara o
issuerno documento de descoberta com oissuer-uriconfigurado, e posteriormente com a reivindicaçãoissde cada token. O Casdoor deriva o emissor da configuraçãooriginemapp.conf, ou do cabeçalho Host quandooriginestá vazio. Se seus aplicativos Spring acessam o Casdoor através de um hostname interno (digamoshttp://casdoor:8000dentro do Docker) enquanto os navegadores usam um público, definaoriginpara a URL pública para que cada token receba o mesmoiss. Veja Configuração. - As funções vêm em duas formas.
/api/userinforetornarolescomo uma lista de nomes de funções quando o escopoprofilefoi concedido. O token de acesso no formato de tokenJWTpadrão contémrolescomo uma lista de objetos de função, cada um com um camponame. O código abaixo lida com ambas. Os tokens de acesso do Casdoor também carregamscopecomo uma string separada por espaços, que o Spring transforma em autoridadesSCOPE_...por padrão.
Se você quiser mais informações sobre o protocolo, veja What is OpenID Connect e ID token vs access token.
1. Aplicação web Spring Boot com oauth2Login()
Adicione spring-boot-starter-web, spring-boot-starter-security e spring-boot-starter-oauth2-client. Em seguida, configure o provedor por emissor:
server:
port: 8080
spring:
security:
oauth2:
client:
registration:
casdoor:
client-id: <client-id>
client-secret: <client-secret>
authorization-grant-type: authorization_code
scope: openid,profile,email
provider:
casdoor:
issuer-uri: https://door.example.com
user-name-attribute: preferred_username
Na inicialização, o Spring Boot busca https://door.example.com/.well-known/openid-configuration e preenche as URIs de autorização, token, userinfo e JWKS. O Spring então valida a assinatura, o emissor, a audiência e o nonce do ID token, chama /api/userinfo porque profile e email estão no escopo, e mescla as claims. preferred_username é o nome de usuário do Casdoor; name no userinfo é o nome de exibição, e o sub padrão é o UUID do usuário.
Sem sua própria configuração, o Spring Boot exige login para todas as URLs. O SecurityFilterChain personalizado abaixo abre a página inicial, mapeia funções do Casdoor para authorities ROLE_ e efetua logout tanto do Casdoor quanto da aplicação:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository clientRegistrations) throws Exception {
OidcClientInitiatedLogoutSuccessHandler oidcLogout =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrations);
oidcLogout.setPostLogoutRedirectUri("{baseUrl}/");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**", "/error").permitAll()
.requestMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated())
.oauth2Login(login -> login
.userInfoEndpoint(userInfo -> userInfo
.userAuthoritiesMapper(casdoorRoles())))
.logout(logout -> logout
.logoutSuccessHandler(oidcLogout));
return http.build();
}
private GrantedAuthoritiesMapper casdoorRoles() {
return authorities -> {
Set<GrantedAuthority> mapped = new HashSet<>(authorities);
for (GrantedAuthority authority : authorities) {
if (authority instanceof OidcUserAuthority oidc && oidc.getUserInfo() != null) {
List<String> roles = oidc.getUserInfo().getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(role -> mapped.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
}
}
return mapped;
};
}
}
hasRole("admin") agora corresponde a usuários que têm a função admin no Casdoor. OidcClientInitiatedLogoutSuccessHandler lê end_session_endpoint do documento de descoberta e redireciona para /api/logout do Casdoor com id_token_hint e post_logout_redirect_uri, fazendo com que a sessão do Casdoor também termine. Sem isso, /logout apenas limpa a sessão do Spring e o próximo login será silencioso.
Em um controller, o usuário autenticado é um OidcUser:
@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of("name", user.getName(), "email", user.getEmail(),
"authorities", user.getAuthorities().toString());
}
A página passo a passo mais antiga para esta configuração está em Spring Security OAuth.
2. API Spring Boot como servidor de recursos OAuth2
Uma API REST não deve redirecionar ninguém. Ela aceita Authorization: Bearer <access token>, verifica a assinatura JWT contra o JWKS do Casdoor e verifica as claims. Adicione spring-boot-starter-web, spring-boot-starter-security e spring-boot-starter-oauth2-resource-server:
server:
port: 9000
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://door.example.com
jwk-set-uri: https://door.example.com/.well-known/jwks
audiences: <client-id>
Com apenas este arquivo, o Spring Boot constrói um JwtDecoder que verifica a assinatura, a expiração, iss e aud. Definir jwk-set-uri ao lado de issuer-uri significa que as chaves são obtidas diretamente, em vez de através da descoberta, e o emissor ainda é validado. A audiência é o client ID da aplicação Casdoor que emitiu o token: se uma SPA ou o gateway da seção 3 obterem o token, use o client ID dessa aplicação.
Há uma verificação específica do Casdoor que vale a pena adicionar. O Casdoor assina tokens de ID e tokens de acesso com a mesma chave e dá a ambos a client ID como audiência, então os validadores padrão também aceitariam um token de ID enviado como bearer token. Todo token do Casdoor tem uma claim tokenType (access-token, id-token ou refresh-token), o que torna a verificação simples. Definir um bean JwtDecoder substitui o do Boot, então o bean readiciona as verificações de emissor e audiência:
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${spring.security.oauth2.resourceserver.jwt.audiences}") String clientId) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains(clientId)),
new JwtClaimValidator<String>("tokenType", "access-token"::equals)));
return decoder;
}
@Bean
SecurityFilterChain apiFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("admin")
.anyRequest().authenticated())
.oauth2ResourceServer(server -> server
.jwt(jwt -> jwt
.decoder(jwtDecoder)
.jwtAuthenticationConverter(casdoorJwtConverter())))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
private JwtAuthenticationConverter casdoorJwtConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("name");
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
if (jwt.getClaims().get("roles") instanceof Collection<?> roles) {
for (Object role : roles) {
Object name = role instanceof Map<?, ?> map ? map.get("name") : role;
if (name instanceof String roleName && !roleName.isEmpty()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
}
}
}
return authorities;
});
return converter;
}
}
O conversor mantém as authorities SCOPE_, adiciona authorities ROLE_ da claim roles (seja ela contendo nomes ou objetos), e usa a claim name (o nome de usuário do Casdoor) como principal. Se a aplicação usar o formato de token JWT-Standard, use preferred_username como a claim principal. Desabilitar CSRF e sessões é correto aqui porque a API nunca vê cookies de navegador; não copie essas duas linhas para a aplicação web.
Para testar sem um navegador, obtenha um token de máquina com o grant client credentials e chame a API:
TOKEN=$(curl -s -u '<client-id>:<client-secret>' \
-d grant_type=client_credentials \
https://door.example.com/api/login/oauth/access_token | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:9000/api/hello
Um token client credentials representa a aplicação, não um usuário, então ele não tem funções. Para saber mais sobre quais claims verificar e por quê, veja JWT validation e OAuth scopes.
3. Spring Cloud Gateway com TokenRelay
Com microsserviços, um layout comum é deixar o gateway lidar com o login e manter os serviços como servidores de recursos. O navegador só fala com o gateway e mantém um cookie de sessão; o gateway armazena o token de acesso do usuário e o filtro TokenRelay o adiciona como Authorization: Bearer ... a cada solicitação proxy. Os serviços da seção 2 validam-no normalmente.
Adicione spring-cloud-starter-gateway-server-webflux e spring-boot-starter-oauth2-client, com o BOM Spring Cloud 2025.0:
server:
port: 8080
spring:
application:
name: gateway
security:
oauth2:
client:
registration:
casdoor:
client-id: <client-id>
client-secret: <client-secret>
authorization-grant-type: authorization_code
scope: openid,profile,email
provider:
casdoor:
issuer-uri: https://door.example.com
user-name-attribute: preferred_username
cloud:
gateway:
server:
webflux:
routes:
- id: orders
uri: http://localhost:9000
predicates:
- Path=/api/orders/**
filters:
- TokenRelay=
- id: users
uri: http://localhost:9001
predicates:
- Path=/api/users/**
filters:
- TokenRelay=
TokenRelay= sem argumento retransmite o token do usuário autenticado. A fábrica de filtros TokenRelay só é criada quando spring.security.oauth2.client.* está configurado, e o armazenamento autorizado padrão de clientes está na memória, então um gateway com várias réplicas precisa de um armazenamento compartilhado ou sessões sticky. Os serviços atrás dele definem audiences como o client ID do gateway.
O Spring Cloud Gateway roda em WebFlux, então sua configuração de segurança é um SecurityWebFilterChain construído a partir de ServerHttpSecurity, não o SecurityFilterChain de servlet:
@Configuration
@EnableWebFluxSecurity
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain gatewayFilterChain(ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrations) {
OidcClientInitiatedServerLogoutSuccessHandler oidcLogout =
new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrations);
oidcLogout.setPostLogoutRedirectUri("{baseUrl}/");
http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/", "/actuator/health").permitAll()
.anyExchange().authenticated())
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessHandler(oidcLogout));
return http.build();
}
}
Mantenha as decisões de autorização nos serviços, onde as funções estão no token; o gateway apenas garante que haja um usuário autenticado. A proteção CSRF permanece ativada no gateway porque é baseada em cookies.
As páginas existentes de Spring Cloud Gateway e Spring Cloud mostram um design diferente, onde um GlobalFilter personalizado verifica um usuário do Casdoor armazenado na sessão do gateway.
Alternativa: o starter Casdoor para Spring Boot
Se você preferir não escrever o YAML, casdoor-spring-boot-starter 2.x (Spring Boot 3 e 4, Java 17+) aceita casdoor.endpoint, casdoor.client-id, casdoor.client-secret, casdoor.organization-name e casdoor.application-name, e define as propriedades spring.security.oauth2.* acima para qualquer starter de Segurança que esteja no classpath. Ele registra o cliente como casdoor, mapeia funções do Casdoor para authorities ROLE_ para login OAuth2 e para o servidor de recursos servlet, e expõe os serviços do Casdoor Java SDK como beans. Suas propriedades têm a menor precedência, então você ainda pode sobrescrever qualquer uma delas. Veja Spring Boot para a configuração.
Erros comuns
- "The Issuer ... provided in the configuration metadata did not match the requested issuer": o
issuer-uridifere do que o Casdoor reporta. Remova qualquer barra final e definaoriginno Casdoor se os nomes de host forem diferentes. redirect_urirejeitado pelo Casdoor: o callback não está nas Redirect URLs da aplicação. Verifique o esquema e a porta, especialmente atrás de um proxy reverso.- "Missing attribute 'preferred_username'": o escopo
profilenão foi solicitado, ou os campos de token da aplicação omitemName. - 401 com erro
invalid_tokenna API: o token veio de outra aplicação (audincorreto), está expirado ou é um ID token.
Referências
- Documento de descoberta e endpoints do Casdoor:
object/wellknown_oidc_discovery.go - Claims de token do Casdoor:
object/token_jwt.go - Referência Spring Boot 3.5: Spring Security, cliente OAuth2 e servidor de recursos
- Spring Security: logout iniciado pelo cliente OIDC
- Spring Security: JWT para servidor de recursos OAuth 2.0
- Spring Cloud Gateway: fábrica de GatewayFilter TokenRelay
- Spring Cloud Gateway: starter
spring-cloud-starter-gateway-server-webflux - Notas de versão do Spring Cloud 2025.0
- casdoor-spring-boot-starter