Let me explain my use case.
I have a problem with my BFF(Backend for Frontend) implementation which is mainly in the logout, as my 10 minutes duration access_token is refreshed before it expires by a call I make from angular. I have an api called /authenticate that makes a call to the authorization-server and this makes the token refresh but I have the problem that after doing this and I want to logout I get an error that the id_token_hint is wrong, this is solved if I go back in the page to return to the client because it makes a reload and redo the authorization flow and just in this case the logout works but not when I do the refresh with this:
This is my config for my Authorization-Server:
application.yml:
logging:
level:
root: INFO
org:
springframework:
security: DEBUG
session: DEBUG
web: DEBUG
spring:
mvc:
problemdetails:
enabled: true
application:
name: bo-oauth-server
main:
allow-bean-definition-overriding: true
config:
import: optional:configserver:${CONFIG_SERVER_URL}
intechbo:
server:
gateway: ${GATEWAY_SERVER_PORT}
oauth: ${OAUTH_SERVER_PORT}
oauth:
issuer: ${OAUTH_SERVER_ISSUER}
server:
servlet:
session:
timeout: 720m
cookie:
max-age: 720m
This is my security config from Authorization-Server:
@Configuration
@Slf4j
@EnableWebSecurity
public class AuthorizationSecurityConfig {
private final PasswordEncoder passwordEncoder;
private final AccessTokenAuthenticationFilter filter;
@Value("${intechbo.server.gateway}")
String gatewayServerPort;
@Value("${intechbo.server.oauth}")
String oauthServerPort;
public AuthorizationSecurityConfig(PasswordEncoder passwordEncoder, AccessTokenAuthenticationFilter filter) {
this.passwordEncoder = passwordEncoder;
this.filter = filter;
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "OPTIONS", "DELETE", "PUT", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("X-Requested-With", "Origin", "Content-Type", "Accept", "Authorization"));
configuration.setAllowCredentials(true);
configuration.addAllowedOrigin(gatewayServerPort);
configuration.addAllowedOrigin(oauthServerPort);
configuration.addAllowedOrigin("http://localhost:4200");
configuration.addAllowedOrigin("http://127.0.0.1:4200");
configuration.addAllowedOrigin("http://gateway-dev.inclub:8090");
configuration.addAllowedOrigin("http://webapp-dev.inclub:4200");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()));
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
http
// Redirect to the login page when not authenticated from the authorization endpoint
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer(
(resourceServer) -> resourceServer.jwt(Customizer.withDefaults())
);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(
HttpSecurity http
) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("styles.css", "styles.css.map", "/css/**", "/js/**", "/images/**", "/webjars/**", "/favicon.ico").permitAll()
.requestMatchers("/login", "/error", "/logged-out").permitAll()
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterAfter((Filter) filter, (Class<? extends Filter>) UsernamePasswordAuthenticationFilter.class)
.formLogin(
formLogin -> formLogin
.loginPage("/login")
.permitAll()
)
.sessionManagement(
sessionManagement -> sessionManagement
.invalidSessionUrl(gatewayServerPort + "/backoffice/home")
);
// .logout(logout -> logout
// .logoutSuccessUrl(gatewayServerPort + "/logged-out")
// .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
// );
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID()
.toString())
.clientId("---------")
.clientSecret(passwordEncoder.encode("-----"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
.redirectUri("https://dev.inclub.world/authorized")
.redirectUri("https://oidcdebugger.com/debug")
.redirectUri("https://oauth.pstmn.io/v1/callback")
.redirectUri("https://oauthdebugger.com/debug")
.redirectUri(gatewayServerPort + "/login/oauth2/code/backoffice-gateway")
.redirectUri("http://gateway-dev.inclub:8090" + "/login/oauth2/code/backoffice-gateway")
.redirectUri("http://gateway-dev.inclub:8090" + "/callback-local")
.redirectUri("http://localhost:4200/auth-callback-local")
.redirectUri("http://127.0.0.1:4200/profile/partner")
.postLogoutRedirectUri(gatewayServerPort + "/logged-out")
.postLogoutRedirectUri("http://gateway-dev.inclub:8090" + "/logged-out")
.scope("read")
.scope("write")
.scope("internal")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.EMAIL)
.tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofMinutes(10)).build())
.clientSettings(clientSettings())
.build();
return new InMemoryRegisteredClientRepository(oidcClient);
}
public void bindAuthenticationProvider(AuthenticationManagerBuilder auth, AuthenticationProvider authenticationProvider) {
auth.authenticationProvider(authenticationProvider);
}
@Bean
public ClientSettings clientSettings() {
return ClientSettings.builder()
.requireProofKey(true)
.build();
}
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return context -> {
Authentication principal = context.getPrincipal();
if (context.getTokenType()
.getValue()
.equals("id_token")) {
context.getClaims()
.claim(
"Test",
"Test Id Token"
);
}
if (context.getTokenType()
.getValue()
.equals("access_token")) {
context.getClaims()
.claim(
"Test",
"Test Access Token"
);
Set<String> authorities = principal.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
context.getClaims()
.claim(
"roles",
authorities
)
.claim(
"user",
principal.getName()
);
}
};
}
@Bean
public AuthorizationServerSettings authorizationServerSettings(@Value("${intechbo.oauth.issuer}") String issuer) {
return AuthorizationServerSettings.builder()
.issuer(issuer)
.build();
}
This is my api of authenticate that I use to refresh my token:
@GetMapping("/authenticate")
public Mono<TokenInfoResponseDto> login(@RegisteredOAuth2AuthorizedClient("backoffice-gateway") OAuth2AuthorizedClient client,
@AuthenticationPrincipal OidcUser user ) {
String jwtToken = client.getAccessToken().getTokenValue();
log.info("access token received from authorization server with value : {}", jwtToken);
Mono<UserInfoResponseDto> userResponse = externalUserService.getUser(getAuth(jwtToken));
log.info("user info received from authorization server with value : {}", userResponse);
TokenInfoResponseDto tokenInfo = TokenInfoResponseDto.builder()
.accessToken(client.getAccessToken().getTokenValue())
.refreshToken(Objects.requireNonNull(client.getRefreshToken())
.getTokenValue()
)
.accessTokenExpireAt(client.getAccessToken().getExpiresAt())
.authorities(extractAuthority(user))
.accessTokenIssueAt(client.getAccessToken().getIssuedAt())
.refreshTokenExpireAt(client.getRefreshToken().getExpiresAt())
// Get id_token_hint
.idTokenHint(user.getIdToken().getTokenValue())
.build();
return Mono.just(tokenInfo)
.zipWith(userResponse)
.map(tuple -> {
TokenInfoResponseDto tokenInfoResponseDto = tuple.getT1();
UserInfoResponseDto userInfoResponseDto = tuple.getT2();
tokenInfoResponseDto.setUserInfo(userInfoResponseDto);
return tokenInfoResponseDto;
})
.log();
}
This is my securityConfig in my oauth2 Client:
@Configuration
@EnableWebFluxSecurity
public class ClientSecurityConfig {
@Autowired
private ReactiveClientRegistrationRepository clientRegistrationRepository;
@Value("${intechbo.server.gateway}")
private String gatewayUrl;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
ServerOAuth2AuthorizationRequestResolver resolver) {
http
.cors(ServerHttpSecurity.CorsSpec::disable)
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(
exchanges -> exchanges
.pathMatchers(SecurityConstants.AUTH_WHITELIST).permitAll()
.pathMatchers("/*.js", "/*.css", "/*.ico", "/*.jpg", "/*.png", "/*.html", "/*.svg").permitAll()
.pathMatchers(SecurityConstants.AUTH_ANGULAR_COMPILER_WHITELIST).permitAll()
.pathMatchers("/backoffice/home/**").permitAll()
.pathMatchers("/backoffice/home").permitAll()
.pathMatchers("/backoffice/authentication/logout-session").permitAll()
.pathMatchers("/backoffice/profile/**").authenticated()
.pathMatchers("/logged-out").permitAll()
.pathMatchers("/authenticate").authenticated()
.anyExchange().authenticated()
)
.oauth2Login(auth ->
auth.authorizationRequestResolver(resolver)
.authenticationSuccessHandler(authenticationSuccessHandler())
)
.oauth2Client(Customizer.withDefaults())
.logout(
logout -> logout
.logoutSuccessHandler(oidcLogoutSuccessHandler())
)
.exceptionHandling(
exceptionHandlingSpec -> exceptionHandlingSpec
.authenticationEntryPoint(authenticationEntryPoint())
);
return http.build();
}
private ServerAuthenticationSuccessHandler authenticationSuccessHandler() {
return new RedirectServerAuthenticationSuccessHandler("/backoffice/authentication/login");
}
private ServerAuthenticationEntryPoint authenticationEntryPoint() {
RedirectServerAuthenticationEntryPoint webAuthenticationEntryPoint =
new RedirectServerAuthenticationEntryPoint("/oauth2/authorization/backoffice-gateway");
MediaTypeServerWebExchangeMatcher textHtmlMatcher =
new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML);
textHtmlMatcher.setUseEquals(true);
return new DelegatingServerAuthenticationEntryPoint(
new DelegatingServerAuthenticationEntryPoint.DelegateEntry(textHtmlMatcher, webAuthenticationEntryPoint));
}
private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository);
// Sets the location that the End-User's User Agent will be redirected to
// after the logout has been performed at the Provider
oidcLogoutSuccessHandler.setPostLogoutRedirectUri(gatewayUrl + "/logged-out");
return oidcLogoutSuccessHandler;
}
@Bean
public ServerOAuth2AuthorizationRequestResolver pkceResolver(ReactiveClientRegistrationRepository repo) {
DefaultServerOAuth2AuthorizationRequestResolver resolver = new DefaultServerOAuth2AuthorizationRequestResolver(repo);
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
return resolver;
}
@Bean
public ReactiveJwtDecoderFactory<ClientRegistration> idTokenDecoderFactory() {
ReactiveOidcIdTokenDecoderFactory idTokenDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
// RSA is the default algorithm, but we are using HS256
idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> SignatureAlgorithm.RS256);
return idTokenDecoderFactory;
}
@Bean
public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Client.setDefaultOAuth2AuthorizedClient(true);
return WebClient.builder()
.filter(oauth2Client)
.build();
}
@Bean
@Primary
public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(ReactiveClientRegistrationRepository clientRegistrationRepository,
ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {
ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.build();
DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
This is my application.yml for my oauth2Client:
logging:
level:
root: INFO
org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping: DEBUG
org:
springframework:
security: DEBUG
session: DEBUG
web: DEBUG
intechbo:
server:
gateway: ${GATEWAY_SERVER_PORT:http}
oauth: ${OAUTH_SERVER_PORT:https}
discover: ${DISCOVER_SERVER_PORT}
webapp: ${WEBAPP_SERVER_PORT}
spring:
application:
name: bo-gateway-server
session:
redis:
repository-type: default
security:
oauth2:
client:
registration:
backoffice-gateway:
provider: spring
client-id: client
client-secret: ----
authorization-grant-type: authorization_code
redirect-uri: ${intechbo.server.gateway}/login/oauth2/code/backoffice-gateway
scope: read,write,openid,profile
backoffice-refresh:
provider: spring
client-id: backoffice-client
client-secret: secret
authorization-grant-type: refresh_token
redirect-uri: ${intechbo.server.gateway}/login/oauth2/code/backoffice-gateway
scope: read,write,openid,profile
provider:
spring:
issuer-uri: ${intechbo.server.oauth}
cloud:
gateway:
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
# - TokenRelay=
# - SaveSession
routes:
- id: account-service-route
uri: http://localhost:8776
# uri: --
predicates:
- Path=/api/v1/account/**
filters:
- TokenRelay=
- SaveSession
- name: Retry
args:
retries: 5
methods: GET
backoff:
firstBackoff: 200ms
maxBackOff: 400ms
# - name: CircuitBreaker
# args:
# name: accountService
# fallbackUri: forward:/account-service-fallback
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 2
redis-rate-limiter.burstCapacity: 2
- id: membership-service-route
uri: http://localhost:8776
predicates:
- Path=/api/v1/membership/**, /api/v1/pay/** , /api/v1/store/**
filters:
- TokenRelay=
- SaveSession
- name: Retry
args:
retries: 5
methods: GET
backoff:
firstBackoff: 50ms
maxBackOff: 400ms
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 2
redis-rate-limiter.burstCapacity: 2
- id: treepointrange-service-route
uri: http://localhost:8776
predicates:
- Path=/api/v1/three/**, /api/v1/placement/**
filters:
- TokenRelay=
- SaveSession
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 2
redis-rate-limiter.burstCapacity: 2
- id: wallet-service-route
uri: http://localhost:8776
predicates:
- Path=/api/v1/wallet/**, /api/v1/wallettransaction/**, /api/v1/withdrawalrequest/**, /api/v1/tokenwallet/**, /api/v1/electronicpurse/**, /api/v1/accountbank/**
filters:
- TokenRelay=
- SaveSession
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 2
redis-rate-limiter.burstCapacity: 2
# - id: angular
# uri: ${intechbo.server.webapp}
# predicates:
# - Path=/backoffice/**
# filters:
## - RewritePath=/backoffice(?<segment>/?.*), /$\{segment}
# - RewritePath=/backoffice(?<segment>/?.*), "/\$\{segment}"
- id: angular
uri: ${intechbo.server.webapp}
predicates:
- Path=/
filters:
- RewritePath=/, /backoffice
- id: static
uri: ${intechbo.server.webapp}
predicates:
- Path=/**
# filters:
# - SaveSession
data:
redis:
port: ${REDIS_SERVER_PORT:6379}
host: ${REDIS_SERVER_HOST:localhost}
password: ${REDIS_SERVER_PASSWORD:}
timeout: 5000
lettuce:
pool:
max-idle: 9
min-idle: 1
max-active: 9
max-wait: 5000
eureka:
client:
service-url:
defaultZone: ${intechbo.server.discover}
fetch-registry: true
register-with-eureka: true
server:
port: 8090
reactive:
session:
timeout: 710m
cookie:
name: INCLUB_SESSION
max-age: 710m
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowSize: 10
slidingWindowType: COUNT_BASED
permittedNumberOfCallsInHalfOpenState: 6
failureRateThreshold: 50
waitDurationInOpenState: 10s
registerHealthIndicator: true
automaticTransitionFromOpenToHalfOpenEnabled: true
instances:
accountService:
baseConfig: default
retry:
instances:
authorizationServer:
maxAttempts: 3
waitDuration: 2500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
timelimiter:
configs:
values:
timeout-duration: 80s
instances:
offersTimeLimiter: # Unique name for TimeLimiter
base-config: values
management:
health:
circuitbreakers:
enabled: true
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
Finally this is my config for my service in angular:
public authenticateTest(): void {
// Quitando la subscripcion al observable para evitar fugas de memoria
this.refreshSub?.unsubscribe();
this.http.get<any>('/authenticate').subscribe({
next: (data: IAuthentication) => {
if (!!data.accessTokenExpireAt) {
const now = Date.now();
const expireAt = new Date(data.accessTokenExpireAt).getTime();
var refreshToken = JSON.stringify(data.refreshToken);
localStorage.setItem('refreshToken', refreshToken);
console.log('Token will expire at', new Date(expireAt));
const delay = expireAt - now - 30000; // Refresh 30 seconds before expiration
console.log('Token will be refreshed in', delay, 'ms');
if (delay > 2000) {
this.refreshSub = interval(delay).subscribe(() => {
console.log('Refreshing token...');
this.authenticateTest();
});
console.log('Refresh interval set');
}
}
this.redirectByProfile(data);
},
error: (error) => console.error(error)
});
}
Finally this is a short video error_logout about what is happening when the token is refresh and the logout stop working, if I logged out before the access token is expired it works but when the token is refreshed give me the error that I showing.