Spring Security OAuth2 Client对接Shopify报错:tokenType cannot be null
我有一个Spring Boot应用,想用OAuth认证调用Shopify API,使用的是Spring Security OAuth2 Client库。OAuth流程已完成,但在交换授权码获取访问令牌时受阻。Shopify返回的访问令牌响应如下:
{ "access_token": "f85632530bf277ec9ac6f649fc327f17", "scope": "write_orders,read_customers" }
此时Spring Security抛出异常:tokenType cannot be null,来自OAuth2AccessToken第72行。我尝试过自定义配置,但OAuth2AccessToken.TokenType是final类,仅存在Bearer这一个实例。推测Shopify不将其视为Bearer令牌,因为它要求用自定义请求头X-Shopify-Access-Token而非Authentication头携带令牌。OAuth2AccessToken被OAuth2AuthorizationCodeAuthenticationToken使用,该类在OAuth2AuthorizationCodeGrantFilter#processAuthorizationResponse中调用,属于库核心逻辑,似乎无法自定义。请问如何用Spring Security OAuth2 Client支持这类访问令牌?
我曾尝试强制设为Bearer令牌类型临时解决,但担心后续库将其当作Bearer令牌处理引发问题,自定义转换器代码如下:
/** * Modified from DefaultMapOAuth2AccessTokenResponseConverter */ public class ShopifyAccessTokenResponseConverter implements Converter<Map<String, Object>, OAuth2AccessTokenResponse> { @Override public OAuth2AccessTokenResponse convert(Map<String, Object> source) { String accessToken = getParameterValue(source, OAuth2ParameterNames.ACCESS_TOKEN); OAuth2AccessToken.TokenType accessTokenType = OAuth2AccessToken.TokenType.BEARER; // as if I had a choice Set<String> scopes = getScopes(source); String refreshToken = getParameterValue(source, OAuth2ParameterNames.REFRESH_TOKEN); return OAuth2AccessTokenResponse.withToken(accessToken) .tokenType(accessTokenType) .scopes(scopes) .refreshToken(refreshToken) .build(); } private static Set<String> getScopes(Map<String, Object> tokenResponseParameters) { if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) { String scope = getParameterValue(tokenResponseParameters, OAuth2ParameterNames.SCOPE); return new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); } return Collections.emptySet(); } private static String getParameterValue(Map<String, Object> tokenResponseParameters, String parameterName) { Object obj = tokenResponseParameters.get(parameterName); return (obj != null) ? obj.toString() : null; } }
1. 保留自定义令牌响应转换器,强制设置TokenType为Bearer
你的临时方案是可行的,Spring Security OAuth2 Client核心逻辑仅要求TokenType不为null,不会强制按Bearer规则处理令牌,只要后续覆盖令牌的携带方式即可。
2. 自定义OAuth2请求拦截器,替换令牌携带方式
要让请求使用X-Shopify-Access-Token头而非默认的Authorization: Bearer <token>,需自定义OAuth2AuthorizedClientManager并添加请求拦截器修改请求头:
@Configuration public class ShopifyOAuth2Config { @Bean public OAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientService authorizedClientService) { OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() .authorizationCode() .refreshToken() .build(); DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager( clientRegistrationRepository, authorizedClientService); authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); return authorizedClientManager; } @Bean public OAuth2RestTemplate shopifyRestTemplate(OAuth2AuthorizedClientManager authorizedClientManager) { ClientRegistration shopifyRegistration = authorizedClientManager.getClientRegistrationRepository() .findByRegistrationId("shopify"); OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(shopifyRegistration); // 添加拦截器替换Authorization头为X-Shopify-Access-Token restTemplate.getInterceptors().add((request, body, execution) -> { OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("shopify") .principal(SecurityContextHolder.getContext().getAuthentication()) .build(); OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest); if (authorizedClient != null && authorizedClient.getAccessToken() != null) { request.getHeaders().remove(HttpHeaders.AUTHORIZATION); request.getHeaders().add("X-Shopify-Access-Token", authorizedClient.getAccessToken().getTokenValue()); } return execution.execute(request, body); }); return restTemplate; } // 注册自定义的令牌响应转换器 @Bean public OAuth2AccessTokenResponseConverter<Map<String, Object>> shopifyAccessTokenResponseConverter() { return new ShopifyAccessTokenResponseConverter(); } @Bean public ClientRegistrationRepository clientRegistrationRepository() { return new InMemoryClientRegistrationRepository(shopifyClientRegistration()); } private ClientRegistration shopifyClientRegistration() { return ClientRegistration.withRegistrationId("shopify") .clientId("你的客户端ID") .clientSecret("你的客户端密钥") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("{baseUrl}/login/oauth2/code/shopify") .authorizationUri("https://{shop}.myshopify.com/admin/oauth/authorize") .tokenUri("https://{shop}.myshopify.com/admin/oauth/access_token") .scope("write_orders", "read_customers") .clientName("Shopify") .build(); } }
3. 关键说明
- 自定义的
ShopifyAccessTokenResponseConverter是必须的,用来补全Spring Security要求的tokenType字段,避免空指针异常。 - 通过拦截器移除默认的
Authorization头,添加Shopify要求的X-Shopify-Access-Token头,确保请求符合Shopify的API规范。 - 无需担心Spring Security把令牌当作Bearer处理,因为拦截器已经替换了令牌的携带方式,核心逻辑中只要TokenType不为null就不会报错。
内容的提问来源于stack exchange,提问作者Miles Pomeroy

