You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring授权服务器与Cloud Gateway的refresh_token流程登出异常问题

问题:Spring Authorization Server与Spring Cloud Gateway刷新令牌时id_token不一致导致登出失败

我已搭建Spring Authorization Server(SAS)与Spring Cloud Gateway(SCG),整体功能正常,但在基于refresh_token的流程中发现问题:
用户登录后,SAS返回access token、oidc token(id_token)和refresh token,均存储于SCG内存中。SAS的会话cookie有效期为30分钟,SCG通过该cookie延长access token有效期;cookie过期后,SCG开始使用refresh_token刷新令牌。每次刷新请求时,SAS会更新id_token,但SCG并未同步更新其内存中的id_token。当用户发起RP-Initiated Logout(依赖方发起登出)时,SCG向SAS发送最初返回的旧id_token,被SAS判定为无效并抛出异常。

请问是否可以配置SAS不刷新id_token,或配置SCG在刷新请求时同步更新内存中的id_token?


方案一:配置SAS刷新令牌时不生成新的id_token

SAS默认在刷新令牌流程中会重新生成id_token,可通过自定义OAuth2TokenGenerator控制这一行为,复用未过期的旧id_token:

  1. 实现自定义IdToken生成器
@Component
public class ReusableIdTokenGenerator implements OAuth2TokenGenerator<OidcIdToken> {

    private final DefaultIdTokenGenerator delegate;

    public ReusableIdTokenGenerator(JWKSource<SecurityContext> jwkSource, AuthorizationServerSettings authorizationServerSettings) {
        this.delegate = new DefaultIdTokenGenerator(jwkSource, authorizationServerSettings);
    }

    @Override
    public OidcIdToken generate(OAuth2TokenContext context) {
        // 仅在刷新令牌授权类型下,尝试复用原id_token
        if (AuthorizationGrantType.REFRESH_TOKEN.equals(context.getAuthorizationGrantType())) {
            OAuth2Authorization authorization = context.getAuthorization();
            OidcIdToken existingIdToken = authorization.getToken(OidcIdToken.class)
                    .map(OAuth2Token::getToken)
                    .map(OidcIdToken::new)
                    .orElse(null);
            
            // 原id_token未过期则直接返回
            if (existingIdToken != null && Instant.now().isBefore(existingIdToken.getExpiresAt())) {
                return existingIdToken;
            }
        }
        // 其他场景(如授权码流程)使用默认生成逻辑
        return delegate.generate(context);
    }

    @Override
    public boolean supports(OAuth2TokenContext context) {
        return delegate.supports(context);
    }
}
  1. 在SAS配置类中替换默认生成器
    在SecurityConfig中添加以下Bean:
@Bean
public OAuth2TokenGenerator<OidcIdToken> idTokenGenerator(JWKSource<SecurityContext> jwkSource, AuthorizationServerSettings authorizationServerSettings) {
    return new ReusableIdTokenGenerator(jwkSource, authorizationServerSettings);
}

方案二:配置SCG刷新令牌时同步更新id_token

SCG默认不会在刷新令牌后更新内存中的id_token,可通过两种方式实现同步:

方法1:自定义OAuth2AuthorizedClientRepository

重写保存逻辑,在刷新令牌后更新id_token:

@Component
public class UpdatingAuthorizedClientRepository implements OAuth2AuthorizedClientRepository {

    private final InMemoryOAuth2AuthorizedClientRepository delegate = new InMemoryOAuth2AuthorizedClientRepository();

    @Override
    public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request) {
        return delegate.loadAuthorizedClient(clientRegistrationId, principal, request);
    }

    @Override
    public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, HttpServletRequest request, HttpServletResponse response) {
        // 判断是否为刷新令牌后的响应
        if (authorizedClient.getAccessToken().getIssuedAt().isAfter(authorizedClient.getRefreshToken().getIssuedAt())) {
            OAuth2AccessTokenResponse tokenResponse = (OAuth2AccessTokenResponse) request.getAttribute(OAuth2AccessTokenResponse.class.getName());
            if (tokenResponse != null && tokenResponse.getAdditionalParameters().containsKey(OidcParameterNames.ID_TOKEN)) {
                String newIdTokenValue = tokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN).toString();
                OidcIdToken newIdToken = new OidcIdToken(
                        newIdTokenValue,
                        authorizedClient.getAccessToken().getIssuedAt(),
                        authorizedClient.getAccessToken().getExpiresAt(),
                        tokenResponse.getAdditionalParameters()
                );
                
                // 创建更新后的授权客户端并保存
                OAuth2AuthorizedClient updatedClient = new OAuth2AuthorizedClient(
                        authorizedClient.getClientRegistration(),
                        authorizedClient.getPrincipalName(),
                        authorizedClient.getAccessToken(),
                        authorizedClient.getRefreshToken(),
                        newIdToken
                );
                delegate.saveAuthorizedClient(updatedClient, principal, request, response);
                return;
            }
        }
        delegate.saveAuthorizedClient(authorizedClient, principal, request, response);
    }

    @Override
    public void removeAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request, HttpServletResponse response) {
        delegate.removeAuthorizedClient(clientRegistrationId, principal, request, response);
    }
}

方法2:监听OAuth2AuthorizedClientUpdatedEvent

通过事件监听机制,在授权客户端更新时同步id_token:

@Component
public class AuthorizedClientUpdateListener {

    private final OAuth2AuthorizedClientRepository authorizedClientRepository;

    public AuthorizedClientUpdateListener(OAuth2AuthorizedClientRepository authorizedClientRepository) {
        this.authorizedClientRepository = authorizedClientRepository;
    }

    @EventListener
    public void handleAuthorizedClientUpdatedEvent(OAuth2AuthorizedClientUpdatedEvent event) {
        OAuth2AuthorizedClient authorizedClient = event.getAuthorizedClient();
        OAuth2AccessTokenResponse tokenResponse = event.getAccessTokenResponse();
        
        if (tokenResponse != null && tokenResponse.getAdditionalParameters().containsKey(OidcParameterNames.ID_TOKEN)) {
            String newIdTokenValue = tokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN).toString();
            OidcIdToken newIdToken = new OidcIdToken(
                    newIdTokenValue,
                    authorizedClient.getAccessToken().getIssuedAt(),
                    authorizedClient.getAccessToken().getExpiresAt(),
                    tokenResponse.getAdditionalParameters()
            );
            
            OAuth2AuthorizedClient updatedClient = new OAuth2AuthorizedClient(
                    authorizedClient.getClientRegistration(),
                    authorizedClient.getPrincipalName(),
                    authorizedClient.getAccessToken(),
                    authorizedClient.getRefreshToken(),
                    newIdToken
            );
            authorizedClientRepository.saveAuthorizedClient(updatedClient, event.getAuthentication(), event.getRequest(), event.getResponse());
        }
    }
}

我的SAS配置

@EnableWebSecurity
public class SecurityConfig {
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http
                .getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(Customizer.withDefaults());    // Enable OpenID Connect 1.0
        RequestMatcher endpointsMatcher = http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).getEndpointsMatcher();
        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)
                        )
                )
                .cors(Customizer.withDefaults())
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                // 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
                .authorizeHttpRequests((authorize) -> authorize
                        .anyRequest().authenticated()
                )
                // Form login handles the redirect to the login page from the
                // authorization server filter chain
                .formLogin(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails userDetails = User.withDefaultPasswordEncoder()
                .username("HDonev")
                .password("123456")
                .roles("DNSPR", "SIGSPR")
                .build();

        return new InMemoryUserDetailsManager(userDetails);
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(PasswordEncoder passwordEncoder) {
        List<String> aud = List.of("http://localhost:8080");
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("aca392b5-e4a7-43fa-aa94-3c6f5b5211f1")
                .clientSecret(passwordEncoder.encode("123456"))
                .clientName("some client name")
                .clientAuthenticationMethods(c -> c.addAll(List.of(ClientAuthenticationMethod.CLIENT_SECRET_POST, ClientAuthenticationMethod.CLIENT_SECRET_BASIC)))
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUris(strings -> strings.addAll(Set.of("http://localhost:9090/home.html", "http://localhost:9090/login/oauth2/code/oauth")))
                .tokenSettings(TokenSettings.builder()
                        .accessTokenTimeToLive(Duration.ofMinutes(60))
                        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
                        .refreshTokenTimeToLive(Duration.ofSeconds(43200))
                        .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                        .reuseRefreshTokens(false)
                        .build())
                .scope(OidcScopes.OPENID)
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(false)
                        .requireProofKey(true)
                        .setting("subsystem", "13900")
                        .setting("client.allowed.resources", aud)
                        .build())
                .postLogoutRedirectUri("http://localhost:9090")
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer() {
        return context -> {
            if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN && context.getPrincipal() instanceof UsernamePasswordAuthenticationToken authenticationToken) {
                RegisteredClient registeredClient = context.getRegisteredClient();

                Set<String> authorities = authenticationToken.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
                context.getClaims().claims(claims -> {
                    List<String> updateAUD = new ArrayList<>();
                    updateAUD.addAll((Collection<? extends String>) claims.get(JwtClaimNames.AUD));
                    updateAUD.addAll(registeredClient.getClientSettings().getSetting("client.allowed.resources"));
                    claims.put(JwtClaimNames.AUD, updateAUD);
                    if (!authorities.isEmpty()) {
                        claims.put("roles", authorities);
                    }
                });
            }
            if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
                UsernamePasswordAuthenticationToken authenticationToken = context.getPrincipal();
                User user = (User) authenticationToken.getPrincipal();
                JwtClaimsSet.Builder claims = context.getClaims();
                claims.claim("user", user);
                claims.expiresAt(Instant.now().plusSeconds(3600));
            }
        };
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws GeneralSecurityException, IOException {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }


    public RSAKey generateRsa() {
        KeyPair keyPair = this.generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        // @formatter:off
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        // @formatter:on
    }

    private KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public AuthorizationServerSettings providerSettings() {
        return AuthorizationServerSettings.builder()
                .authorizationEndpoint("/oauth2/v1/authorize")
                .tokenEndpoint("/oauth2/v1/token")
                .tokenIntrospectionEndpoint("/oauth2/v1/introspect")
                .deviceAuthorizationEndpoint("/oauth2/v1/device_authorization")
                .tokenRevocationEndpoint("/oauth2/v1/revoke")
                .jwkSetEndpoint("/oauth2/v1/jwks")
                .oidcUserInfoEndpoint("/connect/v1/userinfo")
                .oidcLogoutEndpoint("/connect/v1/logout")
                .setting("settings.authorization-server.change-password-endpoint", "/oauth2/v1/change")
                .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setMaxAge(Duration.ofMinutes(60));
        corsConfiguration.setAllowedOrigins(List.of("http://localhost:9000"));
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }
}

我的SCG application.yml配置

server:
  port: 9090
spring:
  application:
    name: gateway
  cloud:
    gateway:
      routes:
        - id: backend
          predicates:
            - Path=/api/book/**
          uri: http://localhost:8080/api/book
          filters:
            - TokenRelay
            - SaveSession
        - id: change-password
          uri: http://localhost:9000/oauth2/v1/change-password
          predicates:
            - Path=/oauth2/v1/change-password
          filters:
            - AddRequestParameter=client_id, ${spring.security.oauth2.client.registration.oauth.client-id}
            - AddRequestParameter=redirect_uri, http://localhost:9090/home.html
            - TokenRelay
        - id: static
          uri: http://localhost:5500
          predicates:
            - Path=/**
  security:
    oauth2:
      client:
        provider:
          oauth:
            authorization-uri: http://localhost:9000/oauth2/v1/authorize
            issuer-uri: http://localhost:9000
            jwk-set-uri: http://localhost:9000/oauth2/v1/jwks
            token-uri: http://localhost:9000/oauth2/v1/token
            user-info-uri: http://localhost:9000/connect/v1/userinfo
        registration:
          oauth:
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_basic
            client-id: aca392b5-e4a7-43fa-aa94-3c6f5b5211f1
            client-secret: 123456
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
            scope: openid
  threads:
    virtual:
      enabled: true

内容的提问来源于stack exchange,提问作者HDonev

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 20:45:53